mirror of
https://github.com/supabase/supabase.git
synced 2026-07-01 01:25:54 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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!
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 Supabase’s 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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 won’t 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
|
||||
|
||||
@@ -98,7 +98,7 @@ vi.mock('./EnterpriseCard', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./DowngradeModal', () => ({
|
||||
default: () => null,
|
||||
DowngradeModal: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./ExitSurveyModal', () => ({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user