547 lines
21 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|