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
+221
View File
@@ -0,0 +1,221 @@
/**
* 登录方式与系统设置的验收:短信关着、邮箱能登录、SMTP 后台可配。
*
* 三个都要真跑,因为这三件事全是"接口回 200 但人用不了"的高危区:
* - 短信关着,登录页就不能出现「验证码」这一档(不然用户点进去只会看到 2017);
* - 邮箱 + 密码要能真的登进来,不是只把邮箱存进库;
* - SMTP 存进库之后要能立刻发信,别等到真要用的时候才发现授权码是错的。
*
* 授权码不进仓库:发信那一步只在给了 SMTP_PASS 环境变量时才跑。
*/
import { chromium } from "playwright-core";
import { mkdirSync, readFileSync } from "node:fs";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
const PHONE = process.env.PHONE ?? "13900000001";
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
const OUT = "e2e/artifacts";
const SMTP_HOST = process.env.SMTP_HOST ?? "smtp.126.com";
const SMTP_USER = process.env.SMTP_USER ?? "";
const SMTP_PASS = process.env.SMTP_PASS ?? "";
let pass = 0;
let fail = 0;
function check(name, ok, extra = "") {
if (ok) {
pass += 1;
console.log(`PASS ${name}`);
} else {
fail += 1;
console.log(`FAIL ${name} ${extra}`);
}
}
function savedPassword() {
try {
return readFileSync(PWD_FILE, "utf8").trim() || null;
} catch {
return null;
}
}
async function post(path, body, token) {
const r = await fetch(`${API}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body ?? {}),
});
return r.json();
}
async function get(path, token) {
const r = await fetch(`${API}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
return r.json();
}
async function patch(path, body, token) {
const r = await fetch(`${API}${path}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
});
return r.json();
}
async function main() {
mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({
executablePath:
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
});
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
const errors = [];
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
page.on("pageerror", (e) => errors.push(String(e)));
const password = savedPassword();
// ① 服务端说清楚现在开着哪几条路
const methods = await get("/auth/methods");
check(
"① /auth/methods 说短信关着、密码与邮箱密码开着",
methods.code === 0 &&
methods.data.password === true &&
methods.data.email_password === true &&
methods.data.sms_code === false,
JSON.stringify(methods.data),
);
// ② 关着的那条路真的挡住了
const sms = await post("/auth/sms/send", { phone: PHONE });
check("② 发验证码的接口回 2017(不是静默成功)", sms.code === 2017, JSON.stringify(sms));
// ③ 登录页不该再出现「验证码」这一档
await page.goto(`${WEB}/login`);
await page.waitForSelector("#account");
const codeTab = await page.getByRole("button", { name: "验证码", exact: true }).count();
const emailTab = await page.getByRole("tab", { name: "邮箱" }).count();
const pwdInput = await page.locator("#password").count();
check("③ 登录页没有验证码那一档、有邮箱档与密码框", codeTab === 0 && emailTab === 1 && pwdInput === 1);
// ④ 手机号 + 密码,从界面登录进去
let loggedIn = false;
if (password) {
await page.getByLabel("手机号").fill(PHONE);
await page.locator("#password").fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
loggedIn = await page
.waitForURL(`${WEB}/`, { timeout: 8000 })
.then(() => true)
.catch(() => false);
}
check("④ 手机号 + 密码能从登录页进首页", loggedIn, "(缺 .local/e2e-account-password.txt 时跳过)");
const tokens = loggedIn
? await page.evaluate(() => localStorage.getItem("dadao.access"))
: (await post("/auth/login/password", { phone: PHONE, password })).data?.tokens?.access_token;
// ⑤ 绑定邮箱,然后**用邮箱登录**
const email = `dadao-e2e-${Date.now()}@example.com`;
const bind = await patch("/auth/me", { email }, tokens);
check("⑤ 账号页能绑邮箱", bind.code === 0 && bind.data.user.email === email, JSON.stringify(bind));
const byEmail = await post("/auth/login/password", { email: email.toUpperCase(), password });
const emailLogin = await post("/auth/login/password", { email, password });
check(
"⑥ 邮箱 + 密码能登录(大小写不敏感)",
emailLogin.code === 0 && emailLogin.data?.tokens?.access_token,
JSON.stringify(emailLogin),
);
check("⑥b 全大写邮箱也能登录", byEmail.code === 0, JSON.stringify(byEmail));
// ⑦ 解绑之后这条路就该关掉,而不是留个后门
const unbind = await patch("/auth/me", { email: "" }, tokens);
const afterUnbind = await post("/auth/login/password", { email, password });
check(
"⑦ 解绑邮箱后邮箱登录回 2013",
unbind.code === 0 && unbind.data.user.email === null && afterUnbind.code === 2013,
JSON.stringify(afterUnbind),
);
// ⑧ 平台管理员才看得到「系统设置」
const me = await get("/auth/me", tokens);
check("⑧ 当前账号被标成平台管理员", me.data?.user?.platform_admin === true, JSON.stringify(me.data?.user));
await page.goto(`${WEB}/account`);
await page.waitForSelector('input[aria-label="昵称"]');
// 侧栏按 /auth/me 的 platform_admin 决定要不要渲染这一条,等它落下来再数
const settingsLink = await page
.waitForFunction(() => document.querySelectorAll('aside a[href="/settings"]').length, {
timeout: 5000,
})
.then((handle) => handle.jsonValue())
.catch(() => 0);
check("⑧b 侧栏里有「系统设置」入口", settingsLink === 1, `count=${settingsLink}`);
await page.screenshot({ path: `${OUT}/settings-entry.png` });
// ⑨ 系统设置页能读、能存;授权码只写不读
await page.goto(`${WEB}/settings`);
await page.waitForSelector('input[aria-label="服务器地址"]');
const before = await get("/settings/smtp", tokens);
check("⑨ 系统设置页读得到当前配置", before.code === 0 && before.data.password_set !== undefined);
if (SMTP_PASS) {
await page.getByLabel("服务器地址").fill(SMTP_HOST);
await page.getByLabel("端口").fill("465");
await page.locator('input[aria-label="账号"]').fill(SMTP_USER);
await page.getByLabel("授权码").fill(SMTP_PASS);
await page.getByRole("button", { name: "保存", exact: true }).click();
await page.waitForTimeout(1200);
const saved = await get("/settings/smtp", tokens);
check(
"⑩ 授权码存进库了,但接口不回显",
saved.data?.password_set === true &&
saved.data?.configured === true &&
!JSON.stringify(saved.data).includes(SMTP_PASS),
JSON.stringify(saved.data),
);
// ⑪ 真发一封,确认这条通道是通的(不是"存了个假配置")
const sent = await post("/settings/smtp/test", { to: SMTP_USER }, tokens);
check("⑪ 测试邮件发送成功", sent.code === 0, JSON.stringify(sent));
if (sent.code !== 0) {
console.log(` 发信失败原文:${sent.message}`);
}
await page.screenshot({ path: `${OUT}/settings-smtp.png` });
} else {
console.log("SKIP ⑩⑪ 没给 SMTP_PASS,跳过保存与发信");
}
// ⑫ 账号页上的邮箱框要能回填刚绑的值(说明前端读的是接口,不是本地缓存)
const rebind = await patch("/auth/me", { email }, tokens);
await page.goto(`${WEB}/account`);
await page.waitForSelector('input[aria-label="邮箱"]');
const emailInput = await page.waitForFunction(
() => {
const el = document.querySelector('input[aria-label="邮箱"]');
return el && el.value.includes("@") ? el.value : null;
},
{ timeout: 5000 },
);
const shownEmail = await emailInput.jsonValue();
check("⑫ 账号页回填刚绑的邮箱", rebind.code === 0 && shownEmail === email, shownEmail);
check("⑬ 控制台零报错", errors.length === 0, errors.slice(0, 2).join(" | "));
await browser.close();
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — pass ${pass}, fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);
}
main().catch((err) => {
console.error("脚本异常", err);
process.exit(1);
});
+59
View File
@@ -0,0 +1,59 @@
/**
* 拍两张文档用的截图:登录页(两种账号名 + 密码)、账号页(邮箱)、系统设置页。
* 需要前端 5180、后端 8100 起着;账号用 dev 的平台管理员。
*/
import { chromium } from "playwright-core";
import { mkdirSync, readFileSync } from "node:fs";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
const PHONE = process.env.PHONE ?? "13900000001";
const OUT = "e2e/artifacts";
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
function savedPassword() {
try {
return readFileSync(PWD_FILE, "utf8").trim();
} catch {
return "";
}
}
const browser = await chromium.launch({
executablePath:
process.env.CHROME ??
`${process.env.LOCALAPPDATA}/ms-playwright/chromium-1223/chrome-win64/chrome.exe`,
});
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
mkdirSync(OUT, { recursive: true });
await page.goto(`${WEB}/login`);
await page.waitForSelector("#account");
await page.screenshot({ path: `${OUT}/login-password.png` });
const r = await fetch(`${API}/auth/login/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE, password: savedPassword() }),
});
const data = await r.json();
await page.addInitScript(
([a, rf]) => {
localStorage.setItem("dadao.access", a);
localStorage.setItem("dadao.refresh", rf);
},
[data.data.tokens.access_token, data.data.tokens.refresh_token],
);
await page.goto(`${WEB}/account`);
await page.waitForSelector('input[aria-label="邮箱"]');
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/account-page.png` });
await page.goto(`${WEB}/settings`);
await page.waitForSelector('input[aria-label="服务器地址"]');
await page.waitForTimeout(800);
await page.screenshot({ path: `${OUT}/settings-page.png` });
await browser.close();
console.log("screenshots written to", OUT);
+2
View File
@@ -14,6 +14,7 @@ import { KnowledgePage } from "./pages/Knowledge";
import { NotificationsPage } from "./pages/Notifications";
import { JoinPage } from "./pages/Join";
import { AccountPage } from "./pages/Account";
import { SettingsPage } from "./pages/Settings";
interface AuthState {
user: User | null;
@@ -90,6 +91,7 @@ export function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/w/:wsId" element={<TasksPage />} />
<Route path="/w/:wsId/board" element={<BoardPage />} />
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
+20 -1
View File
@@ -1,6 +1,6 @@
import { Link } from "react-router-dom";
import { useAuth } from "../App";
import { Logout, Logo, Settings } from "./icons";
import { Logout, Logo, Settings, Sliders } from "./icons";
/**
* 工作区里侧栏长什么样,工作区外面(工作区列表、账号设置)就长什么样。
@@ -64,6 +64,16 @@ export function SideRail({ activeId }: { activeId?: string | null }) {
<Settings size={14} className="text-ink-400" />
账号设置
</Link>
{/* 系统设置是平台级配置(全站共用一份),只给平台管理员看 */}
{user?.platform_admin ? (
<Link
to="/settings"
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-ink-500 transition hover:bg-paper-100 hover:text-ink-800"
>
<Sliders size={14} className="text-ink-400" />
系统设置
</Link>
) : null}
</nav>
<div className="border-t border-line-200 px-3 py-3">
@@ -94,6 +104,15 @@ export function SideRail({ activeId }: { activeId?: string | null }) {
<span className="text-sm font-semibold text-ink-900">达道Task</span>
</Link>
<div className="flex items-center gap-1">
{user?.platform_admin ? (
<Link
to="/settings"
className="rounded-md p-1.5 text-ink-400 hover:bg-ink-100"
aria-label="系统设置"
>
<Sliders size={16} />
</Link>
) : null}
<Link
to="/account"
className="rounded-md p-1.5 text-ink-400 hover:bg-ink-100"
+8
View File
@@ -227,6 +227,14 @@ export const Settings = (p: IconProps) => (
</svg>
);
export const Sliders = (p: IconProps) => (
<svg {...base(p)}>
<path d="M4 7.5h9M17 7.5h3M4 16.5h3M11 16.5h9" />
<circle cx="15" cy="7.5" r="2" />
<circle cx="9" cy="16.5" r="2" />
</svg>
);
export const User = (p: IconProps) => (
<svg {...base(p)}>
<circle cx="12" cy="8.5" r="3.5" />
+1
View File
@@ -125,6 +125,7 @@ export const api = {
get: <T>(path: string, signal?: AbortSignal) => raw<T>(path, { signal }),
post: <T>(path: string, body?: unknown) => raw<T>(path, { method: "POST", body }),
patch: <T>(path: string, body?: unknown) => raw<T>(path, { method: "PATCH", body }),
put: <T>(path: string, body?: unknown) => raw<T>(path, { method: "PUT", body }),
del: <T>(path: string) => raw<T>(path, { method: "DELETE" }),
};
+20
View File
@@ -1,13 +1,33 @@
export interface User {
id: string;
phone: string;
/** 邮箱是账号名(可空):能用它 + 密码登录,也是以后找回密码的去处 */
email?: string | null;
nickname: string;
avatar_url?: string | null;
/** 设没设登录密码;密码本身永远不会回到前端 */
has_password?: boolean;
/** 平台管理员:能进「系统设置」改 SMTP 这类跨租户的配置 */
platform_admin?: boolean;
created_at?: string;
}
/**
* 发信配置的回执:**永远不含授权码**,只说"设没设"。
* `source` 说明当前值是从数据库读的还是从服务器环境变量兜的。
*/
export interface SmtpSettings {
host: string | null;
port: number;
username: string | null;
password_set: boolean;
from_name: string;
use_ssl: boolean;
configured: boolean;
source: "db" | "env" | "none";
updated_at?: string | null;
}
export interface Workspace {
id: string;
name: string;
+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>
+110 -42
View File
@@ -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>
+287
View File
@@ -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>
);
}