Updating authentication email pages (#33958)

* init PrePostTab component

* init

* support auto fixing rounded of inputs/select/textarea

* remove form item layout prepost

* Update FormLayout.tsx

* Update FormLayout.tsx

* Update FormLayout.tsx

* form layout and advanced

* Update advanced.tsx

* protection and hooks

* auth emails

* preview styles

* fix

* parse

---------

Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
This commit is contained in:
Saxon FletcherandJonathan Summers-Muir authored and GitHub committed 2025-03-04 10:22:45 +10:00
1 parent 326d2cbdcb
commit 419252c102
7 files changed
+685 -562

No files matched your search

@@ -6,6 +6,8 @@ import { FormPanel } from 'components/ui/Forms/FormPanel'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import {
Card,
CardContent,
ScrollArea,
Tabs_Shadcn_,
TabsContent_Shadcn_,
@@ -37,20 +39,20 @@ const EmailTemplates = () => {
<AlertError error={authConfigError} subject="Failed to retrieve auth configuration" />
)}
{isLoading && (
<div className="w-[854px]">
<div className="w-[854px] mt-12">
<GenericSkeletonLoader />
</div>
)}
{isSuccess && (
<>
<div className="my-12">
{builtInSMTP ? (
<div className="mb-4">
<EmailRateLimitsAlert />
</div>
) : null}
<FormPanel>
<Card>
<Tabs_Shadcn_ defaultValue={TEMPLATES_SCHEMAS[0].title.trim().replace(/\s+/g, '-')}>
<TabsList_Shadcn_ className="px-4 md:px-8 pt-2 gap-5 overflow-x-scroll no-scrollbar">
<TabsList_Shadcn_ className="pt-2 px-6 gap-5 mb-0 overflow-x-scroll no-scrollbar mb-4">
{TEMPLATES_SCHEMAS.map((template) => {
return (
<TabsTrigger_Shadcn_ value={template.title.trim().replace(/\s+/g, '-')}>
@@ -59,18 +61,17 @@ const EmailTemplates = () => {
)
})}
</TabsList_Shadcn_>
{TEMPLATES_SCHEMAS.map((template) => {
const panelId = template.title.trim().replace(/\s+/g, '-')
return (
<TabsContent_Shadcn_ value={panelId} key={panelId}>
<TabsContent_Shadcn_ value={panelId} key={panelId} className="mt-0">
<TemplateEditor key={template.title} template={template} />
</TabsContent_Shadcn_>
)
})}
</Tabs_Shadcn_>
</FormPanel>
</>
</Card>
</div>
)}
</div>
)
@@ -1,17 +1,7 @@
import { Markdown } from 'components/interfaces/Markdown'
import InformationBox from 'components/ui/InformationBox'
import { ValidateSpamResponse } from 'data/auth/validate-spam-mutation'
import {
cn,
Separator,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
WarningIcon,
} from 'ui'
import { Check, MailWarning } from 'lucide-react'
import { Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
interface SpamValidationProps {
validationResult?: ValidateSpamResponse
@@ -26,23 +16,23 @@ export const SpamValidation = ({ validationResult }: SpamValidationProps) => {
const hasSpamWarning = spamRules.length > 0
return (
<InformationBox
className={cn('mb-2', hasSpamWarning && '!bg-alternative/75', '!px-0', 'rounded-t-none')}
icon={hasSpamWarning && <WarningIcon />}
title={
hasSpamWarning
? 'Email has a high probability of being marked as spam and deliverability may be affected'
: 'Email content is unlikely to be marked as spam'
}
description={
hasSpamWarning ? (
<div className="flex items-start gap-2">
<div className="mt-0.5">
{hasSpamWarning ? (
<MailWarning size={16} strokeWidth={1.5} className="text-warning" />
) : (
<Check size={16} strokeWidth={1.5} className="text-brand" />
)}
</div>
<div className="flex flex-col gap-2">
<h5 className="text-sm">
{hasSpamWarning
? 'Email has a high probability of being marked as spam - review issues below to improve deliverability.'
: 'Email content is unlikely to be marked as spam'}
</h5>
{hasSpamWarning && (
<>
<div className="flex flex-col gap-y-3">
<p>
{hasSpamWarning
? ` Rectify the following issues to improve your email's deliverability in order of priority:`
: ` Address the following issues to improve your email's deliverability:`}
</p>
<Table>
<TableHeader className="font-mono uppercase text-xs [&_th]:h-auto [&_th]:py-2">
<TableRow className="hover:bg-transparent">
@@ -68,8 +58,8 @@ export const SpamValidation = ({ validationResult }: SpamValidationProps) => {
content="Spam validation is powered by [SpamAssassin](https://spamassassin.apache.org/doc.html). Full list of all available warnings can be found [here](https://gist.github.com/ychaouche/a2faff159c2a1fea16019156972c7f8b)."
/>
</>
) : null
}
/>
)}
</div>
</div>
)
}
@@ -1,28 +1,39 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Code, Monitor } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { editor } from 'monaco-editor'
import { useEffect, useMemo, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { toast } from 'sonner'
import { useForm } from 'react-hook-form'
import { useParams } from 'common'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import InformationBox from 'components/ui/InformationBox'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useValidateSpamMutation, ValidateSpamResponse } from 'data/auth/validate-spam-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import type { FormSchema } from 'types'
import {
Form,
Input,
Badge,
Button,
Card,
CardContent,
CardFooter,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Label_Shadcn_,
Tabs_Shadcn_,
TabsContent_Shadcn_,
TabsList_Shadcn_,
TabsTrigger_Shadcn_,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { SpamValidation } from './SpamValidation'
interface TemplateEditorProps {
@@ -33,6 +44,9 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
const { ref: projectRef } = useParams()
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
// Add a ref to the code editor
const editorRef = useRef<editor.IStandaloneCodeEditor>()
// [Joshen] Error state is handled in the parent
const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef })
@@ -49,15 +63,6 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
const { id, properties } = template
const formId = `auth-config-email-templates-${id}`
const INITIAL_VALUES = useMemo(() => {
const result: { [x: string]: string } = {}
Object.keys(properties).forEach((key) => {
result[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string
})
return result
}, [authConfig, properties])
const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as keyof typeof authConfig
const messageProperty = properties[messageSlug]
const builtInSMTP =
@@ -74,7 +79,33 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0)
const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0
const onSubmit = (values: any, { resetForm }: any) => {
// Create form values
const INITIAL_VALUES = useMemo(() => {
const result: { [x: string]: string } = {}
Object.keys(properties).forEach((key) => {
result[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string
})
return result
}, [authConfig, properties])
// Setup React Hook Form
const form = useForm({
defaultValues: INITIAL_VALUES,
})
// Update form values when authConfig changes
useEffect(() => {
if (authConfig) {
const values: { [key: string]: string } = {}
Object.keys(properties).forEach((key) => {
values[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string
})
form.reset(values)
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
}
}, [authConfig, properties, messageSlug, form])
const onSubmit = (values: any) => {
if (!projectRef) return console.error('Project ref is required')
setIsSavingTemplate(true)
@@ -112,10 +143,6 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
onSuccess: () => {
setIsSavingTemplate(false)
toast.success('Successfully updated settings')
resetForm({
values: values,
initialValues: values,
})
setHasUnsavedChanges(false) // Reset the unsaved changes state
},
}
@@ -157,140 +184,202 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
return (
<Form id={formId} className="!border-t-0" initialValues={INITIAL_VALUES} onSubmit={onSubmit}>
{({ resetForm, values, initialValues }: any) => {
const message = (authConfig && authConfig[messageSlug]) ?? ''
const hasChanges =
JSON.stringify(values) !== JSON.stringify(initialValues) || message !== bodyValue
// Single useMemo hook to parse and prepare message variables
const messageVariables = useMemo(() => {
if (!messageProperty?.description) return []
return (
// Parse bullet point format: - `{{ .Variable }}` : Description
const lines = messageProperty.description.split('\n')
const variables: { variable: string; description: string }[] = []
for (const line of lines) {
// Match lines that start with a bullet point followed by a variable in the format {{ .Variable }}
// Handle variations in formatting (with or without backticks, different spacing)
const match = line.match(/-\s*`?({{\s*\.\w+\s*}})`?\s*(?::|-)?\s*(.+)/)
if (match && match[1] && match[2]) {
variables.push({
variable: match[1].replace(/`/g, '').trim(),
description: match[2].trim(),
})
}
}
return variables
}, [messageProperty?.description])
// Check if form values have changed
const formValues = form.watch()
const hasFormChanges = JSON.stringify(formValues) !== JSON.stringify(INITIAL_VALUES)
const hasChanges = hasFormChanges || ((authConfig && authConfig[messageSlug]) ?? '') !== bodyValue
// Function to insert text at cursor position
const insertTextAtCursor = (text: string) => {
if (!editorRef.current) return
const editor = editorRef.current
const selection = editor.getSelection()
if (selection) {
const range = {
startLineNumber: selection.startLineNumber,
startColumn: selection.startColumn,
endLineNumber: selection.endLineNumber,
endColumn: selection.endColumn,
}
editor.executeEdits('insert-variable', [
{
range,
text,
forceMoveMarkers: true,
},
])
// Focus the editor after insertion
editor.focus()
}
}
return (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent>
{Object.keys(properties).map((x: string) => {
const property = properties[x]
if (property.type === 'string' && x !== messageSlug) {
return (
<FormField_Shadcn_
key={x}
control={form.control}
name={x}
render={({ field }) => (
<FormItemLayout
label={property.title}
description={
property.description ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{property.description}
</ReactMarkdown>
) : null
}
labelOptional={
property.descriptionOptional ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{property.descriptionOptional}
</ReactMarkdown>
) : null
}
>
<FormControl_Shadcn_>
<Input_Shadcn_ id={x} {...field} disabled={!canUpdateConfig} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)
}
return null
})}
</CardContent>
{messageProperty && (
<>
<FormSection className="!border-t-0 pb-8 !pt-4">
<FormSectionContent loading={false}>
{Object.keys(properties).map((x: string) => {
const property = properties[x]
if (property.type === 'string') {
return (
<div key={x} className="space-y-3">
<label className="col-span-12 text-sm text-foreground lg:col-span-5">
{property.title}
</label>
<Input
size="small"
layout="vertical"
id={x}
key={x}
name={x}
descriptionText={
property.description ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{property.description}
</ReactMarkdown>
) : null
}
labelOptional={
property.descriptionOptional ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{property.descriptionOptional}
</ReactMarkdown>
) : null
}
disabled={!canUpdateConfig}
/>
</div>
)
}
})}
</FormSectionContent>
</FormSection>
<FormSection className="!mt-0 grid-cols-12 !border-t-0 !pt-0 pb-7">
<FormSectionContent fullWidth loading={false}>
{messageProperty && (
<>
<div className="space-y-3">
<FormSectionLabel>
<span>{messageProperty.title}</span>
</FormSectionLabel>
<InformationBox
defaultVisibility
title="Message variables"
hideCollapse={false}
description={
messageProperty.description && (
<ReactMarkdown>{messageProperty.description}</ReactMarkdown>
)
}
/>
</div>
<Tabs_Shadcn_ defaultValue="source">
<TabsList_Shadcn_ className="gap-3">
<TabsTrigger_Shadcn_ value="source" className="gap-2">
<Code size={14} />
Source
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="preview" className="gap-2">
<Monitor size={14} />
Preview
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<TabsContent_Shadcn_ value="source" className="-space-y-px">
<CodeEditor
id="code-id"
language="html"
isReadOnly={!canUpdateConfig}
className="!mb-0 relative h-96 overflow-hidden rounded border rounded-b-none"
onInputChange={(e: string | undefined) => {
setBodyValue(e ?? '')
if (bodyValue !== e) setHasUnsavedChanges(true)
}}
options={{ wordWrap: 'on', contextmenu: false }}
value={bodyValue}
/>
<SpamValidation validationResult={validationResult} />
</TabsContent_Shadcn_>
<TabsContent_Shadcn_ value="preview">
<Admonition
type="default"
title="The preview may differ slightly from the actual rendering in the email client"
/>
<iframe
className="!mb-0 overflow-hidden h-96 w-full rounded border"
title={id}
srcDoc={bodyValue}
/>
</TabsContent_Shadcn_>
</Tabs_Shadcn_>
</>
)}
<div className="col-span-12 flex w-full">
<FormActions
handleReset={() => {
resetForm({
values: authConfig,
initialValues: authConfig,
})
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
<CardContent className="p-0">
<Label_Shadcn_ className="p-6 pb-4 block">Message body</Label_Shadcn_>
<Tabs_Shadcn_ defaultValue="source">
<TabsList_Shadcn_ className="gap-3 px-6">
<TabsTrigger_Shadcn_ value="source" className="gap-2">
<Code size={14} />
Source
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="preview" className="gap-2">
<Monitor size={14} />
Preview
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<TabsContent_Shadcn_ value="source" className="p-0 mt-0">
<CodeEditor
id="code-id"
language="html"
isReadOnly={!canUpdateConfig}
className="!mb-0 relative h-96 overflow-hidden p-0"
onInputChange={(e: string | undefined) => {
setBodyValue(e ?? '')
if (bodyValue !== e) setHasUnsavedChanges(true)
}}
form={formId}
isSubmitting={isSavingTemplate}
hasChanges={hasChanges}
disabled={!canUpdateConfig}
helper={
preventSaveFromSpamCheck
? 'Please rectify all spam warnings before saving while using the built-in email service'
: !canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
options={{ wordWrap: 'on', contextmenu: false, padding: { top: 16 } }}
value={bodyValue}
editorRef={editorRef}
/>
</div>
</FormSectionContent>
</FormSection>
{messageVariables.length > 0 && (
<div className="px-6 py-3 border-t bg-surface-200">
<div className="flex flex-wrap gap-1">
{messageVariables.map(({ variable, description }) => (
<Tooltip key={variable}>
<TooltipTrigger asChild>
<Button
type="outline"
size="tiny"
className="rounded-full"
onClick={() => insertTextAtCursor(variable)}
>
{variable}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{description || 'Variable description not available'}</p>
</TooltipContent>
</Tooltip>
))}
</div>
</div>
)}
</TabsContent_Shadcn_>
<TabsContent_Shadcn_ value="preview" className="pt-0 mt-0">
<iframe
className="!mb-0 mt-0 overflow-hidden h-96 w-full"
title={id}
srcDoc={bodyValue}
/>
<Admonition
type="default"
title="The preview may differ slightly from the actual rendering in the email client"
className="rounded-none border-0 mb-0"
/>
</TabsContent_Shadcn_>
</Tabs_Shadcn_>
</CardContent>
<CardContent>
<SpamValidation validationResult={validationResult} />
</CardContent>
<CardFooter className="justify-end space-x-2">
{hasChanges && (
<Button
type="default"
onClick={() => {
form.reset(INITIAL_VALUES)
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
setHasUnsavedChanges(false)
}}
>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={
!canUpdateConfig || isSavingTemplate || !hasChanges || preventSaveFromSpamCheck
}
loading={isSavingTemplate}
>
Save changes
</Button>
</CardFooter>
</>
)
}}
</Form>
)}
</form>
</Form_Shadcn_>
)
}
@@ -1,14 +1,16 @@
import { yupResolver } from '@hookform/resolvers/yup'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { number, object, string } from 'yup'
import * as yup from 'yup'
import { Markdown } from 'components/interfaces/Markdown'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import {
ScaffoldContainer,
ScaffoldSection,
ScaffoldSectionTitle,
} from 'components/layouts/Scaffold'
import NoPermission from 'components/ui/NoPermission'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
@@ -18,19 +20,35 @@ import {
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Form,
Input,
InputNumber,
Toggle,
Card,
CardContent,
CardFooter,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
PrePostTab,
Switch,
WarningIcon,
} from 'ui'
import EmailRateLimitsAlert from '../EmailRateLimitsAlert'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { urlRegex } from './../Auth.constants'
import { defaultDisabledSmtpFormValues } from './SmtpForm.constants'
import { generateFormValues, isSmtpEnabled } from './SmtpForm.utils'
import { AlertTriangle, Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
interface SmtpFormValues {
SMTP_ADMIN_EMAIL?: string
SMTP_SENDER_NAME?: string
SMTP_HOST?: string
SMTP_PORT?: number
SMTP_MAX_FREQUENCY?: number
SMTP_USER?: string
SMTP_PASS?: string
ENABLE_SMTP: boolean
}
const SmtpForm = () => {
const { ref: projectRef } = useParams()
const {
@@ -45,46 +63,30 @@ const SmtpForm = () => {
const [enableSmtp, setEnableSmtp] = useState(false)
const [hidden, setHidden] = useState(true)
const formId = 'auth-config-smtp-form'
const initialValues = generateFormValues(authConfig)
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
useEffect(() => {
if (isSuccess && isSmtpEnabled(authConfig)) {
setEnableSmtp(true)
}
}, [isSuccess, authConfig])
const schema = object({
SMTP_ADMIN_EMAIL: string().when([], {
is: () => {
return enableSmtp
},
const smtpSchema = yup.object({
SMTP_ADMIN_EMAIL: yup.string().when('ENABLE_SMTP', {
is: true,
then: (schema) => schema.email('Must be a valid email').required('Sender email is required'),
otherwise: (schema) => schema,
}),
SMTP_SENDER_NAME: string().when([], {
is: () => {
return enableSmtp
},
SMTP_SENDER_NAME: yup.string().when('ENABLE_SMTP', {
is: true,
then: (schema) => schema.required('Sender name is required'),
otherwise: (schema) => schema,
}),
SMTP_HOST: string().when([], {
is: () => {
return enableSmtp
},
SMTP_HOST: yup.string().when('ENABLE_SMTP', {
is: true,
then: (schema) =>
schema
.matches(urlRegex({ excludeSimpleDomains: false }), 'Must be a valid URL or IP address')
.required('Host URL is required.'),
otherwise: (schema) => schema,
}),
SMTP_PORT: number().when([], {
is: () => {
return enableSmtp
},
SMTP_PORT: yup.number().when('ENABLE_SMTP', {
is: true,
then: (schema) =>
schema
.required('Port number is required.')
@@ -92,10 +94,8 @@ const SmtpForm = () => {
.max(65535, 'Must be a valid port number no more than 65535'),
otherwise: (schema) => schema,
}),
SMTP_MAX_FREQUENCY: number().when([], {
is: () => {
return enableSmtp
},
SMTP_MAX_FREQUENCY: yup.number().when('ENABLE_SMTP', {
is: true,
then: (schema) =>
schema
.required('Rate limit is required.')
@@ -103,28 +103,63 @@ const SmtpForm = () => {
.max(32767, 'Must not be more than 32,767 an hour'),
otherwise: (schema) => schema,
}),
SMTP_USER: string().when([], {
is: () => {
return enableSmtp
},
SMTP_USER: yup.string().when('ENABLE_SMTP', {
is: true,
then: (schema) => schema.required('SMTP Username is required'),
otherwise: (schema) => schema,
}),
SMTP_PASS: string().when([], {
is: () => {
return enableSmtp && authConfig?.SMTP_PASS === null
},
then: (schema) => schema.required('SMTP password is required'),
otherwise: (schema) => schema,
}),
SMTP_PASS: yup.string(),
ENABLE_SMTP: yup.boolean().required(),
})
const onSubmit = (values: any, { resetForm }: any) => {
const payload = enableSmtp ? values : defaultDisabledSmtpFormValues
const form = useForm<SmtpFormValues>({
resolver: yupResolver(smtpSchema),
defaultValues: {
SMTP_ADMIN_EMAIL: '',
SMTP_SENDER_NAME: '',
SMTP_HOST: '',
SMTP_PORT: undefined,
SMTP_MAX_FREQUENCY: undefined,
SMTP_USER: '',
SMTP_PASS: '',
ENABLE_SMTP: false,
},
})
// Format payload: Remove redundant value + convert port to string
delete payload.ENABLE_SMTP
payload.SMTP_PORT = payload.SMTP_PORT ? payload.SMTP_PORT.toString() : payload.SMTP_PORT
// Update form values when auth config is loaded
useEffect(() => {
if (authConfig) {
const formValues = generateFormValues(authConfig)
// Convert SMTP_PORT from string to number if it exists
if (formValues.SMTP_PORT) {
formValues.SMTP_PORT = Number(formValues.SMTP_PORT) as any
}
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 = (values: SmtpFormValues) => {
const { ENABLE_SMTP, ...rest } = values
const payload = ENABLE_SMTP ? rest : defaultDisabledSmtpFormValues
// Format payload: Convert port to string
if (payload.SMTP_PORT) {
payload.SMTP_PORT = payload.SMTP_PORT.toString() as any
}
// the SMTP_PASS is write-only, it's never shown. If we don't delete it from the payload, it will replace the
// previously saved value with an empty one
@@ -133,7 +168,7 @@ const SmtpForm = () => {
}
updateAuthConfig(
{ projectRef: projectRef!, config: payload },
{ projectRef: projectRef!, config: payload as any },
{
onError: (error) => {
toast.error(`Failed to update settings: ${error.message}`)
@@ -141,8 +176,6 @@ const SmtpForm = () => {
onSuccess: () => {
setHidden(true)
toast.success('Successfully updated settings')
const updatedFormValues = generateFormValues(payload)
resetForm({ values: updatedFormValues, initialValues: updatedFormValues })
},
}
)
@@ -163,206 +196,284 @@ const SmtpForm = () => {
}
return (
<Form id={formId} initialValues={initialValues} onSubmit={onSubmit} validationSchema={schema}>
{({ resetForm, values }: any) => {
const isValidSmtpConfig = isSmtpEnabled(values)
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess) {
const formValues = generateFormValues(authConfig)
resetForm({ values: formValues, initialValues: formValues })
}
}, [isSuccess, authConfig])
const onResetForm = () => {
setEnableSmtp(isSmtpEnabled(initialValues))
resetForm({ values: initialValues })
}
return (
<>
<FormPanel
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={hasChanges}
handleReset={onResetForm}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
>
<FormSection>
<FormSectionContent className="!col-span-12 !gap-y-2" loading={isLoading}>
<Toggle
size="small"
layout="flex"
name="ENABLE_SMTP"
label="Enable Custom SMTP"
checked={enableSmtp}
disabled={!canUpdateConfig}
// @ts-ignore
onChange={(value: boolean) => setEnableSmtp(value)}
descriptionText={
<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{' '}
<Link
className="underline"
href={`/project/${projectRef}/auth/rate-limits`}
>
here
</Link>
.
</p>
}
/>
{enableSmtp && !isValidSmtpConfig && !isValidSmtpConfig && (
<div className="">
<Alert_Shadcn_ variant="warning">
<AlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>All fields below must be filled</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
The following fields must be filled before custom SMTP can be properly
enabled
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
<ScaffoldContainer>
<ScaffoldSection isFullWidth>
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="ENABLE_SMTP"
render={({ field }) => (
<FormItemLayout
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{' '}
<Link
className="underline"
href={`/project/${projectRef}/auth/rate-limits`}
>
here
</Link>
.
</p>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
</FormSectionContent>
</FormSection>
/>
<FormSection
visible={enableSmtp}
header={<FormSectionLabel>Sender details</FormSectionLabel>}
disabled={!enableSmtp}
>
<FormSectionContent loading={isLoading}>
<Input
name="SMTP_ADMIN_EMAIL"
id="SMTP_ADMIN_EMAIL"
label="Sender email"
descriptionText="This is the email address the emails are sent from"
placeholder="noreply@yourdomain.com"
disabled={!canUpdateConfig}
/>
<Input
name="SMTP_SENDER_NAME"
id="SMTP_SENDER_NAME"
label="Sender name"
descriptionText="Name displayed in the recipient's inbox"
placeholder="The name shown on the email"
disabled={!canUpdateConfig}
/>
</FormSectionContent>
</FormSection>
<FormSection
visible={enableSmtp}
disabled={!enableSmtp}
header={
<FormSectionLabel>
<span>SMTP Provider Settings</span>
<p className="my-4 text-foreground-lighter">
Your SMTP Credentials will always be encrypted in our database.
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={isLoading}>
{values['SMTP_HOST'] && values['SMTP_HOST'].endsWith('.gmail.com') && (
{enableSmtp && !isSmtpEnabled(form.getValues() as any) && (
<div className="mt-4">
<Alert_Shadcn_ variant="warning">
<AlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>Check your SMTP provider</AlertTitle_Shadcn_>
<AlertTitle_Shadcn_>All fields below must be filled</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Not all SMTP providers are designed for the email sending required by
Supabase Auth. It looks like the SMTP provider you entered is designed for
sending personal email messages and not for sending transactional messages.
Although you can ignore this warning, email deliverability may be impacted.
The following fields must be filled before custom SMTP can be properly
enabled
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
<Input
name="SMTP_HOST"
placeholder="your.smtp.host.com"
id="SMTP_HOST"
label="Host"
descriptionText="Hostname or IP address of your SMTP server."
disabled={!canUpdateConfig}
/>
<InputNumber
name="SMTP_PORT"
id="SMTP_PORT"
placeholder="587"
label="Port number"
descriptionText={
<>
<span className="block">
Port used by your SMTP server. Common ports include 25, 465, and 587.{' '}
</span>
<span className="mt-2 block">
Avoid using port 25 as modern SMTP email clients shouldn't use this port,
it is traditionally blocked by residential ISPs and Cloud Hosting
Providers, to curb the amount of spam.
</span>
</>
}
disabled={!canUpdateConfig}
/>
<InputNumber
id="SMTP_MAX_FREQUENCY"
name="SMTP_MAX_FREQUENCY"
label="Minimum interval between emails being sent"
descriptionText="How long between each email can a new email be sent via your SMTP server."
actions={<span className="mr-3 text-foreground-lighter">seconds</span>}
disabled={!canUpdateConfig}
/>
<Input
name="SMTP_USER"
id="SMTP_USER"
label="Username"
placeholder="SMTP Username"
disabled={!canUpdateConfig}
/>
<Input
name="SMTP_PASS"
id="SMTP_PASS"
type={hidden ? 'password' : 'text'}
label="Password"
placeholder={authConfig?.SMTP_PASS === null ? 'SMTP Password' : '••••••••'}
actions={
<Button
icon={hidden ? <Eye /> : <EyeOff />}
type="default"
onClick={() => setHidden(!hidden)}
/>
}
disabled={!canUpdateConfig}
descriptionText={
<span>
For security reasons, the password is write-only. Once saved, it cannot be
retrieved or displayed.
</span>
}
/>
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
</div>
)}
</CardContent>
{enableSmtp && (
<>
<CardContent className="py-6">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-4">
<h3 className="text-sm mb-1">Sender details</h3>
<p className="text-sm text-foreground-lighter">
Configure the sender information for your emails.
</p>
</div>
<div className="col-span-8 space-y-4">
<FormField_Shadcn_
control={form.control}
name="SMTP_ADMIN_EMAIL"
render={({ field }) => (
<FormItemLayout
label="Sender email"
description="This is the email address the emails are sent from"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="noreply@yourdomain.com"
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="SMTP_SENDER_NAME"
render={({ field }) => (
<FormItemLayout
label="Sender name"
description="Name displayed in the recipient's inbox"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="The name shown on the email"
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
</div>
</CardContent>
<CardContent className="py-6">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-4">
<h3 className="text-sm mb-1">SMTP Provider Settings</h3>
<p className="text-sm text-foreground-lighter">
Your SMTP Credentials will always be encrypted in our database.
</p>
</div>
<div className="col-span-8 space-y-4">
{form.watch('SMTP_HOST')?.endsWith('.gmail.com') && (
<Alert_Shadcn_ variant="warning" className="mb-4">
<AlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>Check your SMTP provider</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Not all SMTP providers are designed for the email sending required by
Supabase Auth. It looks like the SMTP provider you entered is designed
for sending personal email messages and not for sending transactional
messages. Although you can ignore this warning, email deliverability
may be impacted.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
<FormField_Shadcn_
control={form.control}
name="SMTP_HOST"
render={({ field }) => (
<FormItemLayout
label="Host"
description="Hostname or IP address of your SMTP server."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="your.smtp.host.com"
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="SMTP_PORT"
render={({ field }) => (
<FormItemLayout
label="Port number"
description={
<>
<span className="block">
Port used by your SMTP server. Common ports include 25, 465, and
587.{' '}
</span>
<span className="mt-2 block">
Avoid using port 25 as modern SMTP email clients shouldn't use
this port, it is traditionally blocked by residential ISPs and
Cloud Hosting Providers, to curb the amount of spam.
</span>
</>
}
>
<FormControl_Shadcn_>
<Input_Shadcn_
type="number"
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
placeholder="587"
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="SMTP_MAX_FREQUENCY"
render={({ field }) => (
<FormItemLayout
label="Minimum interval between emails being sent"
description="How long between each email can a new email be sent via your SMTP server."
>
<FormControl_Shadcn_>
<PrePostTab postTab="seconds">
<Input_Shadcn_
type="number"
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
disabled={!canUpdateConfig}
/>
</PrePostTab>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="SMTP_USER"
render={({ field }) => (
<FormItemLayout
label="Username"
description="Username for your SMTP server"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="SMTP Username"
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="SMTP_PASS"
render={({ field }) => (
<FormItemLayout
label="Password"
description="For security reasons, the password is write-only. Once saved, it cannot be retrieved or displayed."
>
<FormControl_Shadcn_>
<PrePostTab
postTab={
<Button
type="text"
className="p-0"
onClick={() => setHidden(!hidden)}
icon={hidden ? <Eye /> : <EyeOff />}
/>
}
>
<Input_Shadcn_
{...field}
type={hidden ? 'password' : 'text'}
placeholder={
authConfig?.SMTP_PASS === null ? 'SMTP Password' : '••••••••'
}
disabled={!canUpdateConfig}
/>
</PrePostTab>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
</div>
</CardContent>
</>
)}
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
loading={isUpdatingConfig}
disabled={!canUpdateConfig || !form.formState.isDirty}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
</ScaffoldContainer>
)
}
@@ -0,0 +1,39 @@
import { ReactNode } from 'react'
import { useParams } from 'common'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import AuthLayout from './AuthLayout'
interface AuthEmailsLayoutProps {
children: ReactNode
}
export const AuthEmailsLayout = ({ children }: AuthEmailsLayoutProps) => {
const { ref } = useParams()
const navItems = [
{
label: 'Templates',
href: `/project/${ref}/auth/templates`,
},
{
label: 'SMTP Settings',
href: `/project/${ref}/auth/smtp`,
},
]
return (
<DefaultLayout>
<AuthLayout>
<PageLayout
title="Emails"
subtitle="Configure what emails your users receive and how they are sent"
navigationItems={navItems}
>
{children}
</PageLayout>
</AuthLayout>
</DefaultLayout>
)
}
+6 -63
View File
@@ -1,79 +1,22 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { SmtpForm } from 'components/interfaces/Auth'
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
import { AuthEmailsLayout } from 'components/layouts/AuthLayout/AuthEmailsLayout'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { useParams } from 'common'
import { useCurrentPath } from 'hooks/misc/useCurrentPath'
import Link from 'next/link'
import { NavMenu, NavMenuItem } from 'ui'
import {
ScaffoldContainer,
ScaffoldDescription,
ScaffoldDivider,
ScaffoldHeader,
ScaffoldTitle,
} from 'components/layouts/Scaffold'
import NoPermission from 'components/ui/NoPermission'
import type { NextPageWithLayout } from 'types'
import DefaultLayout from 'components/layouts/DefaultLayout'
const PageLayout: NextPageWithLayout = () => {
const SmtpPage: NextPageWithLayout = () => {
const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const isPermissionsLoaded = usePermissionsLoaded()
const { ref } = useParams()
const currentPath = useCurrentPath()
const navMenuItems = [
{
label: 'Templates',
href: `/project/${ref}/auth/templates`,
},
{
label: 'SMTP Settings',
href: `/project/${ref}/auth/smtp`,
},
]
if (isPermissionsLoaded && !canReadAuthSettings) {
return <NoPermission isFullPage resourceText="access your project's email settings" />
}
return (
<div>
<ScaffoldHeader className="pb-0">
<ScaffoldContainer id="auth-page-top">
<ScaffoldTitle>Emails</ScaffoldTitle>
<ScaffoldDescription>
Configure what emails your users receive and how they are sent
</ScaffoldDescription>
<NavMenu
className="border-none max-w-full overflow-y-hidden overflow-x-auto mt-4"
aria-label="Auth email settings navigation"
>
{navMenuItems.map((item) => (
<NavMenuItem key={item.label} active={currentPath === item.href}>
<Link href={item.href}>{item.label}</Link>
</NavMenuItem>
))}
</NavMenu>
</ScaffoldContainer>
</ScaffoldHeader>
<ScaffoldDivider />
<ScaffoldContainer className="my-8 space-y-8">
<SmtpForm />
</ScaffoldContainer>
</div>
)
return <SmtpForm />
}
PageLayout.getLayout = (page) => {
return (
<DefaultLayout>
<AuthLayout>{page}</AuthLayout>
</DefaultLayout>
)
}
export default PageLayout
SmtpPage.getLayout = (page) => <AuthEmailsLayout>{page}</AuthEmailsLayout>
export default SmtpPage
@@ -1,76 +1,26 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { EmailTemplates } from 'components/interfaces/Auth'
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { useCurrentPath } from 'hooks/misc/useCurrentPath'
import Link from 'next/link'
import { NavMenu, NavMenuItem } from 'ui'
import {
ScaffoldContainer,
ScaffoldDescription,
ScaffoldDivider,
ScaffoldHeader,
ScaffoldTitle,
} from 'components/layouts/Scaffold'
import { AuthEmailsLayout } from 'components/layouts/AuthLayout/AuthEmailsLayout'
import { ScaffoldContainer } from 'components/layouts/Scaffold'
import NoPermission from 'components/ui/NoPermission'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import type { NextPageWithLayout } from 'types'
import DefaultLayout from 'components/layouts/DefaultLayout'
const PageLayout: NextPageWithLayout = () => {
const TemplatesPage: NextPageWithLayout = () => {
const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const isPermissionsLoaded = usePermissionsLoaded()
const { ref } = useParams()
const currentPath = useCurrentPath()
const navMenuItems = [
{
label: 'Templates',
href: `/project/${ref}/auth/templates`,
},
{
label: 'SMTP Settings',
href: `/project/${ref}/auth/smtp`,
},
]
if (isPermissionsLoaded && !canReadAuthSettings) {
return <NoPermission isFullPage resourceText="access your project's email settings" />
}
return (
<div>
<ScaffoldHeader className="pb-0">
<ScaffoldContainer id="auth-page-top">
<ScaffoldTitle>Emails</ScaffoldTitle>
<ScaffoldDescription>
Configure what emails your users receive and how they are sent
</ScaffoldDescription>
<NavMenu
className="border-none max-w-full overflow-y-hidden overflow-x-auto mt-4"
aria-label="Auth email settings navigation"
>
{navMenuItems.map((item) => (
<NavMenuItem key={item.label} active={currentPath === item.href}>
<Link href={item.href}>{item.label}</Link>
</NavMenuItem>
))}
</NavMenu>
</ScaffoldContainer>
</ScaffoldHeader>
<ScaffoldDivider />
<ScaffoldContainer className="my-8 space-y-8">
<EmailTemplates />
</ScaffoldContainer>
</div>
<ScaffoldContainer>
<EmailTemplates />
</ScaffoldContainer>
)
}
PageLayout.getLayout = (page) => (
<DefaultLayout>
<AuthLayout>{page}</AuthLayout>
</DefaultLayout>
)
TemplatesPage.getLayout = (page) => <AuthEmailsLayout>{page}</AuthEmailsLayout>
export default PageLayout
export default TemplatesPage