Files
supabase/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx
T
Danny White 476d4a5851 refactor(ui): drop redundant Button variant="default" props (#50161)
## What kind of change does this PR introduce?

Mechanical cleanup on top of the Button default-variant change (#50160).

## What is the current behavior?

Many callsites still pass `variant="default"` even though that is now
the component default.

## What is the new behavior?

Removes redundant static `variant="default"` from legacy `Button` and
`ButtonTooltip` callsites. Keeps explicit defaults where they document
the API:

- `button-default.tsx` and `button-sizes.tsx` demos
- `DocsButton`, which pins neutral styling at the wrapper boundary

## To test

Studio:

- [Auth → Rate
Limits](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/auth/rate-limits):
dirty the form so Cancel appears; Cancel stays neutral, Save stays green
- [Project Settings → API
Keys](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/settings/api-keys):
`DocsButton` in the header actions stays neutral

Design system:

- [Design system →
Button](https://design-system-git-dnywh-dc924ac1-supabase.vercel.app/design-system/docs/components/button):
`button-default` / `button-sizes` still show explicit default styling;
Primary (green) is restricted to the Primary section (and `asChild`)

WWW:

- [www → Brand
assets](https://zone-www-dot-com-git-dnywh-dc924ac1-supabase.vercel.app/brand-assets):
Download logo kit / Download button kit stay neutral
2026-09-11 17:05:26 +10:00

542 lines
20 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, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Card,
CardContent,
CardFooter,
Form,
FormControl,
FormField,
FormInputGroupInput,
Input,
InputGroup,
InputGroupAddon,
InputGroupText,
Switch,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
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 { 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 { useAuthTemplateResetMutation } from '@/data/auth/auth-template-reset-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { preprocessEmptyNumberInput } from '@/lib/forms/zod-number-input'
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: preprocessEmptyNumberInput(
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: preprocessEmptyNumberInput(
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: preprocessEmptyNumberInput(z.coerce.number().optional()),
SMTP_MAX_FREQUENCY: preprocessEmptyNumberInput(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,
isSuccess,
} = useAuthConfigQuery({ projectRef })
const { data: selectedProject } = useSelectedProjectQuery()
const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation()
const { mutateAsync: resetAuthTemplate } = useAuthTemplateResetMutation()
const [showDisableConfirmation, setShowDisableConfirmation] = useState(false)
const [pendingValues, setPendingValues] = useState<SmtpFormValues | null>(null)
const { can: canReadConfig } = useAsyncCheckPermissions(
PermissionAction.READ,
'custom_config_gotrue'
)
const { can: canUpdateConfig } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'custom_config_gotrue'
)
const blockEditingOnReset =
!!selectedProject?.inserted_at &&
isBeforeFreeTierTemplateBlockCutoff(selectedProject.inserted_at)
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
const smtpHost = useWatch({ control: form.control, name: 'SMTP_HOST' })
const enableSmtp = useWatch({ control: form.control, name: 'ENABLE_SMTP' })
const doUpdate = ({
values,
onSuccess,
onError,
}: {
values: SmtpFormValues
onSuccess?: () => void
onError?: () => void
}) => {
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}`)
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,
})
})
}
useEffect(() => {
if (isSuccess) {
const formValues = generateFormValues(authConfig)
form.reset({
...formValues,
ENABLE_SMTP: isSmtpEnabled(authConfig),
} as SmtpFormValues)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess, form])
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 showEnablingAdmonition = form.formState.isDirty && enableSmtp && !isSmtpEnabled(authConfig)
return (
<PageSection>
<PageSectionContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent>
<FormField
control={form.control}
name="ENABLE_SMTP"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Enable custom SMTP"
description={
<p className="text-sm text-foreground-lighter">
Send auth emails through your custom SMTP provider.{' '}
<InlineLink href={`/project/${projectRef}/auth/rate-limits`}>
Rate limits
</InlineLink>{' '}
apply.
</p>
}
>
<FormControl>
<Switch
aria-label="Toggle SMTP"
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateConfig}
/>
</FormControl>
</FormItemLayout>
)}
/>
</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
control={form.control}
name="SMTP_ADMIN_EMAIL"
render={({ field }) => (
<FormItemLayout
label="Sender email address"
description="The email address the emails are sent from."
>
<FormControl>
<Input
{...field}
placeholder="noreply@yourdomain.com"
disabled={!canUpdateConfig}
/>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="SMTP_SENDER_NAME"
render={({ field }) => (
<FormItemLayout
label="Sender name"
description="Name displayed in the recipient's inbox."
>
<FormControl>
<Input
{...field}
placeholder="Your Name"
disabled={!canUpdateConfig}
/>
</FormControl>
</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
control={form.control}
name="SMTP_HOST"
render={({ field }) => (
<FormItemLayout
label="Host"
description="Hostname or IP address of your SMTP server."
>
<FormControl>
<Input
{...field}
placeholder="your.smtp.host.com"
disabled={!canUpdateConfig}
/>
</FormControl>
</FormItemLayout>
)}
/>
{smtpHost?.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
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>
<Input
type="number"
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
placeholder="587"
disabled={!canUpdateConfig}
/>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
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>
<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>
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="SMTP_USER"
render={({ field }) => (
<FormItemLayout
label="Username"
description="Username for your SMTP server."
>
<FormControl>
<Input
{...field}
placeholder="SMTP Username"
disabled={!canUpdateConfig}
/>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
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>
<PasswordInput {...field} reveal copy disabled={!canUpdateConfig} />
</FormControl>
</FormItemLayout>
)}
/>
</div>
</div>
</CardContent>
</>
)}
{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`}>
adjusted further
</InlineLink>{' '}
at any time.
</>
}
/>
)}
<CardFooter className="justify-end gap-x-2">
<div className="flex items-center gap-x-2">
{isDirty && (
<Button
onClick={() => {
form.reset()
}}
>
Cancel
</Button>
)}
<Button
variant="primary"
type="submit"
loading={isUpdatingConfig}
disabled={!canUpdateConfig || !isDirty}
>
Save changes
</Button>
</div>
</CardFooter>
</Card>
</form>
</Form>
</PageSectionContent>
<SmtpDisableConfirmationDialog
open={showDisableConfirmation}
onOpenChange={setShowDisableConfirmation}
onConfirm={handleConfirmDisable}
blockEditingOnReset={blockEditingOnReset}
/>
</PageSection>
)
}