import { zodResolver } from '@hookform/resolvers/zod' import { X } from 'lucide-react' import randomBytes from 'randombytes' import { useEffect, useMemo } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import ReactMarkdown from 'react-markdown' import { toast } from 'sonner' import * as z from 'zod' import { useParams } from 'common' import { convertArgumentTypes } from 'components/interfaces/Database/Functions/Functions.utils' import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import { DocsButton } from 'components/ui/DocsButton' import FunctionSelector from 'components/ui/FunctionSelector' import SchemaSelector from 'components/ui/SchemaSelector' import { AuthConfigResponse } from 'data/auth/auth-config-query' import { useAuthHooksUpdateMutation } from 'data/auth/auth-hooks-update-mutation' import { executeSql } from 'data/sql/execute-sql-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' import { Button, FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Input_Shadcn_, RadioGroupStacked, RadioGroupStackedItem, Separator, Sheet, SheetClose, SheetContent, SheetFooter, SheetHeader, SheetSection, SheetTitle, Switch, cn, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { HOOKS_DEFINITIONS, HOOK_DEFINITION_TITLE, Hook } from './hooks.constants' import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils' interface CreateHookSheetProps { visible: boolean title: HOOK_DEFINITION_TITLE | null authConfig: AuthConfigResponse onClose: () => void onDelete: () => void } export function generateAuthHookSecret() { const secretByteLength = 60 const buffer = randomBytes(secretByteLength) const base64String = buffer.toString('base64') return `v1,whsec_${base64String}` } const FORM_ID = 'create-edit-auth-hook' const FormSchema = z .object({ hookType: z.string(), enabled: z.boolean(), selectedType: z.union([z.literal('https'), z.literal('postgres')]), httpsValues: z.object({ url: z.string(), secret: z.string(), }), postgresValues: z.object({ schema: z.string(), functionName: z.string(), }), }) .superRefine((data, ctx) => { if (data.selectedType === 'https') { if (!data.httpsValues.url.startsWith('https://')) { ctx.addIssue({ path: ['httpsValues', 'url'], code: z.ZodIssueCode.custom, message: 'The URL must start with https://', }) } if (!data.httpsValues.secret) { ctx.addIssue({ path: ['httpsValues', 'secret'], code: z.ZodIssueCode.custom, message: 'Missing secret value', }) } } if (data.selectedType === 'postgres') { if (!data.postgresValues.schema) { ctx.addIssue({ path: ['postgresValues', 'schema'], code: z.ZodIssueCode.custom, message: 'You must select a schema', }) } if (!data.postgresValues.functionName) { ctx.addIssue({ path: ['postgresValues', 'functionName'], code: z.ZodIssueCode.custom, message: 'You must select a Postgres function', }) } } return true }) export const CreateHookSheet = ({ visible, title, authConfig, onClose, onDelete, }: CreateHookSheetProps) => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() const definition = useMemo( () => HOOKS_DEFINITIONS.find((d) => d.title === title) || HOOKS_DEFINITIONS[0], [title] ) const supportedReturnTypes = definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED' ? ['json', 'jsonb', 'void'] : ['json', 'jsonb'] const hook: Hook = useMemo(() => { return { ...definition, enabled: authConfig?.[definition.enabledKey] || false, method: extractMethod( authConfig?.[definition.uriKey] || '', authConfig?.[definition.secretsKey] || '' ), } }, [definition, authConfig]) // if the hook has all parameters, then it is not being created. const isCreating = !isValidHook(hook) const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: { hookType: title || '', enabled: true, selectedType: 'postgres', httpsValues: { url: '', secret: '', }, postgresValues: { schema: 'public', functionName: '', }, }, }) const values = form.watch() const statements = useMemo(() => { let permissionChanges: string[] = [] if (hook.method.type === 'postgres') { if ( hook.method.schema !== '' && hook.method.functionName !== '' && hook.method.functionName !== values.postgresValues.functionName ) { permissionChanges = getRevokePermissionStatements( hook.method.schema, hook.method.functionName ) } } if (values.postgresValues.functionName !== '') { permissionChanges = [ ...permissionChanges, `-- Grant access to function to supabase_auth_admin\ngrant execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} to supabase_auth_admin;`, `-- Grant access to schema to supabase_auth_admin\ngrant usage on schema ${values.postgresValues.schema} to supabase_auth_admin;`, `-- Revoke function permissions from authenticated, anon and public\nrevoke execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} from authenticated, anon, public;`, ] } return permissionChanges }, [hook, values.postgresValues.schema, values.postgresValues.functionName]) const { mutate: updateAuthHooks, isLoading: isUpdatingAuthHooks } = useAuthHooksUpdateMutation({ onSuccess: () => { toast.success(`Successfully created ${values.hookType}.`) if (statements.length > 0) { executeSql({ projectRef, connectionString: project!.connectionString, sql: statements.join('\n'), }) } onClose() }, onError: (error) => { toast.error(`Failed to create hook: ${error.message}`) }, }) const onSubmit: SubmitHandler> = async (values) => { if (!project) return console.error('Project is required') const definition = HOOKS_DEFINITIONS.find((d) => values.hookType === d.title) if (!definition) { return } const enabledLabel = definition.enabledKey const uriLabel = definition.uriKey const secretsLabel = definition.secretsKey let url = '' if (values.selectedType === 'postgres') { url = `pg-functions://postgres/${values.postgresValues.schema}/${values.postgresValues.functionName}` } else { url = values.httpsValues.url } const payload = { [enabledLabel]: values.enabled, [uriLabel]: url, [secretsLabel]: values.selectedType === 'https' ? values.httpsValues.secret : null, } updateAuthHooks({ projectRef: projectRef!, config: payload }) } useEffect(() => { if (visible) { if (definition) { const values = extractMethod( authConfig?.[definition.uriKey] || '', authConfig?.[definition.secretsKey] || '' ) form.reset({ hookType: definition.title, enabled: authConfig?.[definition.enabledKey] || true, selectedType: values.type, httpsValues: { url: (values.type === 'https' && values.url) || '', secret: (values.type === 'https' && values.secret) || '', }, postgresValues: { schema: (values.type === 'postgres' && values.schema) || 'public', functionName: (values.type === 'postgres' && values.functionName) || '', }, }) } else { form.reset({ hookType: title || '', enabled: true, selectedType: 'postgres', httpsValues: { url: '', secret: '', }, postgresValues: { schema: 'public', functionName: '', }, }) } } }, [authConfig, title, visible, definition]) return ( onClose()}>
Close {isCreating ? `Add ${title}` : `Update ${title}`}
( )} /> ( field.onChange(value)} > )} /> {values.selectedType === 'postgres' ? ( <>
( field.onChange(name)} disabled={field.disabled} /> )} /> ( { if (supportedReturnTypes.includes(func.return_type)) { const { value } = convertArgumentTypes(func.argument_types) if (value.length !== 1) return false return value[0].type === 'json' || value[0].type === 'jsonb' } return false }} noResultsLabel={ No function with a single JSON/B argument
and JSON/B {definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED' ? ' or void' : ''}{' '} return type found in this schema.
} />
)} />

The following statements will be executed on the selected function:

) : (
( )} /> ( It should be a base64 encoded hook secret with a prefix `v1,whsec_`. `v1` denotes the signature version, and `whsec_` signifies a symmetric secret. } >
)} />
)}
{!isCreating && (
)}
) }