fix(auth): 注册邮箱与公告 - 放宽公告正文并增加域名白名单

This commit is contained in:
Rou
2026-09-05 13:04:11 +08:00
parent 823cdd27d2
commit 35e92604e6
16 changed files with 480 additions and 77 deletions

View File

@@ -810,13 +810,10 @@ function AnnouncementEditor({
</Form.Item>
<Form.Item
name="content"
label="公告正文"
rules={[
{ required: true, whitespace: true, message: "请填写公告正文" },
{ max: 4000, message: "正文不能超过 4000 个字符" },
]}
label="公告正文(可选)"
rules={[{ max: 4000, message: "正文不能超过 4000 个字符" }]}
>
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 14, maxRows: 24 }} placeholder="填写服务状态、影响范围和用户需要采取的操作" />
<Input.TextArea maxLength={4000} showCount autoSize={{ minRows: 14, maxRows: 24 }} placeholder="可选填写服务状态、影响范围和用户需要采取的操作" />
</Form.Item>
</section>
@@ -835,7 +832,7 @@ function AnnouncementEditor({
</div>
<h3>{watchedTitle?.trim() || "公告标题将在这里显示"}</h3>
{imagePreviewUrl ? <img src={imagePreviewUrl} alt="公告配图预览" className="mt-4 max-h-56 w-full rounded-lg border border-border/70 bg-muted/20 object-contain p-1" /> : null}
{watchedContent?.trim() ? <AnnouncementContent content={watchedContent.trim()} className="admin-announcement-preview-content" /> : <p className="admin-announcement-preview-placeholder">公告正文将在这里显示。</p>}
{watchedContent?.trim() ? <AnnouncementContent content={watchedContent.trim()} className="admin-announcement-preview-content" /> : <p className="admin-announcement-preview-placeholder">此公告仅展示标题。</p>}
</section>
</div>
</Form>
@@ -889,7 +886,7 @@ function AnnouncementEditor({
<div className="is-wide">
<dt>公告正文</dt>
<dd>
<AnnouncementContent content={pending.content} className="admin-announcement-confirm-content" />
{pending.content ? <AnnouncementContent content={pending.content} className="admin-announcement-confirm-content" /> : "无正文"}
</dd>
</div>
</dl>

View File

@@ -8,7 +8,13 @@ import { getAdminEmailSetting, updateAdminEmailSetting, type EmailSetting } from
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
type EmailFormValues = Pick<EmailSetting, "enabled" | "host" | "port" | "username" | "password" | "encryption" | "fromEmail" | "fromName">;
type EmailFormValues = Pick<EmailSetting, "enabled" | "host" | "port" | "username" | "password" | "encryption" | "fromEmail" | "fromName"> & {
registrationAllowedDomains: string;
};
type NormalizedEmailFormValues = Omit<EmailFormValues, "registrationAllowedDomains"> & {
registrationAllowedDomains: string[];
};
export default function EmailSettingsPanel() {
const { message, modal } = App.useApp();
@@ -289,8 +295,25 @@ export default function EmailSettingsPanel() {
</SettingsSectionCard>
</div>
{draftEnabled ? (
<div id="admin-email-smtp" className="admin-settings-anchor">
<Form
form={form}
className="admin-email-form-stack"
layout="vertical"
requiredMark={false}
disabled={loading || refreshing || saving}
onValuesChange={() => {
const values = form.getFieldsValue(true);
setDraftEnabled(Boolean(values.enabled));
setDirty(hasEmailChanges(values, setting));
setSaveError("");
}}
>
<Form.Item name="enabled" valuePropName="checked" hidden>
<Switch />
</Form.Item>
{draftEnabled ? (
<div id="admin-email-smtp" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-email-section admin-email-configuration-section"
icon={<Server className="size-4" aria-hidden="true" />}
@@ -310,28 +333,12 @@ export default function EmailSettingsPanel() {
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存并启用
{draftEnabled ? "保存并启用" : "保存设置"}
</Button>
</div>
</>
}
>
<Form
form={form}
layout="vertical"
requiredMark={false}
disabled={loading || refreshing || saving}
onValuesChange={() => {
const values = form.getFieldsValue(true);
setDraftEnabled(Boolean(values.enabled));
setDirty(hasEmailChanges(values, setting));
setSaveError("");
}}
>
<Form.Item name="enabled" valuePropName="checked" hidden>
<Switch />
</Form.Item>
<div className="admin-email-form-section">
<FormSectionTitle icon={<Server className="size-4" />} title="服务器连接" description="填写 SMTP 主机、端口和传输加密;STARTTLS 通常使用 587,直接 TLS 通常使用 465。" />
<div className="admin-email-form-grid is-connection">
@@ -402,10 +409,53 @@ export default function EmailSettingsPanel() {
</Form.Item>
</div>
</div>
</Form>
</SettingsSectionCard>
</div>
) : null}
<div id="admin-email-registration-domains" className="admin-settings-anchor">
<SettingsSectionCard
className="admin-email-section"
icon={<MailCheck className="size-4" aria-hidden="true" />}
title="3. 电子邮件域名白名单"
description="每行填写一个允许注册的邮箱域名;不在白名单内的邮箱无法注册。"
status={<AdminStatusBadge label={currentValues.registrationAllowedDomains.length > 0 ? "已限制范围" : "不限制服务商"} tone={currentValues.registrationAllowedDomains.length > 0 ? "info" : "neutral"} />}
footer={
<>
<div className="admin-email-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>规则只影响新用户注册,不限制发件邮箱和已有账号找回密码</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销
</Button>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || loading || refreshing} onClick={() => void submitSave()}>
保存设置
</Button>
</div>
</>
}
>
<div className="admin-email-form-section">
<Form.Item
name="registrationAllowedDomains"
label="电子邮件域名白名单"
extra="每行一个域名。留空保存表示不限制邮箱域名。"
rules={[{ validator: (_, value: string | undefined) => validateDomainList(value) }]}
>
<Input.TextArea
autoSize={{ minRows: 9, maxRows: 16 }}
placeholder="gmail.com&#10;163.com&#10;126.com&#10;qq.com"
spellCheck={false}
/>
</Form.Item>
</div>
</SettingsSectionCard>
</div>
) : null}
</Form>
</div>
);
}
@@ -432,10 +482,11 @@ function toEmailFormValues(setting: EmailSetting): EmailFormValues {
encryption: setting.encryption,
fromEmail: setting.fromEmail,
fromName: setting.fromNameInherited ? "" : setting.fromName,
registrationAllowedDomains: setting.registrationAllowedDomains.join("\n"),
};
}
function normalizeEmailFormValues(values: Partial<EmailFormValues>): EmailFormValues {
function normalizeEmailFormValues(values: Partial<EmailFormValues>): NormalizedEmailFormValues {
return {
enabled: Boolean(values.enabled),
host: values.host?.trim() || "",
@@ -445,6 +496,7 @@ function normalizeEmailFormValues(values: Partial<EmailFormValues>): EmailFormVa
encryption: values.encryption === "tls" || values.encryption === "none" ? values.encryption : "starttls",
fromEmail: values.fromEmail?.trim().toLowerCase() || "",
fromName: values.fromName?.trim() || "",
registrationAllowedDomains: normalizeDomains(values.registrationAllowedDomains),
};
}
@@ -453,7 +505,8 @@ function hasEmailChanges(values: Partial<EmailFormValues>, setting: EmailSetting
const draft = normalizeEmailFormValues(values);
const saved = normalizeEmailFormValues(toEmailFormValues(setting));
if (draft.password) return true;
return (Object.keys(saved) as Array<keyof EmailFormValues>).some((key) => key !== "password" && draft[key] !== saved[key]);
const scalarFields: Array<keyof EmailFormValues> = ["enabled", "host", "port", "username", "encryption", "fromEmail", "fromName"];
return scalarFields.some((key) => draft[key] !== saved[key]) || !arraysEqual(draft.registrationAllowedDomains, saved.registrationAllowedDomains);
}
function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | null) {
@@ -466,11 +519,11 @@ function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | nul
return "";
}
function emailResponseMatches(setting: EmailSetting, expected: EmailFormValues) {
function emailResponseMatches(setting: EmailSetting, expected: NormalizedEmailFormValues) {
const actual = normalizeEmailFormValues(toEmailFormValues(setting));
const fields: Array<keyof EmailFormValues> = ["enabled", "host", "port", "username", "encryption", "fromEmail", "fromName"];
if (expected.password && !setting.hasPassword) return false;
return fields.every((key) => actual[key] === expected[key]);
return fields.every((key) => actual[key] === expected[key]) && arraysEqual(actual.registrationAllowedDomains, expected.registrationAllowedDomains);
}
function isEmailSetting(value: unknown): value is EmailSetting {
@@ -485,10 +538,36 @@ function isEmailSetting(value: unknown): value is EmailSetting {
typeof setting.fromEmail === "string" &&
typeof setting.fromName === "string" &&
typeof setting.fromNameInherited === "boolean" &&
typeof setting.hasPassword === "boolean"
typeof setting.hasPassword === "boolean" &&
Array.isArray(setting.registrationAllowedDomains) &&
setting.registrationAllowedDomains.every((value) => typeof value === "string")
);
}
function normalizeDomains(value?: string) {
return Array.from(
new Set(
(value || "")
.split(/[\s,,;;]+/)
.map((domain) => domain.trim().toLowerCase().replace(/^@/, "").replace(/\.$/, ""))
.filter(Boolean),
),
);
}
function arraysEqual(left: string[], right: string[]) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function validateDomainList(value?: string) {
const invalid = invalidDomain(value);
return invalid ? Promise.reject(new Error(`邮箱域名格式不正确:“${invalid}”`)) : Promise.resolve();
}
function invalidDomain(value?: string) {
return normalizeDomains(value).find((domain) => !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(domain));
}
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}