Files
supabase/apps/studio/components/interfaces/Auth/CustomAuthProviders/CreateOrUpdateCustomProviderSheet.tsx
T
Danny White 3bca21b3f8 chore(a11y): convert leftover focus recipes to focus-ring (#48219)
## What kind of change does this PR introduce?

Accessibility cleanup (DEPR-628).

## What is the current behavior?

Leftover call sites still use ad-hoc focus recipes
(`ring-foreground-muted`, `outline-brand`, Dialog/Sheet `focus:` rings,
etc.) instead of the shared utilities from #41575.

## What is the new behavior?

Converts those leftovers across `packages/ui`, Studio, www, docs, and
design-system to `focus-ring`, preferring `focus-visible`. Keeps
documented exceptions (`group-focus-visible`, InputGroup `:has()`).

## To test

Tab through controls (keyboard only). Expect a consistent offset ring on
`:focus-visible`, not a green/brand/custom stack, and no ring animation.

### www (marketing)

Preview:
https://zone-www-dot-com-git-danny-depr-628-focus-ring-fbccf9-supabase.vercel.app

- Global nav on `/`: Product, Developers, Solutions dropdowns; logo;
hamburger + mobile menu
- `/features`: view toggles and feature cards
- `/company`: card links
- `/changelog`: timeline / entry links
- `/partners/catalog`: grid/list toggle and partner cards
- `/pricing`: compute section expand control
- Product / Modules / Solutions sticky navs on product pages (e.g.
`/database`, `/storage`)
- `/state-of-startups`: TwoOptionToggle if present

### docs

Preview:
https://docs-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app

- Any guide page: top nav dropdowns and items
- Narrow viewport: hamburger, then mobile menu links + close
- Guide with PromptPanel / tabs: tab to prompt actions and tab list

### studio (dashboard)

Preview:
https://studio-staging-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app

- Project home: Connect section tiles; drag-handle focus on sortable
sections
- Integrations marketplace (`/project/<ref>/integrations`): featured
cards, list/grid toggle, list rows
- Auth (`/project/<ref>/auth/oauth-apps`,
`/project/<ref>/auth/providers`): open create/edit sheet, tab to close
(X)
- Database policies (`/project/<ref>/database/policies`): open policy
editor sheet, tab to close
- Storage policies (`/project/<ref>/storage/files/policies`): bucket
section links; policy modal close
- Query performance (`/project/<ref>/observability/query-performance`):
info icon buttons on metrics
- Replication pipeline detail (if available): slot lag / status info
icons
- Support (`/support/new`): attachment add/remove controls
- Table editor: spreadsheet import preview checkboxes; row text/JSON
editor TwoOptionToggle
- Any Dialog/Sheet/toast close (X): ring on keyboard focus only, not
mouse click

### design-system

Preview:
https://design-system-git-danny-depr-628-focus-ring-long-tail-supabase.vercel.app

- Colour palette swatches (keyboard focus)
- Form patterns sidepanel example: avatar / focusable control in the
example

## Additional context

- Linear: [DEPR-628](https://linear.app/supabase/issue/DEPR-628)
- Follow-ups: form-group CSS (DEPR-629), Storage columns selection
(DEPR-630), ESLint rule (DEPR-632)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Accessibility & Usability**
* Standardized keyboard focus indicators across navigation, dialogs,
forms, buttons, toggles, links, and tooltips using a consolidated focus
style.
* Improved toggle controls to use proper button semantics (instead of
clickable text), including `aria-pressed`/disabled handling and better
keyboard navigation.

* **Visual Updates**
* Harmonized hover/focus ring visuals across the design system, Studio,
documentation, and marketing pages while preserving existing layout and
interaction behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-23 08:52:22 +10:00

531 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"
tabIndex={undefined}
>
<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>
)
}