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。
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Link, 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 { User } from "../lib/types";
|
|
|
|
/**
|
|
* 账号设置:昵称、邮箱、登录密码、退出登录。
|
|
*
|
|
* 邮箱是**账号名**(能拿它 + 密码登录),不是"通知地址"的附属品,
|
|
* 所以放在基本信息里、和手机号并列,而不是藏在通知偏好里。
|
|
*/
|
|
/** 小节标题:一行标题 + 一句"这是干嘛的",比光秃秃的 h2 省解释。 */
|
|
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>
|
|
);
|
|
}
|
|
|
|
export function AccountPage() {
|
|
const { user, loading, reload, signOut } = useAuth();
|
|
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);
|
|
|
|
const [oldPassword, setOldPassword] = useState("");
|
|
const [newPassword, setNewPassword] = useState("");
|
|
const [confirm, setConfirm] = useState("");
|
|
const [pwdBusy, setPwdBusy] = useState(false);
|
|
const [pwdMsg, setPwdMsg] = useState<string | null>(null);
|
|
const [pwdError, setPwdError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!loading && !user) navigate("/login", { replace: true });
|
|
}, [loading, user, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (user?.nickname) setNickname(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();
|
|
setProfileBusy(true);
|
|
setProfileMsg(null);
|
|
setProfileError(null);
|
|
try {
|
|
// 只发改过的字段:邮箱留空表示"解绑",不能因为没动它就被清掉
|
|
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(
|
|
emailChanged && !email.trim()
|
|
? "邮箱已解绑,之后不能用邮箱登录"
|
|
: "资料已更新",
|
|
);
|
|
} catch (err) {
|
|
setProfileError(err instanceof ApiError ? err.message : "保存失败");
|
|
} finally {
|
|
setProfileBusy(false);
|
|
}
|
|
}
|
|
|
|
async function savePassword(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
setPwdMsg(null);
|
|
setPwdError(null);
|
|
if (newPassword !== confirm) {
|
|
setPwdError("两次输入的新密码不一样");
|
|
return;
|
|
}
|
|
setPwdBusy(true);
|
|
try {
|
|
await api.post("/auth/password", {
|
|
password: newPassword,
|
|
...(hasPassword ? { old_password: oldPassword } : {}),
|
|
});
|
|
await reload();
|
|
setOldPassword("");
|
|
setNewPassword("");
|
|
setConfirm("");
|
|
setPwdMsg(hasPassword ? "密码已修改,下次可用新密码登录" : "密码已设置,之后可以用密码登录");
|
|
} catch (err) {
|
|
setPwdError(err instanceof ApiError ? err.message : "保存失败");
|
|
} finally {
|
|
setPwdBusy(false);
|
|
}
|
|
}
|
|
|
|
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-7">
|
|
<h1 className="font-display text-2xl font-semibold">账号设置</h1>
|
|
<p className="mt-2 text-sm text-ink-500">
|
|
这里改的是你自己:昵称、登录密码、退出登录。工作区里的成员与权限在各自的工作区里管。
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
|
|
<section className="card p-5">
|
|
<SectionLabel hint="别人在成员列表里看到的名字">基本信息</SectionLabel>
|
|
|
|
<form className="mt-4 space-y-4" onSubmit={saveProfile}>
|
|
<div className="flex items-center gap-3">
|
|
<span className="grid h-12 w-12 place-items-center rounded-full bg-primary-900 text-base font-semibold text-paper-50">
|
|
{(user?.nickname ?? "我").trim().slice(0, 1).toUpperCase()}
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-medium text-ink-800">
|
|
{user?.nickname ?? "—"}
|
|
</div>
|
|
<div className="text-2xs text-ink-400">
|
|
{user?.created_at
|
|
? `加入于 ${new Date(user.created_at).toLocaleDateString("zh-CN")}`
|
|
: "—"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Field label="昵称">
|
|
<input
|
|
className="input"
|
|
value={nickname}
|
|
maxLength={32}
|
|
aria-label="昵称"
|
|
onChange={(e) => setNickname(e.target.value)}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="手机号">
|
|
<input
|
|
className="input bg-paper-100 text-ink-500"
|
|
value={user?.phone ?? ""}
|
|
readOnly
|
|
aria-label="手机号"
|
|
/>
|
|
</Field>
|
|
<p className="-mt-2 text-2xs leading-relaxed text-ink-400">
|
|
手机号就是登录账号,暂不支持自助换号;换号等于换账号,需要单独处理。
|
|
</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>}
|
|
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
size="sm"
|
|
disabled={profileBusy || !nickname.trim() || (!nicknameChanged && !emailChanged)}
|
|
>
|
|
{profileBusy ? "保存中…" : "保存资料"}
|
|
</Button>
|
|
</form>
|
|
</section>
|
|
|
|
<div className="space-y-4">
|
|
<section className="card p-5">
|
|
<SectionLabel hint="验证码始终可用,密码是可选的第二条路">登录密码</SectionLabel>
|
|
|
|
<p className="mt-3 text-xs leading-relaxed text-ink-500">
|
|
{hasPassword
|
|
? "已设置密码。登录页可以选「密码」进入;改密码要先输当前密码。"
|
|
: "还没有设置密码。现在只能用手机号 + 验证码登录;设一个密码,换手机或短信不通时也能进。"}
|
|
</p>
|
|
|
|
<form className="mt-4 space-y-3" onSubmit={savePassword}>
|
|
{hasPassword ? (
|
|
<Field label="当前密码">
|
|
<input
|
|
type="password"
|
|
className="input"
|
|
autoComplete="current-password"
|
|
value={oldPassword}
|
|
aria-label="当前密码"
|
|
onChange={(e) => setOldPassword(e.target.value)}
|
|
/>
|
|
</Field>
|
|
) : null}
|
|
|
|
<Field label={hasPassword ? "新密码" : "密码"}>
|
|
<input
|
|
type="password"
|
|
className="input"
|
|
autoComplete="new-password"
|
|
placeholder="至少 8 位,字母和数字都要有"
|
|
value={newPassword}
|
|
aria-label={hasPassword ? "新密码" : "密码"}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="再输一次">
|
|
<input
|
|
type="password"
|
|
className="input"
|
|
autoComplete="new-password"
|
|
value={confirm}
|
|
aria-label="确认密码"
|
|
onChange={(e) => setConfirm(e.target.value)}
|
|
/>
|
|
</Field>
|
|
|
|
{pwdMsg && <InlineAlert tone="success">{pwdMsg}</InlineAlert>}
|
|
{pwdError && <InlineAlert>{pwdError}</InlineAlert>}
|
|
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
size="sm"
|
|
disabled={
|
|
pwdBusy ||
|
|
newPassword.length < 8 ||
|
|
!confirm ||
|
|
(hasPassword && !oldPassword)
|
|
}
|
|
>
|
|
{pwdBusy ? "保存中…" : hasPassword ? "修改密码" : "设置密码"}
|
|
</Button>
|
|
</form>
|
|
</section>
|
|
|
|
<section className="card p-5">
|
|
<SectionLabel hint="在这台设备上清掉登录状态">退出</SectionLabel>
|
|
<p className="mt-3 text-xs leading-relaxed text-ink-500">
|
|
退出只会清掉这台浏览器里的令牌;工作区里的数据不受影响。
|
|
</p>
|
|
<Button variant="ghost" size="sm" className="mt-3" onClick={signOut}>
|
|
退出登录
|
|
</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>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|