feat: implement TASK-WP1-03 registration notice
This commit is contained in:
@@ -30,6 +30,10 @@ input {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
button {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
@@ -157,6 +161,10 @@ input {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.auth-registration-form {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.auth-form label {
|
||||
margin: 0 0 7px;
|
||||
font-size: 13px;
|
||||
@@ -231,6 +239,169 @@ input {
|
||||
color: #3c5b32;
|
||||
}
|
||||
|
||||
.auth-registration-status {
|
||||
align-self: flex-end;
|
||||
margin: -52px 0 14px;
|
||||
padding: 6px 10px;
|
||||
color: #f2f500;
|
||||
background: #111111;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-verified-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-verified-grid > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
min-height: 64px;
|
||||
border: 1px solid #b4b4af;
|
||||
padding: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.auth-verified-grid strong {
|
||||
color: #2f6b45;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.auth-verified-grid span {
|
||||
overflow: hidden;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-text-action {
|
||||
align-self: flex-end;
|
||||
border: 0;
|
||||
padding: 4px 0;
|
||||
color: #075f8f;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-registration-form > .auth-text-action {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-consent {
|
||||
border: 1px solid #b4b4af;
|
||||
padding: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.auth-consent-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auth-checkbox {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.auth-form .auth-checkbox input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 1px 0 0;
|
||||
accent-color: #111111;
|
||||
}
|
||||
|
||||
.auth-consent p {
|
||||
margin: 8px 0 0 28px;
|
||||
color: #8d281b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.auth-dialog-backdrop {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 28px;
|
||||
background: rgba(17, 17, 17, 0.82);
|
||||
}
|
||||
|
||||
.auth-dialog {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
width: min(720px, 100%);
|
||||
max-height: min(760px, calc(100vh - 56px));
|
||||
border: 2px solid #111111;
|
||||
background: #f6f6f4;
|
||||
box-shadow: 12px 12px 0 #f2f500;
|
||||
}
|
||||
|
||||
.auth-dialog > header {
|
||||
border-bottom: 1px solid #111111;
|
||||
padding: 22px 24px 18px;
|
||||
}
|
||||
|
||||
.auth-dialog header p {
|
||||
margin: 0 0 6px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-dialog h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.auth-dialog-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 24px;
|
||||
overflow-y: auto;
|
||||
padding: 6px 24px 18px;
|
||||
}
|
||||
|
||||
.auth-dialog-content section {
|
||||
border-bottom: 1px solid #ccccb9;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.auth-dialog-content h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.auth-dialog-content p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.auth-dialog > footer {
|
||||
border-top: 1px solid #111111;
|
||||
padding: 0 24px 18px;
|
||||
background: #f6f6f4;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
border-left: 4px solid #c7432f;
|
||||
padding: 8px 10px;
|
||||
@@ -304,6 +475,27 @@ input {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.auth-registration-status {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.auth-verified-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-dialog-backdrop {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.auth-dialog {
|
||||
max-height: calc(100vh - 32px);
|
||||
box-shadow: 6px 6px 0 #f2f500;
|
||||
}
|
||||
|
||||
.auth-dialog-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.auth-code-row {
|
||||
grid-template-columns: minmax(0, 1fr) 126px;
|
||||
}
|
||||
|
||||
+266
-31
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user