feat: implement TASK-WP1-02 login sessions

This commit is contained in:
suyx
2026-07-28 16:28:30 +08:00
parent f467e7c09f
commit ee70001d44
20 changed files with 2480 additions and 28 deletions
+267
View File
@@ -0,0 +1,267 @@
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import "./user-auth.css";
type AuthMode = "login" | "register";
type SendState = "idle" | "sending" | "sent" | "error";
interface ErrorEnvelopeBody {
error?: {
details?: { field_errors?: Array<{ message_key?: string }> };
message_key?: string;
};
}
const messageByKey: Record<string, string> = {
"auth.account.suspended": "账号已暂停,请联系管理员。",
"auth.challenge.expired": "验证码已过期,请重新获取。",
"auth.challenge.invalid": "验证码不正确,请检查后重试。",
"auth.challenge.resend_too_soon": "请等待倒计时结束后重新获取验证码。",
"auth.challenge.too_many_attempts": "尝试次数过多,请稍后再试。",
"auth.login.admin_required": "此邮箱需从管理员登录入口进入。",
"auth.login.registration_required": "该邮箱尚未注册,请切换到注册。",
"auth.service.unavailable": "邮件服务暂时不可用,请稍后重试。",
};
function errorMessage(body: ErrorEnvelopeBody) {
const key = body.error?.details?.field_errors?.[0]?.message_key ?? body.error?.message_key;
return key ? (messageByKey[key] ?? "请求未完成,请检查后重试。") : "请求未完成,请检查后重试。";
}
function maskedEmail(email: string) {
const [local = "", domain = ""] = email.split("@", 2);
const visible = local.slice(0, Math.min(2, local.length));
return `${visible}${"*".repeat(Math.max(3, local.length - visible.length))}@${domain}`;
}
export function UserAuthPage() {
const emailId = useId();
const codeId = useId();
const inviteId = useId();
const loginTab = useRef<HTMLButtonElement>(null);
const registerTab = useRef<HTMLButtonElement>(null);
const [mode, setMode] = useState<AuthMode>("login");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [registrationId, setRegistrationId] = useState<string>();
const [sendState, setSendState] = useState<SendState>("idle");
const [countdown, setCountdown] = useState(0);
const [error, setError] = useState<string>();
const [submitting, setSubmitting] = useState(false);
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
useEffect(() => {
if (countdown <= 0) return;
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000);
return () => window.clearInterval(timer);
}, [countdown]);
function switchMode(nextMode: AuthMode) {
if (nextMode === mode) return;
setMode(nextMode);
setCode("");
setInviteCode("");
setRegistrationId(undefined);
setSendState("idle");
setCountdown(0);
setError(undefined);
}
function switchTab(nextMode: AuthMode) {
switchMode(nextMode);
window.requestAnimationFrame(() => (nextMode === "login" ? loginTab : registerTab).current?.focus());
}
async function sendCode() {
if (!emailValid || sendState === "sending" || countdown > 0) return;
setSendState("sending");
setError(undefined);
const endpoint = mode === "login" ? "/api/v1/auth/login/send" : "/api/v1/auth/register/send";
const payload = mode === "login" ? { email } : { email, invite_code: inviteCode };
try {
const response = await fetch(endpoint, {
body: JSON.stringify(payload),
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
method: "POST",
});
const body = await response.json() as ErrorEnvelopeBody & { registration_id?: string };
if (!response.ok || !body.registration_id) {
setSendState("error");
setError(errorMessage(body));
return;
}
setRegistrationId(body.registration_id);
setSendState("sent");
setCountdown(60);
} catch {
setSendState("error");
setError(messageByKey["auth.service.unavailable"]);
}
}
async function login(event: FormEvent) {
event.preventDefault();
if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return;
setSubmitting(true);
setError(undefined);
try {
const response = await fetch("/api/v1/auth/login/complete", {
body: JSON.stringify({ registration_id: registrationId, verification_code: code }),
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
},
method: "POST",
});
const body = await response.json() as ErrorEnvelopeBody;
if (!response.ok) {
setError(errorMessage(body));
return;
}
window.location.assign("/app");
} catch {
setError("登录请求未完成,请重试。");
} finally {
setSubmitting(false);
}
}
return (
<main className="auth-page">
<section className="auth-art" aria-label="Dada 创作艺术带">
<div className="auth-wordmark">DADA</div>
<div className="auth-art-copy">
<span>LOCAL CREATIVE SYSTEM / WINDOWS P0-A</span>
<strong></strong>
</div>
<div className="auth-art-block" aria-hidden="true" />
<div className="auth-art-line" aria-hidden="true" />
</section>
<section className="auth-content">
<a className="auth-admin-link" href="/admin"></a>
<div className="auth-panel">
<div className="auth-tabs" role="tablist" aria-label="认证方式">
<button
aria-selected={mode === "login"}
className="auth-tab"
onClick={() => switchMode("login")}
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
event.preventDefault();
switchTab("register");
}
}}
ref={loginTab}
role="tab"
tabIndex={mode === "login" ? 0 : -1}
type="button"
>
</button>
<button
aria-selected={mode === "register"}
className="auth-tab"
onClick={() => switchMode("register")}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
switchTab("login");
}
}}
ref={registerTab}
role="tab"
tabIndex={mode === "register" ? 0 : -1}
type="button"
>
</button>
</div>
{mode === "login" ? (
<form className="auth-form" onSubmit={login}>
<h1></h1>
<label htmlFor={emailId}></label>
<input
autoComplete="email"
id={emailId}
inputMode="email"
onChange={(event) => setEmail(event.target.value)}
placeholder="请输入邮箱"
type="email"
value={email}
/>
<label htmlFor={codeId}></label>
<div className="auth-code-row">
<input
autoComplete="one-time-code"
id={codeId}
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))}
placeholder="6 位验证码"
value={code}
/>
<button
className="auth-secondary"
disabled={!emailValid || sendState === "sending" || countdown > 0}
onClick={sendCode}
type="button"
>
{sendState === "sending" ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
</button>
</div>
{sendState === "sent" ? (
<p className="auth-status" role="status"> {maskedEmail(email)}</p>
) : null}
{error ? <p className="auth-error" role="alert">{error}</p> : null}
<button
className="auth-primary"
disabled={!registrationId || code.length !== 6 || submitting}
type="submit"
>
{submitting ? "登录中" : "登录"}
</button>
</form>
) : (
<section className="auth-form" aria-labelledby="registration-title">
<h1 id="registration-title"></h1>
<label htmlFor={inviteId}></label>
<input
autoComplete="off"
id={inviteId}
onChange={(event) => setInviteCode(event.target.value)}
value={inviteCode}
/>
<label htmlFor={emailId}></label>
<input
autoComplete="email"
id={emailId}
inputMode="email"
onChange={(event) => setEmail(event.target.value)}
type="email"
value={email}
/>
<button
className="auth-primary"
disabled={!emailValid || inviteCode.trim().length < 8 || sendState === "sending" || countdown > 0}
onClick={sendCode}
type="button"
>
{sendState === "sending" ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
</button>
{error ? <p className="auth-error" role="alert">{error}</p> : null}
</section>
)}
</div>
</section>
<footer className="auth-local-notice">
</footer>
</main>
);
}