| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532 |
- 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<z.infer<typeof FormSchema>>({
- resolver: zodResolver(FormSchema as any),
- 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<typeof FormSchema>) => {
- const identifierValue = (values.identifier || '').replace(/^custom:/i, '').trim()
- const identifier = identifierValue ? `custom:${identifierValue}` : ''
- let payload: Partial<OAuthCustomProviderUpdateVariables> = {}
- 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 (
- <Sheet open={visible} onOpenChange={handleOpenChange}>
- <SheetContent
- size="lg"
- showClose={false}
- className="flex flex-col gap-0"
- tabIndex={undefined}
- >
- <SheetHeader>
- <div className="flex flex-row gap-3 items-center">
- <SheetClose
- className={cn(
- 'text-muted hover:text ring-offset-background transition-opacity hover:opacity-100',
- 'focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
- 'disabled:pointer-events-none data-[state=open]:bg-secondary',
- 'transition'
- )}
- >
- <X className="h-3 w-3" />
- <span className="sr-only">Close</span>
- </SheetClose>
- <SheetTitle className="truncate">
- {isEditMode ? 'Update Custom Auth Provider' : 'Create Custom Auth Provider'}
- </SheetTitle>
- </div>
- </SheetHeader>
- <Form {...form}>
- <form className="grow overflow-auto" onSubmit={form.handleSubmit(onSubmit)} id={FORM_ID}>
- <SheetSection className="grow px-5 space-y-4">
- <FormField
- control={form.control}
- name="identifier"
- render={({ field }) => (
- <FormItemLayout
- layout="horizontal"
- label="Provider Identifier"
- description="Lowercase letters, numbers, and hyphens only. Used in SDK: signInWithOAuth({ provider: 'custom:my-company' })"
- >
- <FormControl>
- <InputGroup>
- <InputGroupAddon align="inline-start">
- <InputGroupText>custom:</InputGroupText>
- </InputGroupAddon>
- <FormInputGroupInput
- {...field}
- placeholder="my-company"
- disabled={isEditMode}
- onChange={(e) => {
- const raw = e.target.value
- const userValue = raw.replace(/^custom:/i, '').trimStart()
- field.onChange(userValue)
- }}
- />
- </InputGroup>
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- control={form.control}
- name="name"
- render={({ field }) => (
- <FormItemLayout layout="horizontal" label="Display Name">
- <FormControl>
- <Input {...field} placeholder="Provider name" />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- control={form.control}
- name="provider_type"
- render={({ field }) => (
- <FormItemLayout layout="horizontal" label="Configuration Method">
- <RadioGroupStacked value={field.value} onValueChange={field.onChange}>
- <RadioGroupStackedItem
- className="[&>div]:px-3"
- value="oidc"
- label="Auto-discovery (Recommended)"
- description="Automatically fetch OAuth endpoints"
- />
- <RadioGroupStackedItem
- className="[&>div]:px-3"
- value="oauth2"
- label="Manual configuration"
- description="Enter endpoints myself"
- />
- </RadioGroupStacked>
- </FormItemLayout>
- )}
- />
- </SheetSection>
- <Separator />
- <SheetSection className="grow px-5 space-y-4">
- <FormSectionLabel>OAuth Endpoints</FormSectionLabel>
- <FormField
- control={form.control}
- name="issuer"
- render={({ field }) => (
- <FormItemLayout
- layout="horizontal"
- label="Issuer URL"
- description="Base URL of your OAuth provider. Discovery runs when you save."
- >
- <FormControl>
- <Input {...field} placeholder="https://auth.company.com" />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </SheetSection>
- {isManualConfiguration ? (
- <SheetSection className="grow px-5 pt-0 space-y-4" key="manual-config">
- <FormField
- control={form.control}
- name="authorization_url"
- render={({ field }) => (
- <FormItemLayout layout="horizontal" label="Authorization URL">
- <FormControl>
- <Input {...field} placeholder="https://auth.company.com/oauth/authorize" />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- control={form.control}
- name="token_url"
- render={({ field }) => (
- <FormItemLayout layout="horizontal" label="Token URL">
- <FormControl>
- <Input {...field} placeholder="https://auth.company.com/oauth/token" />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- control={form.control}
- name="userinfo_url"
- render={({ field }) => (
- <FormItemLayout layout="horizontal" label="Userinfo URL">
- <FormControl>
- <Input {...field} placeholder="https://auth.company.com/oauth/userinfo" />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- control={form.control}
- name="jwks_uri"
- render={({ field }) => (
- <FormItemLayout
- layout="horizontal"
- label="JWKS URI"
- description="Required for ID token verification"
- >
- <FormControl>
- <Input
- {...field}
- placeholder="https://auth.company.com/.well-known/jwks.json"
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </SheetSection>
- ) : (
- <SheetSection className="grow px-5 pt-0 space-y-4" key="discovery-config">
- <FormField
- control={form.control}
- name="discovery_url"
- render={({ field }) => (
- <FormItemLayout
- layout="horizontal"
- label="Discovery URL"
- 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."
- >
- <FormControl>
- <Input
- {...field}
- placeholder={
- issuerUrlValue
- ? `${issuerUrlValue}/.well-known/openid-configuration`
- : 'https://github.company.com/.well-known/openid-configuration'
- }
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </SheetSection>
- )}
- <Separator />
- <SheetSection className="grow px-5 space-y-4">
- <FormField
- control={form.control}
- name="client_id"
- render={({ field }) => (
- <FormItemLayout layout="horizontal" label="Client ID">
- <FormControl>
- <Input {...field} placeholder="Client ID" />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- control={form.control}
- name="client_secret"
- render={({ field }) => (
- <FormItemLayout layout="horizontal" label="Client Secret">
- <FormControl>
- <Input {...field} type="password" placeholder="Client secret" />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </SheetSection>
- <Separator />
- <SheetSection className="grow px-5 space-y-4">
- <FormField
- control={form.control}
- name="scopes"
- render={({ field }) => (
- <FormItemLayout
- layout="horizontal"
- label="Scopes"
- description="Comma-separated list. Common: openid, email, profile"
- >
- <FormControl>
- <Input {...field} placeholder="openid, email, profile" />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- control={form.control}
- name="email_optional"
- render={({ field }) => (
- <FormItemLayout
- layout="horizontal"
- label="Allow users without email"
- description="Allows the user to successfully authenticate when the provider does not return an email address."
- >
- <FormControl>
- <Switch checked={field.value} onCheckedChange={field.onChange} />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </SheetSection>
- <Separator />
- <SheetSection className="grow px-5 space-y-4">
- <FormItemLayout
- layout="horizontal"
- label="Callback URL"
- description="Configure this in your OAuth provider's settings."
- >
- <PasswordInput
- copy
- readOnly
- disabled
- value={`${endpointData}/auth/v1/callback`}
- placeholder={`${endpointData}/auth/v1/callback`}
- />
- </FormItemLayout>
- </SheetSection>
- </form>
- </Form>
- <SheetFooter>
- <Button type="default" onClick={confirmOnClose}>
- Cancel
- </Button>
- <Button htmlType="submit" form={FORM_ID} loading={isCreating || isUpdating}>
- {isEditMode ? 'Update provider' : 'Create and enable provider'}
- </Button>
- </SheetFooter>
- </SheetContent>
- <DiscardChangesConfirmationDialog {...closeConfirmationModalProps} />
- </Sheet>
- )
- }
|