import { zodResolver } from '@hookform/resolvers/zod' import type { CustomOAuthProvider } from '@supabase/auth-js' import { useParams } from 'common' import { X } from 'lucide-react' import { useEffect } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, cn, Form, FormControl, FormField, FormInputGroupInput, Input, InputGroup, InputGroupAddon, InputGroupText, RadioGroupStacked, RadioGroupStackedItem, Separator, Sheet, SheetClose, SheetContent, SheetFooter, SheetHeader, SheetSection, SheetTitle, Switch, useWatch, } from 'ui' import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { FormSectionLabel } from '@/components/ui/Forms/FormSection' import { useProjectApiUrl } from '@/data/config/project-endpoint-query' import { useOAuthCustomProviderCreateMutation } from '@/data/oauth-custom-providers/oauth-custom-provider-create-mutation' import { useOAuthCustomProviderUpdateMutation, type OAuthCustomProviderUpdateVariables, } from '@/data/oauth-custom-providers/oauth-custom-provider-update-mutation' import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' interface CreateOrUpdateCustomProviderSheetProps { visible: boolean providerToEdit?: CustomOAuthProvider onClose: () => void } const SharedFormSchema = z.object({ identifier: z .string() .min(1, 'Please provide an identifier') .regex( /^[a-zA-Z0-9_-]+$/, 'Identifier can only contain letters, numbers, hyphens, and underscores' ), name: z .string() .min(1, 'Please provide a name for your custom provider') .max(100, 'Name must be less than 100 characters'), provider_type: z.enum(['oidc', 'oauth2']).default('oidc'), client_id: z.string().min(1, 'Please provide a client ID').trim(), client_secret: z.string().min(1, 'Please provide a client secret').trim(), email_optional: z.boolean().default(false), issuer: z.string().url('Please provide a valid URL').trim(), // comma-separated scopes in the form, will be transformed to array when sending scopes: z.string().default(''), }) const OidcSchema = SharedFormSchema.extend({ provider_type: z.literal('oidc'), discovery_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''), }) const OAuth2Schema = SharedFormSchema.extend({ provider_type: z.literal('oauth2'), authorization_url: z .union([z.string().url('Please provide a valid URL'), z.literal('')]) .default(''), token_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''), userinfo_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''), jwks_uri: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''), }) const FormSchema = z.discriminatedUnion('provider_type', [OidcSchema, OAuth2Schema]) const FORM_ID = 'create-or-update-custom-provider-form' const initialValues = { name: '', identifier: '', provider_type: 'oidc' as const, issuer: '', authorization_url: '', token_url: '', userinfo_url: '', jwks_uri: '', discovery_url: '', scopes: '', client_id: '', client_secret: '', email_optional: false, } /** Mock autodiscovery endpoint: simulates success or error (random for demo) */ export const CreateOrUpdateCustomProviderSheet = ({ visible, providerToEdit, onClose, }: CreateOrUpdateCustomProviderSheetProps) => { const isEditMode = !!providerToEdit const { ref: projectRef } = useParams() const { hostEndpoint: endpointData } = useProjectApiUrl({ projectRef }) const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: initialValues, }) useEffect(() => { if (visible) { if (providerToEdit) { if (providerToEdit.provider_type === 'oidc') { form.reset({ name: providerToEdit.name, identifier: providerToEdit.identifier.replace('custom:', ''), provider_type: providerToEdit.provider_type, client_id: providerToEdit.client_id, client_secret: 'placeholder', email_optional: providerToEdit.email_optional, issuer: providerToEdit.issuer, discovery_url: providerToEdit.discovery_url, scopes: (providerToEdit.scopes || []).join(', '), }) } else { form.reset({ name: providerToEdit.name, identifier: providerToEdit.identifier.replace('custom:', ''), provider_type: providerToEdit.provider_type, client_id: providerToEdit.client_id, client_secret: 'placeholder', email_optional: providerToEdit.email_optional, issuer: providerToEdit.issuer, authorization_url: providerToEdit.authorization_url, token_url: providerToEdit.token_url, userinfo_url: providerToEdit.userinfo_url, jwks_uri: providerToEdit.jwks_uri, scopes: (providerToEdit.scopes || []).join(', '), }) } } else { form.reset(initialValues) } } }, [visible, providerToEdit, form]) const { mutate: createCustomProvider, isPending: isCreating } = useOAuthCustomProviderCreateMutation({ onSuccess: () => { toast.success('Custom provider created successfully') onClose() }, }) const { mutate: updateCustomProvider, isPending: isUpdating } = useOAuthCustomProviderUpdateMutation({ onSuccess: () => { toast.success('Custom provider updated successfully') onClose() }, }) const onSubmit = async (values: z.infer) => { const identifierValue = (values.identifier || '').replace(/^custom:/i, '').trim() const identifier = identifierValue ? `custom:${identifierValue}` : '' let payload: Partial = {} if (values.provider_type === 'oidc') { payload = { skip_nonce_check: false, discovery_url: values.discovery_url || `${values.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, } } else { const issuer = values.issuer payload = { authorization_url: values.authorization_url || `${issuer.replace(/\/$/, '')}/oauth/authorize`, token_url: values.token_url || `${issuer.replace(/\/$/, '')}/oauth/token`, userinfo_url: values.userinfo_url || `${issuer.replace(/\/$/, '')}/oauth/userinfo`, jwks_uri: values.jwks_uri || `${issuer.replace(/\/$/, '')}/.well-known/jwks.json`, } } if (isEditMode) { // only include the client secret if it was changed, otherwise keep existing secret if (values.client_secret !== 'placeholder') { payload.client_secret = values.client_secret } updateCustomProvider({ identifier, projectRef, clientEndpoint: endpointData, name: values.name, client_id: values.client_id, scopes: values.scopes.split(',').map((s) => s.trim()), issuer: values.issuer, pkce_enabled: true, email_optional: values.email_optional, ...payload, }) } else { createCustomProvider({ identifier, projectRef, clientEndpoint: endpointData, provider_type: values.provider_type, name: values.name, client_id: values.client_id, client_secret: values.client_secret, scopes: values.scopes.split(',').map((s) => s.trim()), issuer: values.issuer, pkce_enabled: true, enabled: true, email_optional: values.email_optional, ...payload, }) } } const isManualConfiguration = useWatch({ control: form.control, name: 'provider_type' }) === 'oauth2' const { confirmOnClose, handleOpenChange, modalProps: closeConfirmationModalProps, } = useConfirmOnClose({ checkIsDirty: () => form.formState.isDirty, onClose: () => { form.reset(initialValues) onClose() }, }) const issuerUrlValue = useWatch({ control: form.control, name: 'issuer' }) return (
Close {isEditMode ? 'Update Custom Auth Provider' : 'Create Custom Auth Provider'}
( custom: { const raw = e.target.value const userValue = raw.replace(/^custom:/i, '').trimStart() field.onChange(userValue) }} /> )} /> ( )} /> ( )} /> OAuth Endpoints ( )} /> {isManualConfiguration ? ( ( )} /> ( )} /> ( )} /> ( )} /> ) : ( ( )} /> )} ( )} /> ( )} /> ( )} /> ( )} />
) }