mirror of
https://github.com/supabase/supabase.git
synced 2026-09-08 19:08:44 +08:00
## What kind of change does this PR introduce? Bug fix. Resolves DEPR-539. ## What is the current behavior? When a focused child unmounts, Radix can move focus to the Sheet wrapper and break the expected tab order. Several callsites suppress the wrapper's tabindex individually. ## What is the new behavior? Sheet still focuses its first interactive child when opened, but the wrapper itself is no longer focusable by default. Callers can opt in with an explicit `tabIndex` when needed. ## Additional context ### Testing Compare this Studio experience on both this branch and `master`: 1. Open any project with an Edge Function. 2. Go to **Edge Functions**, open the function, then click **Test**. 3. Under **Headers**, click **Add Headers**. Click the first header key input, then Tab slowly through the header inputs and remove buttons. On `master`, focus can jump to the whole Sheet. On this branch, focus stays on the controls in order. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved keyboard focus behavior across sheets and panels. * Sheets now focus the first available interactive element when opened, without adding unnecessary focus targets. * Preserved support for programmatic focus and prevented focus from unexpectedly moving to the sheet when focused content is removed. * Updated authentication, integrations, connection, logging, storage, and other sheet interfaces consistently. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
526 lines
19 KiB
TypeScript
526 lines
19 KiB
TypeScript
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),
|
|
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">
|
|
<SheetHeader>
|
|
<div className="flex flex-row gap-3 items-center">
|
|
<SheetClose
|
|
className={cn(
|
|
'text-muted hover:text hover:opacity-100 focus-ring',
|
|
'disabled:pointer-events-none data-[state=open]:bg-secondary'
|
|
)}
|
|
>
|
|
<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 variant="default" onClick={confirmOnClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" form={FORM_ID} loading={isCreating || isUpdating}>
|
|
{isEditMode ? 'Update provider' : 'Create and enable provider'}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
<DiscardChangesConfirmationDialog {...closeConfirmationModalProps} />
|
|
</Sheet>
|
|
)
|
|
}
|