From f45a6085f4a71dbaa96ace44fe65b30037c9f852 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 18 May 2026 14:33:26 +1000 Subject: [PATCH] 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. ## 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 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) --------- Co-authored-by: Joshen Lim --- .../auth/auth-hooks/send-email-hook.mdx | 14 +- .../Preferences/ProfileInformation.tsx | 1 - .../Auth/AuthTemplatesValidation.tsx | 436 ------------------ .../AuthTemplatesValidation.tsx | 384 +++++++++++++++ .../Auth/EmailTemplates/EmailTemplates.tsx | 2 +- .../EmailTemplates/EmailTemplates.types.ts | 64 +++ .../EmailTemplates/EmailTemplates.utils.ts | 6 + .../EmailTemplates/ResetTemplateDialog.tsx | 89 ++++ .../EmailTemplates/TemplateEditor.test.tsx | 259 +++++++++++ .../Auth/EmailTemplates/TemplateEditor.tsx | 200 ++++---- .../Functions/CreateFunction/index.tsx | 1 - .../layouts/AuthLayout/AuthEmailsLayout.tsx | 2 +- .../AuthLayout/AuthLayout.utils.test.ts | 6 +- .../layouts/AuthLayout/AuthLayout.utils.ts | 2 +- .../data/auth/auth-config-update-mutation.ts | 29 +- .../data/auth/auth-template-reset-mutation.ts | 67 +++ .../[ref]/auth/templates/[templateId].tsx | 6 +- apps/studio/types/form.ts | 37 -- apps/studio/types/index.ts | 1 - packages/api-types/types/platform.d.ts | 15 + 20 files changed, 1043 insertions(+), 578 deletions(-) delete mode 100644 apps/studio/components/interfaces/Auth/AuthTemplatesValidation.tsx create mode 100644 apps/studio/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation.tsx create mode 100644 apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.types.ts create mode 100644 apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx create mode 100644 apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx create mode 100644 apps/studio/data/auth/auth-template-reset-mutation.ts delete mode 100644 apps/studio/types/form.ts diff --git a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx index ac5a90549ef..3bd3c6a6fcc 100644 --- a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx +++ b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx @@ -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', diff --git a/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx b/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx index 926fcf9cbb3..89ac65362a0 100644 --- a/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/ProfileInformation.tsx @@ -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(), diff --git a/apps/studio/components/interfaces/Auth/AuthTemplatesValidation.tsx b/apps/studio/components/interfaces/Auth/AuthTemplatesValidation.tsx deleted file mode 100644 index 915c1d06f2c..00000000000 --- a/apps/studio/components/interfaces/Auth/AuthTemplatesValidation.tsx +++ /dev/null @@ -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, -] diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation.tsx new file mode 100644 index 00000000000..3cd04dd25e0 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation.tsx @@ -0,0 +1,384 @@ +import * as z from 'zod' + +import type { AuthTemplate, TemplateVariable, TemplateVariableName } from './EmailTemplates.types' + +const TemplateVariables: Record = { + 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 user_metadata, use this to personalize the + message + + ), + }, + RedirectTo: { + name: 'RedirectTo', + value: '{{ .RedirectTo }}', + description: ( + <> + Redirect URL passed as the redirectTo 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, +] diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx index 2f7f6c4e780..8e3e281af3e 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx @@ -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' diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.types.ts b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.types.ts new file mode 100644 index 00000000000..b48ccd78c7a --- /dev/null +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.types.ts @@ -0,0 +1,64 @@ +import { ReactNode } from 'react' +import z from 'zod' + +export type KebabCase = S extends `${infer A}_${infer B}` + ? `${Lowercase}-${KebabCase}` + : Lowercase + +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 + +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' } +} diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils.ts b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils.ts index e1297392939..c9998a9f746 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils.ts +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils.ts @@ -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 = (id: T) => + id.toLowerCase().replace(/_/g, '-') as KebabCase diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx new file mode 100644 index 00000000000..93409adaa83 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx @@ -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 ( + + + + + + + Reset template to default + + {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.'} + + + + Cancel + + + + + ) +} diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx new file mode 100644 index 00000000000..618585a8efb --- /dev/null +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx @@ -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 + }) => ( +