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>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2026-05-18 14:33:26 +10:00
1 parent 179f00cceb
commit f45a6085f4
20 files changed
+1043 -578

No files matched your search

@@ -642,8 +642,8 @@ supabase functions deploy send-email --no-verify-jwt
Your company is expanding to France and Spain. As part of expansion efforts, the company would like to deliver internationalized email templates to best support local users in their native language. Ensure that you have configured `POSTMARK_SERVER_TOKEN` and `SEND_EMAIL_HOOK_SECRET` in your `.env` file.
```javascript
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0'
import { readAll } from 'https://deno.land/std/io/read_all.ts'
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0'
const postmarkEndpoint = 'https://api.postmarkapp.com/email'
// Replace this with your email
@@ -741,8 +741,8 @@ Deno.serve(async (req) => {
.replace('{{token}}', email_data.token || '')
.replace('{{new_token}}', email_data.new_token || '')
.replace('{{site_url}}', email_data.site_url || '')
.replace('{{old_email}}', email_data.email || '')
.replace('{{new_email}}', email_data.new_email || '')
.replace('{{old_email}}', email_data.old_email || '')
.replace('{{new_email}}', user.new_email || '')
const requestOptions = {
method: 'POST',
@@ -843,8 +843,8 @@ async function sendEmailWithPostmark(user: any, email_data: any, serverToken: st
.replace('{{token}}', email_data.token || '')
.replace('{{new_token}}', email_data.new_token || '')
.replace('{{site_url}}', email_data.site_url || '')
.replace('{{old_email}}', email_data.email || '')
.replace('{{new_email}}', email_data.new_email || '');
.replace('{{old_email}}', email_data.old_email || '')
.replace('{{new_email}}', user.new_email || '');
const requestOptions = {
method: 'POST',
@@ -872,8 +872,8 @@ async function sendEmailWithSendGrid(user: any, email_data: any, apiKey: string)
.replace('{{token}}', email_data.token || '')
.replace('{{new_token}}', email_data.new_token || '')
.replace('{{site_url}}', email_data.site_url || '')
.replace('{{old_email}}', email_data.email || '')
.replace('{{new_email}}', email_data.new_email || '');
.replace('{{old_email}}', email_data.old_email || '')
.replace('{{new_email}}', user.new_email || '');
const requestOptions = {
method: 'POST',
@@ -30,7 +30,6 @@ import z from 'zod'
import { useProfileIdentitiesQuery } from '@/data/profile/profile-identities-query'
import { useProfileUpdateMutation } from '@/data/profile/profile-update-mutation'
import { useProfile } from '@/lib/profile'
import type { FormSchema } from '@/types'
const FormSchema = z.object({
first_name: z.string().optional(),
@@ -1,436 +0,0 @@
import * as z from 'zod'
import type { FormSchema } from '@/types'
const JSON_SCHEMA_VERSION = 'http://json-schema.org/draft-07/schema#'
const CONFIRMATION: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'CONFIRMATION',
type: 'object',
title: 'Confirm sign up',
purpose: 'Ask users to confirm their email address after signing up',
properties: {
MAILER_SUBJECTS_CONFIRMATION: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_CONFIRMATION_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .ConfirmationURL }}\` : URL to confirm the email address for the new account
- \`{{ .Token }}\` : The 6-digit numeric email OTP
- \`{{ .TokenHash }}\` : The hashed token used in the URL
- \`{{ .SiteURL }}\` : The URL of the site
- \`{{ .Email }}\` : The user's email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
- \`{{ .RedirectTo }}\` : The URL of \`emailRedirectTo\` passed in options
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_CONFIRMATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'authentication',
},
}
const INVITE: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'INVITE',
type: 'object',
title: 'Invite user',
purpose: "Invite users who don't yet have an account to sign up",
properties: {
MAILER_SUBJECTS_INVITE: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_INVITE_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .ConfirmationURL }}\` : URL to accept the invitation to create an account
- \`{{ .Token }}\` : The 6-digit numeric email OTP
- \`{{ .TokenHash }}\` : The hashed token used in the URL
- \`{{ .SiteURL }}\` : The URL of the site
- \`{{ .Email }}\` : The user's email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
- \`{{ .RedirectTo }}\` : The URL of \`redirectTo\` passed in options
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_INVITE: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'authentication',
},
}
const MAGIC_LINK: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'MAGIC_LINK',
type: 'object',
title: 'Magic link',
purpose: 'Allow users to sign in via a one-time link sent to their email',
properties: {
MAILER_SUBJECTS_MAGIC_LINK: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_MAGIC_LINK_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .ConfirmationURL }}\` : URL for a one-time login to the user's account
- \`{{ .Token }}\` : The 6-digit numeric email OTP
- \`{{ .TokenHash }}\` : The hashed token used in the URL
- \`{{ .SiteURL }}\` : The URL of the site
- \`{{ .Email }}\` : The user's email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
- \`{{ .RedirectTo }}\` : The URL of \`emailRedirectTo\` passed in options
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_MAGIC_LINK: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'authentication',
},
}
const EMAIL_CHANGE: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'EMAIL_CHANGE',
type: 'object',
title: 'Change email address',
purpose: 'Ask users to verify their new email address after changing it',
properties: {
MAILER_SUBJECTS_EMAIL_CHANGE: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_EMAIL_CHANGE_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .ConfirmationURL }}\` : URL to confirm the email change
- \`{{ .Token }}\` : The 6-digit numeric email OTP
- \`{{ .TokenHash }}\` : The hashed token used in the URL
- \`{{ .SiteURL }}\` : The URL of the site
- \`{{ .Email }}\` : The original user's email address
- \`{{ .NewEmail }}\` : The user's new email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
- \`{{ .RedirectTo }}\` : The URL of \`emailRedirectTo\` passed in options
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_EMAIL_CHANGE: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'authentication',
},
}
const RECOVERY: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'RECOVERY',
type: 'object',
title: 'Reset password',
purpose: 'Allow users to reset their password if they forget it',
properties: {
MAILER_SUBJECTS_RECOVERY: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_RECOVERY_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .ConfirmationURL }}\` : URL to confirm the password reset
- \`{{ .Token }}\` : The 6-digit numeric email OTP
- \`{{ .TokenHash }}\` : The hashed token used in the URL
- \`{{ .SiteURL }}\` : The URL of the site
- \`{{ .Email }}\` : The user's email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
- \`{{ .RedirectTo }}\` : The URL of \`redirectTo\` passed in options
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_RECOVERY: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'authentication',
},
}
const REAUTHENTICATION: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'REAUTHENTICATION',
type: 'object',
title: 'Reauthentication',
purpose: 'Ask users to re-authenticate before performing a sensitive action',
properties: {
MAILER_SUBJECTS_REAUTHENTICATION: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_REAUTHENTICATION_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .Token }}\` : The 6-digit numeric email OTP
- \`{{ .SiteURL }}\` : The URL of the site
- \`{{ .Email }}\` : The user's email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_REAUTHENTICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'authentication',
},
}
// Notifications
const PASSWORD_CHANGED_NOTIFICATION: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'PASSWORD_CHANGED_NOTIFICATION',
type: 'object',
title: 'Password changed',
purpose: 'Notify users when their password has changed',
properties: {
MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .Email }}\` : The user's email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
const EMAIL_CHANGED_NOTIFICATION: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'EMAIL_CHANGED_NOTIFICATION',
type: 'object',
title: 'Email address changed',
purpose: 'Notify users when their email address has changed',
properties: {
MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .Email }}\` : The user's new email address
- \`{{ .OldEmail }}\` : The user's old email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
const PHONE_CHANGED_NOTIFICATION: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'PHONE_CHANGED_NOTIFICATION',
type: 'object',
title: 'Phone number changed',
purpose: 'Notify users when their phone number has changed',
properties: {
MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_PHONE_CHANGED_NOTIFICATION_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .Email }}\` : The user's email address
- \`{{ .Phone }}\` : The user's new phone number
- \`{{ .OldPhone }}\` : The user's old phone number
- \`{{ .Data }}\` : The user's \`user_metadata\`
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
const IDENTITY_LINKED_NOTIFICATION: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'IDENTITY_LINKED_NOTIFICATION',
type: 'object',
title: 'Identity linked',
purpose: 'Notify users when a new identity has been linked to their account',
properties: {
MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_IDENTITY_LINKED_NOTIFICATION_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .Email }}\` : The user's email address
- \`{{ .Provider }}\` : The provider of the newly linked identity
- \`{{ .Data }}\` : The user's \`user_metadata\`
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
const IDENTITY_UNLINKED_NOTIFICATION: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'IDENTITY_UNLINKED_NOTIFICATION',
type: 'object',
title: 'Identity unlinked',
purpose: 'Notify users when an identity has been unlinked from their account',
properties: {
MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_IDENTITY_UNLINKED_NOTIFICATION_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .Email }}\` : The user's email address
- \`{{ .Provider }}\` : The provider of the unlinked identity
- \`{{ .Data }}\` : The user's \`user_metadata\`
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
const MFA_FACTOR_ENROLLED_NOTIFICATION: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'MFA_FACTOR_ENROLLED_NOTIFICATION',
type: 'object',
title: 'Multi-factor authentication method added',
purpose:
'Notify users when a new multi-factor authentication method has been added to their account',
properties: {
MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .Email }}\` : The user's email address
- \`{{ .FactorType }}\` : The type of the newly enrolled MFA factor
- \`{{ .Data }}\` : The user's \`user_metadata\`
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
const MFA_FACTOR_UNENROLLED_NOTIFICATION: FormSchema = {
$schema: JSON_SCHEMA_VERSION,
id: 'MFA_FACTOR_UNENROLLED_NOTIFICATION',
type: 'object',
title: 'Multi-factor authentication method removed',
purpose:
'Notify users when a multi-factor authentication method has been removed from their account',
properties: {
MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION: {
title: 'Subject',
type: 'string',
},
MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION_CONTENT: {
title: 'Body',
descriptionOptional: 'HTML body of your email',
type: 'code',
description: `
- \`{{ .Email }}\` : The user's email address
- \`{{ .FactorType }}\` : The type of the newly enrolled MFA factor
- \`{{ .Data }}\` : The user's \`user_metadata\`
`,
},
},
validationSchema: z.object({
MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
export const TEMPLATES_SCHEMAS = [
CONFIRMATION,
INVITE,
MAGIC_LINK,
EMAIL_CHANGE,
RECOVERY,
REAUTHENTICATION,
// Notifications
PASSWORD_CHANGED_NOTIFICATION,
EMAIL_CHANGED_NOTIFICATION,
PHONE_CHANGED_NOTIFICATION,
IDENTITY_LINKED_NOTIFICATION,
IDENTITY_UNLINKED_NOTIFICATION,
MFA_FACTOR_ENROLLED_NOTIFICATION,
MFA_FACTOR_UNENROLLED_NOTIFICATION,
]
@@ -0,0 +1,384 @@
import * as z from 'zod'
import type { AuthTemplate, TemplateVariable, TemplateVariableName } from './EmailTemplates.types'
const TemplateVariables: Record<TemplateVariableName, TemplateVariable> = {
ConfirmationURL: {
name: 'ConfirmationURL',
value: '{{ .ConfirmationURL }}',
description: 'URL to confirm the email address for the new account',
},
Token: {
name: 'Token',
value: '{{ .Token }}',
description: '6-digit numeric email OTP',
},
TokenHash: {
name: 'TokenHash',
value: '{{ .TokenHash }}',
description: 'Hashed token used in the URL, useful for constructing your own email link',
},
SiteURL: {
name: 'SiteURL',
value: '{{ .SiteURL }}',
description: 'Redirect URL',
},
Email: {
name: 'Email',
value: '{{ .Email }}',
description: "User's current email address",
},
NewEmail: {
name: 'NewEmail',
value: '{{ .NewEmail }}',
description: "User's new email address",
},
OldEmail: {
name: 'OldEmail',
value: '{{ .OldEmail }}',
description: "User's old email address",
},
Phone: {
name: 'Phone',
value: '{{ .Phone }}',
description: "User's new phone number",
},
OldPhone: {
name: 'OldPhone',
value: '{{ .OldPhone }}',
description: "User's old phone number",
},
Provider: {
name: 'Provider',
value: '{{ .Provider }}',
description: 'Provider of newly linked/unlinked identity',
},
FactorType: {
name: 'FactorType',
value: '{{ .FactorType }}',
description: 'Type of newly enrolled/unenrolled MFA factor',
},
Data: {
name: 'Data',
value: '{{ .Data }}',
description: (
<>
User's <code className="text-code-inline">user_metadata</code>, use this to personalize the
message
</>
),
},
RedirectTo: {
name: 'RedirectTo',
value: '{{ .RedirectTo }}',
description: (
<>
Redirect URL passed as the <code className="text-code-inline">redirectTo</code> option in
the auth method call
</>
),
},
}
const CONFIRMATION: AuthTemplate = {
id: 'CONFIRMATION',
type: 'object',
title: 'Confirm sign up',
purpose: 'Ask users to confirm their email address after signing up',
properties: {
MAILER_SUBJECTS_CONFIRMATION: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_CONFIRMATION_CONTENT: { title: 'Body', type: 'code' },
},
variables: [
TemplateVariables.ConfirmationURL,
TemplateVariables.Token,
TemplateVariables.TokenHash,
TemplateVariables.SiteURL,
TemplateVariables.Email,
TemplateVariables.Data,
TemplateVariables.RedirectTo,
],
validationSchema: z.object({
MAILER_SUBJECTS_CONFIRMATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'authentication',
},
}
const INVITE: AuthTemplate = {
id: 'INVITE',
type: 'object',
title: 'Invite user',
purpose: "Invite users who don't yet have an account to sign up",
properties: {
MAILER_SUBJECTS_INVITE: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_INVITE_CONTENT: { title: 'Body', type: 'code' },
},
variables: [
TemplateVariables.ConfirmationURL,
TemplateVariables.Token,
TemplateVariables.TokenHash,
TemplateVariables.SiteURL,
TemplateVariables.Email,
TemplateVariables.Data,
TemplateVariables.RedirectTo,
],
validationSchema: z.object({
MAILER_SUBJECTS_INVITE: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'authentication',
},
}
const MAGIC_LINK: AuthTemplate = {
id: 'MAGIC_LINK',
type: 'object',
title: 'Magic link or OTP',
purpose: 'Send a one-time sign-in link or one-time password',
properties: {
MAILER_SUBJECTS_MAGIC_LINK: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_MAGIC_LINK_CONTENT: { title: 'Body', type: 'code' },
},
variables: [
TemplateVariables.ConfirmationURL,
TemplateVariables.Token,
TemplateVariables.TokenHash,
TemplateVariables.SiteURL,
TemplateVariables.Email,
TemplateVariables.Data,
TemplateVariables.RedirectTo,
],
validationSchema: z.object({
MAILER_SUBJECTS_MAGIC_LINK: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'authentication',
},
}
const EMAIL_CHANGE: AuthTemplate = {
id: 'EMAIL_CHANGE',
type: 'object',
title: 'Change email address',
purpose: 'Ask users to verify their new email address after changing it',
properties: {
MAILER_SUBJECTS_EMAIL_CHANGE: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_EMAIL_CHANGE_CONTENT: { title: 'Body', type: 'code' },
},
variables: [
TemplateVariables.ConfirmationURL,
TemplateVariables.Token,
TemplateVariables.TokenHash,
TemplateVariables.SiteURL,
TemplateVariables.Email,
TemplateVariables.NewEmail,
TemplateVariables.Data,
TemplateVariables.RedirectTo,
],
validationSchema: z.object({
MAILER_SUBJECTS_EMAIL_CHANGE: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'authentication',
},
}
const RECOVERY: AuthTemplate = {
id: 'RECOVERY',
type: 'object',
title: 'Reset password',
purpose: 'Allow users to reset their password if they forget it',
properties: {
MAILER_SUBJECTS_RECOVERY: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_RECOVERY_CONTENT: { title: 'Body', type: 'code' },
},
variables: [
TemplateVariables.ConfirmationURL,
TemplateVariables.Token,
TemplateVariables.TokenHash,
TemplateVariables.SiteURL,
TemplateVariables.Email,
TemplateVariables.Data,
TemplateVariables.RedirectTo,
],
validationSchema: z.object({
MAILER_SUBJECTS_RECOVERY: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'authentication',
},
}
const REAUTHENTICATION: AuthTemplate = {
id: 'REAUTHENTICATION',
type: 'object',
title: 'Reauthentication',
purpose: 'Ask users to re-authenticate before performing a sensitive action',
properties: {
MAILER_SUBJECTS_REAUTHENTICATION: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_REAUTHENTICATION_CONTENT: { title: 'Body', type: 'code' },
},
variables: [
TemplateVariables.Token,
TemplateVariables.SiteURL,
TemplateVariables.Email,
TemplateVariables.Data,
],
validationSchema: z.object({
MAILER_SUBJECTS_REAUTHENTICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'authentication',
},
}
// Notifications
const PASSWORD_CHANGED_NOTIFICATION: AuthTemplate = {
id: 'PASSWORD_CHANGED_NOTIFICATION',
type: 'object',
title: 'Password changed',
purpose: 'Notify users when their password has changed',
properties: {
MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' },
},
variables: [TemplateVariables.Email, TemplateVariables.Data],
validationSchema: z.object({
MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
const EMAIL_CHANGED_NOTIFICATION: AuthTemplate = {
id: 'EMAIL_CHANGED_NOTIFICATION',
type: 'object',
title: 'Email address changed',
purpose: 'Notify users when their email address has changed',
properties: {
MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' },
},
variables: [TemplateVariables.Email, TemplateVariables.OldEmail, TemplateVariables.Data],
validationSchema: z.object({
MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
const PHONE_CHANGED_NOTIFICATION: AuthTemplate = {
id: 'PHONE_CHANGED_NOTIFICATION',
type: 'object',
title: 'Phone number changed',
purpose: 'Notify users when their phone number has changed',
properties: {
MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_PHONE_CHANGED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' },
},
variables: [
TemplateVariables.Email,
TemplateVariables.Phone,
TemplateVariables.OldPhone,
TemplateVariables.Data,
],
validationSchema: z.object({
MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
const IDENTITY_LINKED_NOTIFICATION: AuthTemplate = {
id: 'IDENTITY_LINKED_NOTIFICATION',
type: 'object',
title: 'Identity linked',
purpose: 'Notify users when a new identity has been linked to their account',
properties: {
MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_IDENTITY_LINKED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' },
},
variables: [TemplateVariables.Email, TemplateVariables.Provider, TemplateVariables.Data],
validationSchema: z.object({
MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
const IDENTITY_UNLINKED_NOTIFICATION: AuthTemplate = {
id: 'IDENTITY_UNLINKED_NOTIFICATION',
type: 'object',
title: 'Identity unlinked',
purpose: 'Notify users when an identity has been unlinked from their account',
properties: {
MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_IDENTITY_UNLINKED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' },
},
variables: [TemplateVariables.Email, TemplateVariables.Provider, TemplateVariables.Data],
validationSchema: z.object({
MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
const MFA_FACTOR_ENROLLED_NOTIFICATION: AuthTemplate = {
id: 'MFA_FACTOR_ENROLLED_NOTIFICATION',
type: 'object',
title: 'MFA method added',
purpose: 'Notify users when an MFA method has been added to their account',
properties: {
MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' },
},
variables: [TemplateVariables.Email, TemplateVariables.FactorType, TemplateVariables.Data],
validationSchema: z.object({
MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
const MFA_FACTOR_UNENROLLED_NOTIFICATION: AuthTemplate = {
id: 'MFA_FACTOR_UNENROLLED_NOTIFICATION',
type: 'object',
title: 'MFA method removed',
purpose: 'Notify users when an MFA method has been removed from their account',
properties: {
MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION: { title: 'Subject', type: 'string' },
MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION_CONTENT: { title: 'Body', type: 'code' },
},
variables: [TemplateVariables.Email, TemplateVariables.FactorType, TemplateVariables.Data],
validationSchema: z.object({
MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION: z.string().min(1, 'Subject is required.'),
}),
misc: {
emailTemplateType: 'security',
},
}
export const TEMPLATES_SCHEMAS = [
CONFIRMATION,
INVITE,
MAGIC_LINK,
EMAIL_CHANGE,
RECOVERY,
REAUTHENTICATION,
// Notifications
PASSWORD_CHANGED_NOTIFICATION,
EMAIL_CHANGED_NOTIFICATION,
PHONE_CHANGED_NOTIFICATION,
IDENTITY_LINKED_NOTIFICATION,
IDENTITY_UNLINKED_NOTIFICATION,
MFA_FACTOR_ENROLLED_NOTIFICATION,
MFA_FACTOR_UNENROLLED_NOTIFICATION,
]
@@ -18,7 +18,7 @@ import {
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import * as z from 'zod'
import { TEMPLATES_SCHEMAS } from '../AuthTemplatesValidation'
import { TEMPLATES_SCHEMAS } from './AuthTemplatesValidation'
import { slugifyTitle } from './EmailTemplates.utils'
import AlertError from '@/components/ui/AlertError'
import { InlineLink } from '@/components/ui/InlineLink'
@@ -0,0 +1,64 @@
import { ReactNode } from 'react'
import z from 'zod'
export type KebabCase<S extends string> = S extends `${infer A}_${infer B}`
? `${Lowercase<A>}-${KebabCase<B>}`
: Lowercase<S>
const AUTH_TEMPLATE_TYPES = [
'CONFIRMATION',
'EMAIL_CHANGE',
'INVITE',
'MAGIC_LINK',
'RECOVERY',
'REAUTHENTICATION',
'PASSWORD_CHANGED_NOTIFICATION',
'EMAIL_CHANGED_NOTIFICATION',
'PHONE_CHANGED_NOTIFICATION',
'IDENTITY_LINKED_NOTIFICATION',
'IDENTITY_UNLINKED_NOTIFICATION',
'MFA_FACTOR_ENROLLED_NOTIFICATION',
'MFA_FACTOR_UNENROLLED_NOTIFICATION',
] as const
export type AuthTemplateType = (typeof AUTH_TEMPLATE_TYPES)[number]
export type AuthTemplateResetType = KebabCase<AuthTemplateType>
export type TemplateVariableName =
| 'ConfirmationURL'
| 'Token'
| 'TokenHash'
| 'SiteURL'
| 'Email'
| 'NewEmail'
| 'OldEmail'
| 'Phone'
| 'OldPhone'
| 'Provider'
| 'FactorType'
| 'Data'
| 'RedirectTo'
export type TemplateVariable = {
name: TemplateVariableName
value: string
description: string | ReactNode
}
export interface AuthTemplate {
id: AuthTemplateType
type: 'object'
title: string
purpose: string
properties: {
[x: string]: {
title: string
type: 'boolean' | 'string' | 'select' | 'number' | 'code'
description?: string
descriptionOptional?: string
}
}
variables: TemplateVariable[]
validationSchema: z.AnyZodObject
misc: { emailTemplateType: 'authentication' | 'security' }
}
@@ -1,3 +1,5 @@
import { type AuthTemplateType, type KebabCase } from './EmailTemplates.types'
/**
* Convert template title to URL-friendly slug
* Shared function to ensure slug matching works correctly across multiple files
@@ -6,3 +8,7 @@
export const slugifyTitle = (title: string) => {
return title.trim().replace(/\s+/g, '-').toLowerCase()
}
/* Convert upper camel case to lower kebab case */
export const getAuthTemplateType = <T extends AuthTemplateType>(id: T) =>
id.toLowerCase().replace(/_/g, '-') as KebabCase<T>
@@ -0,0 +1,89 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useState } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
} from 'ui'
import { type AuthTemplate } from './EmailTemplates.types'
import { getAuthTemplateType } from './EmailTemplates.utils'
import { AuthConfigResponse } from '@/data/auth/auth-config-query'
import { useAuthTemplateResetMutation } from '@/data/auth/auth-template-reset-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
export const ResetTemplateDialog = ({
template,
hasUnsavedChanges,
onResetSuccess,
}: {
template: AuthTemplate
hasUnsavedChanges: boolean
onResetSuccess: (config: AuthConfigResponse) => void
}) => {
const { ref: projectRef } = useParams()
const [open, setOpen] = useState(false)
const { can: canUpdateConfig } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'custom_config_gotrue'
)
const { id } = template
const templateType = getAuthTemplateType(id)
const { mutate: resetAuthTemplate, isPending: isResettingTemplate } =
useAuthTemplateResetMutation()
const resetTemplateToDefault = () => {
if (!projectRef) return console.error('Project ref is required')
if (!templateType) return console.error('Template type is required')
resetAuthTemplate(
{
projectRef,
template: templateType,
},
{
onSuccess: (config) => {
toast.success('Email template reset to default')
onResetSuccess(config)
setOpen(false)
},
}
)
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button type="default" htmlType="button" disabled={!canUpdateConfig || isResettingTemplate}>
Reset template
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset template to default</AlertDialogTitle>
<AlertDialogDescription>
{hasUnsavedChanges
? 'This will discard your unsaved changes and use the default subject line and email body content.'
: 'This will remove your custom subject line and email body content. The default values will be used instead.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isResettingTemplate}>Cancel</AlertDialogCancel>
<Button type="warning" onClick={resetTemplateToDefault} loading={isResettingTemplate}>
Reset
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,259 @@
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from 'sonner'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TEMPLATES_SCHEMAS } from './AuthTemplatesValidation'
import { TemplateEditor } from './TemplateEditor'
import { render } from '@/tests/helpers'
const {
resetTemplateMock,
updateAuthConfigMock,
useAuthConfigQueryMock,
useAuthTemplateResetMutationMock,
useAuthConfigUpdateMutationMock,
useAsyncCheckPermissionsMock,
validateSpamMock,
} = vi.hoisted(() => ({
resetTemplateMock: vi.fn(),
updateAuthConfigMock: vi.fn(),
useAuthConfigQueryMock: vi.fn(),
useAuthTemplateResetMutationMock: vi.fn(),
useAuthConfigUpdateMutationMock: vi.fn(),
useAsyncCheckPermissionsMock: vi.fn(),
validateSpamMock: vi.fn(),
}))
vi.mock(import('common'), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
useParams: vi.fn().mockReturnValue({ ref: 'project-ref' }),
}
})
vi.mock('@/components/ui/CodeEditor/CodeEditor', () => ({
CodeEditor: ({
value,
onInputChange,
}: {
value: string
onInputChange: (value: string) => void
}) => (
<textarea
aria-label="Body source"
value={value}
onChange={(event) => onInputChange(event.target.value)}
/>
),
}))
vi.mock('@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges', () => ({
PreventNavigationOnUnsavedChanges: () => null,
}))
vi.mock('@/data/auth/auth-config-query', () => ({
useAuthConfigQuery: useAuthConfigQueryMock,
}))
vi.mock('@/data/auth/auth-config-update-mutation', () => ({
useAuthConfigUpdateMutation: useAuthConfigUpdateMutationMock,
}))
vi.mock('@/data/auth/auth-template-reset-mutation', () => ({
useAuthTemplateResetMutation: useAuthTemplateResetMutationMock,
}))
vi.mock('@/data/auth/validate-spam-mutation', () => ({
useValidateSpamMutation: () => ({ mutate: validateSpamMock }),
}))
vi.mock('@/hooks/misc/useCheckPermissions', () => ({
useAsyncCheckPermissions: useAsyncCheckPermissionsMock,
}))
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
const confirmationTemplate = TEMPLATES_SCHEMAS.find((template) => template.id === 'CONFIRMATION')!
const createAuthConfig = ({
subject = 'Confirm your email address',
body,
hasCustomBody,
hasCustomSubject = false,
}: {
subject?: string
body: string
hasCustomBody: boolean
hasCustomSubject?: boolean
}) => ({
MAILER_SUBJECTS_CONFIRMATION: subject,
MAILER_SUBJECTS_CUSTOM_CONTENTS: {
MAILER_SUBJECTS_CONFIRMATION: hasCustomSubject,
},
MAILER_TEMPLATES_CONFIRMATION_CONTENT: body,
MAILER_TEMPLATES_CUSTOM_CONTENTS: {
MAILER_TEMPLATES_CONFIRMATION_CONTENT: hasCustomBody,
},
SMTP_HOST: 'smtp.example.com',
SMTP_PASS: 'password',
SMTP_USER: 'user',
})
const renderTemplateEditor = ({
body = '<p>Default template</p>',
canUpdateConfig = true,
hasCustomBody = false,
hasCustomSubject = false,
}: {
body?: string
canUpdateConfig?: boolean
hasCustomBody?: boolean
hasCustomSubject?: boolean
} = {}) => {
useAuthConfigQueryMock.mockReturnValue({
data: createAuthConfig({ body, hasCustomBody, hasCustomSubject }),
isSuccess: true,
})
useAsyncCheckPermissionsMock.mockReturnValue({ can: canUpdateConfig })
useAuthConfigUpdateMutationMock.mockReturnValue({ mutate: updateAuthConfigMock })
useAuthTemplateResetMutationMock.mockReturnValue({ mutate: resetTemplateMock })
return render(<TemplateEditor template={confirmationTemplate} />)
}
describe('TemplateEditor reset to default', () => {
beforeEach(() => {
vi.clearAllMocks()
validateSpamMock.mockImplementation((_vars, callbacks) => callbacks?.onSuccess?.({ rules: [] }))
})
const resetAuthConfig = createAuthConfig({
subject: 'Confirm your email address',
body: '<h2>Confirm your email address</h2>\n\n<p>Follow the link below to confirm this email address and finish signing up.</p>\n<p><a href="{{ .ConfirmationURL }}">Confirm email address</a></p>',
hasCustomBody: false,
hasCustomSubject: false,
})
it('hides reset when the API does not mark the body as custom', () => {
renderTemplateEditor()
expect(screen.queryByRole('button', { name: 'Reset template' })).not.toBeInTheDocument()
})
it('shows reset when the API marks the body as custom', () => {
renderTemplateEditor({ hasCustomBody: true })
expect(screen.getByRole('button', { name: 'Reset template' })).toBeInTheDocument()
})
it('shows reset when the API marks the subject as custom', () => {
renderTemplateEditor({ hasCustomSubject: true })
expect(screen.getByRole('button', { name: 'Reset template' })).toBeInTheDocument()
})
it('keeps reset visible while there are unsaved editor changes', async () => {
const user = userEvent.setup()
renderTemplateEditor({ hasCustomBody: true })
await user.clear(screen.getByLabelText('Body source'))
await user.type(screen.getByLabelText('Body source'), '<p>Unsaved body</p>')
expect(screen.getByRole('button', { name: 'Reset template' })).toBeInTheDocument()
})
it('warns that reset discards unsaved changes', async () => {
const user = userEvent.setup()
renderTemplateEditor({ hasCustomBody: true })
await user.clear(screen.getByLabelText('Body source'))
await user.type(screen.getByLabelText('Body source'), '<p>Unsaved body</p>')
await user.click(screen.getByRole('button', { name: 'Reset template' }))
expect(
await screen.findByText(
'This will discard your unsaved changes and use the default subject line and email body content.'
)
).toBeInTheDocument()
})
it('resets the template through the dedicated reset endpoint after confirmation', async () => {
const user = userEvent.setup()
resetTemplateMock.mockImplementation((_vars, callbacks) =>
callbacks?.onSuccess?.(resetAuthConfig)
)
renderTemplateEditor({ hasCustomBody: true })
await user.click(screen.getByRole('button', { name: 'Reset template' }))
const dialog = await screen.findByRole('alertdialog')
await user.click(within(dialog).getByRole('button', { name: 'Reset' }))
await waitFor(() =>
expect(resetTemplateMock).toHaveBeenCalledWith(
{
projectRef: 'project-ref',
template: 'confirmation',
},
expect.any(Object)
)
)
expect(toast.success).toHaveBeenCalledWith('Email template reset to default')
})
it('does not reset through the auth config update payload', async () => {
const user = userEvent.setup()
resetTemplateMock.mockImplementation((_vars, callbacks) =>
callbacks?.onSuccess?.(resetAuthConfig)
)
renderTemplateEditor({ hasCustomBody: true })
await user.click(screen.getByRole('button', { name: 'Reset template' }))
const dialog = await screen.findByRole('alertdialog')
await user.click(within(dialog).getByRole('button', { name: 'Reset' }))
await waitFor(() => expect(resetTemplateMock).toHaveBeenCalled())
expect(updateAuthConfigMock).not.toHaveBeenCalled()
})
it('uses the reset response as the new editor state', async () => {
const user = userEvent.setup()
resetTemplateMock.mockImplementation((_vars, callbacks) =>
callbacks?.onSuccess?.(resetAuthConfig)
)
renderTemplateEditor({
body: '<p>Custom body</p>',
hasCustomBody: true,
hasCustomSubject: true,
})
await user.click(screen.getByRole('button', { name: 'Reset template' }))
const dialog = await screen.findByRole('alertdialog')
await user.click(within(dialog).getByRole('button', { name: 'Reset' }))
await waitFor(() => {
expect(screen.getByDisplayValue('Confirm your email address')).toBeInTheDocument()
expect(screen.getByLabelText('Body source')).toHaveValue(
'<h2>Confirm your email address</h2>\n\n<p>Follow the link below to confirm this email address and finish signing up.</p>\n<p><a href="{{ .ConfirmationURL }}">Confirm email address</a></p>'
)
})
})
it('disables reset when the user cannot update auth config', () => {
renderTemplateEditor({ hasCustomBody: true, canUpdateConfig: false })
expect(screen.getByRole('button', { name: 'Reset template' })).toBeDisabled()
})
})
@@ -2,7 +2,7 @@ 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 { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import ReactMarkdown from 'react-markdown'
import { toast } from 'sonner'
@@ -21,32 +21,54 @@ import {
} 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 { 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 type { FormSchema } from '@/types'
import { DOCS_URL } from '@/lib/constants'
interface TemplateEditorProps {
template: FormSchema
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 editorRef = useRef<editor.IStandaloneCodeEditor>(null)
// [Joshen] Error state is handled in the parent
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({
@@ -56,37 +78,39 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
},
})
const { id, properties } = template
const subjectSlug = Object.keys(properties).find((key) => key.startsWith('MAILER_SUBJECTS_')) as
| EmailTemplateSubjectKey
| undefined
const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as keyof typeof authConfig
const messageProperty = properties[messageSlug]
const builtInSMTP =
isSuccess &&
authConfig &&
(!authConfig.SMTP_HOST || !authConfig.SMTP_USER || !authConfig.SMTP_PASS)
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 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(() => {
const result: { [x: string]: string } = {}
Object.keys(properties).forEach((key) => {
result[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string
})
return result
}, [authConfig, properties])
return getFormValuesFromConfig(authConfig)
}, [authConfig, getFormValuesFromConfig])
const form = useForm({
defaultValues: INITIAL_VALUES,
resolver: zodResolver(template.validationSchema),
})
const onSubmit = (values: any) => {
const onSubmit = (values: z.infer<typeof template.validationSchema>) => {
if (!projectRef) return console.error('Project ref is required')
setIsSavingTemplate(true)
@@ -137,33 +161,14 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
)
}
// Single useMemo hook to parse and prepare message variables
const messageVariables = useMemo(() => {
if (!messageProperty?.description) return []
// Parse bullet point format: - `{{ .Variable }}` : Description
const lines = messageProperty.description.split('\n')
const variables: { variable: string; description: string }[] = []
for (const line of lines) {
// Match lines that start with a bullet point followed by a variable in the format {{ .Variable }}
// Handle variations in formatting (with or without backticks, different spacing)
const match = line.match(/-\s*`?({{\s*\.\w+\s*}})`?\s*(?::|-)?\s*(.+)/)
if (match && match[1] && match[2]) {
variables.push({
variable: match[1].replace(/`/g, '').trim(),
description: match[2].trim(),
})
}
}
return variables
}, [messageProperty?.description])
// 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
@@ -198,14 +203,10 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
// Update form values when authConfig changes
useEffect(() => {
if (authConfig) {
const values: { [key: string]: string } = {}
Object.keys(properties).forEach((key) => {
values[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string
})
form.reset(values)
form.reset(getFormValuesFromConfig(authConfig))
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
}
}, [authConfig, properties, messageSlug, form])
}, [authConfig, getFormValuesFromConfig, messageSlug, form])
useEffect(() => {
if (projectRef && id && !!authConfig) {
@@ -300,27 +301,57 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
editorRef={editorRef}
/>
</div>
{messageVariables.length > 0 && (
<div className="flex flex-wrap gap-1">
{messageVariables.map(({ variable, description }) => (
<Tooltip key={variable}>
<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)}
onClick={() => insertTextAtCursor(variable.value)}
>
{variable}
{variable.value}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{description || 'Variable description not available'}</p>
<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>
</>
) : (
<>
@@ -341,27 +372,42 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
<SpamValidation spamRules={spamRules} />
<CardFooter className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button
type="default"
onClick={() => {
form.reset(INITIAL_VALUES)
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
<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)
}}
>
Cancel
</Button>
/>
)}
<Button
type="primary"
htmlType="submit"
disabled={!canUpdateConfig || isSavingTemplate || !hasChanges}
loading={isSavingTemplate}
>
Save changes
</Button>
<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>
</>
)}
@@ -48,7 +48,6 @@ import { useDatabaseFunctionUpdateMutation } from '@/data/database-functions/dat
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
import type { FormSchema } from '@/types'
const FORM_ID = 'create-function-sidepanel'
@@ -23,7 +23,7 @@ export const AuthEmailsLayout = ({ children }: PropsWithChildren<{}>) => {
]
return (
<AuthLayout title="Email">
<AuthLayout title="Emails">
{showEmails ? (
<PageLayout
title="Emails"
@@ -52,7 +52,7 @@ describe('generateAuthMenu', () => {
expect(names).toContain('Overview')
expect(names).toContain('Users')
expect(names).toContain('OAuth Apps')
expect(names).toContain('Email')
expect(names).toContain('Emails')
expect(names).toContain('Sign In / Providers')
expect(names).toContain('OAuth Server')
expect(names).toContain('Passkeys')
@@ -81,7 +81,7 @@ describe('generateAuthMenu', () => {
expect(names).toContain('Audit Logs')
expect(names).not.toContain('Overview')
expect(names).not.toContain('Email')
expect(names).not.toContain('Emails')
expect(names).not.toContain('Sign In / Providers')
expect(names).not.toContain('Rate Limits')
expect(names).not.toContain('Multi-Factor')
@@ -119,7 +119,7 @@ describe('generateAuthMenu', () => {
it.each([
['signInProviders', 'Sign In / Providers'],
['rateLimits', 'Rate Limits'],
['emails', 'Email'],
['emails', 'Emails'],
['multiFactor', 'Multi-Factor'],
['attackProtection', 'Attack Protection'],
['performance', 'Performance'],
@@ -68,7 +68,7 @@ export function generateAuthMenu(options: GenerateAuthMenuOptions): ProductMenuG
...(features.emails
? [
{
name: 'Email',
name: 'Emails',
key: 'email',
pages: ['templates', 'smtp'],
url: `${baseUrl}/templates`,
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ProjectAuthConfigData } from './auth-config-query'
import { authKeys } from './keys'
import type { components } from '@/data/api'
import { handleError, patch } from '@/data/fetchers'
@@ -43,16 +44,26 @@ export const useAuthConfigUpdateMutation = ({
mutationFn: (vars) => updateAuthConfig(vars),
async onSuccess(data, variables, context) {
const { projectRef, skipInvalidation = false } = variables
await Promise.all([
!skipInvalidation &&
queryClient.invalidateQueries({ queryKey: authKeys.authConfig(projectRef) }),
queryClient.invalidateQueries({ queryKey: lintKeys.lint(projectRef) }),
])
await queryClient.refetchQueries({
queryKey: lintKeys.lint(projectRef),
type: 'active',
})
if (!skipInvalidation) {
queryClient.setQueryData<ProjectAuthConfigData>(authKeys.authConfig(projectRef), data)
await queryClient.invalidateQueries({
queryKey: authKeys.authConfig(projectRef),
refetchType: 'none',
})
}
await onSuccess?.(data, variables, context)
queryClient
.invalidateQueries({ queryKey: lintKeys.lint(projectRef) })
.then(() =>
queryClient.refetchQueries({
queryKey: lintKeys.lint(projectRef),
type: 'active',
})
)
.catch(() => undefined)
},
async onError(data, variables, context) {
if (onError === undefined) {
@@ -0,0 +1,67 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { ProjectAuthConfigData } from './auth-config-query'
import { authKeys } from './keys'
import { type AuthTemplateResetType } from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.types'
import { handleError, post } from '@/data/fetchers'
import { lintKeys } from '@/data/lint/keys'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
type AuthTemplateResetVariables = {
projectRef: string
template: AuthTemplateResetType
}
async function resetAuthTemplate({ projectRef, template }: AuthTemplateResetVariables) {
const { data, error } = await post('/platform/auth/{ref}/templates/{template}/reset', {
params: { path: { ref: projectRef, template } },
})
if (error) handleError(error)
return data
}
type AuthTemplateResetData = Awaited<ReturnType<typeof resetAuthTemplate>>
export const useAuthTemplateResetMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<AuthTemplateResetData, ResponseError, AuthTemplateResetVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<AuthTemplateResetData, ResponseError, AuthTemplateResetVariables>({
mutationFn: (vars) => resetAuthTemplate(vars),
async onSuccess(data, variables, context) {
const { projectRef } = variables
queryClient.setQueryData<ProjectAuthConfigData>(authKeys.authConfig(projectRef), data)
await queryClient.invalidateQueries({
queryKey: authKeys.authConfig(projectRef),
refetchType: 'none',
})
await onSuccess?.(data, variables, context)
void queryClient
.invalidateQueries({ queryKey: lintKeys.lint(projectRef) })
.then(() =>
queryClient.refetchQueries({
queryKey: lintKeys.lint(projectRef),
type: 'active',
})
)
.catch(() => undefined)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to reset email template: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -35,13 +35,13 @@ import {
} from 'ui/src/components/shadcn/ui/breadcrumb'
import * as z from 'zod'
import { TEMPLATES_SCHEMAS } from '@/components/interfaces/Auth/AuthTemplatesValidation'
import { TEMPLATES_SCHEMAS } from '@/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation'
import { slugifyTitle } from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils'
import { TemplateEditor } from '@/components/interfaces/Auth/EmailTemplates/TemplateEditor'
import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout'
import DefaultLayout from '@/components/layouts/DefaultLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { DocsButton } from '@/components/ui/DocsButton'
import NoPermission from '@/components/ui/NoPermission'
import { NoPermission } from '@/components/ui/NoPermission'
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
-37
View File
@@ -1,37 +0,0 @@
interface Enum {
label: string
value: string
icon: string
}
export interface FormSchema {
$schema: string
id?: string
type: 'object'
title: string
purpose?: string
properties: {
[x: string]: {
title: string
type: 'boolean' | 'string' | 'select' | 'number' | 'code'
description?: string
descriptionOptional?: string
enum?: Enum[]
show?: {
key: string
matches: string
}
}
}
validationSchema: any // todo: use Yup type
misc?: {
iconKey?: string
requiresRedirect?: true
helper?: string
alert?: {
title?: string
description?: string
}
emailTemplateType?: 'authentication' | 'security'
}
}
-1
View File
@@ -7,7 +7,6 @@ export {
type Role,
type SupaResponse,
} from './base'
export type * from './form'
export type * from './next'
export { isNextPageWithLayout } from './next'
export type * from './react-query'
+15
View File
@@ -7357,6 +7357,21 @@ export interface components {
MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION: string
MAILER_SUBJECTS_REAUTHENTICATION: string
MAILER_SUBJECTS_RECOVERY: string
MAILER_SUBJECTS_CUSTOM_CONTENTS: {
MAILER_SUBJECTS_CONFIRMATION: boolean
MAILER_SUBJECTS_EMAIL_CHANGE: boolean
MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION: boolean
MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION: boolean
MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION: boolean
MAILER_SUBJECTS_INVITE: boolean
MAILER_SUBJECTS_MAGIC_LINK: boolean
MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION: boolean
MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION: boolean
MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: boolean
MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION: boolean
MAILER_SUBJECTS_REAUTHENTICATION: boolean
MAILER_SUBJECTS_RECOVERY: boolean
}
MAILER_TEMPLATES_CONFIRMATION_CONTENT: string
MAILER_TEMPLATES_CUSTOM_CONTENTS: {
MAILER_TEMPLATES_CONFIRMATION_CONTENT: boolean