mirror of
https://github.com/supabase/supabase.git
synced 2026-07-24 00:17:07 -04:00
b30db91d71
## 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
574 lines
20 KiB
TypeScript
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>
|
|
)
|
|
}
|