+
{template.variables.map((variable) => (
@@ -323,6 +375,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
size="tiny"
className="rounded-full"
onClick={() => insertTextAtCursor(variable.value)}
+ disabled={!canEdit}
>
{variable.value}
@@ -358,7 +411,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => {
{
Cancel
)}
-
Save changes
-
+
>
diff --git a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpDisableConfirmationDialog.tsx b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpDisableConfirmationDialog.tsx
new file mode 100644
index 00000000000..6957caa99df
--- /dev/null
+++ b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpDisableConfirmationDialog.tsx
@@ -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
+ blockEditingOnReset?: boolean
+}
+
+export const SmtpDisableConfirmationDialog = ({
+ open,
+ onOpenChange,
+ onConfirm,
+ blockEditingOnReset = false,
+}: SmtpDisableConfirmationDialogProps) => {
+ return (
+
+
+
+ Disable custom SMTP
+
+
+
+ Switching back to the built-in SMTP service will{' '}
+ reset any custom email templates and{' '}
+
+ reduce the email rate limit to 2 emails per hour
+
+ .
+
+ {!blockEditingOnReset && (
+
+ You won't be able to edit email templates until you set up custom SMTP again or
+ upgrade your plan.
+
+ )}
+
+
+
+
+ Cancel
+
+ Disable custom SMTP
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx
index 84a511536d2..65df8f3b71d 100644
--- a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx
+++ b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx
@@ -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
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(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({
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 = (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 = (values) => {
+ const isDisablingSmtp = !values.ENABLE_SMTP && isSmtpEnabled(authConfig)
+
+ if (isDisablingSmtp) {
+ setPendingValues(values)
+ setShowDisableConfirmation(true)
+ return
+ }
+
+ doUpdate({ values })
+ }
+
+ const handleConfirmDisable = (): Promise => {
+ if (!pendingValues || !projectRef) return Promise.resolve()
+
+ return new Promise((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 (
@@ -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 (
@@ -233,13 +293,12 @@ export const SmtpForm = () => {
layout="flex-row-reverse"
label="Enable custom SMTP"
description={
-
- Emails will be sent using your custom SMTP provider. Email rate limits can
- be adjusted{' '}
+
+ Send auth emails through your custom SMTP provider.{' '}
- here
-
- .
+ Rate limits
+ {' '}
+ apply.
}
>
@@ -253,15 +312,6 @@ export const SmtpForm = () => {
)}
/>
-
- {enableSmtp && !isSmtpEnabled(form.getValues() as any) && (
-
- )}
{enableSmtp && (
@@ -448,23 +498,24 @@ export const SmtpForm = () => {
>
)}
-
- {showFooterMessage &&
- (enableSmtp ? (
-
- Rate limit for sending emails will be increased to 30 and{' '}
+ {showEnablingAdmonition && (
+
+ The email rate limit will be increased to 30 emails per hour after enabling
+ custom SMTP. It can be{' '}
- can be adjusted
+ adjusted further
{' '}
- after enabling custom SMTP
-
- ) : (
-
- Rate limit for sending emails will be reduced to 2 after disabling custom SMTP
-
- ))}
+ at any time.
+ >
+ }
+ />
+ )}
+
+
{isDirty && (
{
+
)
}
diff --git a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.utils.ts b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.utils.ts
index e731f59b55b..bfacbe776b9 100644
--- a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.utils.ts
+++ b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.utils.ts
@@ -12,6 +12,7 @@ export const isSmtpEnabled = (config?: Partial): boolean => {
config?.SMTP_SENDER_NAME &&
config?.SMTP_USER &&
config?.SMTP_HOST &&
+ config?.SMTP_PASS &&
config?.SMTP_PORT &&
(config?.SMTP_MAX_FREQUENCY ?? 0) >= 0
)
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx
index 50858d08a7a..149f7c27486 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx
@@ -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 (
@@ -124,6 +131,15 @@ const DowngradeModal = ({
)}
+ {hasPostCutoffProjects && (
+
+ )}
+
@@ -175,5 +191,3 @@ const DowngradeModal = ({
)
}
-
-export default DowngradeModal
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.test.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.test.tsx
index ba78c21dbc8..7cb5fb3fd3a 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.test.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.test.tsx
@@ -98,7 +98,7 @@ vi.mock('./EnterpriseCard', () => ({
}))
vi.mock('./DowngradeModal', () => ({
- default: () => null,
+ DowngradeModal: () => null,
}))
vi.mock('./ExitSurveyModal', () => ({
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx
index 6ec8fdf5e1f..ef8479914d9 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx
@@ -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'
diff --git a/apps/studio/components/ui/TwoOptionToggle.tsx b/apps/studio/components/ui/TwoOptionToggle.tsx
index 4358e9a24b9..b439bc3e780 100644
--- a/apps/studio/components/ui/TwoOptionToggle.tsx
+++ b/apps/studio/components/ui/TwoOptionToggle.tsx
@@ -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) => (
- onClickOption(option)}
- >
+ {options.map((option, index: number) => {
+ const isDisabled = disabledOptions.includes(option)
+ const optionButton = (
{
+ if (!isDisabled) onClickOption(option)
+ }}
>
- {option}
+
+ {option}
+
-
- ))}
+ )
+
+ if (!isDisabled || !disabledOptionTooltip) return optionButton
+
+ return (
+
+ {optionButton}
+ {disabledOptionTooltip}
+
+ )
+ })}
)
}
diff --git a/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx b/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx
index bbca5ebac8c..baa9cd6665e 100644
--- a/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx
+++ b/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx
@@ -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 = () => {