import { zodResolver } from '@hookform/resolvers/zod' import type { CreateOAuthClientParams, OAuthClient, UpdateOAuthClientParams, } from '@supabase/supabase-js' import { useParams } from 'common' 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' import { Plus, Trash2, Upload, X } from 'lucide-react' import { useEffect, useRef, useState, type ChangeEvent } from 'react' import { useFieldArray, useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, cn, Form_Shadcn_, FormControl_Shadcn_, FormDescription_Shadcn_, FormField_Shadcn_, FormItem_Shadcn_, FormLabel_Shadcn_, FormMessage_Shadcn_, Input_Shadcn_, Separator, Sheet, SheetClose, SheetContent, SheetFooter, SheetHeader, SheetSection, SheetTitle, Switch, } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' 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'), 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, client_id: '', client_secret: '', logo_uri: '', } export const CreateOrUpdateOAuthAppSheet = ({ visible, appToEdit, onSuccess, onCancel, }: CreateOrUpdateOAuthAppSheetProps) => { const { ref: projectRef } = useParams() const uploadButtonRef = useRef(null) const [showRegenerateDialog, setShowRegenerateDialog] = useState(false) const [logoFile, setLogoFile] = useState() const [logoUrl, setLogoUrl] = useState() const [logoRemoved, setLogoRemoved] = useState(false) const isEditMode = !!appToEdit const hasLogo = logoUrl !== undefined const isPublicClient = appToEdit?.client_type === 'public' const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: initialValues, }) const { fields: redirectUriFields, append: appendRedirectUri, remove: removeRedirectUri, } = useFieldArray({ name: 'redirect_uris', control: form.control, }) 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) { setLogoFile(undefined) setLogoRemoved(false) 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, 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 onFileUpload = async (event: ChangeEvent) => { event.persist() const files = event.target.files if (files && files.length > 0) { const file = files[0] setLogoFile(file) setLogoUrl(URL.createObjectURL(file)) setLogoRemoved(false) event.target.value = '' } } const onSubmit = async (data: z.infer) => { const validRedirectUris = data.redirect_uris .map((uri) => uri.value.trim()) .filter((uri) => uri !== '') let uploadedLogoUri: string | undefined = undefined if (logoRemoved) { uploadedLogoUri = '' } else if (logoFile) { const reader = new FileReader() uploadedLogoUri = await new Promise((resolve) => { reader.onloadend = () => resolve(reader.result as string) reader.readAsDataURL(logoFile) }) } else if (logoUrl) { uploadedLogoUri = logoUrl } if (isEditMode && appToEdit) { const payload: UpdateOAuthClientParams = { client_name: data.name, redirect_uris: validRedirectUris, logo_uri: uploadedLogoUri, } updateOAuthApp({ projectRef, clientEndpoint, clientId: appToEdit.client_id, ...payload, }) } else { const payload: CreateOAuthClientParams & { logo_uri?: string; client_type?: string } = { client_name: data.name, client_uri: '', client_type: data.client_type, redirect_uris: validRedirectUris, logo_uri: uploadedLogoUri || undefined, } createOAuthApp({ projectRef, clientEndpoint, ...payload, }) } } const onClose = () => { form.reset(initialValues) onCancel() } const handleRegenerateSecret = () => { setShowRegenerateDialog(true) } const handleConfirmRegenerate = () => { regenerateSecret({ projectRef, clientEndpoint, clientId: appToEdit?.client_id, }) } const handleUploadLogo = () => uploadButtonRef.current?.click() const handleRemoveLogo = () => { setLogoFile(undefined) setLogoUrl(undefined) setLogoRemoved(true) } return ( <> onCancel()}>
Close {isEditMode ? 'Update OAuth app' : 'Create a new OAuth app'}
( )} /> (
{hasLogo && (
)} />
{isEditMode && appToEdit && ( <>
( {}} onCopy={() => toast.success('Client ID copied to clipboard')} /> )} /> {!isPublicClient && ( <> ( {}} /> )} /> )}
)}
Redirect URIs
{redirectUriFields.map((fieldItem, index) => ( (
{ inputField.onChange(e) }} /> {redirectUriFields.length > 1 && (
)} /> ))}
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'} > field.onChange(checked ? 'public' : 'confidential') } disabled={isEditMode} /> )} />
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.

) }