feat(studio): restrict auth email template editing for free projects (#45396)

## What kind of change does this PR introduce?

Feature / abuse-prevention update. Resolves DEPR-198.

## What is the current behaviour?

Free projects using Supabase's built-in email service can edit raw Auth
email template subjects and HTML in Studio. That is the risky cohort
this project is trying to constrain.

## What is the new behaviour?

### Template editing restrictions

For free projects using Supabase's built-in email service, Studio keeps
Auth email templates viewable and previewable but disables subject/body
editing and saving. Editing is unlocked by setting up Custom SMTP,
configuring a send-email hook, or upgrading to a paid plan.

**Grandfathering:** projects created before `2026-06-01T00:00:00Z` (the
platform enforcement cutoff) are exempt; their editing UI stays
unlocked. This mirrors `FREE_TIER_TEMPLATE_BLOCK_CUTOFF_DATE` in the
platform PR exactly.

| After |
| --- |
| <img width="1024" height="759" alt="Emails Authentication Fizz Test
Supabase-173BB09B-0FB9-4133-8202-9E310DDB347A"
src="https://github.com/user-attachments/assets/c966212d-ed0c-443b-8197-440cc2937ef6"
/> |
| <img width="1024" height="759" alt="Emails Authentication Fizz Test
Supabase-CD5845EB-0E45-4779-8989-44E775B2411A"
src="https://github.com/user-attachments/assets/055a64d6-b5e8-4d37-a261-6e280f04536a"
/> |

### Warning dialogs on transitions that reset templates

Two flows now surface a warning before the user commits to a state
change that resets their custom email templates to defaults:

1. **Disabling custom SMTP** (SMTP settings page): a confirmation dialog
warns that templates will be reset to defaults and the email rate limit
reduced to 2 per hour. On confirm, Studio resets all 13 templates via
the existing per-template reset endpoint (`Promise.allSettled`). The
"won't be able to edit" sentence is shown only for post-cutoff projects;
grandfathered projects skip it. The corresponding server-side
enforcement is in the Platform PR:
https://github.com/supabase/platform/pull/33129

2. **Downgrading to the Free plan** (billing settings): an admonition in
the existing downgrade confirmation modal warns that custom templates
will be reset to defaults and won't be editable without custom SMTP. The
admonition is shown only when the org has at least one post-cutoff
project; orgs whose projects are all grandfathered skip it.

| Custom SMTP | Downgrading |
| --- | --- |
| <img width="862" height="586" alt="66764"
src="https://github.com/user-attachments/assets/6470c8a6-2f79-40a5-ad3b-bfe5b0ba9c54"
/> | <img width="1268" height="1552" alt="CleanShot 2026-05-22 at 17 28
37@2x-FEB1901E-38E6-42DF-8C27-0A036D8A1B94"
src="https://github.com/user-attachments/assets/e8caa9e6-c3ed-4787-b771-af77a43eb854"
/> |

### Informational admonition when enabling SMTP

When a user enables custom SMTP for the first time, a sandwiched
admonition above the save footer informs them that the email rate limit
will be increased to 30 per hour and can be adjusted.

_This is just a minor cosmetic change, unrelated to the email template
disabling. Sorry._

| Before | After |
| --- | --- |
| <img width="1024" height="759" alt="Emails Authentication Chisel
Toolshed Supabase-54317D18-803C-4A58-8211-2359355D083B"
src="https://github.com/user-attachments/assets/29eff649-02dc-40f3-a379-0b4d484a76c7"
/> | <img width="1024" height="759" alt="Emails Authentication Chisel
Toolshed Supabase-9E12399E-E9FB-4F9A-B029-A08008EA4B50"
src="https://github.com/user-attachments/assets/e542ed86-4da6-407e-8293-0f4c0f071e18"
/> |

## How to test

All existing projects pre-date the enforcement cutoff
(`2026-06-01T00:00:00Z`) and are grandfathered, so the restriction UI
won't appear by default. To force the restricted state locally,
back-date the cutoff in one file:

In
`apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils.ts`,
temporarily change:

```ts
export const FREE_TIER_TEMPLATE_BLOCK_CUTOFF_DATE = '2026-06-01T00:00:00Z'
```

to:

```ts
export const FREE_TIER_TEMPLATE_BLOCK_CUTOFF_DATE = '2025-01-01T00:00:00Z'
```

Revert before committing. With the cutoff back-dated, use a free-plan
project and:

- **Template restriction + admonition:** navigate to Authentication >
Emails with no custom SMTP configured. Subject/body fields should be
read-only and the "Set up SMTP" admonition should appear, with its
dropdown offering upgrade and send-email hook options.
- **SMTP disable warning:** enable custom SMTP on a project, then
disable it via Authentication > SMTP Settings. The confirmation dialog
should warn that templates will reset to defaults and that editing will
be restricted after disabling.
- **Downgrade warning:** in billing settings, initiate a downgrade to
the Free plan. The downgrade modal should include an admonition warning
about template reset and restricted editing (only if the org has at
least one post-cutoff project).

## Additional context

The default Auth email template copy was also improved across docs,
examples, and UI library snippets (separate prior commits).

The per-template reset button (`ResetTemplateDialog`) was migrated to
the async `AlertDialogAction` pattern introduced in #45960; the dialog
stays open and shows a loading state while the reset is in-flight,
closes on success, and stays open on error.

Closes PRODSEC-183

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Stephen Morgan <stephen@doublethink.co.nz>
This commit is contained in:
Danny White
2026-05-28 15:23:45 +10:00
committed by GitHub
parent 5f4153d9e0
commit a45776ecb9
24 changed files with 665 additions and 141 deletions

View File

@@ -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
<h2>Magic Link</h2>
<h2>Sign in to your account</h2>
<p>Follow this link to login:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Log In</a></p>
<p>Use this link to sign in to your account:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Sign in</a></p>
```
At the `/auth/confirm` endpoint, exchange the hash for the session:

View File

@@ -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!

View File

@@ -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

View File

@@ -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 (
<Admonition
type="default"
layout="responsive"
title="Set up custom SMTP to edit templates"
description="Emails will be sent using the default templates. Set up custom SMTP to edit their subject and body."
actions={
<div className="flex w-full @lg:w-auto">
<Button
asChild
type="default"
className="flex-1 rounded-r-none px-3 @lg:flex-none hover:z-10"
>
<Link href={`/project/${projectRef}/auth/smtp`}>Set up SMTP</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
aria-label="More email template editing options"
className="shrink-0 rounded-l-none px-[4px] py-[5px] -ml-px"
icon={<ChevronDown />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem asChild>
<Link
href={`/org/${organizationSlug}/billing?panel=subscriptionPlan&source=authEmailTemplates`}
>
<div className="flex flex-col gap-y-0.5">
<p className="block text-foreground">Upgrade to Pro</p>
<p className="block text-foreground-lighter text-balance">
Customize templates while using Supabases email service
</p>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/project/${projectRef}/auth/hooks?hook=send-email`}>
<div className="flex flex-col gap-y-0.5">
<p className="block text-foreground">Configure Send Email hook</p>
<p className="block text-foreground-lighter text-balance">
Send auth emails through your own workflow
</p>
</div>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
}
/>
)
}

View File

@@ -0,0 +1,5 @@
import { AUTH_TEMPLATE_TYPES, type AuthTemplateResetType } from './EmailTemplates.types'
import { getAuthTemplateType } from './EmailTemplates.utils'
export const AUTH_TEMPLATE_RESET_TYPES: AuthTemplateResetType[] =
AUTH_TEMPLATE_TYPES.map(getAuthTemplateType)

View File

@@ -19,12 +19,20 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import * as z from 'zod'
import { TEMPLATES_SCHEMAS } from './AuthTemplatesValidation'
import { slugifyTitle } from './EmailTemplates.utils'
import { CustomEmailTemplateRestrictionAdmonition } from './CustomEmailTemplateRestrictionAdmonition'
import {
hasCustomEmailSender,
isCustomEmailTemplateEditingRestricted,
isCustomEmailTemplateRestrictionStatusKnown,
slugifyTitle,
} from './EmailTemplates.utils'
import AlertError from '@/components/ui/AlertError'
import { InlineLink } from '@/components/ui/InlineLink'
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
const notificationEnabledKeys = TEMPLATES_SCHEMAS.filter(
@@ -45,6 +53,9 @@ const NotificationsFormSchema = z.object({
export const EmailTemplates = () => {
const { ref: projectRef } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { data: selectedProject } = useSelectedProjectQuery()
const { can: canUpdateConfig } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'custom_config_gotrue'
@@ -67,10 +78,19 @@ export const EmailTemplates = () => {
},
})
const builtInSMTP =
isSuccess &&
authConfig &&
(!authConfig.SMTP_HOST || !authConfig.SMTP_USER || !authConfig.SMTP_PASS)
const usingBuiltInEmailSender = !hasCustomEmailSender(authConfig)
const isTemplateRestrictionStatusKnown = isCustomEmailTemplateRestrictionStatusKnown({
authConfig,
organization: selectedOrganization,
projectInsertedAt: selectedProject?.inserted_at,
})
const isTemplateEditBlocked =
isTemplateRestrictionStatusKnown &&
isCustomEmailTemplateEditingRestricted({
authConfig,
organization: selectedOrganization,
projectInsertedAt: selectedProject?.inserted_at,
})
const defaultValues = notificationEnabledKeys.reduce(
(acc, key) => {
@@ -85,7 +105,7 @@ export const EmailTemplates = () => {
defaultValues,
})
const onSubmit = (values: any) => {
const onSubmit = (values: z.infer<typeof NotificationsFormSchema>) => {
if (!projectRef) return console.error('Project ref is required')
updateAuthConfig({ projectRef: projectRef, config: { ...values } })
}
@@ -116,7 +136,9 @@ export const EmailTemplates = () => {
{isSuccess && (
<>
<PageSection>
{builtInSMTP && (
{isTemplateEditBlocked ? (
<CustomEmailTemplateRestrictionAdmonition />
) : usingBuiltInEmailSender ? (
<Admonition
type="warning"
title="Set up custom SMTP"
@@ -132,14 +154,13 @@ export const EmailTemplates = () => {
</p>
}
layout="horizontal"
className="mb-4"
actions={
<Button asChild type="default">
<Link href={`/project/${projectRef}/auth/smtp`}>Set up SMTP</Link>
</Button>
}
/>
)}
) : null}
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Authentication</PageSectionTitle>

View File

@@ -5,7 +5,7 @@ export type KebabCase<S extends string> = S extends `${infer A}_${infer B}`
? `${Lowercase<A>}-${KebabCase<B>}`
: Lowercase<S>
const AUTH_TEMPLATE_TYPES = [
export const AUTH_TEMPLATE_TYPES = [
'CONFIRMATION',
'EMAIL_CHANGE',
'INVITE',

View File

@@ -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)
})
})

View File

@@ -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 = <T extends AuthTemplateType>(id: T) =>
id.toLowerCase().replace(/_/g, '-') as KebabCase<T>
export const hasCustomEmailSender = (config?: Partial<AuthConfig>) => {
const hasSendEmailHook = !!config?.HOOK_SEND_EMAIL_ENABLED && !!config?.HOOK_SEND_EMAIL_URI
return isSmtpEnabled(config) || hasSendEmailHook
}
export const isCustomEmailTemplateRestrictionStatusKnown = ({
authConfig,
organization,
projectInsertedAt,
}: {
authConfig?: Partial<AuthConfig>
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<AuthConfig>
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)
}

View File

@@ -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 (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button type="default" htmlType="button" disabled={!canUpdateConfig || isResettingTemplate}>
<Button type="default" htmlType="button" disabled={!canUpdateConfig}>
Reset template
</Button>
</AlertDialogTrigger>
@@ -78,10 +74,17 @@ export const ResetTemplateDialog = ({
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isResettingTemplate}>Cancel</AlertDialogCancel>
<Button type="warning" onClick={resetTemplateToDefault} loading={isResettingTemplate}>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="warning"
loading={isResetting}
onClick={(e) => {
e.preventDefault()
resetTemplateToDefault()
}}
>
Reset
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@@ -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)
)
)

View File

@@ -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 = `
<style>
html {
background: white;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
}
</style>
`
const getPreviewSrcDoc = (html: string) => {
if (/<head[\s>]/i.test(html)) {
return html.replace(/<head([^>]*)>/i, `<head$1>${previewBaseStyles}`)
}
if (/<html[\s>]/i.test(html)) {
return html.replace(/<html([^>]*)>/i, `<html$1><head>${previewBaseStyles}</head>`)
}
if (/<body[\s>]/i.test(html)) {
return `<!doctype html><html><head>${previewBaseStyles}</head>${html}</html>`
}
return `<!doctype html><html><head>${previewBaseStyles}</head><body>${html}</body></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<editor.IStandaloneCodeEditor | null>(null)
const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as EmailTemplateContentKey
const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef })
const { data: authConfig } = useAuthConfigQuery({ projectRef })
const [validationResult, setValidationResult] = useState<ValidateSpamResponse>()
const [bodyValue, setBodyValue] = useState((authConfig && authConfig[messageSlug]) ?? '')
const [, setHasUnsavedChanges] = useState(false)
const [isSavingTemplate, setIsSavingTemplate] = useState(false)
const [activeView, setActiveView] = useState<'source' | 'preview'>('source')
const 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<typeof template.validationSchema>) => {
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
@@ -260,7 +303,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
}
>
<FormControl>
<Input id={x} {...field} disabled={!canUpdateConfig} />
<Input id={x} {...field} readOnly={!canEdit} />
</FormControl>
</FormItemLayout>
)}
@@ -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'
}
/>
</div>
{activeView === 'source' ? (
@@ -290,7 +342,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
<CodeEditor
id="code-id"
language="html"
isReadOnly={!canUpdateConfig}
isReadOnly={!canEdit}
className="mb-0! relative h-96 outline-hidden outline-offset-0 outline-width-0 outline-0"
onInputChange={(e: string | undefined) => {
setBodyValue(e ?? '')
@@ -314,7 +366,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
</InlineLink>
</p>
</div>
<div className="flex flex-wrap gap-x-1">
<div className="flex flex-wrap gap-x-1 gap-y-1.5">
{template.variables.map((variable) => (
<Tooltip key={variable.value}>
<TooltipTrigger asChild>
@@ -323,6 +375,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
size="tiny"
className="rounded-full"
onClick={() => insertTextAtCursor(variable.value)}
disabled={!canEdit}
>
{variable.value}
</Button>
@@ -358,7 +411,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
<iframe
className="mb-0! mt-0 overflow-hidden h-96 w-full rounded-md border bg-white"
title={id}
srcDoc={bodyValue}
srcDoc={previewSrcDoc}
sandbox="allow-scripts allow-forms"
/>
<Admonition
@@ -399,14 +452,15 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
Cancel
</Button>
)}
<Button
<ButtonTooltip
type="primary"
htmlType="submit"
disabled={!canUpdateConfig || isSavingTemplate || !hasChanges}
disabled={!canEdit || isSavingTemplate || !hasChanges}
loading={isSavingTemplate}
tooltip={{ content: { side: 'bottom', text: saveChangesTooltip } }}
>
Save changes
</Button>
</ButtonTooltip>
</div>
</CardFooter>
</>

View File

@@ -0,0 +1,58 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from 'ui'
interface SmtpDisableConfirmationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => Promise<void>
blockEditingOnReset?: boolean
}
export const SmtpDisableConfirmationDialog = ({
open,
onOpenChange,
onConfirm,
blockEditingOnReset = false,
}: SmtpDisableConfirmationDialogProps) => {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Disable custom SMTP</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
Switching back to the built-in SMTP service will{' '}
<strong className="text-foreground">reset any custom email templates</strong> and{' '}
<strong className="text-foreground">
reduce the email rate limit to 2 emails per hour
</strong>
.
</p>
{!blockEditingOnReset && (
<p>
You won't be able to edit email templates until you set up custom SMTP again or
upgrade your plan.
</p>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="warning" onClick={onConfirm}>
Disable custom SMTP
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -9,7 +9,6 @@ import {
Card,
CardContent,
CardFooter,
cn,
Form,
FormControl,
FormField,
@@ -26,14 +25,19 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { urlRegex } from '../Auth.constants'
import { AUTH_TEMPLATE_RESET_TYPES } from '../EmailTemplates/EmailTemplates.constants'
import { isBeforeFreeTierTemplateBlockCutoff } from '../EmailTemplates/EmailTemplates.utils'
import { SmtpDisableConfirmationDialog } from './SmtpDisableConfirmationDialog'
import { defaultDisabledSmtpFormValues } from './SmtpForm.constants'
import { generateFormValues, isSmtpEnabled } from './SmtpForm.utils'
import AlertError from '@/components/ui/AlertError'
import { AlertError } from '@/components/ui/AlertError'
import { InlineLink } from '@/components/ui/InlineLink'
import NoPermission from '@/components/ui/NoPermission'
import { NoPermission } from '@/components/ui/NoPermission'
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
import { useAuthTemplateResetMutation } from '@/data/auth/auth-template-reset-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
const smtpEnabledSchema = z.object({
ENABLE_SMTP: z.literal(true),
@@ -96,9 +100,14 @@ type SmtpFormValues = z.infer<typeof smtpSchema>
export const SmtpForm = () => {
const { ref: projectRef } = useParams()
const { data: authConfig, error: authConfigError, isError } = useAuthConfigQuery({ projectRef })
const { data: selectedProject } = useSelectedProjectQuery()
const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation()
const { mutateAsync: resetAuthTemplate } = useAuthTemplateResetMutation()
const [enableSmtp, setEnableSmtp] = useState(false)
const [showDisableConfirmation, setShowDisableConfirmation] = useState(false)
const [pendingValues, setPendingValues] = useState<SmtpFormValues | null>(null)
const { can: canReadConfig } = useAsyncCheckPermissions(
PermissionAction.READ,
@@ -109,6 +118,10 @@ export const SmtpForm = () => {
'custom_config_gotrue'
)
const blockEditingOnReset =
!!selectedProject?.inserted_at &&
isBeforeFreeTierTemplateBlockCutoff(selectedProject.inserted_at)
const form = useForm<SmtpFormValues>({
resolver: zodResolver(
smtpSchema.superRefine((data, ctx) => {
@@ -137,29 +150,15 @@ export const SmtpForm = () => {
const { isDirty } = form.formState
// Update form values when auth config is loaded
useEffect(() => {
if (authConfig) {
const formValues = generateFormValues(authConfig)
form.reset({
...formValues,
ENABLE_SMTP: isSmtpEnabled(authConfig),
} as SmtpFormValues)
setEnableSmtp(isSmtpEnabled(authConfig))
}
}, [authConfig, form])
// Update enableSmtp state when the form field changes
useEffect(() => {
const subscription = form.watch((value, { name }) => {
if (name === 'ENABLE_SMTP') {
setEnableSmtp(value.ENABLE_SMTP as boolean)
}
})
return () => subscription.unsubscribe()
}, [form])
const onSubmit: SubmitHandler<SmtpFormValues> = (values) => {
const doUpdate = ({
values,
onSuccess,
onError,
}: {
values: SmtpFormValues
onSuccess?: () => void
onError?: () => void
}) => {
const { ENABLE_SMTP, ...rest } = values
const basePayload = ENABLE_SMTP ? rest : defaultDisabledSmtpFormValues
@@ -187,14 +186,76 @@ export const SmtpForm = () => {
{
onError: (error) => {
toast.error(`Failed to update settings: ${error.message}`)
onError?.()
},
onSuccess: () => {
toast.success('Successfully updated settings')
onSuccess?.()
},
}
)
}
const onSubmit: SubmitHandler<SmtpFormValues> = (values) => {
const isDisablingSmtp = !values.ENABLE_SMTP && isSmtpEnabled(authConfig)
if (isDisablingSmtp) {
setPendingValues(values)
setShowDisableConfirmation(true)
return
}
doUpdate({ values })
}
const handleConfirmDisable = (): Promise<void> => {
if (!pendingValues || !projectRef) return Promise.resolve()
return new Promise<void>((resolve, reject) => {
doUpdate({
values: pendingValues,
onSuccess: async () => {
setPendingValues(null)
try {
const results = await Promise.allSettled(
AUTH_TEMPLATE_RESET_TYPES.map((template) =>
resetAuthTemplate({ projectRef, template })
)
)
if (results.some((r) => r.status === 'rejected')) {
toast.error('SMTP disabled, but some email templates could not be reset')
}
} finally {
resolve()
}
},
onError: reject,
})
})
}
// Update form values when auth config is loaded
useEffect(() => {
if (authConfig) {
const formValues = generateFormValues(authConfig)
form.reset({
...formValues,
ENABLE_SMTP: isSmtpEnabled(authConfig),
} as SmtpFormValues)
setEnableSmtp(isSmtpEnabled(authConfig))
}
}, [authConfig, form])
// Update enableSmtp state when the form field changes
useEffect(() => {
const subscription = form.watch((value, { name }) => {
if (name === 'ENABLE_SMTP') {
setEnableSmtp(value.ENABLE_SMTP as boolean)
}
})
return () => subscription.unsubscribe()
}, [form])
if (isError) {
return (
<PageSection>
@@ -215,8 +276,7 @@ export const SmtpForm = () => {
)
}
const showFooterMessage =
form.formState.isDirty && ((enableSmtp && !isSmtpEnabled(authConfig)) || !enableSmtp)
const showEnablingAdmonition = form.formState.isDirty && enableSmtp && !isSmtpEnabled(authConfig)
return (
<PageSection>
@@ -233,13 +293,12 @@ export const SmtpForm = () => {
layout="flex-row-reverse"
label="Enable custom SMTP"
description={
<p className="max-w-full prose text-sm text-foreground-lighter">
Emails will be sent using your custom SMTP provider. Email rate limits can
be adjusted{' '}
<p className="text-sm text-foreground-lighter">
Send auth emails through your custom SMTP provider.{' '}
<InlineLink href={`/project/${projectRef}/auth/rate-limits`}>
here
</InlineLink>
.
Rate limits
</InlineLink>{' '}
apply.
</p>
}
>
@@ -253,15 +312,6 @@ export const SmtpForm = () => {
</FormItemLayout>
)}
/>
{enableSmtp && !isSmtpEnabled(form.getValues() as any) && (
<Admonition
type="warning"
title="All fields must be filled"
description="Each of the fields below must be filled before custom SMTP can be enabled."
className="bg-warning-200 border-warning-400 mt-4"
/>
)}
</CardContent>
{enableSmtp && (
@@ -448,23 +498,24 @@ export const SmtpForm = () => {
</>
)}
<CardFooter
className={cn(showFooterMessage ? 'justify-between' : 'justify-end', 'gap-x-2')}
>
{showFooterMessage &&
(enableSmtp ? (
<p className="text-sm text-foreground-light">
Rate limit for sending emails will be increased to 30 and{' '}
{showEnablingAdmonition && (
<Admonition
type="default"
className="rounded-none border-x-0 border-t-0"
description={
<>
The email rate limit will be increased to 30 emails per hour after enabling
custom SMTP. It can be{' '}
<InlineLink href={`/project/${projectRef}/auth/rate-limits`}>
can be adjusted
adjusted further
</InlineLink>{' '}
after enabling custom SMTP
</p>
) : (
<p className="text-sm text-foreground-light">
Rate limit for sending emails will be reduced to 2 after disabling custom SMTP
</p>
))}
at any time.
</>
}
/>
)}
<CardFooter className="justify-end gap-x-2">
<div className="flex items-center gap-x-2">
{isDirty && (
<Button
@@ -491,6 +542,12 @@ export const SmtpForm = () => {
</form>
</Form>
</PageSectionContent>
<SmtpDisableConfirmationDialog
open={showDisableConfirmation}
onOpenChange={setShowDisableConfirmation}
onConfirm={handleConfirmDisable}
blockEditingOnReset={blockEditingOnReset}
/>
</PageSection>
)
}

View File

@@ -12,6 +12,7 @@ export const isSmtpEnabled = (config?: Partial<AuthConfig>): boolean => {
config?.SMTP_SENDER_NAME &&
config?.SMTP_USER &&
config?.SMTP_HOST &&
config?.SMTP_PASS &&
config?.SMTP_PORT &&
(config?.SMTP_MAX_FREQUENCY ?? 0) >= 0
)

View File

@@ -13,6 +13,7 @@ import {
} from 'ui'
import { Admonition } from 'ui-patterns'
import { isBeforeFreeTierTemplateBlockCutoff } from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils'
import { getComputeSize, OrgProject } from '@/data/projects/org-projects-infinite-query'
import type { OrgSubscription, ProjectAddon } from '@/data/subscriptions/types'
@@ -57,7 +58,7 @@ const ProjectDowngradeListItem = ({ projectAddon }: { projectAddon: ProjectAddon
)
}
const DowngradeModal = ({
export const DowngradeModal = ({
visible,
subscription,
onClose,
@@ -86,6 +87,12 @@ const DowngradeModal = ({
return computeSize === 'micro'
})
// Only warn about template reset if at least one project is post-cutoff.
// Pre-cutoff projects are grandfathered and keep template editing access after downgrade.
const hasPostCutoffProjects = projects.some(
(project) => !isBeforeFreeTierTemplateBlockCutoff(project.inserted_at)
)
return (
<AlertDialog open={visible} onOpenChange={onClose}>
<AlertDialogContent size="large">
@@ -124,6 +131,15 @@ const DowngradeModal = ({
)}
</div>
{hasPostCutoffProjects && (
<Admonition
type="warning"
className="mt-2"
title="Any custom email templates will be reset"
description="Downgrading will reset your custom email templates to their defaults. You wont be able to edit them unless you set up custom SMTP after downgrading."
/>
)}
<ul className="mt-4 space-y-5 text-sm">
<li className="flex items-center gap-3">
<PauseCircle size={18} />
@@ -175,5 +191,3 @@ const DowngradeModal = ({
</AlertDialog>
)
}
export default DowngradeModal

View File

@@ -98,7 +98,7 @@ vi.mock('./EnterpriseCard', () => ({
}))
vi.mock('./DowngradeModal', () => ({
default: () => null,
DowngradeModal: () => null,
}))
vi.mock('./ExitSurveyModal', () => ({

View File

@@ -10,7 +10,7 @@ import { plans as subscriptionsPlans } from 'shared-data/plans'
import { Button, cn, SidePanel } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import DowngradeModal from './DowngradeModal'
import { DowngradeModal } from './DowngradeModal'
import { EnterpriseCard } from './EnterpriseCard'
import { ExitSurveyModal } from './ExitSurveyModal'
import MembersExceedLimitModal from './MembersExceedLimitModal'

View File

@@ -1,4 +1,4 @@
import { cn } from 'ui'
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
interface TwoOptionToggleProps {
options: string[]
@@ -6,6 +6,8 @@ interface TwoOptionToggleProps {
activeOption: string
onClickOption: (value: string) => void
borderOverride: string
disabledOptions?: string[]
disabledOptionTooltip?: string
}
export const TwoOptionToggle = ({
@@ -14,6 +16,8 @@ export const TwoOptionToggle = ({
activeOption,
onClickOption,
borderOverride = 'border-stronger',
disabledOptions = [],
disabledOptionTooltip,
}: TwoOptionToggleProps) => {
const buttonStyle = (
isActive: boolean
@@ -36,28 +40,43 @@ export const TwoOptionToggle = ({
'transition-all ease-in-out border border-strong'
)}
/>
{options.map((option, index: number) => (
<span
key={`toggle_${index}`}
style={{ width: width + 1 }}
className={`
${activeOption === option ? 'text-foreground' : 'text-foreground-light'}
${index === 0 ? 'right-0' : 'left-0'}
${buttonStyle(activeOption === option)}
cursor-pointer
`}
onClick={() => onClickOption(option)}
>
{options.map((option, index: number) => {
const isDisabled = disabledOptions.includes(option)
const optionButton = (
<span
key={`toggle_${index}`}
style={{ width: width + 1 }}
className={cn(
'capitalize hover:text-foreground',
activeOption === option ? 'text-foreground' : 'text-foreground-light'
activeOption === option ? 'text-foreground' : 'text-foreground-light',
index === 0 ? 'right-0' : 'left-0',
buttonStyle(activeOption === option),
isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
)}
onClick={() => {
if (!isDisabled) onClickOption(option)
}}
>
{option}
<span
className={cn(
'capitalize hover:text-foreground',
activeOption === option ? 'text-foreground' : 'text-foreground-light',
isDisabled && 'hover:text-foreground-light'
)}
>
{option}
</span>
</span>
</span>
))}
)
if (!isDisabled || !disabledOptionTooltip) return optionButton
return (
<Tooltip key={`toggle_${index}`}>
<TooltipTrigger asChild>{optionButton}</TooltipTrigger>
<TooltipContent side="top">{disabledOptionTooltip}</TooltipContent>
</Tooltip>
)
})}
</div>
)
}

View File

@@ -36,7 +36,12 @@ import {
import * as z from 'zod'
import { TEMPLATES_SCHEMAS } from '@/components/interfaces/Auth/EmailTemplates/AuthTemplatesValidation'
import { slugifyTitle } from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils'
import { CustomEmailTemplateRestrictionAdmonition } from '@/components/interfaces/Auth/EmailTemplates/CustomEmailTemplateRestrictionAdmonition'
import {
isCustomEmailTemplateEditingRestricted,
isCustomEmailTemplateRestrictionStatusKnown,
slugifyTitle,
} from '@/components/interfaces/Auth/EmailTemplates/EmailTemplates.utils'
import { TemplateEditor } from '@/components/interfaces/Auth/EmailTemplates/TemplateEditor'
import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
@@ -45,6 +50,8 @@ import { NoPermission } from '@/components/ui/NoPermission'
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
import type { NextPageWithLayout } from '@/types'
@@ -68,6 +75,21 @@ const RedirectToTemplates = () => {
)
const { data: authConfig, isPending: isLoadingConfig } = useAuthConfigQuery({ projectRef })
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const { data: selectedProject } = useSelectedProjectQuery()
const isTemplateRestrictionStatusKnown = isCustomEmailTemplateRestrictionStatusKnown({
authConfig,
organization: selectedOrganization,
projectInsertedAt: selectedProject?.inserted_at,
})
const isTemplateEditBlocked =
isTemplateRestrictionStatusKnown &&
isCustomEmailTemplateEditingRestricted({
authConfig,
organization: selectedOrganization,
projectInsertedAt: selectedProject?.inserted_at,
})
const isTemplateEditorReadOnly = !isTemplateRestrictionStatusKnown || isTemplateEditBlocked
const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation({
onError: (error) => {
@@ -264,8 +286,13 @@ const RedirectToTemplates = () => {
</PageSectionMeta>
)}
<PageSectionContent>
{isTemplateEditBlocked && (
<div className="mb-4">
<CustomEmailTemplateRestrictionAdmonition />
</div>
)}
<Card>
<TemplateEditor template={template} />
<TemplateEditor template={template} isReadOnly={isTemplateEditorReadOnly} />
</Card>
</PageSectionContent>
</PageSection>

View File

@@ -1,9 +1,9 @@
<h2>Confirm your signup</h2>
<h2>Confirm your email</h2>
<p>Follow this link to confirm your user:</p>
<p>Confirm this email address to finish setting up your account.</p>
<p>
<a
href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}"
>Confirm your email</a
>Confirm your email address</a
>
</p>

View File

@@ -1,9 +1,9 @@
<h2>Reset Password</h2>
<h2>Reset password</h2>
<p>Follow this link to reset the password for your user:</p>
<p>Use this link to choose a new password for your account.</p>
<p>
<a
href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next={{ .RedirectTo }}"
>Reset Password</a
>Reset password</a
>
</p>

View File

@@ -1,4 +1,4 @@
<h2>Magic Link</h2>
<h2>Sign in to your account</h2>
<p>Follow this link to login:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Log In</a></p>
<p>Use this link to sign in to your account.</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Sign in</a></p>

View File

@@ -87,11 +87,11 @@ port = 54324
# sender_name = "Admin"
[auth.email.template.confirmation]
subject = "Confirm Your Email"
subject = "Confirm your email"
content_path = "./supabase/auth/email/confirmation.html"
[auth.email.template.magic_link]
subject = "Your Magic Link"
subject = "Your sign-in link"
content_path = "./supabase/auth/email/magic-link.html"
[storage.buckets.avatars]