mirror of
https://github.com/supabase/supabase.git
synced 2026-09-09 19:42:46 +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 -->
545 lines
21 KiB
TypeScript
545 lines
21 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import type {
|
|
CreateOAuthClientParams,
|
|
OAuthClient,
|
|
UpdateOAuthClientParams,
|
|
} from '@supabase/supabase-js'
|
|
import { useParams } from 'common'
|
|
import { Storage } from 'icons'
|
|
import { ImageOff, Trash2, X } from 'lucide-react'
|
|
import { useEffect, useState } from 'react'
|
|
import { useForm, useWatch } from 'react-hook-form'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Button,
|
|
cn,
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormLabel,
|
|
Input,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Separator,
|
|
Sheet,
|
|
SheetClose,
|
|
SheetContent,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetSection,
|
|
SheetTitle,
|
|
Switch,
|
|
} from 'ui'
|
|
import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
|
|
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray'
|
|
import * as z from 'zod'
|
|
|
|
import { LogoPicker } from './LogoPicker'
|
|
import { InlineLink } from '@/components/ui/InlineLink'
|
|
import Panel from '@/components/ui/Panel'
|
|
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
|
|
import { useOAuthServerAppCreateMutation } from '@/data/oauth-server-apps/oauth-server-app-create-mutation'
|
|
import { useOAuthServerAppRegenerateSecretMutation } from '@/data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation'
|
|
import { useOAuthServerAppUpdateMutation } from '@/data/oauth-server-apps/oauth-server-app-update-mutation'
|
|
import { DOCS_URL } from '@/lib/constants'
|
|
|
|
interface CreateOrUpdateOAuthAppSheetProps {
|
|
visible: boolean
|
|
appToEdit?: OAuthClient
|
|
onSuccess: (app: OAuthClient) => void
|
|
onCancel: () => void
|
|
}
|
|
|
|
const FormSchema = z.object({
|
|
name: z
|
|
.string()
|
|
.min(1, 'Please provide a name for your OAuth app')
|
|
.max(100, 'Name must be less than 100 characters'),
|
|
type: z.enum(['manual', 'dynamic']).default('manual'),
|
|
redirect_uris: z
|
|
.object({
|
|
value: z.string().trim().url('Please provide a valid URL'),
|
|
})
|
|
.array()
|
|
.min(1, 'At least one redirect URI is required'),
|
|
client_type: z.enum(['public', 'confidential']).default('confidential'),
|
|
token_endpoint_auth_method: z
|
|
.enum(['client_secret_basic', 'client_secret_post', 'none'])
|
|
.default('client_secret_basic'),
|
|
client_id: z.string().optional(),
|
|
client_secret: z.string().optional(),
|
|
logo_uri: z.string().optional(),
|
|
})
|
|
|
|
const FORM_ID = 'create-or-update-oauth-app-form'
|
|
|
|
const initialValues = {
|
|
name: '',
|
|
type: 'manual' as const,
|
|
redirect_uris: [{ value: '' }],
|
|
client_type: 'confidential' as const,
|
|
token_endpoint_auth_method: 'client_secret_basic' as const,
|
|
client_id: '',
|
|
client_secret: '',
|
|
logo_uri: '',
|
|
}
|
|
|
|
export const CreateOrUpdateOAuthAppSheet = ({
|
|
visible,
|
|
appToEdit,
|
|
onSuccess,
|
|
onCancel,
|
|
}: CreateOrUpdateOAuthAppSheetProps) => {
|
|
const { ref: projectRef } = useParams()
|
|
|
|
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false)
|
|
const [storagePickerOpen, setStoragePickerOpen] = useState(false)
|
|
const [logoUrl, setLogoUrl] = useState<string>()
|
|
|
|
const isEditMode = !!appToEdit
|
|
const hasLogo = logoUrl !== undefined
|
|
const isPublicClient = appToEdit?.client_type === 'public'
|
|
|
|
const form = useForm<z.infer<typeof FormSchema>>({
|
|
resolver: zodResolver(FormSchema),
|
|
defaultValues: initialValues,
|
|
})
|
|
|
|
const clientType = useWatch({ control: form.control, name: 'client_type' })
|
|
|
|
const { hostEndpoint: clientEndpoint } = useProjectApiUrl({ projectRef })
|
|
|
|
const { mutate: createOAuthApp, isPending: isCreating } = useOAuthServerAppCreateMutation({
|
|
onSuccess: (data) => {
|
|
toast.success(`Successfully created OAuth app "${data.client_name}"`)
|
|
onSuccess(data)
|
|
},
|
|
})
|
|
const { mutate: updateOAuthApp, isPending: isUpdating } = useOAuthServerAppUpdateMutation({
|
|
onSuccess: (data) => {
|
|
toast.success(`Successfully updated OAuth app "${data.client_name}"`)
|
|
onSuccess(data)
|
|
},
|
|
})
|
|
const { mutate: regenerateSecret, isPending: isRegenerating } =
|
|
useOAuthServerAppRegenerateSecretMutation({
|
|
onSuccess: (data) => {
|
|
if (data) {
|
|
toast.success(`Successfully regenerated client secret for "${appToEdit?.client_name}"`)
|
|
onSuccess(data)
|
|
setShowRegenerateDialog(false)
|
|
}
|
|
},
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (!visible) {
|
|
setStoragePickerOpen(false)
|
|
}
|
|
}, [visible])
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
if (appToEdit) {
|
|
form.reset({
|
|
name: appToEdit.client_name,
|
|
type: 'manual' as const,
|
|
redirect_uris:
|
|
appToEdit.redirect_uris && appToEdit.redirect_uris.length > 0
|
|
? appToEdit.redirect_uris.map((uri) => ({ value: uri }))
|
|
: [{ value: '' }],
|
|
client_type: appToEdit.client_type,
|
|
token_endpoint_auth_method:
|
|
(appToEdit.token_endpoint_auth_method as
|
|
| 'client_secret_basic'
|
|
| 'client_secret_post'
|
|
| 'none') || 'client_secret_basic',
|
|
client_id: appToEdit.client_id,
|
|
client_secret: '****************************************************************',
|
|
logo_uri: appToEdit.logo_uri || undefined,
|
|
})
|
|
setLogoUrl(appToEdit.logo_uri || undefined)
|
|
} else {
|
|
form.reset(initialValues)
|
|
setLogoUrl(undefined)
|
|
}
|
|
}
|
|
}, [visible, appToEdit, form])
|
|
|
|
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
|
|
const validRedirectUris = data.redirect_uris
|
|
.map((uri) => uri.value.trim())
|
|
.filter((uri) => uri !== '')
|
|
|
|
const uploadedLogoUri = data.logo_uri?.trim() ?? ''
|
|
|
|
if (isEditMode && appToEdit) {
|
|
const payload: UpdateOAuthClientParams & { token_endpoint_auth_method?: string } = {
|
|
client_name: data.name,
|
|
redirect_uris: validRedirectUris,
|
|
logo_uri: uploadedLogoUri,
|
|
token_endpoint_auth_method:
|
|
data.client_type === 'public' ? 'none' : data.token_endpoint_auth_method,
|
|
}
|
|
|
|
updateOAuthApp({
|
|
projectRef,
|
|
clientEndpoint,
|
|
clientId: appToEdit.client_id,
|
|
...payload,
|
|
})
|
|
} else {
|
|
const payload: CreateOAuthClientParams & {
|
|
logo_uri?: string
|
|
client_type?: string
|
|
token_endpoint_auth_method?: string
|
|
} = {
|
|
client_name: data.name,
|
|
client_uri: '',
|
|
client_type: data.client_type,
|
|
redirect_uris: validRedirectUris,
|
|
logo_uri: uploadedLogoUri || undefined,
|
|
token_endpoint_auth_method:
|
|
data.client_type === 'public' ? 'none' : data.token_endpoint_auth_method,
|
|
}
|
|
|
|
createOAuthApp({
|
|
projectRef,
|
|
clientEndpoint,
|
|
...payload,
|
|
})
|
|
}
|
|
}
|
|
|
|
const onClose = () => {
|
|
form.reset(initialValues)
|
|
onCancel()
|
|
}
|
|
|
|
const handleRegenerateSecret = () => {
|
|
setShowRegenerateDialog(true)
|
|
}
|
|
|
|
const handleConfirmRegenerate = () => {
|
|
regenerateSecret({
|
|
projectRef,
|
|
clientEndpoint,
|
|
clientId: appToEdit?.client_id,
|
|
})
|
|
}
|
|
|
|
const handlePickLogoFromStorage = (uri: string) => {
|
|
setLogoUrl(uri)
|
|
form.setValue('logo_uri', uri)
|
|
}
|
|
|
|
const handleRemoveLogo = () => {
|
|
setLogoUrl(undefined)
|
|
form.setValue('logo_uri', '')
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{projectRef ? (
|
|
<LogoPicker
|
|
open={storagePickerOpen}
|
|
onOpenChange={setStoragePickerOpen}
|
|
onSelect={handlePickLogoFromStorage}
|
|
/>
|
|
) : null}
|
|
<Sheet open={visible} onOpenChange={() => onCancel()}>
|
|
<SheetContent
|
|
size="lg"
|
|
showClose={false}
|
|
className="flex flex-col gap-0"
|
|
aria-describedby={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 OAuth app' : 'Create a new OAuth app'}
|
|
</SheetTitle>
|
|
</div>
|
|
</SheetHeader>
|
|
<SheetSection className="overflow-auto grow px-0">
|
|
<Form {...form}>
|
|
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)} id={FORM_ID}>
|
|
<div className="px-5 flex items-start justify-between gap-4">
|
|
<div className="grow space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItemLayout label="Name">
|
|
<FormControl>
|
|
<Input {...field} placeholder="My OAuth App" />
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="logo_uri"
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
label="Logo"
|
|
description={`Paste an absolute image URL/path or select one from a public File Storage bucket.`}
|
|
>
|
|
<FormControl>
|
|
<div className="flex w-full flex-col gap-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-center h-10 w-10 shrink-0 text-foreground-lighter overflow-hidden rounded-full bg-cover border'
|
|
)}
|
|
title={logoUrl ? undefined : 'No image selected'}
|
|
style={{
|
|
backgroundImage: logoUrl ? `url("${logoUrl}")` : 'none',
|
|
}}
|
|
>
|
|
{!hasLogo && <ImageOff size={14} />}
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
<div className="group relative min-w-0 flex-1">
|
|
<Input
|
|
{...field}
|
|
value={field.value ?? ''}
|
|
className={cn('flex-1', projectRef ? 'pr-10' : '')}
|
|
placeholder="https://example.com/logo.png"
|
|
onChange={(event) => {
|
|
field.onChange(event)
|
|
const next = event.target.value.trim()
|
|
setLogoUrl(next.length > 0 ? next : undefined)
|
|
}}
|
|
/>
|
|
{projectRef ? (
|
|
<Button
|
|
variant="default"
|
|
size="tiny"
|
|
icon={<Storage strokeWidth={1.5} />}
|
|
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 justify-center overflow-hidden px-1 transition-all duration-150 group-hover:w-36 group-focus-within:w-36 [&_span]:hidden group-hover:[&_span]:block group-focus-within:[&_span]:block"
|
|
onClick={() => setStoragePickerOpen(true)}
|
|
>
|
|
<span className="hidden whitespace-nowrap opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100">
|
|
Select from Storage
|
|
</span>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
{field.value ? (
|
|
<Button
|
|
variant="default"
|
|
size="tiny"
|
|
icon={<Trash2 size={12} />}
|
|
onClick={handleRemoveLogo}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{isEditMode && appToEdit && (
|
|
<>
|
|
<Separator />
|
|
<div className="px-5">
|
|
<Panel>
|
|
<Panel.Content className="space-y-2">
|
|
<FormField
|
|
control={form.control}
|
|
name="client_id"
|
|
render={() => (
|
|
<FormItemLayout label="Client ID">
|
|
<FormControl>
|
|
<PasswordInput
|
|
copy
|
|
readOnly
|
|
className="input-mono"
|
|
value={appToEdit.client_id}
|
|
onChange={() => {}}
|
|
onCopy={() => toast.success('Client ID copied to clipboard')}
|
|
/>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
|
|
{!isPublicClient && (
|
|
<>
|
|
<FormField
|
|
control={form.control}
|
|
name="client_secret"
|
|
render={() => (
|
|
<FormItemLayout
|
|
label="Client Secret"
|
|
description="Client secret is hidden for security. Use the regenerate button to create a new one."
|
|
>
|
|
<FormControl>
|
|
<Input
|
|
readOnly
|
|
type="password"
|
|
className="input-mono"
|
|
value="****************************************************************"
|
|
onChange={() => {}}
|
|
/>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
|
|
<Button
|
|
variant="default"
|
|
onClick={handleRegenerateSecret}
|
|
className="w-min"
|
|
disabled={isRegenerating}
|
|
>
|
|
Regenerate client secret
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Panel.Content>
|
|
</Panel>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="px-5 gap-2 flex flex-col">
|
|
<FormLabel className="text-foreground">Redirect URIs</FormLabel>
|
|
<SingleValueFieldArray
|
|
control={form.control}
|
|
name="redirect_uris"
|
|
valueFieldName="value"
|
|
createEmptyRow={() => ({ value: '' })}
|
|
placeholder="https://example.com/callback"
|
|
addLabel="Add redirect URI"
|
|
removeLabel="Remove redirect URI"
|
|
minimumRows={1}
|
|
rowsClassName="space-y-2"
|
|
/>
|
|
<FormDescription className="text-foreground-lighter">
|
|
URLs where users will be redirected after authentication.
|
|
</FormDescription>
|
|
</div>
|
|
|
|
<Separator />
|
|
<FormField
|
|
control={form.control}
|
|
name="client_type"
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
label="Public Client"
|
|
layout="flex"
|
|
description={
|
|
<>
|
|
If enabled, the Authorization Code with PKCE (Proof Key for Code Exchange)
|
|
flow can be used, particularly beneficial for applications that cannot
|
|
securely store Client Secrets, such as native and mobile apps. This cannot
|
|
be changed after creation.{' '}
|
|
<InlineLink
|
|
href={`${DOCS_URL}/guides/auth/oauth-server/getting-started#register-an-oauth-client`}
|
|
>
|
|
Learn more
|
|
</InlineLink>
|
|
</>
|
|
}
|
|
className={'px-5'}
|
|
>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value === 'public'}
|
|
onCheckedChange={(checked) => {
|
|
const newType = checked ? 'public' : 'confidential'
|
|
field.onChange(newType)
|
|
form.setValue(
|
|
'token_endpoint_auth_method',
|
|
newType === 'public' ? 'none' : 'client_secret_basic'
|
|
)
|
|
}}
|
|
disabled={isEditMode}
|
|
/>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
|
|
{clientType === 'confidential' && (
|
|
<FormField
|
|
control={form.control}
|
|
name="token_endpoint_auth_method"
|
|
render={({ field }) => (
|
|
<FormItemLayout
|
|
label="Token Endpoint Auth Method"
|
|
description="How the client authenticates with the token endpoint. The client secret is included in either the Authorization header or the request body."
|
|
className="px-5"
|
|
>
|
|
<FormControl>
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger className="text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="client_secret_basic" className="text-sm">
|
|
HTTP Basic Auth header (client_secret_basic)
|
|
</SelectItem>
|
|
<SelectItem value="client_secret_post" className="text-sm">
|
|
Request body (client_secret_post)
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
)}
|
|
/>
|
|
)}
|
|
</form>
|
|
</Form>
|
|
</SheetSection>
|
|
<SheetFooter>
|
|
<Button variant="default" disabled={isCreating || isUpdating} onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" form={FORM_ID} loading={isCreating || isUpdating}>
|
|
{isEditMode ? 'Update app' : 'Create app'}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
<ConfirmationModal
|
|
variant="warning"
|
|
visible={showRegenerateDialog}
|
|
loading={isRegenerating}
|
|
title="Confirm regenerating client secret"
|
|
confirmLabel="Confirm"
|
|
onCancel={() => setShowRegenerateDialog(false)}
|
|
onConfirm={handleConfirmRegenerate}
|
|
>
|
|
<p className="text-sm text-foreground-light">
|
|
Are you sure you wish to regenerate the client secret for "{appToEdit?.client_name}"?
|
|
You'll need to update it in all applications that use it. This action cannot be undone.
|
|
</p>
|
|
</ConfirmationModal>
|
|
</>
|
|
)
|
|
}
|