短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。
先发码再注册,不是"注册时顺带发信"
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:
@@ -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);
|
||||
@@ -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`);
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user