mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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>
|
||||
|
||||
+38
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user