Files
supabase/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx
T
Danny WhiteandJoshen Lim f45a6085f4 feat(studio): add auth email template reset flow (#45572)
## What kind of change does this PR introduce?

Feature. Resolves AUTH-879.

## What is the current behavior?

Studio does not provide a way to reset an Auth email template back to
the default subject and body once it has been customised.

## What is the new behavior?

Studio shows a `Reset template` action when Platform reports that the
selected Auth email template subject or body has been customised. The
action opens a confirmation dialog, calls the dedicated Platform reset
endpoint, and refreshes the editor with the default subject and body
returned by the API.

The Auth config save/reset mutations now run their user-facing success
handling before refreshing Auth lint data, so the success toast and
local editor cleanup are not delayed by lint refetches.

## Additional context

Depends on supabase/platform#32417.



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

* **New Features**
* Reset email templates to defaults via a confirmation dialog; button
appears when custom content is detected and respects update permissions.
* Typed email-template definitions and improved template variable
display and descriptions.

* **Tests**
* Added tests covering template reset visibility, confirmation flow,
state updates, permission handling, and toast notifications.

* **Documentation**
* Example email template placeholders updated for internationalization
and provider snippets.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45572)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-05-18 14:33:26 +10:00

419 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import type { editor } from 'monaco-editor'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import ReactMarkdown from 'react-markdown'
import { toast } from 'sonner'
import {
Button,
CardContent,
CardFooter,
Form,
FormControl,
FormField,
Input,
Label,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import z from 'zod'
import type { AuthTemplate } from './EmailTemplates.types'
import { ResetTemplateDialog } from './ResetTemplateDialog'
import { SpamValidation } from './SpamValidation'
import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
import { InlineLink } from '@/components/ui/InlineLink'
import { TwoOptionToggle } from '@/components/ui/TwoOptionToggle'
import type { AuthConfigResponse } from '@/data/auth/auth-config-query'
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
import { useValidateSpamMutation, ValidateSpamResponse } from '@/data/auth/validate-spam-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { DOCS_URL } from '@/lib/constants'
interface TemplateEditorProps {
template: AuthTemplate
}
type EmailTemplateContentKey = Extract<
keyof AuthConfigResponse,
`MAILER_TEMPLATES_${string}_CONTENT`
>
type EmailTemplateSubjectKey = Exclude<
Extract<keyof AuthConfigResponse, `MAILER_SUBJECTS_${string}`>,
'MAILER_SUBJECTS_CUSTOM_CONTENTS'
>
export const TemplateEditor = ({ template }: TemplateEditorProps) => {
const { ref: projectRef } = useParams()
const { can: canUpdateConfig } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'custom_config_gotrue'
)
const { id, properties } = template
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as EmailTemplateContentKey
const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef })
const [validationResult, setValidationResult] = useState<ValidateSpamResponse>()
const [bodyValue, setBodyValue] = useState((authConfig && authConfig[messageSlug]) ?? '')
const [, setHasUnsavedChanges] = useState(false)
const [isSavingTemplate, setIsSavingTemplate] = useState(false)
const [activeView, setActiveView] = useState<'source' | 'preview'>('source')
const { mutate: validateSpam } = useValidateSpamMutation()
const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation({
onError: (error) => {
setIsSavingTemplate(false)
toast.error(`Failed to update email templates: ${error.message}`)
},
})
const subjectSlug = Object.keys(properties).find((key) => key.startsWith('MAILER_SUBJECTS_')) as
| EmailTemplateSubjectKey
| undefined
const messageProperty = properties[messageSlug]
const builtInSMTP =
isSuccess &&
authConfig &&
(!authConfig.SMTP_HOST || !authConfig.SMTP_USER || !authConfig.SMTP_PASS)
const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0)
const getFormValuesFromConfig = useCallback(
(config: AuthConfigResponse | undefined) => {
const result: { [x: string]: string } = {}
Object.keys(properties).forEach((key) => {
result[key] = ((config && config[key as keyof typeof config]) ?? '') as string
})
return result
},
[properties]
)
const INITIAL_VALUES = useMemo(() => {
return getFormValuesFromConfig(authConfig)
}, [authConfig, getFormValuesFromConfig])
const form = useForm({
defaultValues: INITIAL_VALUES,
resolver: zodResolver(template.validationSchema),
})
const onSubmit = (values: z.infer<typeof template.validationSchema>) => {
if (!projectRef) return console.error('Project ref is required')
setIsSavingTemplate(true)
const payload = { ...values }
// Because the template content uses the code editor which is not a form component
// its state is kept separately from the form state, hence why we manually inject it here
delete payload[messageSlug]
if (messageProperty) payload[messageSlug] = bodyValue
const [subjectKey] = Object.keys(properties)
validateSpam(
{
projectRef,
template: {
subject: payload[subjectKey],
content: payload[messageSlug],
},
},
{
onSuccess: (res) => {
setValidationResult(res)
const spamRules = (res?.rules ?? []).filter((rule) => rule.score > 0)
const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0
if (preventSaveFromSpamCheck) {
setIsSavingTemplate(false)
toast.error(
'Please rectify all spam warnings before saving while using the built-in email service'
)
} else {
updateAuthConfig(
{ projectRef: projectRef, config: payload },
{
onSuccess: () => {
setIsSavingTemplate(false)
setHasUnsavedChanges(false) // Reset the unsaved changes state
toast.success('Successfully updated email template')
},
}
)
}
},
onError: () => setIsSavingTemplate(false),
}
)
}
// Check if form values have changed
const formValues = form.watch()
const baselineValues = INITIAL_VALUES
const baselineBodyValue = (authConfig && authConfig[messageSlug]) ?? ''
const hasCustomTemplate =
authConfig?.MAILER_TEMPLATES_CUSTOM_CONTENTS?.[messageSlug] === true ||
(subjectSlug !== undefined &&
authConfig?.MAILER_SUBJECTS_CUSTOM_CONTENTS?.[subjectSlug] === true)
const hasFormChanges = JSON.stringify(formValues) !== JSON.stringify(baselineValues)
const hasChanges = hasFormChanges || baselineBodyValue !== bodyValue
// Function to insert text at cursor position
const insertTextAtCursor = (text: string) => {
if (!editorRef.current) return
const editor = editorRef.current
const selection = editor.getSelection()
if (selection) {
const range = {
startLineNumber: selection.startLineNumber,
startColumn: selection.startColumn,
endLineNumber: selection.endLineNumber,
endColumn: selection.endColumn,
}
editor.executeEdits('insert-variable', [
{
range,
text,
forceMoveMarkers: true,
},
])
// Focus the editor after insertion
editor.focus()
}
}
// Update form values when authConfig changes
useEffect(() => {
if (authConfig) {
form.reset(getFormValuesFromConfig(authConfig))
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
}
}, [authConfig, getFormValuesFromConfig, messageSlug, form])
useEffect(() => {
if (projectRef && id && !!authConfig) {
const [subjectKey] = Object.keys(properties)
validateSpam({
projectRef,
template: {
subject: authConfig[subjectKey as keyof typeof authConfig] as string,
content: authConfig[messageSlug],
},
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
useEffect(() => {
if (!hasChanges) setValidationResult(undefined)
}, [hasChanges])
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent>
{Object.keys(properties).map((x: string) => {
const property = properties[x]
if (property.type === 'string' && x !== messageSlug) {
return (
<FormField
key={x}
control={form.control}
name={x}
render={({ field }) => (
<FormItemLayout
className="gap-y-3"
layout="vertical"
label={property.title}
description={
property.description ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{property.description}
</ReactMarkdown>
) : null
}
labelOptional={
property.descriptionOptional ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{property.descriptionOptional}
</ReactMarkdown>
) : null
}
>
<FormControl>
<Input id={x} {...field} disabled={!canUpdateConfig} />
</FormControl>
</FormItemLayout>
)}
/>
)
}
return null
})}
</CardContent>
{messageProperty && (
<>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<Label>Body</Label>
<TwoOptionToggle
width={60}
options={['preview', 'source']}
activeOption={activeView}
onClickOption={(option) => setActiveView(option as 'source' | 'preview')}
borderOverride="border-muted"
/>
</div>
{activeView === 'source' ? (
<>
<div className="overflow-hidden rounded-md border dark:border-control overflow-hidden [&_.monaco-editor]:outline-0 [&_.monaco-editor-background]:bg-surface-200/30! [&_.monaco-editor_.margin]:bg-surface-200/30! dark:[&_.monaco-editor-background]:bg-surface-300! dark:[&_.monaco-editor_.margin]:bg-surface-300!">
<CodeEditor
id="code-id"
language="html"
isReadOnly={!canUpdateConfig}
className="mb-0! relative h-96 outline-hidden outline-offset-0 outline-width-0 outline-0"
onInputChange={(e: string | undefined) => {
setBodyValue(e ?? '')
if (bodyValue !== e) setHasUnsavedChanges(true)
}}
options={{ wordWrap: 'on', contextmenu: false, padding: { top: 16 } }}
value={bodyValue}
editorRef={editorRef}
/>
</div>
<div className="flex flex-col gap-y-2">
<div className="flex flex-col">
<p className="text-sm">Template variables</p>
<p className="text-sm text-foreground-lighter">
Data placeholders that can be inserted into the subject or body.{' '}
<InlineLink
href={`${DOCS_URL}/guides/local-development/customizing-email-templates#template-variables`}
>
Learn more
</InlineLink>
</p>
</div>
<div className="flex flex-wrap gap-x-1">
{template.variables.map((variable) => (
<Tooltip key={variable.value}>
<TooltipTrigger asChild>
<Button
type="outline"
size="tiny"
className="rounded-full"
onClick={() => insertTextAtCursor(variable.value)}
>
{variable.value}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{variable.description}
{variable.name === 'Token' &&
template.variables.some((x) => x.name === 'ConfirmationURL') && (
<>
, which can be used instead of{' '}
<code className="text-code-inline">ConfirmationURL</code>
</>
)}
{variable.name === 'SiteURL' && (
<>
{' '}
as defined in{' '}
<InlineLink href={`/project/${projectRef}/auth/url-configuration`}>
URL Configuration
</InlineLink>
</>
)}
</TooltipContent>
</Tooltip>
))}
</div>
</div>
</>
) : (
<>
<iframe
className="mb-0! mt-0 overflow-hidden h-96 w-full rounded-md border bg-white"
title={id}
srcDoc={bodyValue}
sandbox="allow-scripts allow-forms"
/>
<Admonition
type="default"
title="Email rendering may differ"
description="The preview shown here may differ slightly from how your email appears in the recipient’s email client."
/>
</>
)}
</CardContent>
<SpamValidation spamRules={spamRules} />
<CardFooter className="flex flex-row justify-between gap-2">
{hasCustomTemplate && (
<ResetTemplateDialog
template={template}
hasUnsavedChanges={hasChanges}
onResetSuccess={(config: AuthConfigResponse) => {
form.reset(getFormValuesFromConfig(config))
setBodyValue((config && config[messageSlug]) ?? '')
setValidationResult(undefined)
setHasUnsavedChanges(false)
}}
/>
)}
<div className="ml-auto flex flex-row gap-2">
{hasChanges && (
<Button
type="default"
htmlType="button"
onClick={() => {
form.reset(INITIAL_VALUES)
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
setHasUnsavedChanges(false)
}}
>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={!canUpdateConfig || isSavingTemplate || !hasChanges}
loading={isSavingTemplate}
>
Save changes
</Button>
</div>
</CardFooter>
</>
)}
</form>
<PreventNavigationOnUnsavedChanges hasChanges={hasChanges} />
</Form>
)
}