Files
supabase/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx
T
Gildas Garcia c6067a1a19 Chore yup to zod (#44803)
## Problem

We currently have 2 libraries for schema validation: `yup` that was used
with `formik` and `zod` which is now the preferred one.

## Solution

- Migrate to `zod`
- Fix validation isn't applied on email template form
- Fix `react-hook-form` form state usage

No visual changes.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Switched form validation to a unified Zod-based approach across
authentication UIs.

* **Improvements**
* Template editor and email templates now validate via provided Zod
schemas.
* SMTP and captcha settings receive conditional validation, improved
numeric handling, and clearer required-field behavior.
  * Validation import/style consistency tidied.

* **Bug Fixes**
* Consistent dirty-state detection so Save/Cancel visibility and
enabled/disabled behavior are reliable across auth forms.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-14 12:42:26 +02:00

497 lines
19 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useEffect, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Card,
CardContent,
CardFooter,
cn,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
FormInputGroupInput,
Input_Shadcn_,
InputGroup,
InputGroupAddon,
InputGroupText,
Switch,
} from 'ui'
import { Admonition, PageSection, PageSectionContent } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { urlRegex } from '../Auth.constants'
import { defaultDisabledSmtpFormValues } from './SmtpForm.constants'
import { generateFormValues, isSmtpEnabled } from './SmtpForm.utils'
import AlertError from '@/components/ui/AlertError'
import { InlineLink } from '@/components/ui/InlineLink'
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'
const smtpEnabledSchema = z.object({
ENABLE_SMTP: z.literal(true),
SMTP_ADMIN_EMAIL: z
.string()
.trim()
.min(1, 'Sender email address is required')
.email('Must be a valid email'),
SMTP_SENDER_NAME: z.string().trim().min(1, 'Sender name is required'),
SMTP_HOST: z
.string()
.trim()
.min(1, 'Host URL is required')
.regex(urlRegex({ excludeSimpleDomains: false }), 'Must be a valid URL or IP address'),
SMTP_PORT: z.preprocess(
(val) => (val === '' || val == null ? undefined : val),
z.coerce
.number({
required_error: 'Port number is required',
invalid_type_error: 'Port number is required',
})
.min(1, 'Must be a valid port number more than 0')
.max(65535, 'Must be a valid port number no more than 65535')
),
SMTP_MAX_FREQUENCY: z.preprocess(
(val) => (val === '' || val == null ? undefined : val),
z.coerce
.number({
required_error: 'Rate limit is required',
invalid_type_error: 'Rate limit is required',
})
.min(1, 'Must be more than 0')
.max(32767, 'Must not be more than 32,767 an hour')
),
SMTP_USER: z.string().trim().min(1, 'SMTP Username is required'),
SMTP_PASS: z.string().trim().optional(),
})
const smtpDisabledSchema = z.object({
ENABLE_SMTP: z.literal(false),
SMTP_ADMIN_EMAIL: z.string().optional(),
SMTP_SENDER_NAME: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.preprocess(
(val) => (val === '' || val == null ? undefined : val),
z.coerce.number().optional()
),
SMTP_MAX_FREQUENCY: z.preprocess(
(val) => (val === '' || val == null ? undefined : val),
z.coerce.number().optional()
),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
})
const smtpSchema = z.discriminatedUnion('ENABLE_SMTP', [smtpEnabledSchema, smtpDisabledSchema])
type SmtpFormValues = z.infer<typeof smtpSchema>
export const SmtpForm = () => {
const { ref: projectRef } = useParams()
const { data: authConfig, error: authConfigError, isError } = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation()
const [enableSmtp, setEnableSmtp] = useState(false)
const { can: canReadConfig } = useAsyncCheckPermissions(
PermissionAction.READ,
'custom_config_gotrue'
)
const { can: canUpdateConfig } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'custom_config_gotrue'
)
const form = useForm<SmtpFormValues>({
resolver: zodResolver(
smtpSchema.superRefine((data, ctx) => {
const isEnablingSmtp = data.ENABLE_SMTP && !isSmtpEnabled(authConfig)
if (isEnablingSmtp && !data.SMTP_PASS) {
ctx.addIssue({
code: 'custom',
message: 'SMTP Password is required',
path: ['SMTP_PASS'],
})
}
})
),
defaultValues: {
SMTP_ADMIN_EMAIL: '',
SMTP_SENDER_NAME: '',
SMTP_HOST: '',
SMTP_PORT: undefined,
SMTP_MAX_FREQUENCY: undefined,
SMTP_USER: '',
SMTP_PASS: '',
ENABLE_SMTP: false,
},
})
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 { ENABLE_SMTP, ...rest } = values
const basePayload = ENABLE_SMTP ? rest : defaultDisabledSmtpFormValues
// When enabling SMTP, set RATE_LIMIT_EMAIL_SENT to 30
// When disabling, backend will handle resetting to default
const isEnablingSmtp = ENABLE_SMTP && !isSmtpEnabled(authConfig)
const payload = {
...basePayload,
...(isEnablingSmtp && { RATE_LIMIT_EMAIL_SENT: 30 }),
}
// 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
if (payload.SMTP_PASS === '') {
delete payload.SMTP_PASS
}
updateAuthConfig(
{ projectRef: projectRef!, config: payload as any },
{
onError: (error) => {
toast.error(`Failed to update settings: ${error.message}`)
},
onSuccess: () => {
toast.success('Successfully updated settings')
},
}
)
}
if (isError) {
return (
<PageSection>
<PageSectionContent>
<AlertError error={authConfigError} subject="Failed to retrieve auth configuration" />
</PageSectionContent>
</PageSection>
)
}
if (!canReadConfig) {
return (
<PageSection>
<PageSectionContent>
<NoPermission resourceText="view SMTP settings" />
</PageSectionContent>
</PageSection>
)
}
const showFooterMessage =
form.formState.isDirty && ((enableSmtp && !isSmtpEnabled(authConfig)) || !enableSmtp)
return (
<PageSection>
<PageSectionContent>
<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{' '}
<InlineLink href={`/project/${projectRef}/auth/rate-limits`}>
here
</InlineLink>
.
</p>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</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 && (
<>
<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 text-balance">
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 address"
description="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="Your Name"
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 text-balance">
Your SMTP credentials will always be encrypted in our database.
</p>
</div>
<div className="col-span-8 space-y-4">
<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>
)}
/>
{form.watch('SMTP_HOST')?.endsWith('.gmail.com') && (
<Admonition
type="warning"
title="Check your SMTP provider"
description="It looks like the SMTP provider you entered is designed
for sending personal rather than transactional email messages. Email deliverability may
be impacted."
className="mb-4 bg-warning-200 border-warning-400"
/>
)}
<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 465 and 587.
Avoid using port 25 as it is often blocked by providers to curb
spam.
</span>
</>
}
>
<FormControl_Shadcn_>
<Input_Shadcn_
type="number"
value={field.value}
onChange={(e) => field.onChange(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 per user"
description="The minimum time in seconds between emails before another email can be sent to the same user."
>
<FormControl_Shadcn_>
<InputGroup>
<FormInputGroupInput
type="number"
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
disabled={!canUpdateConfig}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>seconds</InputGroupText>
</InputGroupAddon>
</InputGroup>
</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="Password for your SMTP server. For security reasons, this password cannot be viewed once saved."
>
<FormControl_Shadcn_>
<Input {...field} reveal copy disabled={!canUpdateConfig} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
</div>
</CardContent>
</>
)}
<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{' '}
<InlineLink href={`/project/${projectRef}/auth/rate-limits`}>
can be adjusted
</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>
))}
<div className="flex items-center gap-x-2">
{isDirty && (
<Button
type="default"
onClick={() => {
form.reset()
setEnableSmtp(isSmtpEnabled(authConfig))
}}
>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
loading={isUpdatingConfig}
disabled={!canUpdateConfig || !isDirty}
>
Save changes
</Button>
</div>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</PageSectionContent>
</PageSection>
)
}