CreateOrUpdateCustomProviderSheet.tsx532 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import type { CustomOAuthProvider } from '@supabase/auth-js' |
| 3 | import { useParams } from 'common' |
| 4 | import { X } from 'lucide-react' |
| 5 | import { useEffect } from 'react' |
| 6 | import { useForm } from 'react-hook-form' |
| 7 | import { toast } from 'sonner' |
| 8 | import { |
| 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' |
| 32 | import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' |
| 33 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 34 | import * as z from 'zod' |
| 35 | |
| 36 | import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' |
| 37 | import { FormSectionLabel } from '@/components/ui/Forms/FormSection' |
| 38 | import { useProjectApiUrl } from '@/data/config/project-endpoint-query' |
| 39 | import { useOAuthCustomProviderCreateMutation } from '@/data/oauth-custom-providers/oauth-custom-provider-create-mutation' |
| 40 | import { |
| 41 | useOAuthCustomProviderUpdateMutation, |
| 42 | type OAuthCustomProviderUpdateVariables, |
| 43 | } from '@/data/oauth-custom-providers/oauth-custom-provider-update-mutation' |
| 44 | import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' |
| 45 | |
| 46 | interface CreateOrUpdateCustomProviderSheetProps { |
| 47 | visible: boolean |
| 48 | providerToEdit?: CustomOAuthProvider |
| 49 | onClose: () => void |
| 50 | } |
| 51 | |
| 52 | const 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 | |
| 73 | const 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 | |
| 78 | const 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 | |
| 88 | const FormSchema = z.discriminatedUnion('provider_type', [OidcSchema, OAuth2Schema]) |
| 89 | |
| 90 | const FORM_ID = 'create-or-update-custom-provider-form' |
| 91 | |
| 92 | const 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 | |
| 110 | export 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 | } |