feat: surface send-email hook status in template UI and hook deletion dialog (#46319)

## What kind of change does this PR introduce?

Feature. Implements
[AUTH-1215](https://linear.app/supabase/issue/AUTH-1215/improve-studio-ui-when-send-email-hook-is-active-or-deleted).
Follow-up to #45396.

## What is the current behavior?

When a send-email hook is configured, email templates are bypassed
entirely. Auth passes event metadata to the hook, not rendered HTML. The
template list and editor give no indication of this.

Deleting the send-email hook silently reverts Auth to using email
templates with no warning. For post-cutoff Free plan projects without
custom SMTP, this also locks template editing.

## What is the new behavior?

### Admonition when send-email hook is active

A new `SendEmailHookActiveAdmonition` is shown on both the template list
and individual template editor pages when `HOOK_SEND_EMAIL_ENABLED` and
`HOOK_SEND_EMAIL_URI` are set:

> **Email templates are not used**
> A Send Email hook is active. Event metadata is passed directly to your
hook, meaning these templates are bypassed entirely.

With a **Manage hook** link to the hooks page.

### AlertDialog for Send Email hook deletion

Deleting the Send Email hook now uses a dedicated
`DeleteSendEmailHookConfirmationDialog`:

- **Always:** "The {default or built-in} email templates will be used to
send auth emails."
- **Post-cutoff Free plan, no custom SMTP:** adds "Email templates
cannot be edited on the Free plan without custom SMTP."

The dialog stays open with a loading state while the deletion is
in-flight and closes on success.

| After |
| --- |
| <img width="1862" height="880" alt="CleanShot 2026-05-25 at 15 57
41@2x"
src="https://github.com/user-attachments/assets/8a441bb2-9112-4b19-bd0b-02c9d1989ec1"
/> |
| <img width="884" height="578" alt="CleanShot 2026-05-25 at 15 57
21@2x"
src="https://github.com/user-attachments/assets/44e5bd79-2bd9-44ee-8f53-5fdaeefd68c6"
/> |

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a Send Email hook warning in the template editor with “Manage
hook” and “Learn more” links.
* **UI Improvements**
* Refined template editor alerts to reflect when templates are bypassed
vs blocked.
* Updated hook cards/actions to a dropdown with separate Edit and Delete
flows, including documentation links.
* **Bug Fixes**
* Improved template editor and hook deletion flows to better reflect
pending states and current authentication configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
authored and GitHub committed 2026-06-29 17:09:31 +08:00
1 parent 4fa106e53c
commit 8192d97008
8 files changed
+478 -227

No files matched your search

@@ -26,6 +26,7 @@ import {
isCustomEmailTemplateRestrictionStatusKnown,
slugifyTitle,
} from './EmailTemplates.utils'
import { SendEmailHookActiveAdmonition } from './SendEmailHookActiveAdmonition'
import AlertError from '@/components/ui/AlertError'
import { InlineLink } from '@/components/ui/InlineLink'
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
@@ -79,6 +80,9 @@ export const EmailTemplates = () => {
})
const usingBuiltInEmailSender = !hasCustomEmailSender(authConfig)
const hasSendEmailHook = !!(
authConfig?.HOOK_SEND_EMAIL_ENABLED && authConfig?.HOOK_SEND_EMAIL_URI
)
const isTemplateRestrictionStatusKnown = isCustomEmailTemplateRestrictionStatusKnown({
authConfig,
organization: selectedOrganization,
@@ -135,10 +139,24 @@ export const EmailTemplates = () => {
)}
{isSuccess && (
<>
{isTemplateEditBlocked && !hasSendEmailHook && (
<PageSection>
<PageSectionContent>
<CustomEmailTemplateRestrictionAdmonition />
</PageSectionContent>
</PageSection>
)}
{hasSendEmailHook && (
<PageSection>
<PageSectionContent>
<SendEmailHookActiveAdmonition />
</PageSectionContent>
</PageSection>
)}
<PageSection>
{isTemplateEditBlocked ? (
<CustomEmailTemplateRestrictionAdmonition />
) : usingBuiltInEmailSender ? (
{usingBuiltInEmailSender && !isTemplateEditBlocked && (
<Admonition
type="warning"
title="Set up custom SMTP"
@@ -160,7 +178,7 @@ export const EmailTemplates = () => {
</Button>
}
/>
) : null}
)}
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Authentication</PageSectionTitle>
@@ -0,0 +1,38 @@
import { useParams } from 'common'
import Link from 'next/link'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { InlineLink } from '@/components/ui/InlineLink'
import { DOCS_URL } from '@/lib/constants'
/**
* Shown on template list and editor pages when a send-email hook is active.
* The hook bypasses template rendering entirely — Auth sends event metadata to
* the hook, not rendered HTML — so templates are irrelevant while it's enabled.
*/
export const SendEmailHookActiveAdmonition = () => {
const { ref: projectRef } = useParams()
return (
<Admonition
type="default"
layout="responsive"
title="Email templates are not used"
description={
<>
A Send Email hook is active. Email data is sent to your hook instead of using these
templates.{' '}
<InlineLink href={`${DOCS_URL}/guides/auth/auth-hooks/send-email-hook`}>
Learn more
</InlineLink>
</>
}
actions={
<Button asChild variant="default">
<Link href={`/project/${projectRef}/auth/hooks?hook=send-email`}>Manage hook</Link>
</Button>
}
/>
)
}
@@ -124,7 +124,7 @@ const renderTemplateEditor = ({
})
useAsyncCheckPermissionsMock.mockReturnValue({ can: canUpdateConfig })
useAuthConfigUpdateMutationMock.mockReturnValue({ mutate: updateAuthConfigMock })
useAuthTemplateResetMutationMock.mockReturnValue({ mutate: resetTemplateMock })
useAuthTemplateResetMutationMock.mockReturnValue({ mutate: resetTemplateMock, isPending: false })
return render(<TemplateEditor template={confirmationTemplate} />)
}
@@ -27,6 +27,7 @@ import {
SheetTitle,
Switch,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import * as z from 'zod'
@@ -38,6 +39,7 @@ import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialo
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'
@@ -165,7 +167,7 @@ export const CreateHookSheet = ({
})
const isDirty = form.formState.isDirty
const values = form.watch()
const { postgresValues, hookType, selectedType, enabled } = form.watch()
const {
confirmOnClose,
handleOpenChange,
@@ -181,7 +183,7 @@ export const CreateHookSheet = ({
if (
hook.method.schema !== '' &&
hook.method.functionName !== '' &&
hook.method.functionName !== values.postgresValues.functionName
hook.method.functionName !== postgresValues.functionName
) {
permissionChanges = getRevokePermissionStatements(
hook.method.schema,
@@ -190,9 +192,9 @@ export const CreateHookSheet = ({
}
}
if (values.postgresValues.functionName !== '') {
const schema = values.postgresValues.schema
const functionName = values.postgresValues.functionName
if (postgresValues.functionName !== '') {
const schema = postgresValues.schema
const functionName = postgresValues.functionName
permissionChanges = [
...permissionChanges,
safeSql`-- Grant access to function to supabase_auth_admin
@@ -204,11 +206,11 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti
]
}
return permissionChanges
}, [hook, values.postgresValues.schema, values.postgresValues.functionName])
}, [hook, postgresValues.schema, postgresValues.functionName])
const { mutate: updateAuthHooks, isPending: isUpdatingAuthHooks } = useAuthHooksUpdateMutation({
onSuccess: () => {
toast.success(`Successfully created ${values.hookType}.`)
toast.success(`Successfully ${isCreating ? 'created' : 'updated'} ${hookType}.`)
if (statements.length > 0) {
executeSql({
projectRef,
@@ -306,7 +308,7 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti
<DocsButton href={`${DOCS_URL}/guides/auth/auth-hooks/${hook.docSlug}`} />
</SheetHeader>
<Separator />
<SheetSection className="overflow-auto grow px-0">
<SheetSection className="overflow-auto grow p-0">
<Form {...form}>
<form
id={FORM_ID}
@@ -319,11 +321,11 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="flex"
className="px-5"
label={`Enable ${values.hookType}`}
layout="flex-row-reverse"
className="px-5 [&>div:first-child]:xl:w-1/5"
label={`Enable ${hookType}`}
description={
values.hookType === 'Send SMS hook'
hookType === 'Send SMS hook'
? 'SMS Provider settings will be disabled in favor of SMS hooks'
: undefined
}
@@ -338,12 +340,32 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti
</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 label="Hook type" className="px-5">
<FormItemLayout layout="horizontal" label="Hook type" className="px-5">
<FormControl>
<RadioGroupStacked
value={field.value}
@@ -368,89 +390,94 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti
</FormItemLayout>
)}
/>
{values.selectedType === 'postgres' ? (
{selectedType === 'postgres' ? (
<>
<div className="grid grid-cols-2 gap-8 px-5">
<FormField
key="postgresValues.schema"
control={form.control}
name="postgresValues.schema"
render={({ field }) => (
<FormItemLayout
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
label="Postgres function"
description="This function will be called by Supabase Auth each time the hook is triggered"
>
<FormControl>
<FunctionSelector
size="small"
schema={values.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>
<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'
}
/>
</FormControl>
</FormItemLayout>
)}
/>
</div>
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 && (
<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>
<>
<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>
</>
)}
</>
) : (
@@ -461,11 +488,12 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti
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} />
<Input {...field} placeholder="https://www.example.com" />
</FormControl>
</FormItemLayout>
)}
@@ -476,11 +504,12 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti
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 hook secret with a prefix{' '}
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">
@@ -496,8 +525,7 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti
<Input {...field} className="rounded-r-none border-r-0" />
<Button
variant="default"
size="small"
className="rounded-l-none text-xs"
className="rounded-l-none text-xs h-auto"
onClick={() => {
const authHookSecret = generateAuthHookSecret()
form.setValue('httpsValues.secret', authHookSecret, {
@@ -0,0 +1,169 @@
import { joinSqlFragments } from '@supabase/pg-meta'
import { useParams } from 'common'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
cn,
} from 'ui'
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
import { isBeforeFreeTierTemplateBlockCutoff } from '../EmailTemplates/EmailTemplates.utils'
import { isSmtpEnabled } from '../SmtpForm/SmtpForm.utils'
import { type Hook } from './hooks.constants'
import { getRevokePermissionStatements } from './hooks.utils'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
import { useAuthHooksUpdateMutation } from '@/data/auth/auth-hooks-update-mutation'
import { executeSql } from '@/data/sql/execute-sql-mutation'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
interface DeleteHookConfirmationDialogProps {
hook?: Hook
onOpenChange: (open: boolean) => void
onDeleteSuccess: () => void
}
export const DeleteHookConfirmationDialog = ({
hook,
onOpenChange,
onDeleteSuccess,
}: DeleteHookConfirmationDialogProps) => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { data: authConfig } = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthHooks, isPending: isDeletingAuthHook } = useAuthHooksUpdateMutation({
onSuccess: async () => {
if (!hook) return
const { method } = hook
if (method.type === 'postgres') {
try {
const revokeStatements = getRevokePermissionStatements(method.schema, method.functionName)
await executeSql({
projectRef,
connectionString: project!.connectionString,
sql: joinSqlFragments(revokeStatements, '\n'),
})
toast.success(`Successfully deleted ${hook.title}`)
} catch (error) {
toast.warning(
`Deleted ${hook.title}, but failed to revoke permissions on ${method.schema}.${method.functionName}. You may want to revoke them manually.`
)
}
}
onDeleteSuccess()
},
onError: (error) => {
toast.error(`Failed to delete hook: ${error.message}`)
},
})
const open = !!hook
// Whether deleting the Send Email hook will lock template editing:
// post-cutoff Free plan projects without custom SMTP.
const willLockTemplates =
!!selectedOrganization &&
selectedOrganization.plan?.id === 'free' &&
!!project?.inserted_at &&
!isBeforeFreeTierTemplateBlockCutoff(project.inserted_at) &&
!isSmtpEnabled(authConfig)
const handleDeleteSendEmailHook = async (): Promise<void> => {
if (!hook) return
updateAuthHooks({
projectRef: projectRef!,
config: {
[hook.enabledKey]: false,
[hook.uriKey]: null,
[hook.secretsKey]: null,
},
})
}
if (hook?.id === 'send-email') {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm to delete Send Email hook</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
The {willLockTemplates ? 'default' : 'built-in'} email templates will be used when
sending authentication-related emails.
</p>
{willLockTemplates && (
<p>Email templates cannot be edited on the Free plan without custom SMTP.</p>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="danger"
loading={isDeletingAuthHook}
onClick={(e) => {
e.preventDefault()
handleDeleteSendEmailHook()
}}
>
Delete hook
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
return (
<ConfirmationModal
visible={!!hook && hook.id !== 'send-email'}
size="small"
variant="destructive"
loading={isDeletingAuthHook}
title={`Confirm to delete ${hook?.title}`}
className={cn('md:px-0', hook?.method.type === 'postgres' && 'pb-0')}
confirmLabel="Delete"
confirmLabelLoading="Deleting"
onCancel={() => onOpenChange(false)}
onConfirm={handleDeleteSendEmailHook}
>
<div>
<p className="md:px-5 text-sm text-foreground-light">
Are you sure you want to delete the {hook?.title}?
</p>
{hook?.method.type === 'postgres' && (
<>
<p className="md:px-5 text-sm text-foreground-light">
The following statements will be executed on the {hook?.method.schema}.
{hook?.method.functionName} function:
</p>
<div className="mt-4 h-72">
<CodeEditor
isReadOnly
id="deletion-hook-editor"
language="pgsql"
value={getRevokePermissionStatements(
hook?.method.schema,
hook?.method.functionName
).join('\n\n')}
/>
</div>
</>
)}
</div>
</ConfirmationModal>
)
}
@@ -1,20 +1,28 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Check, Webhook } from 'lucide-react'
import { Badge } from 'ui'
import { BookOpen, Check, Edit, MoreVertical, Trash, Webhook } from 'lucide-react'
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { Hook } from './hooks.constants'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { DocsButton } from '@/components/ui/DocsButton'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { DOCS_URL } from '@/lib/constants'
interface HookCardProps {
hook: Hook
onSelect: () => void
onSelectEdit: () => void
onSelectDelete: () => void
}
export const HookCard = ({ hook, onSelect }: HookCardProps) => {
export const HookCard = ({ hook, onSelectEdit, onSelectDelete }: HookCardProps) => {
const { can: canUpdateAuthHook } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, '*')
return (
@@ -27,14 +35,14 @@ export const HookCard = ({ hook, onSelect }: HookCardProps) => {
<span className="text-sm text-foreground-lighter">{hook.subtitle}</span>
<div className="text-sm flex flex-row space-x-5 py-4">
{hook.method.type === 'postgres' ? (
<div className="flex flex-col w-full space-y-2">
<div className="flex flex-col w-full space-y-2 max-w-xl">
<div className="flex flex-row items-center">
<span className="text-foreground-light w-20">type</span>
<span className="text-foreground-light w-20">Type</span>
<span className="text-foreground">Postgres function</span>
</div>
<div className="flex flex-row items-center">
<label htmlFor="schema" className="text-foreground-light w-20">
schema
Schema
</label>
<Input
id="schema"
@@ -49,7 +57,7 @@ export const HookCard = ({ hook, onSelect }: HookCardProps) => {
</div>
<div className="flex flex-row items-center">
<label htmlFor="functionName" className="text-foreground-light w-20">
function
Function
</label>
<Input
id="functionName"
@@ -64,14 +72,14 @@ export const HookCard = ({ hook, onSelect }: HookCardProps) => {
</div>
</div>
) : (
<div className="flex flex-col w-full space-y-2">
<div className="flex flex-col w-full space-y-2 max-w-xl">
<div className="flex flex-row items-center">
<span className="text-foreground-light w-20">type</span>
<span className="text-foreground-light w-20">Type</span>
<span className="text-foreground">HTTPS endpoint</span>
</div>
<div className="flex flex-row items-center">
<label htmlFor="url" className="text-foreground-light w-20">
endpoint
Endpoint
</label>
<Input
id="url"
@@ -86,7 +94,7 @@ export const HookCard = ({ hook, onSelect }: HookCardProps) => {
</div>
<div className="flex flex-row items-center">
<label htmlFor="secret" className="text-foreground-light w-20">
secret
Secret
</label>
<Input
id="secret"
@@ -103,38 +111,78 @@ export const HookCard = ({ hook, onSelect }: HookCardProps) => {
</div>
)}
</div>
<div className="flex flex-row gap-2">
<ButtonTooltip
variant="default"
disabled={!canUpdateAuthHook}
onClick={() => onSelect()}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateAuthHook
? 'You need additional permissions to configure auth hooks'
: undefined,
},
}}
>
Configure hook
</ButtonTooltip>
<DocsButton href={`${DOCS_URL}/guides/auth/auth-hooks/${hook.docSlug}`} />
</div>
</div>
<div>
{hook.enabled ? (
<Badge className="space-x-1" variant="success">
<div className="h-3.5 w-3.5 bg-brand rounded-full flex justify-center items-center">
<Check className="h-2 w-2 text-background-overlay " strokeWidth={6} />
</div>
<span>Enabled</span>
</Badge>
) : (
<Badge variant="warning">
<span>Disabled</span>
</Badge>
)}
<div className="flex items-center gap-x-2">
{hook.enabled ? (
<Badge className="space-x-1" variant="success">
<div className="h-3.5 w-3.5 bg-brand rounded-full flex justify-center items-center">
<Check className="h-2 w-2 text-background-overlay " strokeWidth={6} />
</div>
<span>Enabled</span>
</Badge>
) : (
<Badge variant="warning">
<span>Disabled</span>
</Badge>
)}
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="default"
className="px-1"
icon={<MoreVertical />}
aria-label={`Open actions for ${hook.title}`}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canUpdateAuthHook}
onClick={onSelectEdit}
tooltip={{
content: {
text: !canUpdateAuthHook
? 'You need additional permissions to configure auth hooks'
: undefined,
side: 'left',
},
}}
>
<Edit size={12} />
<span>Edit hook</span>
</DropdownMenuItemTooltip>
<DropdownMenuItem asChild className="gap-x-2">
<a
target="_blank"
rel="noreferrer noopener"
href={`${DOCS_URL}/guides/auth/auth-hooks/${hook.docSlug}`}
>
<BookOpen size={12} />
<span>Documentation</span>
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canUpdateAuthHook}
onClick={onSelectDelete}
tooltip={{
content: {
text: !canUpdateAuthHook
? 'You need additional permissions to delete auth hooks'
: undefined,
side: 'left',
},
}}
>
<Trash size={12} />
<span>Delete hook</span>
</DropdownMenuItemTooltip>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
@@ -1,11 +1,8 @@
import { joinSqlFragments } from '@supabase/pg-meta/src/pg-format'
import { useParams } from 'common'
import { parseAsString, useQueryState } from 'nuqs'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { cn } from 'ui'
import { EmptyStatePresentational, GenericSkeletonLoader } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import {
PageSection,
PageSectionAside,
@@ -17,21 +14,18 @@ import {
import { AddHookDropdown } from './AddHookDropdown'
import { CreateHookSheet } from './CreateHookSheet'
import { DeleteHookConfirmationDialog } from './DeleteHookConfirmationDialog'
import { HookCard } from './HookCard'
import { Hook, HOOKS_DEFINITIONS } from './hooks.constants'
import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils'
import { extractMethod, isValidHook } from './hooks.utils'
import { AlertError } from '@/components/ui/AlertError'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
import { useAuthConfigQuery } 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 { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
export const HooksListing = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const {
data: authConfig,
error: authConfigError,
@@ -41,32 +35,10 @@ export const HooksListing = () => {
const [hook, setHook] = useQueryState('hook', parseAsString)
const [selectedHookForDeletion, setSelectedHookForDeletion] = useState<Hook | null>(null)
const [selectedHookForDeletion, setSelectedHookForDeletion] = useState<Hook>()
const [addHookAsideOpen, setAddHookAsideOpen] = useState(false)
const [addHookEmptyOpen, setAddHookEmptyOpen] = useState(false)
const { mutate: updateAuthHooks, isPending: isDeletingAuthHook } = useAuthHooksUpdateMutation({
onSuccess: async () => {
if (!selectedHookForDeletion) return
const { method } = selectedHookForDeletion
if (method.type === 'postgres') {
const revokeStatements = getRevokePermissionStatements(method.schema, method.functionName)
await executeSql({
projectRef,
connectionString: project!.connectionString,
sql: joinSqlFragments(revokeStatements, '\n'),
})
}
toast.success(`${selectedHookForDeletion.title} has been deleted.`)
setSelectedHookForDeletion(null)
setHook(null)
},
onError: (error) => {
toast.error(`Failed to delete hook: ${error.message}`)
},
})
const hooks: Hook[] = HOOKS_DEFINITIONS.map((definition) => {
return {
...definition,
@@ -158,7 +130,14 @@ export const HooksListing = () => {
<div className="-space-y-px">
{validHooks.map((hook) => {
return <HookCard key={hook.enabledKey} hook={hook} onSelect={() => setHook(hook.id)} />
return (
<HookCard
key={hook.enabledKey}
hook={hook}
onSelectEdit={() => setHook(hook.id)}
onSelectDelete={() => setSelectedHookForDeletion(hook)}
/>
)
})}
</div>
@@ -173,54 +152,16 @@ export const HooksListing = () => {
authConfig={authConfig!}
/>
<ConfirmationModal
visible={!!selectedHookForDeletion}
size="large"
variant="destructive"
loading={isDeletingAuthHook}
title={`Confirm to delete ${selectedHookForDeletion?.title}`}
className={cn('md:px-0', selectedHookForDeletion?.method.type === 'postgres' && 'pb-0')}
confirmLabel="Delete"
confirmLabelLoading="Deleting"
onCancel={() => setSelectedHookForDeletion(null)}
onConfirm={() => {
if (!selectedHookForDeletion) return
updateAuthHooks({
projectRef: projectRef!,
config: {
[selectedHookForDeletion.enabledKey]: false,
[selectedHookForDeletion.uriKey]: null,
[selectedHookForDeletion.secretsKey]: null,
},
})
<DeleteHookConfirmationDialog
hook={selectedHookForDeletion}
onOpenChange={(open) => {
if (!open) setSelectedHookForDeletion(undefined)
}}
>
<div>
<p className="md:px-5 text-sm text-foreground-light">
Are you sure you want to delete the {selectedHookForDeletion?.title}?
</p>
{selectedHookForDeletion?.method.type === 'postgres' && (
<>
<p className="md:px-5 text-sm text-foreground-light">
The following statements will be executed on the{' '}
{selectedHookForDeletion?.method.schema}.
{selectedHookForDeletion?.method.functionName} function:
</p>
<div className="mt-4 h-72">
<CodeEditor
isReadOnly
id="deletion-hook-editor"
language="pgsql"
value={getRevokePermissionStatements(
selectedHookForDeletion?.method.schema,
selectedHookForDeletion?.method.functionName
).join('\n\n')}
/>
</div>
</>
)}
</div>
</ConfirmationModal>
onDeleteSuccess={() => {
setSelectedHookForDeletion(undefined)
setHook(null)
}}
/>
</PageSectionContent>
</PageSection>
)
@@ -50,6 +50,7 @@ import {
isCustomEmailTemplateRestrictionStatusKnown,
slugifyTitle,
} from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils'
import { SendEmailHookActiveAdmonition } from '@/components/interfaces/Auth/EmailTemplates/SendEmailHookActiveAdmonition'
import { TemplateEditor } from '@/components/interfaces/Auth/EmailTemplates/TemplateEditor'
import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
@@ -98,6 +99,9 @@ const RedirectToTemplates = () => {
projectInsertedAt: selectedProject?.inserted_at,
})
const isTemplateEditorReadOnly = !isTemplateRestrictionStatusKnown || isTemplateEditBlocked
const hasSendEmailHook = !!(
authConfig?.HOOK_SEND_EMAIL_ENABLED && authConfig?.HOOK_SEND_EMAIL_URI
)
const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation({
onError: (error) => {
@@ -280,7 +284,7 @@ const RedirectToTemplates = () => {
)}
<PageSection>
{showConfigurationSection && (
{(showConfigurationSection || isTemplateEditBlocked) && (
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Content</PageSectionTitle>
@@ -288,11 +292,16 @@ const RedirectToTemplates = () => {
</PageSectionMeta>
)}
<PageSectionContent>
{isTemplateEditBlocked && (
{isTemplateEditBlocked && !hasSendEmailHook && (
<div className="mb-4">
<CustomEmailTemplateRestrictionAdmonition />
</div>
)}
{hasSendEmailHook && (
<div className="mb-4">
<SendEmailHookActiveAdmonition />
</div>
)}
<Card>
<TemplateEditor template={template} isReadOnly={isTemplateEditorReadOnly} />
</Card>