CreateOrUpdateOAuthAppSheet.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import type {
  3. CreateOAuthClientParams,
  4. OAuthClient,
  5. UpdateOAuthClientParams,
  6. } from '@supabase/supabase-js'
  7. import { useParams } from 'common'
  8. import { Storage } from 'icons'
  9. import { ImageOff, Trash2, X } from 'lucide-react'
  10. import { useEffect, useState } from 'react'
  11. import { useForm } from 'react-hook-form'
  12. import { toast } from 'sonner'
  13. import {
  14. Button,
  15. cn,
  16. Form,
  17. FormControl,
  18. FormDescription,
  19. FormField,
  20. FormLabel,
  21. Input,
  22. Select,
  23. SelectContent,
  24. SelectItem,
  25. SelectTrigger,
  26. SelectValue,
  27. Separator,
  28. Sheet,
  29. SheetClose,
  30. SheetContent,
  31. SheetFooter,
  32. SheetHeader,
  33. SheetSection,
  34. SheetTitle,
  35. Switch,
  36. } from 'ui'
  37. import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
  38. import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
  39. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  40. import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray'
  41. import * as z from 'zod'
  42. import { LogoPicker } from './LogoPicker'
  43. import { InlineLink } from '@/components/ui/InlineLink'
  44. import Panel from '@/components/ui/Panel'
  45. import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
  46. import { useOAuthServerAppCreateMutation } from '@/data/oauth-server-apps/oauth-server-app-create-mutation'
  47. import { useOAuthServerAppRegenerateSecretMutation } from '@/data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation'
  48. import { useOAuthServerAppUpdateMutation } from '@/data/oauth-server-apps/oauth-server-app-update-mutation'
  49. import { DOCS_URL } from '@/lib/constants'
  50. interface CreateOrUpdateOAuthAppSheetProps {
  51. visible: boolean
  52. appToEdit?: OAuthClient
  53. onSuccess: (app: OAuthClient) => void
  54. onCancel: () => void
  55. }
  56. const FormSchema = z.object({
  57. name: z
  58. .string()
  59. .min(1, 'Please provide a name for your OAuth app')
  60. .max(100, 'Name must be less than 100 characters'),
  61. type: z.enum(['manual', 'dynamic']).default('manual'),
  62. redirect_uris: z
  63. .object({
  64. value: z.string().trim().url('Please provide a valid URL'),
  65. })
  66. .array()
  67. .min(1, 'At least one redirect URI is required'),
  68. client_type: z.enum(['public', 'confidential']).default('confidential'),
  69. token_endpoint_auth_method: z
  70. .enum(['client_secret_basic', 'client_secret_post', 'none'])
  71. .default('client_secret_basic'),
  72. client_id: z.string().optional(),
  73. client_secret: z.string().optional(),
  74. logo_uri: z.string().optional(),
  75. })
  76. const FORM_ID = 'create-or-update-oauth-app-form'
  77. const initialValues = {
  78. name: '',
  79. type: 'manual' as const,
  80. redirect_uris: [{ value: '' }],
  81. client_type: 'confidential' as const,
  82. token_endpoint_auth_method: 'client_secret_basic' as const,
  83. client_id: '',
  84. client_secret: '',
  85. logo_uri: '',
  86. }
  87. export const CreateOrUpdateOAuthAppSheet = ({
  88. visible,
  89. appToEdit,
  90. onSuccess,
  91. onCancel,
  92. }: CreateOrUpdateOAuthAppSheetProps) => {
  93. const { ref: projectRef } = useParams()
  94. const [showRegenerateDialog, setShowRegenerateDialog] = useState(false)
  95. const [storagePickerOpen, setStoragePickerOpen] = useState(false)
  96. const [logoUrl, setLogoUrl] = useState<string>()
  97. const isEditMode = !!appToEdit
  98. const hasLogo = logoUrl !== undefined
  99. const isPublicClient = appToEdit?.client_type === 'public'
  100. const form = useForm<z.infer<typeof FormSchema>>({
  101. resolver: zodResolver(FormSchema as any),
  102. defaultValues: initialValues,
  103. })
  104. const { hostEndpoint: clientEndpoint } = useProjectApiUrl({ projectRef })
  105. const { mutate: createOAuthApp, isPending: isCreating } = useOAuthServerAppCreateMutation({
  106. onSuccess: (data) => {
  107. toast.success(`Successfully created OAuth app "${data.client_name}"`)
  108. onSuccess(data)
  109. },
  110. })
  111. const { mutate: updateOAuthApp, isPending: isUpdating } = useOAuthServerAppUpdateMutation({
  112. onSuccess: (data) => {
  113. toast.success(`Successfully updated OAuth app "${data.client_name}"`)
  114. onSuccess(data)
  115. },
  116. })
  117. const { mutate: regenerateSecret, isPending: isRegenerating } =
  118. useOAuthServerAppRegenerateSecretMutation({
  119. onSuccess: (data) => {
  120. if (data) {
  121. toast.success(`Successfully regenerated client secret for "${appToEdit?.client_name}"`)
  122. onSuccess(data)
  123. setShowRegenerateDialog(false)
  124. }
  125. },
  126. })
  127. useEffect(() => {
  128. if (!visible) {
  129. setStoragePickerOpen(false)
  130. }
  131. }, [visible])
  132. useEffect(() => {
  133. if (visible) {
  134. if (appToEdit) {
  135. form.reset({
  136. name: appToEdit.client_name,
  137. type: 'manual' as const,
  138. redirect_uris:
  139. appToEdit.redirect_uris && appToEdit.redirect_uris.length > 0
  140. ? appToEdit.redirect_uris.map((uri) => ({ value: uri }))
  141. : [{ value: '' }],
  142. client_type: appToEdit.client_type,
  143. token_endpoint_auth_method:
  144. (appToEdit.token_endpoint_auth_method as
  145. | 'client_secret_basic'
  146. | 'client_secret_post'
  147. | 'none') || 'client_secret_basic',
  148. client_id: appToEdit.client_id,
  149. client_secret: '****************************************************************',
  150. logo_uri: appToEdit.logo_uri || undefined,
  151. })
  152. setLogoUrl(appToEdit.logo_uri || undefined)
  153. } else {
  154. form.reset(initialValues)
  155. setLogoUrl(undefined)
  156. }
  157. }
  158. }, [visible, appToEdit, form])
  159. const onSubmit = async (data: z.infer<typeof FormSchema>) => {
  160. const validRedirectUris = data.redirect_uris
  161. .map((uri) => uri.value.trim())
  162. .filter((uri) => uri !== '')
  163. const uploadedLogoUri = data.logo_uri?.trim() ?? ''
  164. if (isEditMode && appToEdit) {
  165. const payload: UpdateOAuthClientParams & { token_endpoint_auth_method?: string } = {
  166. client_name: data.name,
  167. redirect_uris: validRedirectUris,
  168. logo_uri: uploadedLogoUri,
  169. token_endpoint_auth_method:
  170. data.client_type === 'public' ? 'none' : data.token_endpoint_auth_method,
  171. }
  172. updateOAuthApp({
  173. projectRef,
  174. clientEndpoint,
  175. clientId: appToEdit.client_id,
  176. ...payload,
  177. })
  178. } else {
  179. const payload: CreateOAuthClientParams & {
  180. logo_uri?: string
  181. client_type?: string
  182. token_endpoint_auth_method?: string
  183. } = {
  184. client_name: data.name,
  185. client_uri: '',
  186. client_type: data.client_type,
  187. redirect_uris: validRedirectUris,
  188. logo_uri: uploadedLogoUri || undefined,
  189. token_endpoint_auth_method:
  190. data.client_type === 'public' ? 'none' : data.token_endpoint_auth_method,
  191. }
  192. createOAuthApp({
  193. projectRef,
  194. clientEndpoint,
  195. ...payload,
  196. })
  197. }
  198. }
  199. const onClose = () => {
  200. form.reset(initialValues)
  201. onCancel()
  202. }
  203. const handleRegenerateSecret = () => {
  204. setShowRegenerateDialog(true)
  205. }
  206. const handleConfirmRegenerate = () => {
  207. regenerateSecret({
  208. projectRef,
  209. clientEndpoint,
  210. clientId: appToEdit?.client_id,
  211. })
  212. }
  213. const handlePickLogoFromStorage = (uri: string) => {
  214. setLogoUrl(uri)
  215. form.setValue('logo_uri', uri)
  216. }
  217. const handleRemoveLogo = () => {
  218. setLogoUrl(undefined)
  219. form.setValue('logo_uri', '')
  220. }
  221. return (
  222. <>
  223. {projectRef ? (
  224. <LogoPicker
  225. open={storagePickerOpen}
  226. onOpenChange={setStoragePickerOpen}
  227. onSelect={handlePickLogoFromStorage}
  228. />
  229. ) : null}
  230. <Sheet open={visible} onOpenChange={() => onCancel()}>
  231. <SheetContent
  232. size="lg"
  233. showClose={false}
  234. className="flex flex-col gap-0"
  235. tabIndex={undefined}
  236. aria-describedby={undefined}
  237. >
  238. <SheetHeader>
  239. <div className="flex flex-row gap-3 items-center">
  240. <SheetClose
  241. className={cn(
  242. 'text-muted hover:text ring-offset-background transition-opacity hover:opacity-100',
  243. 'focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
  244. 'disabled:pointer-events-none data-[state=open]:bg-secondary',
  245. 'transition'
  246. )}
  247. >
  248. <X className="h-3 w-3" />
  249. <span className="sr-only">Close</span>
  250. </SheetClose>
  251. <SheetTitle className="truncate">
  252. {isEditMode ? 'Update OAuth app' : 'Create a new OAuth app'}
  253. </SheetTitle>
  254. </div>
  255. </SheetHeader>
  256. <SheetSection className="overflow-auto grow px-0">
  257. <Form {...form}>
  258. <form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)} id={FORM_ID}>
  259. <div className="px-5 flex items-start justify-between gap-4">
  260. <div className="grow space-y-4">
  261. <FormField
  262. control={form.control}
  263. name="name"
  264. render={({ field }) => (
  265. <FormItemLayout label="Name">
  266. <FormControl>
  267. <Input {...field} placeholder="My OAuth App" />
  268. </FormControl>
  269. </FormItemLayout>
  270. )}
  271. />
  272. <FormField
  273. control={form.control}
  274. name="logo_uri"
  275. render={({ field }) => (
  276. <FormItemLayout
  277. label="Logo"
  278. description={`Paste an absolute image URL/path or select one from a public File Storage bucket.`}
  279. >
  280. <FormControl>
  281. <div className="flex w-full flex-col gap-3">
  282. <div className="flex flex-wrap items-center gap-2">
  283. <div
  284. className={cn(
  285. 'flex items-center justify-center h-10 w-10 shrink-0 text-foreground-lighter overflow-hidden rounded-full bg-cover border'
  286. )}
  287. title={logoUrl ? undefined : 'No image selected'}
  288. style={{
  289. backgroundImage: logoUrl ? `url("${logoUrl}")` : 'none',
  290. }}
  291. >
  292. {!hasLogo && <ImageOff size={14} />}
  293. </div>
  294. <div className="flex min-w-0 flex-1 items-center gap-2">
  295. <div className="group relative min-w-0 flex-1">
  296. <Input
  297. {...field}
  298. value={field.value ?? ''}
  299. className={cn('flex-1', projectRef ? 'pr-10' : '')}
  300. placeholder="https://example.com/logo.png"
  301. onChange={(event) => {
  302. field.onChange(event)
  303. const next = event.target.value.trim()
  304. setLogoUrl(next.length > 0 ? next : undefined)
  305. }}
  306. />
  307. {projectRef ? (
  308. <Button
  309. type="default"
  310. size="tiny"
  311. icon={<Storage strokeWidth={1.5} />}
  312. className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 justify-center overflow-hidden px-1 transition-all duration-150 group-hover:w-36 group-focus-within:w-36 [&_span]:hidden group-hover:[&_span]:block group-focus-within:[&_span]:block"
  313. onClick={() => setStoragePickerOpen(true)}
  314. >
  315. <span className="hidden whitespace-nowrap opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100">
  316. Select from Storage
  317. </span>
  318. </Button>
  319. ) : null}
  320. </div>
  321. {field.value ? (
  322. <Button
  323. type="default"
  324. size="tiny"
  325. icon={<Trash2 size={12} />}
  326. onClick={handleRemoveLogo}
  327. />
  328. ) : null}
  329. </div>
  330. </div>
  331. </div>
  332. </FormControl>
  333. </FormItemLayout>
  334. )}
  335. />
  336. </div>
  337. </div>
  338. {isEditMode && appToEdit && (
  339. <>
  340. <Separator />
  341. <div className="px-5">
  342. <Panel>
  343. <Panel.Content className="space-y-2">
  344. <FormField
  345. control={form.control}
  346. name="client_id"
  347. render={() => (
  348. <FormItemLayout label="Client ID">
  349. <FormControl>
  350. <PasswordInput
  351. copy
  352. readOnly
  353. className="input-mono"
  354. value={appToEdit.client_id}
  355. onChange={() => {}}
  356. onCopy={() => toast.success('Client ID copied to clipboard')}
  357. />
  358. </FormControl>
  359. </FormItemLayout>
  360. )}
  361. />
  362. {!isPublicClient && (
  363. <>
  364. <FormField
  365. control={form.control}
  366. name="client_secret"
  367. render={() => (
  368. <FormItemLayout
  369. label="Client Secret"
  370. description="Client secret is hidden for security. Use the regenerate button to create a new one."
  371. >
  372. <FormControl>
  373. <Input
  374. readOnly
  375. type="password"
  376. className="input-mono"
  377. value="****************************************************************"
  378. onChange={() => {}}
  379. />
  380. </FormControl>
  381. </FormItemLayout>
  382. )}
  383. />
  384. <Button
  385. type="default"
  386. onClick={handleRegenerateSecret}
  387. className="w-min"
  388. disabled={isRegenerating}
  389. >
  390. Regenerate client secret
  391. </Button>
  392. </>
  393. )}
  394. </Panel.Content>
  395. </Panel>
  396. </div>
  397. </>
  398. )}
  399. <div className="px-5 gap-2 flex flex-col">
  400. <FormLabel className="text-foreground">Redirect URIs</FormLabel>
  401. <SingleValueFieldArray
  402. control={form.control}
  403. name="redirect_uris"
  404. valueFieldName="value"
  405. createEmptyRow={() => ({ value: '' })}
  406. placeholder="https://example.com/callback"
  407. addLabel="Add redirect URI"
  408. removeLabel="Remove redirect URI"
  409. minimumRows={1}
  410. rowsClassName="space-y-2"
  411. />
  412. <FormDescription className="text-foreground-lighter">
  413. URLs where users will be redirected after authentication.
  414. </FormDescription>
  415. </div>
  416. <Separator />
  417. <FormField
  418. control={form.control}
  419. name="client_type"
  420. render={({ field }) => (
  421. <FormItemLayout
  422. label="Public Client"
  423. layout="flex"
  424. description={
  425. <>
  426. If enabled, the Authorization Code with PKCE (Proof Key for Code Exchange)
  427. flow can be used, particularly beneficial for applications that cannot
  428. securely store Client Secrets, such as native and mobile apps. This cannot
  429. be changed after creation.{' '}
  430. <InlineLink
  431. href={`${DOCS_URL}/guides/auth/oauth-server/getting-started#register-an-oauth-client`}
  432. >
  433. Learn more
  434. </InlineLink>
  435. </>
  436. }
  437. className={'px-5'}
  438. >
  439. <FormControl>
  440. <Switch
  441. checked={field.value === 'public'}
  442. onCheckedChange={(checked) => {
  443. const newType = checked ? 'public' : 'confidential'
  444. field.onChange(newType)
  445. form.setValue(
  446. 'token_endpoint_auth_method',
  447. newType === 'public' ? 'none' : 'client_secret_basic'
  448. )
  449. }}
  450. disabled={isEditMode}
  451. />
  452. </FormControl>
  453. </FormItemLayout>
  454. )}
  455. />
  456. {form.watch('client_type') === 'confidential' && (
  457. <FormField
  458. control={form.control}
  459. name="token_endpoint_auth_method"
  460. render={({ field }) => (
  461. <FormItemLayout
  462. label="Token Endpoint Auth Method"
  463. description="How the client authenticates with the token endpoint. The client secret is included in either the Authorization header or the request body."
  464. className="px-5"
  465. >
  466. <FormControl>
  467. <Select value={field.value} onValueChange={field.onChange}>
  468. <SelectTrigger className="text-sm">
  469. <SelectValue />
  470. </SelectTrigger>
  471. <SelectContent>
  472. <SelectItem value="client_secret_basic" className="text-sm">
  473. HTTP Basic Auth header (client_secret_basic)
  474. </SelectItem>
  475. <SelectItem value="client_secret_post" className="text-sm">
  476. Request body (client_secret_post)
  477. </SelectItem>
  478. </SelectContent>
  479. </Select>
  480. </FormControl>
  481. </FormItemLayout>
  482. )}
  483. />
  484. )}
  485. </form>
  486. </Form>
  487. </SheetSection>
  488. <SheetFooter>
  489. <Button type="default" disabled={isCreating || isUpdating} onClick={onClose}>
  490. Cancel
  491. </Button>
  492. <Button htmlType="submit" form={FORM_ID} loading={isCreating || isUpdating}>
  493. {isEditMode ? 'Update app' : 'Create app'}
  494. </Button>
  495. </SheetFooter>
  496. </SheetContent>
  497. </Sheet>
  498. <ConfirmationModal
  499. variant="warning"
  500. visible={showRegenerateDialog}
  501. loading={isRegenerating}
  502. title="Confirm regenerating client secret"
  503. confirmLabel="Confirm"
  504. onCancel={() => setShowRegenerateDialog(false)}
  505. onConfirm={handleConfirmRegenerate}
  506. >
  507. <p className="text-sm text-foreground-light">
  508. Are you sure you wish to regenerate the client secret for "{appToEdit?.client_name}"?
  509. You'll need to update it in all applications that use it. This action cannot be undone.
  510. </p>
  511. </ConfirmationModal>
  512. </>
  513. )
  514. }