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 } 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() const isEditMode = !!appToEdit const hasLogo = logoUrl !== undefined const isPublicClient = appToEdit?.client_type === 'public' const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: initialValues, }) 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) => { 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 ? ( ) : null} onCancel()}>
Close {isEditMode ? 'Update OAuth app' : 'Create a new OAuth app'}
( )} /> (
{!hasLogo && }
{ field.onChange(event) const next = event.target.value.trim() setLogoUrl(next.length > 0 ? next : undefined) }} /> {projectRef ? ( ) : null}
{field.value ? (
)} />
{isEditMode && appToEdit && ( <>
( {}} onCopy={() => toast.success('Client ID copied to clipboard')} /> )} /> {!isPublicClient && ( <> ( {}} /> )} /> )}
)}
Redirect URIs ({ value: '' })} placeholder="https://example.com/callback" addLabel="Add redirect URI" removeLabel="Remove redirect URI" minimumRows={1} rowsClassName="space-y-2" /> URLs where users will be redirected after authentication.
( 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.{' '} Learn more } className={'px-5'} > { const newType = checked ? 'public' : 'confidential' field.onChange(newType) form.setValue( 'token_endpoint_auth_method', newType === 'public' ? 'none' : 'client_secret_basic' ) }} disabled={isEditMode} /> )} /> {form.watch('client_type') === 'confidential' && ( ( )} /> )}
setShowRegenerateDialog(false)} onConfirm={handleConfirmRegenerate} >

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.

) }