Files
supabase/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx
Gildas Garcia b30db91d71 chore: cleanup UI patterns exports (#47406)
## Problem

We now export components under a subpath in ui-patterns to avoid barrel
files as they slow down every tools (from IDE to linters, etc.) and may
also affect bundles our users have to download.

## Solution

- Remove the UI patterns index file
- Fix invalid impors
2026-06-30 09:23:17 +02:00

574 lines
20 KiB
TypeScript

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 { Admonition } from 'ui-patterns/admonition'
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 { InlineLink } from '@/components/ui/InlineLink'
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-mutation'
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<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
hookType: title || '',
enabled: true,
selectedType: 'postgres',
httpsValues: {
url: '',
secret: '',
},
postgresValues: {
schema: 'public',
functionName: '',
},
},
})
const isDirty = form.formState.isDirty
const { postgresValues, hookType, selectedType, enabled } = form.watch()
const {
confirmOnClose,
handleOpenChange,
modalProps: discardChangesModalProps,
} = useConfirmOnClose({
checkIsDirty: () => isDirty,
onClose,
})
const statements = useMemo(() => {
let permissionChanges: Array<SafeSqlFragment> = []
if (hook.method.type === 'postgres') {
if (
hook.method.schema !== '' &&
hook.method.functionName !== '' &&
hook.method.functionName !== postgresValues.functionName
) {
permissionChanges = getRevokePermissionStatements(
hook.method.schema,
hook.method.functionName
)
}
}
if (postgresValues.functionName !== '') {
const schema = postgresValues.schema
const functionName = postgresValues.functionName
permissionChanges = [
...permissionChanges,
safeSql`-- Grant access to function to supabase_auth_admin
grant execute on function ${ident(schema)}.${ident(functionName)} to supabase_auth_admin;`,
safeSql`-- Grant access to schema to supabase_auth_admin
grant usage on schema ${ident(schema)} to supabase_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, postgresValues.schema, postgresValues.functionName])
const { mutate: updateAuthHooks, isPending: isUpdatingAuthHooks } = useAuthHooksUpdateMutation({
onSuccess: () => {
toast.success(`Successfully ${isCreating ? 'created' : 'updated'} ${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<z.infer<typeof FormSchema>> = 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 (
<Sheet open={visible} onOpenChange={handleOpenChange}>
<SheetContent
aria-describedby={undefined}
size="lg"
showClose={false}
className="flex flex-col gap-0"
>
<SheetHeader className="py-3 flex flex-row justify-between items-center border-b-0">
<SheetTitle className="truncate">
{isCreating ? `Add ${title}` : `Update ${title}`}
</SheetTitle>
<DocsButton href={`${DOCS_URL}/guides/auth/auth-hooks/${hook.docSlug}`} />
</SheetHeader>
<Separator />
<SheetSection className="overflow-auto grow p-0">
<Form {...form}>
<form
id={FORM_ID}
className="space-y-6 w-full py-5 flex-1"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
key="enabled"
name="enabled"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
className="px-5 [&>div:first-child]:xl:w-1/5"
label={`Enable ${hookType}`}
description={
hookType === 'Send SMS hook'
? 'SMS Provider settings will be disabled in favor of SMS hooks'
: undefined
}
>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={field.disabled}
/>
</FormControl>
</FormItemLayout>
)}
/>
{hook.id === 'send-email' && enabled && (
<div className="px-5">
<Admonition type="default" title="This hook replaces your email templates">
<p className="text-balance">
While enabled, email data is sent to this hook instead. Your configured{' '}
<InlineLink href={`/project/${projectRef}/auth/templates`}>
email templates
</InlineLink>{' '}
will not be used.{' '}
<InlineLink href={`${DOCS_URL}/guides/auth/auth-hooks/send-email-hook`}>
Learn more
</InlineLink>
.
</p>
</Admonition>
</div>
)}
<Separator />
<FormField
control={form.control}
name="selectedType"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Hook type" className="px-5">
<FormControl>
<RadioGroupStacked
value={field.value}
onValueChange={(value) => field.onChange(value)}
>
<RadioGroupStackedItem
value="postgres"
id="postgres"
key="postgres"
label="Postgres"
description="Used to call a Postgres function."
/>
<RadioGroupStackedItem
value="https"
id="https"
key="https"
label="HTTPS"
description="Used to call any HTTPS endpoint."
/>
</RadioGroupStacked>
</FormControl>
</FormItemLayout>
)}
/>
{selectedType === 'postgres' ? (
<>
<FormField
key="postgresValues.schema"
control={form.control}
name="postgresValues.schema"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
className="px-5"
label="Postgres Schema"
description="Postgres schema where the function is defined"
>
<FormControl>
<SchemaSelector
size="small"
showError={false}
stopScrollPropagation
selectedSchemaName={field.value}
onSelectSchema={(name) => field.onChange(name)}
disabled={field.disabled}
/>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
key="postgresValues.functionName"
control={form.control}
name="postgresValues.functionName"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
className="px-5"
label="Postgres function"
description="This function will be called by Supabase Auth each time the hook is triggered"
>
<FormControl>
<FunctionSelector
size="small"
schema={postgresValues.schema}
value={field.value}
stopScrollPropagation
onChange={field.onChange}
disabled={field.disabled}
filterFunction={(func) => {
if (supportedReturnTypes.includes(func.return_type)) {
const { value } = convertArgumentTypes({
type: func.type,
value: func.argument_types,
})
if (value.length !== 1) return false
return value[0].type === 'json' || value[0].type === 'jsonb'
}
return false
}}
noResultsLabel={
<span>
No function with a single JSON/B argument
<br />
and JSON/B
{definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED'
? ' or void'
: ''}{' '}
return type found in this schema.
</span>
}
/>
</FormControl>
</FormItemLayout>
)}
/>
{statements.length > 0 && (
<>
<Separator className="mb-4" />
<div className="h-72 w-full gap-3 flex flex-col">
<p className="text-sm text-foreground-light px-5">
The following statements will be executed on the selected function:
</p>
<CodeEditor
isReadOnly
id="postgres-hook-editor"
language="pgsql"
value={statements.join('\n\n')}
/>
</div>
</>
)}
</>
) : (
<div className="flex flex-col gap-4 px-5">
<FormField
key="httpsValues.url"
control={form.control}
name="httpsValues.url"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="URL"
description="Supabase Auth will send a HTTPS POST request to this URL each time the hook is triggered."
>
<FormControl>
<Input {...field} placeholder="https://www.example.com" />
</FormControl>
</FormItemLayout>
)}
/>
<FormField
key="httpsValues.secret"
control={form.control}
name="httpsValues.secret"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Secret"
description={
<div className="flex items-center gap-x-2">
<p>
Should be a base64 encoded secret with a prefix{' '}
<code className="text-code-inline">v1,whsec_</code>.
</p>
<InfoTooltip side="bottom" className="w-60 text-center">
<code className="text-code-inline">v1</code> denotes the signature
version and <code className="text-code-inline">whsec_</code> signifies
a symmetric secret.
</InfoTooltip>
</div>
}
>
<FormControl>
<div className="flex flex-row">
<Input {...field} className="rounded-r-none border-r-0" />
<Button
variant="default"
className="rounded-l-none text-xs h-auto"
onClick={() => {
const authHookSecret = generateAuthHookSecret()
form.setValue('httpsValues.secret', authHookSecret, {
shouldDirty: true,
})
}}
>
Generate secret
</Button>
</div>
</FormControl>
</FormItemLayout>
)}
/>
</div>
)}
</form>
</Form>
</SheetSection>
<SheetFooter>
{!isCreating && (
<div className="flex-1">
<Button variant="danger" onClick={() => onDelete()}>
Delete hook
</Button>
</div>
)}
<Button disabled={isUpdatingAuthHooks} variant="default" onClick={confirmOnClose}>
Cancel
</Button>
<Button
form={FORM_ID}
type="submit"
disabled={isUpdatingAuthHooks}
loading={isUpdatingAuthHooks}
>
{isCreating ? 'Create hook' : 'Update hook'}
</Button>
</SheetFooter>
</SheetContent>
<DiscardChangesConfirmationDialog {...discardChangesModalProps} />
</Sheet>
)
}