diff --git a/apps/docs/content/guides/auth/auth-email-passwordless.mdx b/apps/docs/content/guides/auth/auth-email-passwordless.mdx index b98a1f43a0f..647211150ba 100644 --- a/apps/docs/content/guides/auth/auth-email-passwordless.mdx +++ b/apps/docs/content/guides/auth/auth-email-passwordless.mdx @@ -145,10 +145,10 @@ That's it for the implicit flow. If you're using PKCE flow, edit the Magic Link [email template](/docs/guides/auth/auth-email-templates) to send a token hash: ```html -

Magic Link

+

Sign in to your account

-

Follow this link to login:

-

Log In

+

Use this link to sign in to your account:

+

Sign in

``` At the `/auth/confirm` endpoint, exchange the hash for the session: diff --git a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx index 82e348a086d..f7fd26e7894 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx @@ -268,7 +268,7 @@ npm run dev And then open the browser to [localhost:3000/login](http://localhost:3000/login) and you should see the completed app. -When you enter your email and password, you will receive an email with the title **Confirm Your Signup**. Congrats 🎉!!! +When you enter your email and password, you will receive an email with the title **Confirm your email**. Congrats 🎉!!! At this stage you have a fully functional application! diff --git a/apps/docs/content/guides/self-hosting/custom-email-templates.mdx b/apps/docs/content/guides/self-hosting/custom-email-templates.mdx index 090dfc390c3..694ab393496 100644 --- a/apps/docs/content/guides/self-hosting/custom-email-templates.mdx +++ b/apps/docs/content/guides/self-hosting/custom-email-templates.mdx @@ -159,7 +159,7 @@ services: environment: GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED: 'true' # 👈 enabling the notification is required GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION: 'http://templates-server/password_changed_notification.html' - GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: 'Your password has been changed' + GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION: 'Your password was changed' templates-server: image: caddy:2-alpine diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx new file mode 100644 index 00000000000..1ac63e3accc --- /dev/null +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition.tsx @@ -0,0 +1,73 @@ +import { useParams } from 'common' +import { ChevronDown } from 'lucide-react' +import Link from 'next/link' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' +import { Admonition } from 'ui-patterns/admonition' + +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' + +export const CustomEmailTemplateRestrictionAdmonition = () => { + const { ref: projectRef } = useParams() + const { data: selectedOrganization } = useSelectedOrganizationQuery() + const organizationSlug = selectedOrganization?.slug ?? '_' + + return ( + + + + + } /> - )} + ) : null} Authentication diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.types.ts b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.types.ts index b48ccd78c7a..e1d2f192243 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.types.ts +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.types.ts @@ -5,7 +5,7 @@ export type KebabCase = S extends `${infer A}_${infer B}` ? `${Lowercase}-${KebabCase}` : Lowercase -const AUTH_TEMPLATE_TYPES = [ +export const AUTH_TEMPLATE_TYPES = [ 'CONFIRMATION', 'EMAIL_CHANGE', 'INVITE', diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils.test.ts b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils.test.ts new file mode 100644 index 00000000000..0354df11c1c --- /dev/null +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' + +import { + FREE_TIER_TEMPLATE_BLOCK_CUTOFF_DATE, + hasCustomEmailSender, + isCustomEmailTemplateEditingRestricted, + isCustomEmailTemplateRestrictionStatusKnown, +} from './EmailTemplates.utils' +import type { Organization } from '@/types' + +const freeOrganization = { plan: { id: 'free', name: 'Free' } } as unknown as Organization +const proOrganization = { plan: { id: 'pro', name: 'Pro' } } as unknown as Organization + +// Dates relative to the cutoff +const PRE_CUTOFF = '2025-01-01T00:00:00Z' +const POST_CUTOFF = '2026-12-01T00:00:00Z' + +describe('EmailTemplates.utils', () => { + it('waits for auth config, organization, and project before resolving restriction status', () => { + expect( + isCustomEmailTemplateRestrictionStatusKnown({ + authConfig: {}, + organization: freeOrganization, + projectInsertedAt: POST_CUTOFF, + }) + ).toBe(true) + + expect( + isCustomEmailTemplateRestrictionStatusKnown({ + authConfig: undefined, + organization: freeOrganization, + projectInsertedAt: POST_CUTOFF, + }) + ).toBe(false) + + expect( + isCustomEmailTemplateRestrictionStatusKnown({ + authConfig: {}, + organization: undefined, + projectInsertedAt: POST_CUTOFF, + }) + ).toBe(false) + + expect( + isCustomEmailTemplateRestrictionStatusKnown({ + authConfig: {}, + organization: freeOrganization, + projectInsertedAt: undefined, + }) + ).toBe(false) + }) + + it('restricts post-cutoff free projects that use the built-in email sender', () => { + expect( + isCustomEmailTemplateEditingRestricted({ + authConfig: {}, + organization: freeOrganization, + projectInsertedAt: POST_CUTOFF, + }) + ).toBe(true) + }) + + it('does not restrict pre-cutoff free projects (grandfathered)', () => { + expect( + isCustomEmailTemplateEditingRestricted({ + authConfig: {}, + organization: freeOrganization, + projectInsertedAt: PRE_CUTOFF, + }) + ).toBe(false) + }) + + it('uses the correct cutoff date', () => { + expect(FREE_TIER_TEMPLATE_BLOCK_CUTOFF_DATE).toBe('2026-06-03T00:00:00Z') + }) + + it('allows paid projects that use the built-in email sender', () => { + expect( + isCustomEmailTemplateEditingRestricted({ + authConfig: {}, + organization: proOrganization, + projectInsertedAt: POST_CUTOFF, + }) + ).toBe(false) + }) + + it('allows projects with custom SMTP configured', () => { + const authConfig = { + SMTP_ADMIN_EMAIL: 'support@example.com', + SMTP_SENDER_NAME: 'Example', + SMTP_USER: 'smtp-user', + SMTP_HOST: 'smtp.example.com', + SMTP_PASS: '******', + SMTP_PORT: '587', + SMTP_MAX_FREQUENCY: 60, + } + + expect(hasCustomEmailSender(authConfig)).toBe(true) + expect( + isCustomEmailTemplateEditingRestricted({ + authConfig, + organization: freeOrganization, + projectInsertedAt: POST_CUTOFF, + }) + ).toBe(false) + }) + + it('restricts projects when custom SMTP is incomplete', () => { + expect( + isCustomEmailTemplateEditingRestricted({ + authConfig: { + SMTP_ADMIN_EMAIL: 'support@example.com', + SMTP_SENDER_NAME: 'Example', + SMTP_USER: 'smtp-user', + SMTP_HOST: 'smtp.example.com', + SMTP_PORT: '587', + SMTP_MAX_FREQUENCY: 60, + }, + organization: freeOrganization, + projectInsertedAt: POST_CUTOFF, + }) + ).toBe(true) + }) + + it('allows projects with a configured send-email hook', () => { + expect( + isCustomEmailTemplateEditingRestricted({ + authConfig: { + HOOK_SEND_EMAIL_ENABLED: true, + HOOK_SEND_EMAIL_URI: 'https://example.com/auth/send-email', + }, + organization: freeOrganization, + projectInsertedAt: POST_CUTOFF, + }) + ).toBe(false) + }) +}) diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils.ts b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils.ts index c9998a9f746..91a8389f598 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils.ts +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils.ts @@ -1,4 +1,18 @@ +import dayjs from 'dayjs' + +import { isSmtpEnabled } from '../SmtpForm/SmtpForm.utils' import { type AuthTemplateType, type KebabCase } from './EmailTemplates.types' +import type { components } from '@/data/api' +import type { Organization } from '@/types' + +type AuthConfig = components['schemas']['GoTrueConfigResponse'] + +/** + * Projects created on or after this date are subject to the free-tier template editing + * restriction. Projects created before it are grandfathered and keep editing access. + * Must stay in sync with FREE_TIER_TEMPLATE_BLOCK_CUTOFF_DATE in the platform. + */ +export const FREE_TIER_TEMPLATE_BLOCK_CUTOFF_DATE = '2026-06-03T00:00:00Z' /** * Convert template title to URL-friendly slug @@ -12,3 +26,47 @@ export const slugifyTitle = (title: string) => { /* Convert upper camel case to lower kebab case */ export const getAuthTemplateType = (id: T) => id.toLowerCase().replace(/_/g, '-') as KebabCase + +export const hasCustomEmailSender = (config?: Partial) => { + const hasSendEmailHook = !!config?.HOOK_SEND_EMAIL_ENABLED && !!config?.HOOK_SEND_EMAIL_URI + + return isSmtpEnabled(config) || hasSendEmailHook +} + +export const isCustomEmailTemplateRestrictionStatusKnown = ({ + authConfig, + organization, + projectInsertedAt, +}: { + authConfig?: Partial + organization?: Organization + projectInsertedAt?: string +}) => { + return authConfig !== undefined && organization !== undefined && projectInsertedAt !== undefined +} + +export const isBeforeFreeTierTemplateBlockCutoff = (projectInsertedAt?: string) => { + return dayjs(projectInsertedAt).isBefore(FREE_TIER_TEMPLATE_BLOCK_CUTOFF_DATE) +} + +export const isCustomEmailTemplateEditingRestricted = ({ + authConfig, + organization, + projectInsertedAt, +}: { + authConfig?: Partial + organization?: Organization + projectInsertedAt?: string +}) => { + const isPaidPlan = organization?.plan?.id !== undefined && organization.plan.id !== 'free' + if (isPaidPlan) return false + + // Grandfathering: projects created before the cutoff date keep editing access. + // Mirrors FREE_TIER_TEMPLATE_BLOCK_CUTOFF_DATE enforcement in the platform. + if (projectInsertedAt && isBeforeFreeTierTemplateBlockCutoff(projectInsertedAt)) { + return false + } + + // Temporary Studio-side paygate while Platform/Auth own the exact eligibility cohort. + return !hasCustomEmailSender(authConfig) +} diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx index 93409adaa83..4ebe7269a37 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/ResetTemplateDialog.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { toast } from 'sonner' import { AlertDialog, + AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, @@ -39,23 +40,18 @@ export const ResetTemplateDialog = ({ const { id } = template const templateType = getAuthTemplateType(id) - const { mutate: resetAuthTemplate, isPending: isResettingTemplate } = - useAuthTemplateResetMutation() + const { mutate: resetAuthTemplate, isPending: isResetting } = useAuthTemplateResetMutation() - const resetTemplateToDefault = () => { - if (!projectRef) return console.error('Project ref is required') - if (!templateType) return console.error('Template type is required') + const resetTemplateToDefault = async () => { + if (!projectRef) throw new Error('Project ref is required') + if (!templateType) throw new Error('Template type is required') resetAuthTemplate( - { - projectRef, - template: templateType, - }, + { projectRef, template: templateType }, { onSuccess: (config) => { toast.success('Email template reset to default') onResetSuccess(config) - setOpen(false) }, } ) @@ -64,7 +60,7 @@ export const ResetTemplateDialog = ({ return ( - @@ -78,10 +74,17 @@ export const ResetTemplateDialog = ({ - Cancel - + diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx index 618585a8efb..dac6766a961 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.test.tsx @@ -199,10 +199,7 @@ describe('TemplateEditor reset to default', () => { await waitFor(() => expect(resetTemplateMock).toHaveBeenCalledWith( - { - projectRef: 'project-ref', - template: 'confirmation', - }, + { projectRef: 'project-ref', template: 'confirmation' }, expect.any(Object) ) ) diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx index 29f08d4027d..51a24452e5e 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx @@ -24,9 +24,11 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' import type { AuthTemplate } from './EmailTemplates.types' +import { hasCustomEmailSender } from './EmailTemplates.utils' import { ResetTemplateDialog } from './ResetTemplateDialog' import { SpamValidation } from './SpamValidation' import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { InlineLink } from '@/components/ui/InlineLink' import { TwoOptionToggle } from '@/components/ui/TwoOptionToggle' @@ -39,6 +41,7 @@ import { DOCS_URL } from '@/lib/constants' interface TemplateEditorProps { template: AuthTemplate + isReadOnly?: boolean } type EmailTemplateContentKey = Extract< @@ -50,24 +53,55 @@ type EmailTemplateSubjectKey = Exclude< 'MAILER_SUBJECTS_CUSTOM_CONTENTS' > -export const TemplateEditor = ({ template }: TemplateEditorProps) => { +const previewBaseStyles = ` + +` + +const getPreviewSrcDoc = (html: string) => { + if (/]/i.test(html)) { + return html.replace(/]*)>/i, `${previewBaseStyles}`) + } + + if (/]/i.test(html)) { + return html.replace(/]*)>/i, `${previewBaseStyles}`) + } + + if (/]/i.test(html)) { + return `${previewBaseStyles}${html}` + } + + return `${previewBaseStyles}${html}` +} + +export const TemplateEditor = ({ template, isReadOnly = false }: TemplateEditorProps) => { const { ref: projectRef } = useParams() const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) + const canEdit = canUpdateConfig && !isReadOnly const { id, properties } = template const editorRef = useRef(null) const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as EmailTemplateContentKey - const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef }) + const { data: authConfig } = useAuthConfigQuery({ projectRef }) const [validationResult, setValidationResult] = useState() const [bodyValue, setBodyValue] = useState((authConfig && authConfig[messageSlug]) ?? '') const [, setHasUnsavedChanges] = useState(false) const [isSavingTemplate, setIsSavingTemplate] = useState(false) const [activeView, setActiveView] = useState<'source' | 'preview'>('source') + const previewSrcDoc = useMemo(() => getPreviewSrcDoc(bodyValue), [bodyValue]) const { mutate: validateSpam } = useValidateSpamMutation() @@ -83,10 +117,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { | undefined const messageProperty = properties[messageSlug] - const builtInSMTP = - isSuccess && - authConfig && - (!authConfig.SMTP_HOST || !authConfig.SMTP_USER || !authConfig.SMTP_PASS) + const builtInSMTP = !hasCustomEmailSender(authConfig) const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0) @@ -112,6 +143,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { const onSubmit = (values: z.infer) => { if (!projectRef) return console.error('Project ref is required') + if (!canEdit) return setIsSavingTemplate(true) @@ -171,6 +203,13 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { authConfig?.MAILER_SUBJECTS_CUSTOM_CONTENTS?.[subjectSlug] === true) const hasFormChanges = JSON.stringify(formValues) !== JSON.stringify(baselineValues) const hasChanges = hasFormChanges || baselineBodyValue !== bodyValue + const saveChangesTooltip = !canUpdateConfig + ? 'You need additional permissions to edit templates' + : isReadOnly + ? 'Set up custom SMTP to edit and save templates' + : !hasChanges + ? 'Make a change before saving' + : undefined // Function to insert text at cursor position const insertTextAtCursor = (text: string) => { @@ -227,6 +266,10 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { if (!hasChanges) setValidationResult(undefined) }, [hasChanges]) + useEffect(() => { + if (!canEdit) setActiveView('preview') + }, [canEdit]) + return (
@@ -260,7 +303,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { } > - + )} @@ -280,8 +323,17 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { width={60} options={['preview', 'source']} activeOption={activeView} - onClickOption={(option) => setActiveView(option as 'source' | 'preview')} + onClickOption={(option) => { + if (!canEdit && option === 'source') return + setActiveView(option as 'source' | 'preview') + }} borderOverride="border-muted" + disabledOptions={!canEdit ? ['source'] : []} + disabledOptionTooltip={ + !canUpdateConfig + ? 'You need additional permissions to edit templates' + : 'Set up custom SMTP to edit the source' + } /> {activeView === 'source' ? ( @@ -290,7 +342,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { { setBodyValue(e ?? '') @@ -314,7 +366,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {

-
+
{template.variables.map((variable) => ( @@ -323,6 +375,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { size="tiny" className="rounded-full" onClick={() => insertTextAtCursor(variable.value)} + disabled={!canEdit} > {variable.value} @@ -358,7 +411,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {