mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## What The custom SMTP password field showed a "Reveal" and "Copy" button next to text saying "this password cannot be viewed once saved" — contradictory, since those buttons implied there was something to reveal or copy. In reality the backend never returns the saved password, so the field was always blank and those buttons acted on an empty string. ## Why Reported in FE-3765: users found it confusing whether saving other fields would blank out their password, and the Reveal/Copy buttons appeared broken. ## Fix - Removed the non-functional Reveal/Copy buttons from the password input. - When a password is already saved, the field now shows a `••••••••••••••••` placeholder and copy reading "Stored password is hidden. Enter a new password to replace it." — matching the existing `STORED_SECRET_PLACEHOLDER` pattern already used in the Replication destination forms (BigQuery, ClickHouse, Snowflake, etc). - No behavior change: leaving the field blank on save still preserves the existing password (unchanged logic). ## Testing - Manually verified in the running app. - Added a component test (`SmtpForm.test.tsx`) covering both the "password already saved" and "fresh setup" states. - `tsc --noEmit`, `eslint`, and `prettier --check` all pass with no new errors/warnings. Fixes [FE-3765](https://linear.app/supabase/issue/FE-3765/custom-smtp-password-field-ux-issues) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Updates** * Configured SMTP passwords are masked, with a notice that entering a new password will replace the stored one. * For new SMTP setups, the password field prompts for the SMTP server password and does not show the stored-password notice. * The SMTP password field no longer provides controls to reveal or copy the password. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
559 lines
21 KiB
TypeScript
559 lines
21 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 { 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, STORED_SECRET_PLACEHOLDER } 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={
|
|
isSmtpEnabled(authConfig)
|
|
? 'Stored password is hidden. Enter a new password to replace it.'
|
|
: 'Password for your SMTP server.'
|
|
}
|
|
>
|
|
<FormControl>
|
|
<Input
|
|
{...field}
|
|
type="password"
|
|
autoComplete="new-password"
|
|
data-1p-ignore
|
|
data-lpignore="true"
|
|
data-form-type="other"
|
|
data-bwignore
|
|
placeholder={
|
|
isSmtpEnabled(authConfig)
|
|
? STORED_SECRET_PLACEHOLDER
|
|
: undefined
|
|
}
|
|
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>
|
|
)
|
|
}
|