feat(auth): 短信登录默认关,换成手机号/邮箱 + 密码,发信邮箱进后台
CI / backend (push) Canceled after 0s

用户要求:内测的固定验证码 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:
Steven
2026-11-03 00:00:11 +08:00
parent 23687d6e37
commit 7558637567
29 changed files with 1702 additions and 93 deletions
+53 -9
View File
@@ -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>