CreateOrUpdateCustomProviderSheet.tsx532 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import type { CustomOAuthProvider } from '@supabase/auth-js'
3import { useParams } from 'common'
4import { X } from 'lucide-react'
5import { useEffect } from 'react'
6import { useForm } from 'react-hook-form'
7import { toast } from 'sonner'
8import {
9 Button,
10 cn,
11 Form,
12 FormControl,
13 FormField,
14 FormInputGroupInput,
15 Input,
16 InputGroup,
17 InputGroupAddon,
18 InputGroupText,
19 RadioGroupStacked,
20 RadioGroupStackedItem,
21 Separator,
22 Sheet,
23 SheetClose,
24 SheetContent,
25 SheetFooter,
26 SheetHeader,
27 SheetSection,
28 SheetTitle,
29 Switch,
30 useWatch,
31} from 'ui'
32import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
33import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
34import * as z from 'zod'
35
36import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
37import { FormSectionLabel } from '@/components/ui/Forms/FormSection'
38import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
39import { useOAuthCustomProviderCreateMutation } from '@/data/oauth-custom-providers/oauth-custom-provider-create-mutation'
40import {
41 useOAuthCustomProviderUpdateMutation,
42 type OAuthCustomProviderUpdateVariables,
43} from '@/data/oauth-custom-providers/oauth-custom-provider-update-mutation'
44import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
45
46interface CreateOrUpdateCustomProviderSheetProps {
47 visible: boolean
48 providerToEdit?: CustomOAuthProvider
49 onClose: () => void
50}
51
52const SharedFormSchema = z.object({
53 identifier: z
54 .string()
55 .min(1, 'Please provide an identifier')
56 .regex(
57 /^[a-zA-Z0-9_-]+$/,
58 'Identifier can only contain letters, numbers, hyphens, and underscores'
59 ),
60 name: z
61 .string()
62 .min(1, 'Please provide a name for your custom provider')
63 .max(100, 'Name must be less than 100 characters'),
64 provider_type: z.enum(['oidc', 'oauth2']).default('oidc'),
65 client_id: z.string().min(1, 'Please provide a client ID').trim(),
66 client_secret: z.string().min(1, 'Please provide a client secret').trim(),
67 email_optional: z.boolean().default(false),
68 issuer: z.string().url('Please provide a valid URL').trim(),
69 // comma-separated scopes in the form, will be transformed to array when sending
70 scopes: z.string().default(''),
71})
72
73const OidcSchema = SharedFormSchema.extend({
74 provider_type: z.literal('oidc'),
75 discovery_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''),
76})
77
78const OAuth2Schema = SharedFormSchema.extend({
79 provider_type: z.literal('oauth2'),
80 authorization_url: z
81 .union([z.string().url('Please provide a valid URL'), z.literal('')])
82 .default(''),
83 token_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''),
84 userinfo_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''),
85 jwks_uri: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''),
86})
87
88const FormSchema = z.discriminatedUnion('provider_type', [OidcSchema, OAuth2Schema])
89
90const FORM_ID = 'create-or-update-custom-provider-form'
91
92const initialValues = {
93 name: '',
94 identifier: '',
95 provider_type: 'oidc' as const,
96 issuer: '',
97 authorization_url: '',
98 token_url: '',
99 userinfo_url: '',
100 jwks_uri: '',
101 discovery_url: '',
102 scopes: '',
103 client_id: '',
104 client_secret: '',
105 email_optional: false,
106}
107
108/** Mock autodiscovery endpoint: simulates success or error (random for demo) */
109
110export const CreateOrUpdateCustomProviderSheet = ({
111 visible,
112 providerToEdit,
113 onClose,
114}: CreateOrUpdateCustomProviderSheetProps) => {
115 const isEditMode = !!providerToEdit
116 const { ref: projectRef } = useParams()
117 const { hostEndpoint: endpointData } = useProjectApiUrl({ projectRef })
118 const form = useForm<z.infer<typeof FormSchema>>({
119 resolver: zodResolver(FormSchema as any),
120 defaultValues: initialValues,
121 })
122
123 useEffect(() => {
124 if (visible) {
125 if (providerToEdit) {
126 if (providerToEdit.provider_type === 'oidc') {
127 form.reset({
128 name: providerToEdit.name,
129 identifier: providerToEdit.identifier.replace('custom:', ''),
130 provider_type: providerToEdit.provider_type,
131 client_id: providerToEdit.client_id,
132 client_secret: 'placeholder',
133 email_optional: providerToEdit.email_optional,
134 issuer: providerToEdit.issuer,
135 discovery_url: providerToEdit.discovery_url,
136 scopes: (providerToEdit.scopes || []).join(', '),
137 })
138 } else {
139 form.reset({
140 name: providerToEdit.name,
141 identifier: providerToEdit.identifier.replace('custom:', ''),
142 provider_type: providerToEdit.provider_type,
143 client_id: providerToEdit.client_id,
144 client_secret: 'placeholder',
145 email_optional: providerToEdit.email_optional,
146 issuer: providerToEdit.issuer,
147 authorization_url: providerToEdit.authorization_url,
148 token_url: providerToEdit.token_url,
149 userinfo_url: providerToEdit.userinfo_url,
150 jwks_uri: providerToEdit.jwks_uri,
151 scopes: (providerToEdit.scopes || []).join(', '),
152 })
153 }
154 } else {
155 form.reset(initialValues)
156 }
157 }
158 }, [visible, providerToEdit, form])
159
160 const { mutate: createCustomProvider, isPending: isCreating } =
161 useOAuthCustomProviderCreateMutation({
162 onSuccess: () => {
163 toast.success('Custom provider created successfully')
164 onClose()
165 },
166 })
167 const { mutate: updateCustomProvider, isPending: isUpdating } =
168 useOAuthCustomProviderUpdateMutation({
169 onSuccess: () => {
170 toast.success('Custom provider updated successfully')
171 onClose()
172 },
173 })
174
175 const onSubmit = async (values: z.infer<typeof FormSchema>) => {
176 const identifierValue = (values.identifier || '').replace(/^custom:/i, '').trim()
177 const identifier = identifierValue ? `custom:${identifierValue}` : ''
178
179 let payload: Partial<OAuthCustomProviderUpdateVariables> = {}
180 if (values.provider_type === 'oidc') {
181 payload = {
182 skip_nonce_check: false,
183 discovery_url:
184 values.discovery_url ||
185 `${values.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
186 }
187 } else {
188 const issuer = values.issuer
189 payload = {
190 authorization_url:
191 values.authorization_url || `${issuer.replace(/\/$/, '')}/oauth/authorize`,
192 token_url: values.token_url || `${issuer.replace(/\/$/, '')}/oauth/token`,
193 userinfo_url: values.userinfo_url || `${issuer.replace(/\/$/, '')}/oauth/userinfo`,
194 jwks_uri: values.jwks_uri || `${issuer.replace(/\/$/, '')}/.well-known/jwks.json`,
195 }
196 }
197
198 if (isEditMode) {
199 // only include the client secret if it was changed, otherwise keep existing secret
200 if (values.client_secret !== 'placeholder') {
201 payload.client_secret = values.client_secret
202 }
203 updateCustomProvider({
204 identifier,
205 projectRef,
206 clientEndpoint: endpointData,
207 name: values.name,
208 client_id: values.client_id,
209 scopes: values.scopes.split(',').map((s) => s.trim()),
210 issuer: values.issuer,
211 pkce_enabled: true,
212 email_optional: values.email_optional,
213 ...payload,
214 })
215 } else {
216 createCustomProvider({
217 identifier,
218 projectRef,
219 clientEndpoint: endpointData,
220 provider_type: values.provider_type,
221 name: values.name,
222 client_id: values.client_id,
223 client_secret: values.client_secret,
224 scopes: values.scopes.split(',').map((s) => s.trim()),
225 issuer: values.issuer,
226 pkce_enabled: true,
227 enabled: true,
228 email_optional: values.email_optional,
229 ...payload,
230 })
231 }
232 }
233
234 const isManualConfiguration =
235 useWatch({ control: form.control, name: 'provider_type' }) === 'oauth2'
236
237 const {
238 confirmOnClose,
239 handleOpenChange,
240 modalProps: closeConfirmationModalProps,
241 } = useConfirmOnClose({
242 checkIsDirty: () => form.formState.isDirty,
243 onClose: () => {
244 form.reset(initialValues)
245 onClose()
246 },
247 })
248
249 const issuerUrlValue = useWatch({ control: form.control, name: 'issuer' })
250
251 return (
252 <Sheet open={visible} onOpenChange={handleOpenChange}>
253 <SheetContent
254 size="lg"
255 showClose={false}
256 className="flex flex-col gap-0"
257 tabIndex={undefined}
258 >
259 <SheetHeader>
260 <div className="flex flex-row gap-3 items-center">
261 <SheetClose
262 className={cn(
263 'text-muted hover:text ring-offset-background transition-opacity hover:opacity-100',
264 'focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
265 'disabled:pointer-events-none data-[state=open]:bg-secondary',
266 'transition'
267 )}
268 >
269 <X className="h-3 w-3" />
270 <span className="sr-only">Close</span>
271 </SheetClose>
272 <SheetTitle className="truncate">
273 {isEditMode ? 'Update Custom Auth Provider' : 'Create Custom Auth Provider'}
274 </SheetTitle>
275 </div>
276 </SheetHeader>
277 <Form {...form}>
278 <form className="grow overflow-auto" onSubmit={form.handleSubmit(onSubmit)} id={FORM_ID}>
279 <SheetSection className="grow px-5 space-y-4">
280 <FormField
281 control={form.control}
282 name="identifier"
283 render={({ field }) => (
284 <FormItemLayout
285 layout="horizontal"
286 label="Provider Identifier"
287 description="Lowercase letters, numbers, and hyphens only. Used in SDK: signInWithOAuth({ provider: 'custom:my-company' })"
288 >
289 <FormControl>
290 <InputGroup>
291 <InputGroupAddon align="inline-start">
292 <InputGroupText>custom:</InputGroupText>
293 </InputGroupAddon>
294 <FormInputGroupInput
295 {...field}
296 placeholder="my-company"
297 disabled={isEditMode}
298 onChange={(e) => {
299 const raw = e.target.value
300 const userValue = raw.replace(/^custom:/i, '').trimStart()
301 field.onChange(userValue)
302 }}
303 />
304 </InputGroup>
305 </FormControl>
306 </FormItemLayout>
307 )}
308 />
309
310 <FormField
311 control={form.control}
312 name="name"
313 render={({ field }) => (
314 <FormItemLayout layout="horizontal" label="Display Name">
315 <FormControl>
316 <Input {...field} placeholder="Provider name" />
317 </FormControl>
318 </FormItemLayout>
319 )}
320 />
321
322 <FormField
323 control={form.control}
324 name="provider_type"
325 render={({ field }) => (
326 <FormItemLayout layout="horizontal" label="Configuration Method">
327 <RadioGroupStacked value={field.value} onValueChange={field.onChange}>
328 <RadioGroupStackedItem
329 className="[&>div]:px-3"
330 value="oidc"
331 label="Auto-discovery (Recommended)"
332 description="Automatically fetch OAuth endpoints"
333 />
334 <RadioGroupStackedItem
335 className="[&>div]:px-3"
336 value="oauth2"
337 label="Manual configuration"
338 description="Enter endpoints myself"
339 />
340 </RadioGroupStacked>
341 </FormItemLayout>
342 )}
343 />
344 </SheetSection>
345 <Separator />
346 <SheetSection className="grow px-5 space-y-4">
347 <FormSectionLabel>OAuth Endpoints</FormSectionLabel>
348 <FormField
349 control={form.control}
350 name="issuer"
351 render={({ field }) => (
352 <FormItemLayout
353 layout="horizontal"
354 label="Issuer URL"
355 description="Base URL of your OAuth provider. Discovery runs when you save."
356 >
357 <FormControl>
358 <Input {...field} placeholder="https://auth.company.com" />
359 </FormControl>
360 </FormItemLayout>
361 )}
362 />
363 </SheetSection>
364 {isManualConfiguration ? (
365 <SheetSection className="grow px-5 pt-0 space-y-4" key="manual-config">
366 <FormField
367 control={form.control}
368 name="authorization_url"
369 render={({ field }) => (
370 <FormItemLayout layout="horizontal" label="Authorization URL">
371 <FormControl>
372 <Input {...field} placeholder="https://auth.company.com/oauth/authorize" />
373 </FormControl>
374 </FormItemLayout>
375 )}
376 />
377 <FormField
378 control={form.control}
379 name="token_url"
380 render={({ field }) => (
381 <FormItemLayout layout="horizontal" label="Token URL">
382 <FormControl>
383 <Input {...field} placeholder="https://auth.company.com/oauth/token" />
384 </FormControl>
385 </FormItemLayout>
386 )}
387 />
388 <FormField
389 control={form.control}
390 name="userinfo_url"
391 render={({ field }) => (
392 <FormItemLayout layout="horizontal" label="Userinfo URL">
393 <FormControl>
394 <Input {...field} placeholder="https://auth.company.com/oauth/userinfo" />
395 </FormControl>
396 </FormItemLayout>
397 )}
398 />
399 <FormField
400 control={form.control}
401 name="jwks_uri"
402 render={({ field }) => (
403 <FormItemLayout
404 layout="horizontal"
405 label="JWKS URI"
406 description="Required for ID token verification"
407 >
408 <FormControl>
409 <Input
410 {...field}
411 placeholder="https://auth.company.com/.well-known/jwks.json"
412 />
413 </FormControl>
414 </FormItemLayout>
415 )}
416 />
417 </SheetSection>
418 ) : (
419 <SheetSection className="grow px-5 pt-0 space-y-4" key="discovery-config">
420 <FormField
421 control={form.control}
422 name="discovery_url"
423 render={({ field }) => (
424 <FormItemLayout
425 layout="horizontal"
426 label="Discovery URL"
427 description="Leave empty to use standard path: {issuer}/.well-known/openid-configuration. Only needed if your provider uses a non-standard discovery path. Discovery runs when you save."
428 >
429 <FormControl>
430 <Input
431 {...field}
432 placeholder={
433 issuerUrlValue
434 ? `${issuerUrlValue}/.well-known/openid-configuration`
435 : 'https://github.company.com/.well-known/openid-configuration'
436 }
437 />
438 </FormControl>
439 </FormItemLayout>
440 )}
441 />
442 </SheetSection>
443 )}
444 <Separator />
445 <SheetSection className="grow px-5 space-y-4">
446 <FormField
447 control={form.control}
448 name="client_id"
449 render={({ field }) => (
450 <FormItemLayout layout="horizontal" label="Client ID">
451 <FormControl>
452 <Input {...field} placeholder="Client ID" />
453 </FormControl>
454 </FormItemLayout>
455 )}
456 />
457 <FormField
458 control={form.control}
459 name="client_secret"
460 render={({ field }) => (
461 <FormItemLayout layout="horizontal" label="Client Secret">
462 <FormControl>
463 <Input {...field} type="password" placeholder="Client secret" />
464 </FormControl>
465 </FormItemLayout>
466 )}
467 />
468 </SheetSection>
469 <Separator />
470 <SheetSection className="grow px-5 space-y-4">
471 <FormField
472 control={form.control}
473 name="scopes"
474 render={({ field }) => (
475 <FormItemLayout
476 layout="horizontal"
477 label="Scopes"
478 description="Comma-separated list. Common: openid, email, profile"
479 >
480 <FormControl>
481 <Input {...field} placeholder="openid, email, profile" />
482 </FormControl>
483 </FormItemLayout>
484 )}
485 />
486 <FormField
487 control={form.control}
488 name="email_optional"
489 render={({ field }) => (
490 <FormItemLayout
491 layout="horizontal"
492 label="Allow users without email"
493 description="Allows the user to successfully authenticate when the provider does not return an email address."
494 >
495 <FormControl>
496 <Switch checked={field.value} onCheckedChange={field.onChange} />
497 </FormControl>
498 </FormItemLayout>
499 )}
500 />
501 </SheetSection>
502 <Separator />
503 <SheetSection className="grow px-5 space-y-4">
504 <FormItemLayout
505 layout="horizontal"
506 label="Callback URL"
507 description="Configure this in your OAuth provider's settings."
508 >
509 <PasswordInput
510 copy
511 readOnly
512 disabled
513 value={`${endpointData}/auth/v1/callback`}
514 placeholder={`${endpointData}/auth/v1/callback`}
515 />
516 </FormItemLayout>
517 </SheetSection>
518 </form>
519 </Form>
520 <SheetFooter>
521 <Button type="default" onClick={confirmOnClose}>
522 Cancel
523 </Button>
524 <Button htmlType="submit" form={FORM_ID} loading={isCreating || isUpdating}>
525 {isEditMode ? 'Update provider' : 'Create and enable provider'}
526 </Button>
527 </SheetFooter>
528 </SheetContent>
529 <DiscardChangesConfirmationDialog {...closeConfirmationModalProps} />
530 </Sheet>
531 )
532}