用户要求:内测的固定验证码 123456 等于"知道手机号就能进任何账号",先关掉; 登录改用账号密码,并多一种"邮箱当账号名"的方式;发信邮箱要能在后台改。 短信那条路(默认关): - `SMS_LOGIN_ENABLED=false`,`/auth/sms/send` 与 `/auth/login` 统一回 2017,不是静默失败; - 生产校验改成"开着短信登录就必须配真通道",不开时 provider 是 dev 也不拦; - 新增 `GET /auth/methods`,登录页据此渲染,关着的路不出现(少一次"点进去才发现不能用")。 邮箱登录: - `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引),登录与绑定都归一成小写; - `POST /auth/login/password` 收 `phone` 或 `email`(二选一),失败统一 2013、连错 10 次锁 15 分钟; - 绑定在 `PATCH /auth/me`:格式错 2018、被占用 2019、留空串表示解绑。 系统设置(跨租户,只有平台管理员能改): - 新表 `system_setting`(不挂租户、无 RLS,写权限由 `deps.platform_admin` 把关); - `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,授权码只写不读,留空表示沿用旧值; - `services/mail.py` 只用标准库 smtplib:465 走 SSL、587 走 STARTTLS,认证/拒收/网络失败都归 2020 但消息不同; - Web 新增「系统设置」页(状态卡 + 测试发信),侧栏只对平台管理员显示入口;账号页补「邮箱」。 验证:pytest 130 passed、ruff check/format 全绿、tsc 通过、vite build 通过; `web/e2e/login-email-smtp.mjs` 15 项全绿(含用 126 授权码真发一封测试邮件)。 顺带记下的两个坑:`platform_admin_phones` 是 list,pydantic-settings 会按 JSON 解析, 要用 `NoDecode` 交给 before-validator 拆逗号;迁移号写成 0009 会撞既有的 0009,一并改成 0010。
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../App";
|
||||
import { SideRail } from "../components/SideRail";
|
||||
import { Button, Field, InlineAlert } from "../components/ui";
|
||||
@@ -7,10 +7,10 @@ import { ApiError, api } from "../lib/client";
|
||||
import type { User } from "../lib/types";
|
||||
|
||||
/**
|
||||
* 账号设置:昵称、登录密码、退出登录。
|
||||
* 账号设置:昵称、邮箱、登录密码、退出登录。
|
||||
*
|
||||
* 密码是**可选**的第二条登录路径:没设过的人一直走验证码,
|
||||
* 界面上的文案要能让人看懂"现在设的是第一道密码"还是"在改已有的密码"。
|
||||
* 邮箱是**账号名**(能拿它 + 密码登录),不是"通知地址"的附属品,
|
||||
* 所以放在基本信息里、和手机号并列,而不是藏在通知偏好里。
|
||||
*/
|
||||
/** 小节标题:一行标题 + 一句"这是干嘛的",比光秃秃的 h2 省解释。 */
|
||||
function SectionLabel({ hint, children }: { hint?: string; children: React.ReactNode }) {
|
||||
@@ -27,6 +27,7 @@ export function AccountPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [profileBusy, setProfileBusy] = useState(false);
|
||||
const [profileMsg, setProfileMsg] = useState<string | null>(null);
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
@@ -44,9 +45,12 @@ export function AccountPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.nickname) setNickname(user.nickname);
|
||||
}, [user?.nickname]);
|
||||
setEmail(user?.email ?? "");
|
||||
}, [user?.nickname, user?.email]);
|
||||
|
||||
const hasPassword = Boolean(user?.has_password);
|
||||
const nicknameChanged = nickname.trim() !== (user?.nickname ?? "");
|
||||
const emailChanged = email.trim() !== (user?.email ?? "");
|
||||
|
||||
async function saveProfile(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -54,9 +58,18 @@ export function AccountPage() {
|
||||
setProfileMsg(null);
|
||||
setProfileError(null);
|
||||
try {
|
||||
await api.patch<User>("/auth/me", { nickname: nickname.trim() });
|
||||
// 只发改过的字段:邮箱留空表示"解绑",不能因为没动它就被清掉
|
||||
const patch: { nickname?: string; email?: string } = {};
|
||||
if (nicknameChanged) patch.nickname = nickname.trim();
|
||||
if (emailChanged) patch.email = email.trim();
|
||||
|
||||
await api.patch<User>("/auth/me", patch);
|
||||
await reload();
|
||||
setProfileMsg("昵称已更新");
|
||||
setProfileMsg(
|
||||
emailChanged && !email.trim()
|
||||
? "邮箱已解绑,之后不能用邮箱登录"
|
||||
: "资料已更新",
|
||||
);
|
||||
} catch (err) {
|
||||
setProfileError(err instanceof ApiError ? err.message : "保存失败");
|
||||
} finally {
|
||||
@@ -146,6 +159,21 @@ export function AccountPage() {
|
||||
手机号就是登录账号,暂不支持自助换号;换号等于换账号,需要单独处理。
|
||||
</p>
|
||||
|
||||
<Field label="邮箱">
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
placeholder="绑定后可以用邮箱 + 密码登录"
|
||||
value={email}
|
||||
maxLength={255}
|
||||
aria-label="邮箱"
|
||||
onChange={(e) => setEmail(e.target.value.trim())}
|
||||
/>
|
||||
</Field>
|
||||
<p className="-mt-2 text-2xs leading-relaxed text-ink-400">
|
||||
邮箱是第二个账号名:绑上之后,登录页可以用「邮箱 + 密码」进入。留空保存就是解绑。
|
||||
</p>
|
||||
|
||||
{profileMsg && <InlineAlert tone="success">{profileMsg}</InlineAlert>}
|
||||
{profileError && <InlineAlert>{profileError}</InlineAlert>}
|
||||
|
||||
@@ -153,9 +181,9 @@ export function AccountPage() {
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={profileBusy || !nickname.trim() || nickname.trim() === user?.nickname}
|
||||
disabled={profileBusy || !nickname.trim() || (!nicknameChanged && !emailChanged)}
|
||||
>
|
||||
{profileBusy ? "保存中…" : "保存昵称"}
|
||||
{profileBusy ? "保存中…" : "保存资料"}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -235,6 +263,22 @@ export function AccountPage() {
|
||||
退出登录
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{user?.platform_admin ? (
|
||||
<section className="card p-5">
|
||||
<SectionLabel hint="平台级配置,只有平台管理员看得到">系统设置</SectionLabel>
|
||||
<p className="mt-3 text-xs leading-relaxed text-ink-500">
|
||||
发信邮箱(SMTP)在那儿改:填完可以当场发一封测试邮件,确认通道通不通。
|
||||
改完即时生效,不用登服务器改配置、也不用重启。
|
||||
</p>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary-700 hover:underline"
|
||||
>
|
||||
打开系统设置 →
|
||||
</Link>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+110
-42
@@ -30,33 +30,61 @@ const HIGHLIGHTS = [
|
||||
},
|
||||
];
|
||||
|
||||
const PHONE_RE = /^1[3-9]\d{9}$/;
|
||||
const EMAIL_RE = /^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$/;
|
||||
|
||||
/** 服务端现在开放哪些登录方式。先问再渲染,免得用户点进去才发现这条路关着。 */
|
||||
type Methods = { password: boolean; email_password: boolean; sms_code: boolean };
|
||||
|
||||
export function LoginPage() {
|
||||
const { reload } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const next = safeNext(params.get("next"));
|
||||
const [phone, setPhone] = useState("");
|
||||
const [account, setAccount] = useState("");
|
||||
const [accountType, setAccountType] = useState<"phone" | "email">("phone");
|
||||
const [code, setCode] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [mode, setMode] = useState<"code" | "password">("code");
|
||||
const [mode, setMode] = useState<"code" | "password">("password");
|
||||
const [methods, setMethods] = useState<Methods>({ password: true, email_password: true, sms_code: false });
|
||||
const [devCode, setDevCode] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.get<Methods>("/auth/methods")
|
||||
.then((data) => {
|
||||
if (!alive) return;
|
||||
setMethods(data);
|
||||
// 短信关着就别停在"验证码"这一档
|
||||
setMode(data.sms_code ? "password" : "password");
|
||||
})
|
||||
.catch(() => {
|
||||
/* 拿不到就按"只有密码"渲染,至少不会把人挡在门外 */
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const timer = setTimeout(() => setCountdown((v) => v - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [countdown]);
|
||||
|
||||
const phoneValid = /^1[3-9]\d{9}$/.test(phone);
|
||||
const phoneValid = PHONE_RE.test(account);
|
||||
const emailValid = EMAIL_RE.test(account.trim());
|
||||
const accountValid = accountType === "phone" ? phoneValid : emailValid;
|
||||
|
||||
async function sendCode() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api.post<{ dev_code?: string }>("/auth/sms/send", { phone });
|
||||
const data = await api.post<{ dev_code?: string }>("/auth/sms/send", { phone: account });
|
||||
setDevCode(data.dev_code ?? null);
|
||||
setCountdown(60);
|
||||
} catch (err) {
|
||||
@@ -73,9 +101,9 @@ export function LoginPage() {
|
||||
try {
|
||||
const data =
|
||||
mode === "code"
|
||||
? await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code })
|
||||
? await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone: account, code })
|
||||
: await api.post<{ user: User; tokens: Tokens }>("/auth/login/password", {
|
||||
phone,
|
||||
...(accountType === "phone" ? { phone: account } : { email: account.trim() }),
|
||||
password,
|
||||
});
|
||||
session.save(data.tokens);
|
||||
@@ -88,6 +116,13 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const subtitle =
|
||||
mode === "code"
|
||||
? "手机号验证码登录,第一次登录会自动创建账号。"
|
||||
: methods.sms_code
|
||||
? "手机号或邮箱 + 密码。还没设过密码的,先用验证码登录一次再设。"
|
||||
: "用手机号或邮箱 + 密码登录。";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col lg:flex-row">
|
||||
{/* 左:品牌页 */}
|
||||
@@ -143,46 +178,79 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold">登录</h2>
|
||||
<p className="mt-1.5 text-sm text-ink-500">
|
||||
{mode === "code"
|
||||
? "手机号验证码登录,第一次登录会自动创建账号。"
|
||||
: "用手机号和登录密码进入。没设过密码的,先用验证码登录一次再设。"}
|
||||
</p>
|
||||
<p className="mt-1.5 text-sm text-ink-500">{subtitle}</p>
|
||||
|
||||
<div className="mt-5 inline-flex rounded-lg bg-paper-200/60 p-0.5 text-xs">
|
||||
{(
|
||||
[
|
||||
["code", "验证码"],
|
||||
["password", "密码"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(key);
|
||||
setError(null);
|
||||
}}
|
||||
className={`rounded-[6px] px-3 py-1.5 font-medium transition ${
|
||||
mode === key ? "bg-white text-ink-900 shadow-sm" : "text-ink-500 hover:text-ink-700"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 只有服务端真开着验证码,才给这两个档位;否则只留密码表单 */}
|
||||
{methods.sms_code && (
|
||||
<div className="mt-5 inline-flex rounded-lg bg-paper-200/60 p-0.5 text-xs">
|
||||
{(
|
||||
[
|
||||
["password", "密码"],
|
||||
["code", "验证码"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(key);
|
||||
setError(null);
|
||||
}}
|
||||
className={`rounded-[6px] px-3 py-1.5 font-medium transition ${
|
||||
mode === key ? "bg-white text-ink-900 shadow-sm" : "text-ink-500 hover:text-ink-700"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-5 space-y-4" onSubmit={submit}>
|
||||
<Field label="手机号">
|
||||
{/* 验证码只能发到手机号,所以这一档不给"邮箱"选项 */}
|
||||
{mode === "password" && (
|
||||
<div className="inline-flex rounded-lg bg-paper-200/60 p-0.5 text-xs" role="tablist" aria-label="账号类型">
|
||||
{(
|
||||
[
|
||||
["phone", "手机号"],
|
||||
["email", "邮箱"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={accountType === key}
|
||||
onClick={() => {
|
||||
setAccountType(key);
|
||||
setAccount("");
|
||||
setError(null);
|
||||
}}
|
||||
className={`rounded-[6px] px-3 py-1.5 font-medium transition ${
|
||||
accountType === key
|
||||
? "bg-white text-ink-900 shadow-sm"
|
||||
: "text-ink-500 hover:text-ink-700"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label={accountType === "phone" || mode === "code" ? "手机号" : "邮箱"}>
|
||||
<input
|
||||
id="phone"
|
||||
id="account"
|
||||
className="input h-11"
|
||||
inputMode="numeric"
|
||||
autoComplete="tel"
|
||||
placeholder="13800138000"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.trim())}
|
||||
maxLength={11}
|
||||
type={accountType === "phone" || mode === "code" ? "tel" : "email"}
|
||||
inputMode={accountType === "phone" || mode === "code" ? "numeric" : "email"}
|
||||
autoComplete={accountType === "phone" || mode === "code" ? "tel" : "email"}
|
||||
placeholder={
|
||||
accountType === "phone" || mode === "code" ? "13800138000" : "you@example.com"
|
||||
}
|
||||
value={account}
|
||||
onChange={(e) => setAccount(e.target.value.trim())}
|
||||
maxLength={accountType === "phone" || mode === "code" ? 11 : 255}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -238,7 +306,7 @@ export function LoginPage() {
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="mt-2 w-full"
|
||||
disabled={busy || !phoneValid || (mode === "code" ? code.length < 4 : password.length < 1)}
|
||||
disabled={busy || !accountValid || (mode === "code" ? code.length < 4 : password.length < 1)}
|
||||
>
|
||||
{busy ? "处理中…" : "登录"}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../App";
|
||||
import { SideRail } from "../components/SideRail";
|
||||
import { Button, Field, InlineAlert } from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import type { SmtpSettings } from "../lib/types";
|
||||
|
||||
/**
|
||||
* 系统设置:平台级配置,全站共用一份,只有平台管理员能进。
|
||||
*
|
||||
* 目前只有「发信邮箱」一项——它承载的是找回密码、以后的通知邮件,
|
||||
* 属于"改一次、全站受影响"的东西,所以放后台可改,而不是写死在服务器 env 里。
|
||||
*
|
||||
* 授权码只写不读:接口永远不会把它发回来,页面上也只显示"已设置 / 未设置"。
|
||||
*/
|
||||
|
||||
function SectionLabel({ hint, children }: { hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-ink-800">{children}</h2>
|
||||
{hint ? <p className="mt-0.5 text-2xs text-ink-400">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SOURCE_HINT: Record<SmtpSettings["source"], string> = {
|
||||
db: "正在用后台保存的配置",
|
||||
env: "后台还没保存过,先用的是服务器环境变量",
|
||||
none: "还没配过,发信功能不可用",
|
||||
};
|
||||
|
||||
export function SettingsPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
const [host, setHost] = useState("");
|
||||
const [port, setPort] = useState("465");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [fromName, setFromName] = useState("达道Task");
|
||||
const [useSsl, setUseSsl] = useState(true);
|
||||
const [saved, setSaved] = useState<SmtpSettings | null>(null);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [testTo, setTestTo] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) navigate("/login", { replace: true });
|
||||
}, [loading, user, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
// 非平台管理员不该看到这个页面,也别去请求(后端会 3004)
|
||||
if (loading || !user || !user.platform_admin) return;
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.get<SmtpSettings>("/settings/smtp");
|
||||
if (!alive) return;
|
||||
setSaved(data);
|
||||
setHost(data.host ?? "");
|
||||
setPort(String(data.port ?? 465));
|
||||
setUsername(data.username ?? "");
|
||||
setFromName(data.from_name ?? "达道Task");
|
||||
setUseSsl(data.use_ssl);
|
||||
setTestTo(data.username ?? "");
|
||||
} catch (err) {
|
||||
if (alive) setError(err instanceof ApiError ? err.message : "读取配置失败");
|
||||
} finally {
|
||||
if (alive) setReady(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [loading, user]);
|
||||
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.put<SmtpSettings>("/settings/smtp", {
|
||||
host: host.trim(),
|
||||
port: Number(port) || 465,
|
||||
username: username.trim(),
|
||||
// 留空 = 不改授权码;接口把空串当"沿用旧值"
|
||||
password: password.trim() || null,
|
||||
from_name: fromName.trim() || "达道Task",
|
||||
use_ssl: useSsl,
|
||||
});
|
||||
setSaved(data);
|
||||
setPassword("");
|
||||
setMsg(data.configured ? "已保存,现在就能发信了" : "已保存,但还差必填项");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "保存失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
setTesting(true);
|
||||
setMsg(null);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.post<{ to: string; message?: string }>("/settings/smtp/test", {
|
||||
to: testTo.trim() || null,
|
||||
});
|
||||
setMsg(`测试邮件已发到 ${data.to},去收件箱看一眼(可能在垃圾箱)`);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "发信失败");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !ready) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
|
||||
<SideRail />
|
||||
<main className="min-w-0 flex-1 p-8 text-sm text-ink-400">读取配置…</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user?.platform_admin) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
|
||||
<SideRail />
|
||||
<main className="min-w-0 flex-1">
|
||||
<div className="w-full px-5 py-8 lg:px-8 xl:px-12">
|
||||
<div className="card p-6 text-sm text-ink-600">
|
||||
系统设置只有平台管理员能改。你在自己的账号页能改昵称、邮箱和密码。
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
|
||||
<SideRail />
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
<div className="w-full px-5 py-6 lg:px-8 lg:py-8 xl:px-12 2xl:px-16">
|
||||
<div className="mb-5">
|
||||
<h1 className="text-lg font-semibold tracking-tight text-ink-900">系统设置</h1>
|
||||
<p className="mt-1 text-xs text-ink-500">
|
||||
这里的改动全站生效,改完不用重启服务。只有平台管理员能进来。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<section className="card p-5">
|
||||
<SectionLabel hint="126/163 这类邮箱要用「客户端授权码」,不是登录密码">
|
||||
发信邮箱(SMTP)
|
||||
</SectionLabel>
|
||||
|
||||
<form className="mt-4 grid gap-4 sm:grid-cols-2" onSubmit={save}>
|
||||
<Field label="服务器地址">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="smtp.126.com"
|
||||
value={host}
|
||||
aria-label="服务器地址"
|
||||
onChange={(e) => setHost(e.target.value.trim())}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="端口">
|
||||
<input
|
||||
className="input"
|
||||
inputMode="numeric"
|
||||
value={port}
|
||||
aria-label="端口"
|
||||
onChange={(e) => setPort(e.target.value.replace(/[^\d]/g, ""))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="账号">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="shufenhf@126.com"
|
||||
value={username}
|
||||
aria-label="账号"
|
||||
onChange={(e) => setUsername(e.target.value.trim())}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={saved?.password_set ? "授权码(留空表示不改)" : "授权码"}>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder={saved?.password_set ? "已设置,留空保持原样" : "客户端授权码"}
|
||||
value={password}
|
||||
autoComplete="new-password"
|
||||
aria-label="授权码"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="发件人显示名">
|
||||
<input
|
||||
className="input"
|
||||
value={fromName}
|
||||
aria-label="发件人显示名"
|
||||
onChange={(e) => setFromName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<label className="flex items-end gap-2 pb-2 text-xs text-ink-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useSsl}
|
||||
aria-label="使用 SSL"
|
||||
onChange={(e) => setUseSsl(e.target.checked)}
|
||||
/>
|
||||
使用 SSL(465 端口要勾,587 一般走 STARTTLS)
|
||||
</label>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
{msg && <InlineAlert tone="success">{msg}</InlineAlert>}
|
||||
{error && <InlineAlert>{error}</InlineAlert>}
|
||||
<Button type="submit" variant="primary" size="sm" disabled={busy}>
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
<section className="card p-5">
|
||||
<SectionLabel hint={SOURCE_HINT[saved?.source ?? "none"]}>当前状态</SectionLabel>
|
||||
<dl className="mt-3 space-y-2 text-xs">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="text-ink-400">能不能发信</dt>
|
||||
<dd className={saved?.configured ? "font-medium text-primary-700" : "text-clay-600"}>
|
||||
{saved?.configured ? "可以" : "还不能"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="text-ink-400">授权码</dt>
|
||||
<dd className="text-ink-600">{saved?.password_set ? "已设置" : "未设置"}</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="text-ink-400">上次修改</dt>
|
||||
<dd className="text-ink-600">
|
||||
{saved?.updated_at
|
||||
? new Date(saved.updated_at).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="card p-5">
|
||||
<SectionLabel hint="别等真要用的时候才发现发不出去">发一封测试邮件</SectionLabel>
|
||||
<div className="mt-3 space-y-3">
|
||||
<Field label="收件人">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="留空就发给当前账号绑的邮箱"
|
||||
value={testTo}
|
||||
aria-label="测试收件人"
|
||||
onChange={(e) => setTestTo(e.target.value.trim())}
|
||||
/>
|
||||
</Field>
|
||||
<Button variant="ghost" size="sm" onClick={sendTest} disabled={testing}>
|
||||
{testing ? "发送中…" : "发送测试邮件"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user