feat: implement TASK-WP1-03 registration notice
This commit is contained in:
@@ -30,6 +30,10 @@ input {
|
|||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-page {
|
.auth-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -157,6 +161,10 @@ input {
|
|||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-registration-form {
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-form label {
|
.auth-form label {
|
||||||
margin: 0 0 7px;
|
margin: 0 0 7px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -231,6 +239,169 @@ input {
|
|||||||
color: #3c5b32;
|
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 {
|
.auth-error {
|
||||||
border-left: 4px solid #c7432f;
|
border-left: 4px solid #c7432f;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -304,6 +475,27 @@ input {
|
|||||||
margin-left: 0;
|
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 {
|
.auth-code-row {
|
||||||
grid-template-columns: minmax(0, 1fr) 126px;
|
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";
|
import "./user-auth.css";
|
||||||
|
|
||||||
@@ -20,6 +21,16 @@ const messageByKey: Record<string, string> = {
|
|||||||
"auth.challenge.too_many_attempts": "尝试次数过多,请稍后再试。",
|
"auth.challenge.too_many_attempts": "尝试次数过多,请稍后再试。",
|
||||||
"auth.login.admin_required": "此邮箱需从管理员登录入口进入。",
|
"auth.login.admin_required": "此邮箱需从管理员登录入口进入。",
|
||||||
"auth.login.registration_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": "邮件服务暂时不可用,请稍后重试。",
|
"auth.service.unavailable": "邮件服务暂时不可用,请稍后重试。",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,22 +45,47 @@ function maskedEmail(email: string) {
|
|||||||
return `${visible}${"*".repeat(Math.max(3, local.length - visible.length))}@${domain}`;
|
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() {
|
export function UserAuthPage() {
|
||||||
const emailId = useId();
|
const emailId = useId();
|
||||||
const codeId = useId();
|
const codeId = useId();
|
||||||
const inviteId = useId();
|
const inviteId = useId();
|
||||||
|
const creatorNameId = useId();
|
||||||
|
const socialId = useId();
|
||||||
const loginTab = useRef<HTMLButtonElement>(null);
|
const loginTab = useRef<HTMLButtonElement>(null);
|
||||||
const registerTab = 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 [mode, setMode] = useState<AuthMode>("login");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [inviteCode, setInviteCode] = 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 [registrationId, setRegistrationId] = useState<string>();
|
||||||
const [sendState, setSendState] = useState<SendState>("idle");
|
const [sendState, setSendState] = useState<SendState>("idle");
|
||||||
const [countdown, setCountdown] = useState(0);
|
const [countdown, setCountdown] = useState(0);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
|
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
|
||||||
|
const registrationReady = Boolean(
|
||||||
|
registrationId
|
||||||
|
&& /^[0-9]{6}$/.test(code)
|
||||||
|
&& creatorName.trim()
|
||||||
|
&& socialHandle.trim()
|
||||||
|
&& privacyConsentAccepted,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (countdown <= 0) return;
|
if (countdown <= 0) return;
|
||||||
@@ -57,10 +93,28 @@ export function UserAuthPage() {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [countdown]);
|
}, [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) {
|
function switchMode(nextMode: AuthMode) {
|
||||||
if (nextMode === mode) return;
|
if (nextMode === mode) return;
|
||||||
setMode(nextMode);
|
setMode(nextMode);
|
||||||
setCode("");
|
resetRegistrationDetails();
|
||||||
setInviteCode("");
|
setInviteCode("");
|
||||||
setRegistrationId(undefined);
|
setRegistrationId(undefined);
|
||||||
setSendState("idle");
|
setSendState("idle");
|
||||||
@@ -68,6 +122,43 @@ export function UserAuthPage() {
|
|||||||
setError(undefined);
|
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) {
|
function switchTab(nextMode: AuthMode) {
|
||||||
switchMode(nextMode);
|
switchMode(nextMode);
|
||||||
window.requestAnimationFrame(() => (nextMode === "login" ? loginTab : registerTab).current?.focus());
|
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 (
|
return (
|
||||||
<main className="auth-page">
|
<>
|
||||||
|
<main className="auth-page">
|
||||||
<section className="auth-art" aria-label="Dada 创作艺术带">
|
<section className="auth-art" aria-label="Dada 创作艺术带">
|
||||||
<div className="auth-wordmark">DADA</div>
|
<div className="auth-wordmark">DADA</div>
|
||||||
<div className="auth-art-copy">
|
<div className="auth-art-copy">
|
||||||
@@ -227,34 +354,109 @@ export function UserAuthPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<section className="auth-form" aria-labelledby="registration-title">
|
<form className="auth-form auth-registration-form" onSubmit={completeRegistration}>
|
||||||
<h1 id="registration-title">邀请码注册</h1>
|
<h1>{registrationId ? "完善注册资料" : "邀请码注册"}</h1>
|
||||||
<label htmlFor={inviteId}>邀请码</label>
|
{!registrationId ? (
|
||||||
<input
|
<>
|
||||||
autoComplete="off"
|
<label htmlFor={inviteId}>邀请码</label>
|
||||||
id={inviteId}
|
<input
|
||||||
onChange={(event) => setInviteCode(event.target.value)}
|
autoComplete="off"
|
||||||
value={inviteCode}
|
id={inviteId}
|
||||||
/>
|
onChange={(event) => setInviteCode(event.target.value)}
|
||||||
<label htmlFor={emailId}>邮箱</label>
|
value={inviteCode}
|
||||||
<input
|
/>
|
||||||
autoComplete="email"
|
<label htmlFor={emailId}>邮箱</label>
|
||||||
id={emailId}
|
<input
|
||||||
inputMode="email"
|
autoComplete="email"
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
id={emailId}
|
||||||
type="email"
|
inputMode="email"
|
||||||
value={email}
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
/>
|
placeholder="请输入邮箱"
|
||||||
<button
|
type="email"
|
||||||
className="auth-primary"
|
value={email}
|
||||||
disabled={!emailValid || inviteCode.trim().length < 8 || sendState === "sending" || countdown > 0}
|
/>
|
||||||
onClick={sendCode}
|
<button
|
||||||
type="button"
|
className="auth-primary"
|
||||||
>
|
disabled={!emailValid || inviteCode.trim().length < 8 || sendState === "sending" || countdown > 0}
|
||||||
{sendState === "sending" ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
|
onClick={sendCode}
|
||||||
</button>
|
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}
|
{error ? <p className="auth-error" role="alert">{error}</p> : null}
|
||||||
</section>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -262,6 +464,39 @@ export function UserAuthPage() {
|
|||||||
<footer className="auth-local-notice">
|
<footer className="auth-local-notice">
|
||||||
测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。
|
测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。
|
||||||
</footer>
|
</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}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -14,7 +14,7 @@
|
|||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
||||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts --config playwright.config.ts",
|
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts --config playwright.config.ts",
|
||||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||||
@@ -43,7 +43,9 @@
|
|||||||
"test:wp1-01": "node scripts/run-wp1-01-validation.mjs",
|
"test:wp1-01": "node scripts/run-wp1-01-validation.mjs",
|
||||||
"test:wp1-01:red": "node scripts/run-wp1-01-validation.mjs --phase red",
|
"test:wp1-01:red": "node scripts/run-wp1-01-validation.mjs --phase red",
|
||||||
"test:wp1-02": "node scripts/run-wp1-02-validation.mjs",
|
"test:wp1-02": "node scripts/run-wp1-02-validation.mjs",
|
||||||
"test:wp1-02:red": "node scripts/run-wp1-02-validation.mjs --phase red"
|
"test:wp1-02:red": "node scripts/run-wp1-02-validation.mjs --phase red",
|
||||||
|
"test:wp1-03": "node scripts/run-wp1-03-validation.mjs",
|
||||||
|
"test:wp1-03:red": "node scripts/run-wp1-03-validation.mjs --phase red"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export * from "./api.js";
|
|||||||
export * from "./auth.js";
|
export * from "./auth.js";
|
||||||
export * from "./bootstrap.js";
|
export * from "./bootstrap.js";
|
||||||
export * from "./events.js";
|
export * from "./events.js";
|
||||||
|
export * from "./registration-notice.js";
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export const registrationNoticeSections = [
|
||||||
|
{
|
||||||
|
title: "外部服务",
|
||||||
|
body: "注册验证码通过 Resend 发送;创作功能会按用户主动操作调用 AI 网关;使用 DYN004 自动定位时会调用高德处理地点文字和坐标。未使用相应功能时不会为该功能发起调用。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "本机存储与保护",
|
||||||
|
body: "P0-A 数据保存在当前 Windows 电脑的 LocalDataRoot,依赖当前 Windows 用户登录和文件系统权限保护。Dada 不提供应用层加密或云备份;机器损坏、系统重装或 LocalDataRoot 被删除后数据不可恢复,重要图片请主动下载。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "超级管理员访问",
|
||||||
|
body: "超级管理员可按产品权限查看运营信息,并可打开用户私有图片或完整提示词;每次打开都会记录访问审计。审计记录在规定保留期内不可人工修改或删除。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "DYN004 坐标",
|
||||||
|
body: "DYN004 可在用户主动使用时处理和保存原始定位坐标;坐标属于私有项目数据,账号注销时按规则删除。高德不可用或达到应用硬上限时,自动定位停止。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "账号注销与保留",
|
||||||
|
body: "账号注销后,账号资料、项目、图片、提示词、原始坐标、导出成品和未使用点数立即不可恢复。允许的匿名生成与点数事件及私有访问审计最多保留 180 天,且不得保留可回溯个人或单次作品的信息。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "无备份与迁移",
|
||||||
|
body: "测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。P0-A 不提供可编辑项目包或业务数据的导出、导入和迁移;用户下载的图片仅用于自行留存。",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const registrationNoticeContent = registrationNoticeSections
|
||||||
|
.map((section) => `${section.title}\n${section.body}`)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
export const registrationNotice = Object.freeze({
|
||||||
|
content: registrationNoticeContent,
|
||||||
|
contentSha256: "10dea4fb1b54d7208c017c126693d2061d8261bc5f8bc2b656309329ef22a791",
|
||||||
|
effectiveAt: "2026-07-28",
|
||||||
|
sections: registrationNoticeSections,
|
||||||
|
title: "内测使用与隐私告知",
|
||||||
|
version: "p0a-registration-notice-v1",
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const phaseIndex = process.argv.indexOf("--phase");
|
||||||
|
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
||||||
|
if (!['red', 'green'].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
||||||
|
const runId = process.env.DADA_TDD_RUN_ID ?? `wp1-03-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||||
|
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||||
|
const noticeCase = "TDD-WP1-NOTICE-001-registration-consent";
|
||||||
|
const slotCase = "TDD-WP1-SLOT-001-stage-limit";
|
||||||
|
const directories = {
|
||||||
|
[noticeCase]: resolve(runDirectory, "cases", noticeCase),
|
||||||
|
[slotCase]: resolve(runDirectory, "cases", slotCase),
|
||||||
|
};
|
||||||
|
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||||
|
for (const directory of Object.values(directories)) mkdirSync(directory, { recursive: true });
|
||||||
|
|
||||||
|
const commandsToRun = phase === "red"
|
||||||
|
? [
|
||||||
|
["notice-unit", ["exec", "vitest", "run", "tests/unit/wp1-03-notice.test.ts"]],
|
||||||
|
["slot-integration", ["exec", "vitest", "run", "tests/integration/wp1-03-slot-limit.test.ts"]],
|
||||||
|
["notice-e2e", ["exec", "playwright", "test", "tests/e2e/user-registration.spec.ts", "--config", "playwright.config.ts"]],
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
["unit", ["test:unit"]],
|
||||||
|
["api", ["test:api"]],
|
||||||
|
["e2e", ["test:e2e"]],
|
||||||
|
["integration", ["test:integration"]],
|
||||||
|
["tdd-trace", ["validate:tdd-trace"]],
|
||||||
|
];
|
||||||
|
const environment = {
|
||||||
|
...process.env,
|
||||||
|
DADA_EVIDENCE_DIR_NOTICE: directories[noticeCase],
|
||||||
|
DADA_EVIDENCE_DIR_SLOT: directories[slotCase],
|
||||||
|
};
|
||||||
|
const commandResults = [];
|
||||||
|
for (const [name, args] of commandsToRun) {
|
||||||
|
const command = `pnpm ${args.join(" ")}`;
|
||||||
|
const executable = process.env.ComSpec ?? "cmd.exe";
|
||||||
|
const started_at = new Date().toISOString();
|
||||||
|
const execution = spawnSync(executable, ["/d", "/s", "/c", command], { encoding: "utf8", env: environment });
|
||||||
|
if (execution.stdout) process.stdout.write(execution.stdout);
|
||||||
|
if (execution.stderr) process.stderr.write(execution.stderr);
|
||||||
|
commandResults.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
|
||||||
|
}
|
||||||
|
|
||||||
|
const byName = Object.fromEntries(commandResults.map((result) => [result.name, result]));
|
||||||
|
const statuses = phase === "red"
|
||||||
|
? {
|
||||||
|
[noticeCase]: byName["notice-unit"].exit_code !== 0 && byName["notice-e2e"].exit_code !== 0 ? "red_confirmed" : "failed",
|
||||||
|
[slotCase]: byName["slot-integration"].exit_code === 0 ? "preexisting_green" : "red_confirmed",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
[noticeCase]: commandResults.every((result) => result.exit_code === 0) ? "awaiting_manual_review" : "failed",
|
||||||
|
[slotCase]: commandResults.every((result) => result.exit_code === 0) ? "passed" : "failed",
|
||||||
|
};
|
||||||
|
for (const directory of Object.values(directories)) {
|
||||||
|
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
if (phase === "red") {
|
||||||
|
writeFileSync(resolve(directories[noticeCase], "red-observation.json"), `${JSON.stringify({
|
||||||
|
expected_failures: ["registration notice contract missing", "DVPM8 second stage missing"],
|
||||||
|
status: statuses[noticeCase],
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
writeFileSync(resolve(directories[slotCase], "red-observation.json"), `${JSON.stringify({
|
||||||
|
explanation: "TASK-WP1-01 already implemented the normative slot transaction; no failure was fabricated.",
|
||||||
|
status: statuses[slotCase],
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
const expected = phase === "red"
|
||||||
|
? { [noticeCase]: ["red-observation.json"], [slotCase]: ["red-observation.json"] }
|
||||||
|
: {
|
||||||
|
[noticeCase]: ["response.json", "db-diff.json", "screenshots/notice-expanded.png", "manual-review.json"],
|
||||||
|
[slotCase]: ["response.json", "db-diff.json", "concurrency-trace.json"],
|
||||||
|
};
|
||||||
|
const manifest = {
|
||||||
|
path: "tasks.manifest.json",
|
||||||
|
sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(),
|
||||||
|
};
|
||||||
|
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
||||||
|
const worktreeDirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
|
||||||
|
const results = Object.entries(statuses).map(([testId, status]) => {
|
||||||
|
const evidence_refs = expected[testId];
|
||||||
|
const missing_evidence = evidence_refs.filter((file) => !existsSync(resolve(directories[testId], file)));
|
||||||
|
const result = {
|
||||||
|
acceptance_criteria: ["AC-01", "AC-32", "AC-41", "AC-45", "AC-56"],
|
||||||
|
automation: ["automated", "manual_review"],
|
||||||
|
commit,
|
||||||
|
evidence_refs,
|
||||||
|
manifest,
|
||||||
|
missing_evidence,
|
||||||
|
phase,
|
||||||
|
requirements: ["AUTH-01", "AUTH-03", "NFR-04", "PRIV-04", "PRIV-06"],
|
||||||
|
run_id: runId,
|
||||||
|
status,
|
||||||
|
task_id: "TASK-WP1-03",
|
||||||
|
test_id: testId,
|
||||||
|
work_package: "WP-1",
|
||||||
|
worktree_under_test: worktreeDirty ? "uncommitted implementation" : "clean committed implementation",
|
||||||
|
};
|
||||||
|
writeFileSync(resolve(directories[testId], "result.json"), `${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const redAccepted = phase === "red" && statuses[noticeCase] === "red_confirmed" && ["preexisting_green", "red_confirmed"].includes(statuses[slotCase]);
|
||||||
|
const greenAccepted = phase === "green" && statuses[noticeCase] === "awaiting_manual_review" && statuses[slotCase] === "passed";
|
||||||
|
const summary = { cases: results.map(({ missing_evidence, status, test_id }) => ({ missing_evidence, status, test_id })), phase, run_id: runId, status: redAccepted ? "red_confirmed" : greenAccepted ? "awaiting_manual_review" : "failed" };
|
||||||
|
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`);
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
if (!redAccepted && !greenAccepted) process.exit(1);
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createApp } from "../../apps/api/src/app.js";
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
import { registrationNotice } from "../../packages/shared-contracts/src/registration-notice.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const services: RegistrationService[] = [];
|
||||||
|
const writeHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp1-03-notice-"));
|
||||||
|
roots.push(root);
|
||||||
|
const resend = new MockResendAdapter();
|
||||||
|
const registration = new RegistrationService({
|
||||||
|
challengePepper: Buffer.alloc(32, 0x74),
|
||||||
|
clock: () => Date.parse("2026-07-28T09:30:00.000Z"),
|
||||||
|
codeGenerator: () => "418205",
|
||||||
|
currentPrivacyNoticeVersion: registrationNotice.version,
|
||||||
|
databasePath: join(root, "dada.sqlite3"),
|
||||||
|
inviteCodeGenerator: () => "DADA-WP1-03-NOTICE",
|
||||||
|
invitePepper: Buffer.alloc(32, 0x75),
|
||||||
|
resend,
|
||||||
|
sessionPepper: Buffer.alloc(32, 0x76),
|
||||||
|
});
|
||||||
|
services.push(registration);
|
||||||
|
return { registration, resend };
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(registration: RegistrationService) {
|
||||||
|
return {
|
||||||
|
consents: registration.database.prepare("SELECT COUNT(*) AS count FROM privacy_consents").get().count,
|
||||||
|
credits: registration.database.prepare("SELECT COUNT(*) AS count FROM credit_accounts").get().count,
|
||||||
|
invite_used: registration.database.prepare("SELECT COALESCE(SUM(used_count), 0) AS count FROM invite_codes").get().count,
|
||||||
|
sessions: registration.database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count,
|
||||||
|
users: registration.database.prepare("SELECT COUNT(*) AS count FROM users").get().count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEvidence(file: string, value: unknown) {
|
||||||
|
const directory = process.env.DADA_EVIDENCE_DIR_NOTICE;
|
||||||
|
if (!directory) return;
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const service of services.splice(0)) service.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TDD-WP1-NOTICE-001-registration-consent", () => {
|
||||||
|
it("rejects missing/stale consent without side effects and records current consent atomically", async () => {
|
||||||
|
const { registration, resend } = createHarness();
|
||||||
|
const invite = registration.createInvite({ expiresAt: Date.parse("2026-07-29T09:30:00.000Z"), maxUses: 1 });
|
||||||
|
const sent = await registration.sendRegistrationCode({ email: "notice-api@example.invalid", inviteCode: invite.code });
|
||||||
|
const code = resend.readLatestCode("notice-api@example.invalid");
|
||||||
|
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
||||||
|
const before = snapshot(registration);
|
||||||
|
const basePayload = {
|
||||||
|
creator_name: "Notice User",
|
||||||
|
privacy_notice_version: registrationNotice.version,
|
||||||
|
registration_id: sent.registrationId,
|
||||||
|
social_id: "@notice_user",
|
||||||
|
verification_code: code,
|
||||||
|
};
|
||||||
|
|
||||||
|
const missingConsent = await app.inject({
|
||||||
|
headers: { ...writeHeaders, "idempotency-key": "wp1-03-consent-missing-0000000000001" },
|
||||||
|
method: "POST",
|
||||||
|
payload: { ...basePayload, privacy_consent_accepted: false },
|
||||||
|
url: "/api/v1/auth/register/complete",
|
||||||
|
});
|
||||||
|
expect(missingConsent.statusCode).toBe(400);
|
||||||
|
expect(missingConsent.json()).toMatchObject({
|
||||||
|
error: { details: { field_errors: [{ message_key: "auth.privacy.consent_required" }] } },
|
||||||
|
});
|
||||||
|
expect(snapshot(registration)).toEqual(before);
|
||||||
|
|
||||||
|
const staleVersion = await app.inject({
|
||||||
|
headers: { ...writeHeaders, "idempotency-key": "wp1-03-consent-stale-00000000000001" },
|
||||||
|
method: "POST",
|
||||||
|
payload: { ...basePayload, privacy_consent_accepted: true, privacy_notice_version: "stale-notice" },
|
||||||
|
url: "/api/v1/auth/register/complete",
|
||||||
|
});
|
||||||
|
expect(staleVersion.statusCode).toBe(400);
|
||||||
|
expect(staleVersion.json()).toMatchObject({
|
||||||
|
error: { details: { field_errors: [{ message_key: "auth.privacy.notice_version_invalid" }] } },
|
||||||
|
});
|
||||||
|
expect(snapshot(registration)).toEqual(before);
|
||||||
|
|
||||||
|
const completed = await app.inject({
|
||||||
|
headers: { ...writeHeaders, "idempotency-key": "wp1-03-consent-success-0000000000001" },
|
||||||
|
method: "POST",
|
||||||
|
payload: { ...basePayload, privacy_consent_accepted: true },
|
||||||
|
url: "/api/v1/auth/register/complete",
|
||||||
|
});
|
||||||
|
expect(completed.statusCode).toBe(200);
|
||||||
|
const after = snapshot(registration);
|
||||||
|
expect(after).toEqual({ consents: 1, credits: 1, invite_used: 1, sessions: 1, users: 1 });
|
||||||
|
const consent = registration.database.prepare("SELECT notice_version, consented_at FROM privacy_consents").get();
|
||||||
|
expect(consent).toEqual({
|
||||||
|
consented_at: Date.parse("2026-07-28T09:30:00.000Z"),
|
||||||
|
notice_version: registrationNotice.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
writeEvidence("response.json", {
|
||||||
|
accepted: completed.json().status,
|
||||||
|
missing_consent: missingConsent.json().error.details.field_errors[0].message_key,
|
||||||
|
stale_version: staleVersion.json().error.details.field_errors[0].message_key,
|
||||||
|
});
|
||||||
|
writeEvidence("db-diff.json", {
|
||||||
|
after,
|
||||||
|
before,
|
||||||
|
consent_recorded_at: "2026-07-28T09:30:00.000Z",
|
||||||
|
notice_content_sha256: registrationNotice.contentSha256,
|
||||||
|
notice_effective_at: registrationNotice.effectiveAt,
|
||||||
|
notice_version: registrationNotice.version,
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { createServer, type ViteDevServer } from "vite";
|
||||||
|
|
||||||
|
let vite: ViteDevServer;
|
||||||
|
let webUrl: string;
|
||||||
|
|
||||||
|
test.use({ trace: "off" });
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
vite = await createServer({
|
||||||
|
configFile: resolve("apps/web/vite.config.ts"),
|
||||||
|
root: resolve("apps/web"),
|
||||||
|
server: { host: "127.0.0.1", port: 0 },
|
||||||
|
});
|
||||||
|
await vite.listen();
|
||||||
|
const address = vite.httpServer?.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||||
|
webUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => vite.close());
|
||||||
|
|
||||||
|
test("TDD-WP1-NOTICE-001 expands DVPM8 only after successful code delivery", async ({ page }) => {
|
||||||
|
await page.route("**/api/v1/auth/register/send", (route) => route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
status: 200,
|
||||||
|
body: JSON.stringify({
|
||||||
|
challenge_expires_at: "2026-07-28T09:10:00.000Z",
|
||||||
|
registration_id: "00000000-0000-4000-8000-000000000003",
|
||||||
|
resend_available_at: "2026-07-28T09:01:00.000Z",
|
||||||
|
status: "verification_sent",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
await page.goto(webUrl);
|
||||||
|
await page.getByRole("tab", { name: "注册" }).click();
|
||||||
|
await page.getByRole("textbox", { name: "邀请码" }).fill("DADA-P0A-TEST-7K2");
|
||||||
|
await page.getByRole("textbox", { name: "邮箱" }).fill("registration@example.invalid");
|
||||||
|
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "完善注册资料" })).toBeVisible();
|
||||||
|
await expect(page.getByText("邀请码 · 已验证")).toBeVisible();
|
||||||
|
await expect(page.getByText("邮箱 · 已验证")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "修改邀请码和邮箱" })).toBeVisible();
|
||||||
|
await expect(page.getByLabel("验证码")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("创作署名")).toBeVisible();
|
||||||
|
await expect(page.getByLabel("社交 ID")).toBeVisible();
|
||||||
|
await expect(page.getByRole("checkbox", { name: /同意/ })).not.toBeChecked();
|
||||||
|
await expect(page.getByRole("button", { name: "注册并进入 Dada" })).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "查看全文" }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "内测使用与隐私告知" });
|
||||||
|
const noticeClose = page.getByRole("button", { name: "我已阅读" });
|
||||||
|
await expect(noticeClose).toBeFocused();
|
||||||
|
await expect(dialog).toContainText("AI 网关");
|
||||||
|
await expect(dialog).toContainText("Resend");
|
||||||
|
await expect(dialog).toContainText("高德");
|
||||||
|
await expect(dialog).toContainText("DYN004");
|
||||||
|
await expect(dialog).toContainText("180 天");
|
||||||
|
await expect(dialog).toContainText("不会迁移到正式系统");
|
||||||
|
|
||||||
|
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_NOTICE;
|
||||||
|
if (evidenceDirectory) {
|
||||||
|
mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true });
|
||||||
|
await page.screenshot({ path: resolve(evidenceDirectory, "screenshots", "notice-expanded.png") });
|
||||||
|
}
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await expect(noticeClose).toBeFocused();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "查看全文" })).toBeFocused();
|
||||||
|
await expect(page.getByRole("checkbox", { name: /同意/ })).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TDD-WP1-SLOT-001 preserves profile fields when final capacity recheck fails", async ({ page }) => {
|
||||||
|
await page.route("**/api/v1/auth/register/send", (route) => route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
status: 200,
|
||||||
|
body: JSON.stringify({
|
||||||
|
challenge_expires_at: "2026-07-28T09:10:00.000Z",
|
||||||
|
registration_id: "00000000-0000-4000-8000-000000000004",
|
||||||
|
resend_available_at: "2026-07-28T09:01:00.000Z",
|
||||||
|
status: "verification_sent",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
await page.route("**/api/v1/auth/register/complete", async (route) => {
|
||||||
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 150));
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
status: 409,
|
||||||
|
body: JSON.stringify({
|
||||||
|
error: {
|
||||||
|
code: "REGISTRATION_REJECTED",
|
||||||
|
correlation_id: "00000000-0000-4000-8000-000000000005",
|
||||||
|
details: { field_errors: [{ field: "invite_code", message_key: "auth.registration.stage_limit_reached" }] },
|
||||||
|
message_key: "registration.rejected",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(webUrl);
|
||||||
|
await page.getByRole("tab", { name: "注册" }).click();
|
||||||
|
await page.getByRole("textbox", { name: "邀请码" }).fill("DADA-P0A-TEST-9M4");
|
||||||
|
await page.getByRole("textbox", { name: "邮箱" }).fill("capacity@example.invalid");
|
||||||
|
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||||
|
await page.getByLabel("验证码").fill("418205");
|
||||||
|
await page.getByLabel("创作署名").fill("Capacity User");
|
||||||
|
await page.getByLabel("社交 ID").fill("@capacity_user");
|
||||||
|
await page.getByRole("checkbox", { name: /同意/ }).check();
|
||||||
|
await page.getByRole("button", { name: "注册并进入 Dada" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "注册中" })).toBeDisabled();
|
||||||
|
await expect(page.getByRole("alert")).toContainText("本轮内测名额已满");
|
||||||
|
await expect(page.getByLabel("创作署名")).toHaveValue("Capacity User");
|
||||||
|
await expect(page.getByLabel("社交 ID")).toHaveValue("@capacity_user");
|
||||||
|
await expect(page.getByRole("checkbox", { name: /同意/ })).toBeChecked();
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
|
||||||
|
const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url));
|
||||||
|
const Database = requireFromApi("better-sqlite3");
|
||||||
|
const fixedNow = Date.parse("2026-07-28T09:00:00.000Z");
|
||||||
|
const roots: string[] = [];
|
||||||
|
const services: RegistrationService[] = [];
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp1-03-slot-"));
|
||||||
|
roots.push(root);
|
||||||
|
const databasePath = join(root, "dada.sqlite3");
|
||||||
|
const resend = new MockResendAdapter();
|
||||||
|
const service = new RegistrationService({
|
||||||
|
challengePepper: Buffer.alloc(32, 0x71),
|
||||||
|
clock: () => fixedNow,
|
||||||
|
codeGenerator: () => "731905",
|
||||||
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
||||||
|
databasePath,
|
||||||
|
inviteCodeGenerator: () => `DADA-SLOT-${randomUUID()}`,
|
||||||
|
invitePepper: Buffer.alloc(32, 0x72),
|
||||||
|
resend,
|
||||||
|
sessionPepper: Buffer.alloc(32, 0x73),
|
||||||
|
});
|
||||||
|
services.push(service);
|
||||||
|
return { databasePath, resend, service };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDatabase<T>(databasePath: string, operation: (database: any) => T): T {
|
||||||
|
const database = new Database(databasePath);
|
||||||
|
try { return operation(database); } finally { database.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSubject(
|
||||||
|
databasePath: string,
|
||||||
|
index: number,
|
||||||
|
role: "user" | "super_admin",
|
||||||
|
status: "active" | "suspended" | "deleted",
|
||||||
|
) {
|
||||||
|
withDatabase(databasePath, (database) => {
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO users (
|
||||||
|
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||||
|
registration_id, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
randomUUID(),
|
||||||
|
`slot-fixture-${role}-${status}-${index}@example.invalid`,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
role === "user" ? 1 : 0,
|
||||||
|
randomUUID(),
|
||||||
|
fixedNow - 1_000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEvidence(file: string, value: unknown) {
|
||||||
|
const directory = process.env.DADA_EVIDENCE_DIR_SLOT;
|
||||||
|
if (!directory) return;
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const service of services.splice(0)) service.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TDD-WP1-SLOT-001-stage-limit", () => {
|
||||||
|
it("counts active and suspended users while excluding deleted users and multiple super_admins", async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
for (let index = 0; index < 8; index += 1) seedSubject(harness.databasePath, index, "user", "active");
|
||||||
|
seedSubject(harness.databasePath, 8, "user", "suspended");
|
||||||
|
seedSubject(harness.databasePath, 9, "user", "deleted");
|
||||||
|
seedSubject(harness.databasePath, 10, "super_admin", "active");
|
||||||
|
seedSubject(harness.databasePath, 11, "super_admin", "active");
|
||||||
|
|
||||||
|
const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 3 });
|
||||||
|
const tenth = await harness.service.sendRegistrationCode({
|
||||||
|
email: "stage-tenth@example.invalid",
|
||||||
|
inviteCode: invite.code,
|
||||||
|
});
|
||||||
|
const completed = harness.service.completeRegistration({
|
||||||
|
code: harness.resend.readLatestCode("stage-tenth@example.invalid"),
|
||||||
|
creatorName: "Tenth User",
|
||||||
|
idempotencyKey: "wp1-03-stage-tenth-complete-00000001",
|
||||||
|
privacyConsentAccepted: true,
|
||||||
|
privacyNoticeVersion: "p0a-registration-notice-v1",
|
||||||
|
registrationId: tenth.registrationId,
|
||||||
|
socialId: "@stage_tenth",
|
||||||
|
});
|
||||||
|
expect(completed.status).toBe("registered");
|
||||||
|
|
||||||
|
await expect(harness.service.sendRegistrationCode({
|
||||||
|
email: "stage-eleventh@example.invalid",
|
||||||
|
inviteCode: invite.code,
|
||||||
|
})).rejects.toMatchObject({ reason: "stage_limit_reached" });
|
||||||
|
|
||||||
|
const counts = withDatabase(harness.databasePath, (database) => ({
|
||||||
|
active_and_suspended_users: database.prepare(`
|
||||||
|
SELECT COUNT(*) AS count FROM users
|
||||||
|
WHERE role = 'user' AND status IN ('active', 'suspended')
|
||||||
|
`).get().count,
|
||||||
|
deleted_users: database.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'user' AND status = 'deleted'").get().count,
|
||||||
|
super_admins: database.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'super_admin'").get().count,
|
||||||
|
}));
|
||||||
|
expect(counts).toEqual({ active_and_suspended_users: 10, deleted_users: 1, super_admins: 2 });
|
||||||
|
writeEvidence("response.json", { eleventh: "stage_limit_reached", tenth: completed.status });
|
||||||
|
writeEvidence("db-diff.json", { ...counts, failed_invite_use_delta: 0, failed_user_delta: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rechecks capacity after challenge issuance under BEGIN IMMEDIATE", async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
for (let index = 0; index < 9; index += 1) seedSubject(harness.databasePath, index, "user", "active");
|
||||||
|
const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 2 });
|
||||||
|
const sent = await harness.service.sendRegistrationCode({
|
||||||
|
email: "stage-race@example.invalid",
|
||||||
|
inviteCode: invite.code,
|
||||||
|
});
|
||||||
|
seedSubject(harness.databasePath, 9, "user", "suspended");
|
||||||
|
|
||||||
|
expect(() => harness.service.completeRegistration({
|
||||||
|
code: harness.resend.readLatestCode("stage-race@example.invalid"),
|
||||||
|
creatorName: "Race User",
|
||||||
|
idempotencyKey: "wp1-03-stage-race-complete-000000001",
|
||||||
|
privacyConsentAccepted: true,
|
||||||
|
privacyNoticeVersion: "p0a-registration-notice-v1",
|
||||||
|
registrationId: sent.registrationId,
|
||||||
|
socialId: "@stage_race",
|
||||||
|
})).toThrowError(expect.objectContaining({ reason: "stage_limit_reached" }));
|
||||||
|
|
||||||
|
const state = withDatabase(harness.databasePath, (database) => ({
|
||||||
|
consents: database.prepare("SELECT COUNT(*) AS count FROM privacy_consents").get().count,
|
||||||
|
invite_used: database.prepare("SELECT used_count AS count FROM invite_codes WHERE invite_id = ?").get(invite.inviteId).count,
|
||||||
|
sessions: database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count,
|
||||||
|
users: database.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'user'").get().count,
|
||||||
|
}));
|
||||||
|
expect(state).toEqual({ consents: 0, invite_used: 0, sessions: 0, users: 10 });
|
||||||
|
writeEvidence("concurrency-trace.json", {
|
||||||
|
final_recheck: "stage_limit_reached",
|
||||||
|
mode: "BEGIN IMMEDIATE",
|
||||||
|
side_effects: state,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { registrationNotice } from "../../packages/shared-contracts/src/index.js";
|
||||||
|
|
||||||
|
describe("TDD-WP1-NOTICE-001-registration-consent", () => {
|
||||||
|
it("freezes a versioned and hash-verifiable registration notice", () => {
|
||||||
|
expect(registrationNotice.version).toMatch(/^p0a-registration-notice-v[1-9][0-9]*$/);
|
||||||
|
expect(registrationNotice.effectiveAt).toMatch(/^20[0-9]{2}-[0-9]{2}-[0-9]{2}$/);
|
||||||
|
expect(registrationNotice.sections.length).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(
|
||||||
|
createHash("sha256").update(registrationNotice.content, "utf8").digest("hex"),
|
||||||
|
).toBe(registrationNotice.contentSha256);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"AI 网关",
|
||||||
|
"Resend",
|
||||||
|
"高德",
|
||||||
|
"DYN004",
|
||||||
|
"Windows",
|
||||||
|
"文件系统权限",
|
||||||
|
"应用层加密",
|
||||||
|
"云备份",
|
||||||
|
"LocalDataRoot",
|
||||||
|
"超级管理员",
|
||||||
|
"账号注销",
|
||||||
|
"180 天",
|
||||||
|
"不自动备份",
|
||||||
|
"不会迁移到正式系统",
|
||||||
|
])("contains the frozen topic %s", (topic) => {
|
||||||
|
expect(registrationNotice.content).toContain(topic);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user