import { zodResolver } from '@hookform/resolvers/zod' import { ident, joinSqlFragments, safeSql, type SafeSqlFragment, } from '@supabase/pg-meta/src/pg-format' import { useParams } from 'common' import randomBytes from 'randombytes' import { useEffect, useMemo } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Form, FormControl, FormField, Input, RadioGroupStacked, RadioGroupStackedItem, Separator, Sheet, SheetContent, SheetFooter, SheetHeader, SheetSection, SheetTitle, Switch, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InfoTooltip } from 'ui-patterns/info-tooltip' import * as z from 'zod' import { Hook, HOOK_DEFINITION_TITLE, HOOKS_DEFINITIONS } from './hooks.constants' import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils' import { convertArgumentTypes } from '@/components/interfaces/Database/Functions/Functions.utils' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' 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 { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' import { DOCS_URL } from '@/lib/constants' 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 as any), defaultValues: { hookType: title || '', enabled: true, selectedType: 'postgres', httpsValues: { url: '', secret: '', }, postgresValues: { schema: 'public', functionName: '', }, }, }) const isDirty = form.formState.isDirty const values = form.watch() const { confirmOnClose, handleOpenChange, modalProps: discardChangesModalProps, } = useConfirmOnClose({ checkIsDirty: () => isDirty, onClose, }) const statements = useMemo(() => { let permissionChanges: Array = [] 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 !== '') { const schema = values.postgresValues.schema const functionName = values.postgresValues.functionName permissionChanges = [ ...permissionChanges, safeSql`-- Grant access to function to briven_auth_admin grant execute on function ${ident(schema)}.${ident(functionName)} to briven_auth_admin;`, safeSql`-- Grant access to schema to briven_auth_admin grant usage on schema ${ident(schema)} to briven_auth_admin;`, safeSql`-- Revoke function permissions from authenticated, anon and public revoke execute on function ${ident(schema)}.${ident(functionName)} from authenticated, anon, public;`, ] } return permissionChanges }, [hook, values.postgresValues.schema, values.postgresValues.functionName]) const { mutate: updateAuthHooks, isPending: isUpdatingAuthHooks } = useAuthHooksUpdateMutation({ onSuccess: () => { toast.success(`Successfully created ${values.hookType}.`) if (statements.length > 0) { executeSql({ projectRef, connectionString: project!.connectionString, sql: joinSqlFragments(statements, '\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: isCreating ? true : authConfig?.[definition.enabledKey], 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: '', }, }) } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [authConfig, title, visible, definition]) return ( {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.
} />
)} />
{statements.length > 0 && (

The following statements will be executed on the selected function:

)} ) : (
( )} /> (

Should be a base64 encoded hook secret with a prefix{' '} v1,whsec_.

v1 denotes the signature version and whsec_ signifies a symmetric secret.
} >
)} /> )}
{!isCreating && (
)}
) }