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