feat: implement TASK-WP1-03 registration notice

This commit is contained in:
suyx
2026-07-28 16:48:49 +08:00
parent 4d81f9c723
commit aec4c83eca
10 changed files with 1049 additions and 33 deletions
+266 -31
View File
@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import { registrationNotice } from "@dada/shared-contracts";
import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import "./user-auth.css";
@@ -20,6 +21,16 @@ const messageByKey: Record<string, string> = {
"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": "邮件服务暂时不可用,请稍后重试。",
};
@@ -34,22 +45,47 @@ function maskedEmail(email: string) {
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 [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;
@@ -57,10 +93,28 @@ export function UserAuthPage() {
return () => window.clearInterval(timer);
}, [countdown]);
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);
setCode("");
resetRegistrationDetails();
setInviteCode("");
setRegistrationId(undefined);
setSendState("idle");
@@ -68,6 +122,43 @@ export function UserAuthPage() {
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());
@@ -129,8 +220,44 @@ export function UserAuthPage() {
}
}
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);
}
}
return (
<main className="auth-page">
<>
<main className="auth-page">
<section className="auth-art" aria-label="Dada 创作艺术带">
<div className="auth-wordmark">DADA</div>
<div className="auth-art-copy">
@@ -227,34 +354,109 @@ export function UserAuthPage() {
</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>
<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}
</section>
</form>
)}
</div>
</section>
@@ -262,6 +464,39 @@ export function UserAuthPage() {
<footer className="auth-local-notice">
</footer>
</main>
</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}
</>
);
}