mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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 --> [](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:
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'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user