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 必需的。
241 lines
8.1 KiB
TypeScript
241 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|