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 + }) => ( +