From 8192d97008d0c5bdce83c38c5328e521745b90a1 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:09:31 -0600 Subject: [PATCH] feat: surface send-email hook status in template UI and hook deletion dialog (#46319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 | | --- | | CleanShot 2026-05-25 at 15 57
41@2x | | CleanShot 2026-05-25 at 15 57
21@2x | ## 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. --------- Co-authored-by: Joshen Lim Co-authored-by: Claude Sonnet 4.5 Co-authored-by: Cursor --- .../Auth/EmailTemplates/EmailTemplates.tsx | 26 ++- .../SendEmailHookActiveAdmonition.tsx | 38 +++ .../EmailTemplates/TemplateEditor.test.tsx | 2 +- .../interfaces/Auth/Hooks/CreateHookSheet.tsx | 220 ++++++++++-------- .../Hooks/DeleteHookConfirmationDialog.tsx | 169 ++++++++++++++ .../interfaces/Auth/Hooks/HookCard.tsx | 136 +++++++---- .../interfaces/Auth/Hooks/HooksListing.tsx | 101 ++------ .../[ref]/auth/templates/[templateId].tsx | 13 +- 8 files changed, 478 insertions(+), 227 deletions(-) create mode 100644 apps/studio/components/interfaces/Auth/EmailTemplates/SendEmailHookActiveAdmonition.tsx create mode 100644 apps/studio/components/interfaces/Auth/Hooks/DeleteHookConfirmationDialog.tsx diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx index e8f79f55cab..391b58d28e0 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx @@ -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 && ( + + + + + + )} + + {hasSendEmailHook && ( + + + + + + )} + - {isTemplateEditBlocked ? ( - - ) : usingBuiltInEmailSender ? ( + {usingBuiltInEmailSender && !isTemplateEditBlocked && ( { } /> - ) : null} + )} Authentication diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/SendEmailHookActiveAdmonition.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/SendEmailHookActiveAdmonition.tsx new file mode 100644 index 00000000000..85aeb69b711 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/SendEmailHookActiveAdmonition.tsx @@ -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 ( + + A Send Email hook is active. Email data is sent to your hook instead of using these + templates.{' '} + + Learn more + + + } + actions={ + + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx index dac6766a961..933268de677 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx @@ -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() } diff --git a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx index c1dbe06022e..dd552d05533 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx @@ -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 - +
( )} /> + + {hook.id === 'send-email' && enabled && ( +
+ +

+ While enabled, email data is sent to this hook instead. Your configured{' '} + + email templates + {' '} + will not be used.{' '} + + Learn more + + . +

+
+
+ )} + + ( - + )} /> - {values.selectedType === 'postgres' ? ( + {selectedType === 'postgres' ? ( <> -
- ( - - - field.onChange(name)} - disabled={field.disabled} - /> - - - )} - /> - ( - - - { - 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={ - - 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. -
+ ( + + + field.onChange(name)} + disabled={field.disabled} + /> + + + )} + /> + ( + + + { + 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={ + + 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: -

- -
+ <> + +
+

+ The following statements will be executed on the selected function: +

+ +
+ )} ) : ( @@ -461,11 +488,12 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti name="httpsValues.url" render={({ field }) => ( - + )} @@ -476,11 +504,12 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti name="httpsValues.secret" render={({ field }) => (

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

@@ -496,8 +525,7 @@ revoke execute on function ${ident(schema)}.${ident(functionName)} from authenti