Files
tyx_AI_xhs/apps/web/src/user-auth.tsx
T
suyx 92434aec17
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
feat(POSTV1-03): 增加本机测试直达入口
2026-08-05 14:53:20 +08:00

547 lines
21 KiB
TypeScript

import { registrationNotice } from "@dada/shared-contracts";
import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } 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.email.already_registered": "该邮箱已注册,请切换到登录。",
"auth.invite.disabled": "邀请码已停用,请更换邀请码。",
"auth.invite.exhausted": "邀请码使用次数已耗尽,请更换邀请码。",
"auth.invite.expired": "邀请码已过期,请更换邀请码。",
"auth.invite.not_found": "邀请码无效,请检查后重试。",
"auth.privacy.consent_required": "请阅读并同意《内测使用与隐私告知》。",
"auth.privacy.notice_version_invalid": "告知版本已更新,请重新阅读后同意。",
"auth.profile.invalid": "请检查创作署名和社交 ID。",
"auth.registration.login_required": "该邮箱已注册,请切换到登录。",
"auth.registration.stage_limit_reached": "本轮内测名额已满,请返回登录。",
"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}`;
}
function maskedRegistrationEmail(email: string) {
const [local = "", domain = ""] = email.split("@", 2);
return `${local.slice(0, 1)}***@${domain.slice(0, 1)}***`;
}
function maskedInvite(inviteCode: string) {
return `••••${inviteCode.slice(-3)}`;
}
export function UserAuthPage() {
const emailId = useId();
const codeId = useId();
const inviteId = useId();
const creatorNameId = useId();
const socialId = useId();
const loginTab = useRef<HTMLButtonElement>(null);
const registerTab = useRef<HTMLButtonElement>(null);
const noticeButton = useRef<HTMLButtonElement>(null);
const noticeCloseButton = useRef<HTMLButtonElement>(null);
const noticeDialog = useRef<HTMLElement>(null);
const [mode, setMode] = useState<AuthMode>("login");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [creatorName, setCreatorName] = useState("");
const [socialHandle, setSocialHandle] = useState("");
const [privacyConsentAccepted, setPrivacyConsentAccepted] = useState(false);
const [noticeOpen, setNoticeOpen] = useState(false);
const [registrationId, setRegistrationId] = useState<string>();
const [sendState, setSendState] = useState<SendState>("idle");
const [countdown, setCountdown] = useState(0);
const [error, setError] = useState<string>();
const [localTestAvailable, setLocalTestAvailable] = useState(false);
const [localTestError, setLocalTestError] = useState<string>();
const [localTestSubmitting, setLocalTestSubmitting] = useState(false);
const [submitting, setSubmitting] = useState(false);
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
const registrationReady = Boolean(
registrationId
&& /^[0-9]{6}$/.test(code)
&& creatorName.trim()
&& socialHandle.trim()
&& privacyConsentAccepted,
);
useEffect(() => {
if (countdown <= 0) return;
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000);
return () => window.clearInterval(timer);
}, [countdown]);
useEffect(() => {
const controller = new AbortController();
void fetch("/api/v1/auth/local-test", { credentials: "same-origin", signal: controller.signal })
.then(async (response) => {
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return;
const body = await response.json() as { available?: boolean };
if (body.available === true) setLocalTestAvailable(true);
})
.catch(() => undefined);
return () => controller.abort();
}, []);
useEffect(() => {
if (!noticeOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
noticeCloseButton.current?.focus();
return () => {
document.body.style.overflow = previousOverflow;
};
}, [noticeOpen]);
function resetRegistrationDetails() {
setCode("");
setCreatorName("");
setSocialHandle("");
setPrivacyConsentAccepted(false);
setNoticeOpen(false);
}
function switchMode(nextMode: AuthMode) {
if (nextMode === mode) return;
setMode(nextMode);
resetRegistrationDetails();
setInviteCode("");
setRegistrationId(undefined);
setSendState("idle");
setCountdown(0);
setError(undefined);
}
function modifyRegistrationEntry() {
resetRegistrationDetails();
setRegistrationId(undefined);
setSendState("idle");
setCountdown(0);
setError(undefined);
window.requestAnimationFrame(() => document.getElementById(inviteId)?.focus());
}
function closeNotice() {
setNoticeOpen(false);
window.requestAnimationFrame(() => noticeButton.current?.focus());
}
function handleNoticeKeyDown(event: KeyboardEvent<HTMLElement>) {
if (event.key === "Escape") {
event.preventDefault();
closeNotice();
return;
}
if (event.key === "Tab") {
const focusable = Array.from(
noticeDialog.current?.querySelectorAll<HTMLElement>("button, [href], input, [tabindex]:not([tabindex='-1'])") ?? [],
).filter((element) => !element.hasAttribute("disabled"));
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
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);
}
}
async function completeRegistration(event: FormEvent) {
event.preventDefault();
if (!registrationReady || !registrationId || submitting) return;
setSubmitting(true);
setError(undefined);
try {
const response = await fetch("/api/v1/auth/register/complete", {
body: JSON.stringify({
creator_name: creatorName,
privacy_consent_accepted: privacyConsentAccepted,
privacy_notice_version: registrationNotice.version,
registration_id: registrationId,
social_id: socialHandle,
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);
}
}
async function enterLocalTest() {
if (localTestSubmitting) return;
setLocalTestSubmitting(true);
setLocalTestError(undefined);
try {
const response = await fetch("/api/v1/auth/local-test", {
credentials: "same-origin",
method: "POST",
});
if (!response.ok) {
setLocalTestError("本机测试会话未能建立,请重试。");
return;
}
window.location.assign("/app");
} catch {
setLocalTestError("本机测试会话未能建立,请重试。");
} finally {
setLocalTestSubmitting(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/login">管理员登录</a>
<div className="auth-panel">
{localTestAvailable ? (
<div className="auth-test-entry">
<button className="auth-primary" disabled={localTestSubmitting} onClick={enterLocalTest} type="button">
{localTestSubmitting ? "正在进入" : "直接进入本机测试"}
</button>
{localTestError ? <p className="auth-error" role="alert">{localTestError}</p> : null}
</div>
) : null}
<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>
) : (
<form className="auth-form auth-registration-form" onSubmit={completeRegistration}>
<h1>{registrationId ? "完善注册资料" : "邀请码注册"}</h1>
{!registrationId ? (
<>
<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)}
placeholder="请输入邮箱"
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>
</>
) : (
<>
<div className="auth-registration-status">验证码已发送</div>
<div className="auth-verified-grid">
<div>
<strong>邀请码 · 已验证</strong>
<span>{maskedInvite(inviteCode)}</span>
</div>
<div>
<strong>邮箱 · 已验证</strong>
<span>{maskedRegistrationEmail(email)}</span>
</div>
</div>
<button className="auth-text-action" onClick={modifyRegistrationEntry} type="button">
修改邀请码和邮箱
</button>
<label htmlFor={codeId}>验证码</label>
<input
autoComplete="one-time-code"
id={codeId}
inputMode="numeric"
maxLength={6}
onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))}
placeholder="输入 6 位验证码"
value={code}
/>
<label htmlFor={creatorNameId}>创作署名</label>
<input
autoComplete="nickname"
id={creatorNameId}
maxLength={80}
onChange={(event) => setCreatorName(event.target.value)}
placeholder="成品中显示的名称"
value={creatorName}
/>
<label htmlFor={socialId}>社交 ID</label>
<input
autoComplete="off"
id={socialId}
maxLength={80}
onChange={(event) => setSocialHandle(event.target.value)}
placeholder="成品中显示的账号文本"
value={socialHandle}
/>
<div className="auth-consent">
<div className="auth-consent-title">
<strong>《内测使用与隐私告知》</strong>
<button
className="auth-text-action"
onClick={() => setNoticeOpen(true)}
ref={noticeButton}
type="button"
>
查看全文
</button>
</div>
<label className="auth-checkbox">
<input
checked={privacyConsentAccepted}
onChange={(event) => setPrivacyConsentAccepted(event.target.checked)}
type="checkbox"
/>
我已阅读并同意《内测使用与隐私告知》
</label>
{!privacyConsentAccepted ? <p>完成注册前必须阅读并勾选同意。</p> : null}
</div>
<button className="auth-primary" disabled={!registrationReady || submitting} type="submit">
{submitting ? "注册中" : "注册并进入 Dada"}
</button>
</>
)}
{error ? <p className="auth-error" role="alert">{error}</p> : null}
</form>
)}
</div>
</section>
<footer className="auth-local-notice">
测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。
</footer>
</main>
{noticeOpen ? (
<div className="auth-dialog-backdrop">
<section
aria-labelledby="registration-notice-title"
aria-modal="true"
className="auth-dialog"
onKeyDown={handleNoticeKeyDown}
ref={noticeDialog}
role="dialog"
>
<header>
<div>
<p>版本 {registrationNotice.version} · 生效日期 {registrationNotice.effectiveAt}</p>
<h2 id="registration-notice-title">{registrationNotice.title}</h2>
</div>
</header>
<div className="auth-dialog-content">
{registrationNotice.sections.map((section) => (
<section key={section.title}>
<h3>{section.title}</h3>
<p>{section.body}</p>
</section>
))}
</div>
<footer>
<button className="auth-primary" onClick={closeNotice} ref={noticeCloseButton} type="button">
我已阅读
</button>
</footer>
</section>
</div>
) : null}
</>
);
}