feat(auth): 邮箱验证码注册,生产重新有了自助入口
CI / backend (push) Canceled after 0s

短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。

先发码再注册,不是"注册时顺带发信"

  POST /auth/email/code 只做三件事:查重、限流、发码。
  填完密码才被告知"这个邮箱注册过了"最气人,所以占用(2019)在发码前就回。

邮箱既是账号名也是唯一联系方式,所以必须真验一次码

  验证通过时间落 app_user.email_verified_at,/auth/me 回 email_verified。
  "随手填一个"会让账号变成没人能找回的孤岛。

app_user.phone 改为可空(迁移 0011)

  邮箱注册的账号本来就没有手机号。user.phone 对这类账号回空串,
  成员列表同理,不再伪装成一串星号。

防刷

  同邮箱 60 秒一次(2022)、验证码 10 分钟有效、错 5 次即作废(2021)。
  6 位数可以被刮,不设上限等于把注册口子交给脚本。

本机联调开关 EMAIL_CODE_DEV_ECHO

  为 true 时把码回给调用方且不发真信——否则每跑一次 e2e 就往真实收件箱塞一封。
  生产打开会让 validate_runtime() 拒绝启动。

前端

  新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时;
  服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods
  决定要不要显示「还没有账号?用邮箱注册」。

验证

  pytest 139 passed、ruff check / format 全绿
  tsc --noEmit 0、vite build 通过
  e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来
    → 同一邮箱再发码回 2019 → 控制台零报错
  e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信)
  e2e account.mjs 14 项、knowledge.mjs 全绿

顺带修的两处测试

  test_account 的字段白名单补 email_verified(接口确实多了这个字段);
  test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的
  EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。
This commit is contained in:
Steven
2026-11-03 01:04:26 +08:00
parent c0c5230fd1
commit b5170ad1ce
20 changed files with 955 additions and 16 deletions
+98
View File
@@ -0,0 +1,98 @@
/**
* 注册的验收:从登录页走到注册页,把账号建出来,再用它登回来。
*
* 这里验的是"一个人真能自己进来",所以每一步都从界面走:
* ① 登录页有「用邮箱注册」(服务端开着才会出现)
* ② 注册页拿到验证码(本机 EMAIL_CODE_DEV_ECHO 会把码回给页面,不发真信)
* ③ 提交后直接进首页,说明注册即登录
* ④ 退出、用「邮箱 + 密码」再登一次,证明邮箱真的是账号名
* ⑤ 同一个邮箱再发码,必须被挡(2019),而不是又建一个号
*/
import { chromium } from "playwright-core";
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 CHROME =
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
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 ? ` — ${extra}` : ""}`);
}
}
const email = `reg${Date.now()}@example.com`;
const password = `dadao${Math.floor(Math.random() * 900000 + 1000)}`;
const browser = await chromium.launch({ executablePath: CHROME });
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)));
try {
await page.goto(`${WEB}/login`);
await page.waitForSelector("#account");
const registerLink = page.getByRole("link", { name: "用邮箱注册" });
check("登录页有「用邮箱注册」入口", (await registerLink.count()) > 0);
await registerLink.click();
await page.waitForURL("**/register");
await page.waitForSelector("#email");
check("能进注册页", true);
await page.locator("#email").fill(email);
await page.getByRole("button", { name: "获取验证码" }).click();
await page.waitForSelector("text=开发环境验证码", { timeout: 15000 });
const notice = await page.locator("text=开发环境验证码").first().innerText();
const code = notice.match(/\d{6}/)?.[0] ?? "";
check("本机拿得到验证码(生产只发到邮箱)", code.length === 6, notice);
await page.locator("#code").fill(code);
await page.locator("#password").fill(password);
await page.locator("#confirm").fill(password);
await page.locator("#nickname").fill("注册验收");
await page.getByRole("button", { name: "注册并登录" }).click();
const landed = await page
.waitForURL(`${WEB}/`, { timeout: 15000 })
.then(() => true)
.catch(() => false);
check("注册完直接进首页(注册即登录)", landed, page.url());
// 退出后用「邮箱 + 密码」登回来:邮箱真是账号名
await page.getByRole("button", { name: "退出登录" }).last().click();
await page.waitForURL("**/login");
await page.getByRole("tab", { name: "邮箱" }).click();
await page.locator("#account").fill(email);
await page.locator("#password").fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
const back = await page
.waitForURL(`${WEB}/`, { timeout: 15000 })
.then(() => true)
.catch(() => false);
check("邮箱 + 密码能登回来", back);
// 同一个邮箱再发码:必须拦住,别再建一个号
const again = await page.request.post(`${API}/auth/email/code`, { data: { email } });
const body = await again.json();
check("已注册的邮箱再发码回 2019", body.code === 2019, JSON.stringify(body));
check("控制台无报错", errors.length === 0, errors.slice(0, 2).join(" | "));
} catch (err) {
fail += 1;
console.log(`FAIL 脚本异常 — ${err}`);
} finally {
await browser.close();
}
console.log(`\n${fail === 0 ? "ALL PASS" : "FAILED"} — pass ${pass}, fail ${fail}`);
process.exit(fail === 0 ? 0 : 1);
+32
View File
@@ -0,0 +1,32 @@
/**
* 拍注册页的截图(文档用)。需要前端 5180、后端 8100 起着。
*
* 用本机 dev 的 EMAIL_CODE_DEV_ECHO:把码回给页面,截图里能看到"开发环境验证码"那一行,
* 正好说明生产只会发到邮箱。真提交一次再拍,页面右侧是"下一步会怎样"的说明,不必留成功态。
*/
import { chromium } from "playwright-core";
import { mkdirSync } from "node:fs";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const OUT = "e2e/artifacts";
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 } });
await page.goto(`${WEB}/register`);
await page.waitForSelector("#email");
await page.locator("#email").fill("shufen@126.com");
await page.locator("#password").fill("dadao2026");
await page.locator("#confirm").fill("dadao2026");
await page.locator("#nickname").fill("淑芬");
await page.getByRole("button", { name: "获取验证码" }).click();
await page.waitForSelector("text=开发环境验证码", { timeout: 15000 });
await page.screenshot({ path: `${OUT}/register.png` });
await browser.close();
console.log(`${OUT}/register.png`);
+2
View File
@@ -5,6 +5,7 @@ import { UpdatePrompt } from "./components/UpdatePrompt";
import { api, authExpired, session } from "./lib/client";
import type { User, Workspace } from "./lib/types";
import { LoginPage } from "./pages/Login";
import { RegisterPage } from "./pages/Register";
import { HomePage } from "./pages/Home";
import { WorkspacePage } from "./pages/Workspace";
import { TasksPage } from "./pages/Tasks";
@@ -89,6 +90,7 @@ export function App() {
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} />
+22 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../App";
import { Book, Check, Logo, TicketIcon } from "../components/icons";
import { Button, Field, InlineAlert } from "../components/ui";
@@ -34,7 +34,12 @@ 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 };
type Methods = {
password: boolean;
email_password: boolean;
sms_code: boolean;
email_register: boolean;
};
export function LoginPage() {
const { reload } = useAuth();
@@ -46,7 +51,12 @@ export function LoginPage() {
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [mode, setMode] = useState<"code" | "password">("password");
const [methods, setMethods] = useState<Methods>({ password: true, email_password: true, sms_code: false });
const [methods, setMethods] = useState<Methods>({
password: true,
email_password: true,
sms_code: false,
email_register: true,
});
const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -312,6 +322,15 @@ export function LoginPage() {
</Button>
</form>
{methods.email_register && (
<p className="mt-6 text-sm text-ink-500">
还没有账号?
<Link className="ml-1 font-medium text-primary-700 hover:underline" to="/register">
用邮箱注册
</Link>
</p>
)}
<p className="mt-8 text-2xs leading-relaxed text-ink-400">
登录即表示同意《服务条款》与《隐私政策》。我们不会把你的内容用于训练模型。
</p>
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { Logo } from "../components/icons";
import { Button, Field, InlineAlert } from "../components/ui";
import { ApiError, api, session, type Tokens } from "../lib/client";
import type { User } from "../lib/types";
const EMAIL_RE = /^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$/;
/** 跟后端 `password_problem` 同一套规则;本地只做"别白跑一趟"的提示,真判定在服务端。 */
function passwordHint(password: string): string | null {
if (password.length < 8) return "至少 8 位";
if (password.length > 64) return "最多 64 位";
if (!/[A-Za-z]/.test(password) || !/\d/.test(password)) return "要同时有字母和数字";
return null;
}
/**
* 注册:邮箱 + 验证码 + 密码。
*
* 为什么必须先验一次邮箱:短信登录关掉以后,邮箱是账号名也是唯一联系方式,
* "随手填一个" 会让账号变成没人能找回的孤岛。
*/
export function RegisterPage() {
const { reload } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [nickname, setNickname] = useState("");
const [countdown, setCountdown] = useState(0);
const [sent, setSent] = useState(false);
const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [open, setOpen] = useState(true);
useEffect(() => {
let alive = true;
api
.get<{ email_register: boolean }>("/auth/methods")
.then((data) => {
if (alive) setOpen(data.email_register);
})
.catch(() => {
/* 拿不到就按开着渲染,提交时服务端会给出真答案 */
});
return () => {
alive = false;
};
}, []);
useEffect(() => {
if (countdown <= 0) return;
const timer = setTimeout(() => setCountdown((v) => v - 1), 1000);
return () => clearTimeout(timer);
}, [countdown]);
const emailValid = EMAIL_RE.test(email.trim());
const weak = password ? passwordHint(password) : null;
const mismatch = confirm.length > 0 && confirm !== password;
const canSubmit =
!busy && emailValid && code.length >= 4 && !weak && password.length > 0 && confirm === password;
async function sendCode() {
setError(null);
setBusy(true);
try {
const data = await api.post<{ dev_code?: string }>("/auth/email/code", {
email: email.trim(),
});
setDevCode(data.dev_code ?? null);
setSent(true);
setCountdown(60);
} catch (err) {
setError(err instanceof ApiError ? err.message : "发送失败");
} finally {
setBusy(false);
}
}
async function submit(event: React.FormEvent) {
event.preventDefault();
if (!canSubmit) return;
setError(null);
setBusy(true);
try {
const data = await api.post<{ user: User; tokens: Tokens }>("/auth/register", {
email: email.trim(),
code,
password,
...(nickname.trim() ? { nickname: nickname.trim() } : {}),
});
session.save(data.tokens);
await reload();
navigate("/", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "注册失败");
} finally {
setBusy(false);
}
}
return (
<main className="flex min-h-full items-center justify-center bg-paper-100 px-5 py-12">
<div className="w-full max-w-sm">
<div className="mb-8 flex items-center gap-2.5">
<Logo size={30} />
<div className="leading-tight">
<div className="text-sm font-semibold text-ink-900">达道Task</div>
<div className="text-2xs text-ink-400">任务 · 工单 · 知识</div>
</div>
</div>
<h1 className="text-2xl font-semibold">创建账号</h1>
<p className="mt-1.5 text-sm text-ink-500">
用邮箱注册:先收一个验证码,再设一个密码。邮箱就是你的登录账号。
</p>
{!open && (
<div className="mt-5">
<InlineAlert>这台服务暂未开放自助注册,请联系管理员开通账号。</InlineAlert>
</div>
)}
<form className="mt-5 space-y-4" onSubmit={submit}>
<Field label="邮箱">
<input
id="email"
className="input h-11"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
maxLength={255}
disabled={!open}
/>
</Field>
<Field label="验证码">
<div className="flex gap-2">
<input
id="code"
className="input h-11 tracking-[0.3em]"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6 位数字"
value={code}
onChange={(e) => setCode(e.target.value.trim())}
maxLength={6}
disabled={!open}
/>
<Button
type="button"
variant="ghost"
className="h-11 shrink-0 px-3.5"
disabled={!open || !emailValid || busy || countdown > 0}
onClick={sendCode}
>
{countdown > 0 ? `${countdown}s` : sent ? "重新发送" : "获取验证码"}
</Button>
</div>
</Field>
<Field label="密码">
<input
id="password"
type="password"
className="input h-11"
autoComplete="new-password"
placeholder="至少 8 位,字母 + 数字"
value={password}
onChange={(e) => setPassword(e.target.value)}
maxLength={64}
disabled={!open}
/>
{weak && <span className="mt-1 block text-2xs text-clay-600">{weak}</span>}
</Field>
<Field label="确认密码">
<input
id="confirm"
type="password"
className="input h-11"
autoComplete="new-password"
placeholder="再输一次"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
maxLength={64}
disabled={!open}
/>
{mismatch && <span className="mt-1 block text-2xs text-clay-600">两次输入不一致</span>}
</Field>
<Field label="昵称(可不填)">
<input
id="nickname"
className="input h-11"
autoComplete="nickname"
placeholder="留空就用邮箱前缀"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
maxLength={32}
disabled={!open}
/>
</Field>
{devCode && (
<div className="flex items-start gap-2 rounded-lg border border-clay-500/20 bg-clay-50 px-3 py-2 text-xs text-clay-700">
<span className="mt-px text-clay-500">开发环境验证码:{devCode}</span>
<span className="text-clay-600/70">(生产环境只发到邮箱,不回显)</span>
</div>
)}
{error && <InlineAlert>{error}</InlineAlert>}
<Button
type="submit"
variant="primary"
size="lg"
className="mt-2 w-full"
disabled={!open || !canSubmit}
>
{busy ? "处理中…" : "注册并登录"}
</Button>
</form>
<p className="mt-6 text-sm text-ink-500">
已经有账号了?
<Link className="ml-1 font-medium text-primary-700 hover:underline" to="/login">
去登录
</Link>
</p>
</div>
</main>
);
}