feat: implement TASK-WP1-02 login sessions
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
import type { RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, RegistrationSendResponse, RegistrationSendRequest } from "./types.gen.js";
|
||||
import type { LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, LogoutResponse, RegistrationSendResponse, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js";
|
||||
|
||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||
|
||||
@@ -45,6 +45,15 @@ export async function checkBrowserSupport(body: {
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function completeLogin(body: LoginCompleteRequest, options: ClientOptions = {}): Promise<LoginCompleteResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/login/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<LoginCompleteResponse>;
|
||||
}
|
||||
|
||||
export async function completeRegistration(body: RegistrationCompleteRequest, options: ClientOptions = {}): Promise<RegistrationCompleteResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
@@ -104,6 +113,22 @@ export async function getUserSession(options: ClientOptions = {}): Promise<UserS
|
||||
return response.json() as Promise<UserSessionResponse>;
|
||||
}
|
||||
|
||||
export async function logoutUser(options: ClientOptions = {}): Promise<LogoutResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/logout`, { method: "POST", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<LogoutResponse>;
|
||||
}
|
||||
|
||||
export async function sendLoginCode(body: LoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/login/send`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<RegistrationSendResponse>;
|
||||
}
|
||||
|
||||
export async function sendRegistrationCode(body: RegistrationSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
@@ -78,7 +78,7 @@ export type ErrorDetails = {
|
||||
|
||||
export type ErrorEnvelope = {
|
||||
"error": {
|
||||
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE";
|
||||
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE" | "AUTH_ENTRY_REJECTED" | "AUTH_RATE_LIMITED" | "AUTH_CSRF_INVALID";
|
||||
"correlation_id": string;
|
||||
"details": {
|
||||
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||
@@ -102,6 +102,32 @@ export type ErrorEnvelope = {
|
||||
|
||||
export type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable";
|
||||
|
||||
export type LoginCompleteRequest = {
|
||||
"registration_id": string;
|
||||
"verification_code": string;
|
||||
};
|
||||
|
||||
export type LoginCompleteResponse = {
|
||||
"audience": "user";
|
||||
"credits": CreditSummary;
|
||||
"session_expires_at": string;
|
||||
"status": "authenticated";
|
||||
"user": AuthenticatedUser;
|
||||
};
|
||||
|
||||
export type LoginSendRequest = {
|
||||
"email": string;
|
||||
};
|
||||
|
||||
export type LogoutHeaders = {
|
||||
"idempotency-key": string;
|
||||
"x-csrf-token": string;
|
||||
};
|
||||
|
||||
export type LogoutResponse = {
|
||||
"status": "logged_out";
|
||||
};
|
||||
|
||||
export type ModelConfigSseEvent = {
|
||||
"config_set_version": number;
|
||||
"entity_ref": string;
|
||||
@@ -170,7 +196,7 @@ export type SseEvent = {
|
||||
"runtime_availability_version": number;
|
||||
};
|
||||
|
||||
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE";
|
||||
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE" | "AUTH_ENTRY_REJECTED" | "AUTH_RATE_LIMITED" | "AUTH_CSRF_INVALID";
|
||||
|
||||
export type StateSseEvent = {
|
||||
"entity_ref": string;
|
||||
|
||||
+16
-6
@@ -1,8 +1,8 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { ToolchainProbe } from "./toolchain-probe.js";
|
||||
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
||||
import { UserAuthPage } from "./user-auth.js";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
@@ -13,8 +13,18 @@ if (!root) {
|
||||
// Cache failure leaves public assets network-backed and must not create alternate persistence.
|
||||
void registerPublicAssetServiceWorker().catch(() => undefined);
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ToolchainProbe />
|
||||
</StrictMode>,
|
||||
);
|
||||
const appRoot = createRoot(root);
|
||||
let authRevision = 0;
|
||||
|
||||
function renderAuthenticationEntry() {
|
||||
authRevision += 1;
|
||||
appRoot.render(
|
||||
<StrictMode>
|
||||
<UserAuthPage key={authRevision} />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
// Any authenticated surface can dispatch this after a revoked/invalid session response.
|
||||
window.addEventListener("dada:session-invalid", renderAuthenticationEntry);
|
||||
renderAuthenticationEntry();
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
:root {
|
||||
color: #111111;
|
||||
background: #f6f6f4;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
font-synthesis: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
background: #f6f6f4;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
input {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 230px minmax(520px, 1fr) 48px;
|
||||
}
|
||||
|
||||
.auth-art {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 30%) 1fr;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid #c8c8c3;
|
||||
background: #eeeee9;
|
||||
}
|
||||
|
||||
.auth-wordmark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 48px;
|
||||
color: #111111;
|
||||
background: #f2f500;
|
||||
font-family: Arial Black, "Segoe UI", sans-serif;
|
||||
font-size: 96px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.auth-art-copy {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 54px;
|
||||
padding: 26px 54px 20px;
|
||||
}
|
||||
|
||||
.auth-art-copy span {
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-art-copy strong {
|
||||
max-width: 520px;
|
||||
font-size: 27px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.auth-art-block {
|
||||
position: absolute;
|
||||
top: 90px;
|
||||
right: 16%;
|
||||
width: 31%;
|
||||
height: 92px;
|
||||
background: #b9bab4;
|
||||
}
|
||||
|
||||
.auth-art-line {
|
||||
position: absolute;
|
||||
right: 4%;
|
||||
bottom: 38px;
|
||||
width: 48%;
|
||||
height: 48px;
|
||||
border-right: 1px solid #111111;
|
||||
border-bottom: 1px solid #111111;
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
position: relative;
|
||||
width: min(1180px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 32px 28px 64px;
|
||||
}
|
||||
|
||||
.auth-admin-link {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
right: 30px;
|
||||
color: #333333;
|
||||
font-size: 13px;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
width: min(480px, 100%);
|
||||
margin-left: 84px;
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
border: 1px solid #8a8a86;
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
border: 0;
|
||||
color: #222222;
|
||||
background: #f6f6f4;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-tab + .auth-tab {
|
||||
border-left: 1px solid #8a8a86;
|
||||
}
|
||||
|
||||
.auth-tab[aria-selected="true"] {
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 350px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.auth-form h1 {
|
||||
margin: 0 0 20px;
|
||||
font-size: 27px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.auth-form label {
|
||||
margin: 0 0 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-form input {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #777773;
|
||||
border-radius: 0;
|
||||
padding: 0 13px;
|
||||
color: #111111;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.auth-form input:focus {
|
||||
border-color: #111111;
|
||||
outline: 2px solid #f2f500;
|
||||
}
|
||||
|
||||
.auth-code-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 142px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.auth-code-row input {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.auth-secondary,
|
||||
.auth-primary {
|
||||
height: 46px;
|
||||
border: 1px solid #777773;
|
||||
border-radius: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-secondary {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.auth-primary {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
color: #111111;
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.auth-secondary:not(:disabled),
|
||||
.auth-primary:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-secondary:disabled,
|
||||
.auth-primary:disabled {
|
||||
color: #777773;
|
||||
background: #deded9;
|
||||
}
|
||||
|
||||
.auth-status,
|
||||
.auth-error {
|
||||
min-height: 20px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
color: #3c5b32;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
border-left: 4px solid #c7432f;
|
||||
padding: 8px 10px;
|
||||
color: #8d281b;
|
||||
background: #fff0ed;
|
||||
}
|
||||
|
||||
.auth-local-notice {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
padding: 0 18px;
|
||||
color: #f2f500;
|
||||
background: #111111;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.auth-page {
|
||||
grid-template-rows: 150px minmax(560px, 1fr) auto;
|
||||
}
|
||||
|
||||
.auth-art {
|
||||
grid-template-columns: 42% 58%;
|
||||
}
|
||||
|
||||
.auth-wordmark {
|
||||
padding: 0 18px;
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.auth-art-copy {
|
||||
gap: 30px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.auth-art-copy span {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.auth-art-copy strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.auth-art-block {
|
||||
top: 54px;
|
||||
right: 8%;
|
||||
width: 33%;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.auth-art-line {
|
||||
right: 2%;
|
||||
bottom: 20px;
|
||||
width: 44%;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
padding: 56px 20px 42px;
|
||||
}
|
||||
|
||||
.auth-admin-link {
|
||||
top: 22px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.auth-code-row {
|
||||
grid-template-columns: minmax(0, 1fr) 126px;
|
||||
}
|
||||
|
||||
.auth-local-notice {
|
||||
min-height: 56px;
|
||||
padding-block: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.auth-code-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-secondary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useId, useRef, useState, type FormEvent } 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.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}`;
|
||||
}
|
||||
|
||||
export function UserAuthPage() {
|
||||
const emailId = useId();
|
||||
const codeId = useId();
|
||||
const inviteId = useId();
|
||||
const loginTab = useRef<HTMLButtonElement>(null);
|
||||
const registerTab = useRef<HTMLButtonElement>(null);
|
||||
const [mode, setMode] = useState<AuthMode>("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
function switchMode(nextMode: AuthMode) {
|
||||
if (nextMode === mode) return;
|
||||
setMode(nextMode);
|
||||
setCode("");
|
||||
setInviteCode("");
|
||||
setRegistrationId(undefined);
|
||||
setSendState("idle");
|
||||
setCountdown(0);
|
||||
setError(undefined);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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">管理员登录</a>
|
||||
<div className="auth-panel">
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
{error ? <p className="auth-error" role="alert">{error}</p> : null}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="auth-local-notice">
|
||||
测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user