feat: implement TASK-WP1-04 admin security
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
:root {
|
||||
color: #121212;
|
||||
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-auth-page {
|
||||
min-height: 100vh;
|
||||
background: #ffffff;
|
||||
display: grid;
|
||||
grid-template-rows: 8px 72px 1fr;
|
||||
}
|
||||
|
||||
.admin-auth-accent {
|
||||
background: #eaff00;
|
||||
}
|
||||
|
||||
.admin-auth-header {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #dedede;
|
||||
display: flex;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
.admin-auth-wordmark {
|
||||
color: #111111;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-auth-panel {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
margin: 48px 20px 96px;
|
||||
width: min(440px, calc(100vw - 40px));
|
||||
}
|
||||
|
||||
.admin-auth-kicker {
|
||||
color: #606060;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.admin-auth-panel h1 {
|
||||
font-size: 26px;
|
||||
line-height: 1.35;
|
||||
margin: 0 0 36px;
|
||||
}
|
||||
|
||||
.admin-auth-panel label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.admin-auth-panel input {
|
||||
border: 1px solid #b9b9b9;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
height: 48px;
|
||||
min-width: 0;
|
||||
padding: 0 13px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-auth-panel input:focus {
|
||||
border-color: #111111;
|
||||
box-shadow: 0 0 0 2px #eaff00;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-auth-send-row {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) 124px;
|
||||
}
|
||||
|
||||
.admin-auth-panel button {
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.admin-auth-panel button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.admin-auth-send {
|
||||
background: #ffffff;
|
||||
border: 1px solid #111111;
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
.admin-auth-code-field {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.admin-auth-error {
|
||||
border-left: 3px solid #c93333;
|
||||
color: #8d1717;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
margin: 20px 0 0;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.admin-auth-submit {
|
||||
background: #151515;
|
||||
border: 1px solid #151515;
|
||||
color: #ffffff;
|
||||
margin-top: 28px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-auth-return {
|
||||
color: #363636;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
margin-top: 24px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.admin-auth-header {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.admin-auth-panel {
|
||||
align-self: start;
|
||||
margin-top: 72px;
|
||||
}
|
||||
|
||||
.admin-auth-send-row {
|
||||
grid-template-columns: minmax(0, 1fr) 112px;
|
||||
}
|
||||
|
||||
.admin-auth-send {
|
||||
width: 112px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useId, useState, type FormEvent } from "react";
|
||||
|
||||
import "./admin-auth.css";
|
||||
|
||||
interface ErrorEnvelopeBody {
|
||||
error?: {
|
||||
details?: { field_errors?: Array<{ message_key?: string }> };
|
||||
};
|
||||
}
|
||||
|
||||
function adminErrorMessage(body: ErrorEnvelopeBody) {
|
||||
const key = body.error?.details?.field_errors?.[0]?.message_key;
|
||||
if (key === "auth.challenge.invalid") return "验证码不正确,请检查后重试。";
|
||||
if (key === "auth.challenge.expired") return "验证码已过期,请重新获取。";
|
||||
if (key === "auth.challenge.resend_too_soon") return "请等待倒计时结束后重新获取验证码。";
|
||||
if (key === "auth.account.suspended" || key === "admin.auth.not_allowed") return "无法使用管理员入口,请联系部署维护人员。";
|
||||
return "管理员登录暂时无法完成,请稍后重试。";
|
||||
}
|
||||
|
||||
export function AdminAuthPage() {
|
||||
const emailId = useId();
|
||||
const codeId = useId();
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [registrationId, setRegistrationId] = useState<string>();
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
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]);
|
||||
|
||||
async function sendCode() {
|
||||
if (!emailValid || sending || countdown > 0) return;
|
||||
setSending(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin-auth/login/send", {
|
||||
body: JSON.stringify({ email }),
|
||||
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) {
|
||||
setError(adminErrorMessage(body));
|
||||
return;
|
||||
}
|
||||
setRegistrationId(body.registration_id);
|
||||
setCountdown(60);
|
||||
window.requestAnimationFrame(() => document.getElementById(codeId)?.focus());
|
||||
} catch {
|
||||
setError("管理员登录暂时无法完成,请稍后重试。");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function completeLogin(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin-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(adminErrorMessage(body));
|
||||
return;
|
||||
}
|
||||
window.location.assign("/admin");
|
||||
} catch {
|
||||
setError("管理员登录暂时无法完成,请稍后重试。");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="admin-auth-page">
|
||||
<div className="admin-auth-accent" aria-hidden="true" />
|
||||
<header className="admin-auth-header">
|
||||
<a className="admin-auth-wordmark" href="/" aria-label="Dada 普通用户登录">DADA</a>
|
||||
</header>
|
||||
<section className="admin-auth-panel">
|
||||
<p className="admin-auth-kicker">ADMIN</p>
|
||||
<h1 id="admin-auth-heading">管理员邮箱验证码登录</h1>
|
||||
<form onSubmit={completeLogin} noValidate>
|
||||
<label htmlFor={emailId}>管理员邮箱</label>
|
||||
<div className="admin-auth-send-row">
|
||||
<input
|
||||
id={emailId}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value);
|
||||
setRegistrationId(undefined);
|
||||
setCode("");
|
||||
setCountdown(0);
|
||||
setError(undefined);
|
||||
}}
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
<button
|
||||
aria-label="获取验证码"
|
||||
className="admin-auth-send"
|
||||
disabled={!emailValid || sending || countdown > 0}
|
||||
onClick={sendCode}
|
||||
type="button"
|
||||
>
|
||||
{sending ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
|
||||
</button>
|
||||
</div>
|
||||
{registrationId ? (
|
||||
<div className="admin-auth-code-field">
|
||||
<label htmlFor={codeId}>验证码</label>
|
||||
<input
|
||||
id={codeId}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
onChange={(event) => setCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
value={code}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <p className="admin-auth-error" role="alert">{error}</p> : null}
|
||||
<button className="admin-auth-submit" disabled={!registrationId || code.length !== 6 || submitting} type="submit">
|
||||
{submitting ? "登录中" : "登录后台"}
|
||||
</button>
|
||||
</form>
|
||||
<a className="admin-auth-return" href="/">返回普通用户登录</a>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
import type { LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, LogoutResponse, RegistrationSendResponse, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js";
|
||||
import type { AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AdminSessionResponse, UserSessionResponse, LogoutResponse, RegistrationSendResponse, AdminLoginSendRequest, 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 completeAdminLogin(body: AdminLoginCompleteRequest, options: ClientOptions = {}): Promise<AdminLoginCompleteResponse> {
|
||||
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/admin-auth/login/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AdminLoginCompleteResponse>;
|
||||
}
|
||||
|
||||
export async function completeLogin(body: LoginCompleteRequest, options: ClientOptions = {}): Promise<LoginCompleteResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
@@ -63,6 +72,13 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op
|
||||
return response.json() as Promise<RegistrationCompleteResponse>;
|
||||
}
|
||||
|
||||
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AdminSessionResponse>;
|
||||
}
|
||||
|
||||
export async function getBootstrap(options: ClientOptions = {}): Promise<{
|
||||
"app_version": string;
|
||||
"dependencies": Array<{
|
||||
@@ -120,6 +136,15 @@ export async function logoutUser(options: ClientOptions = {}): Promise<LogoutRes
|
||||
return response.json() as Promise<LogoutResponse>;
|
||||
}
|
||||
|
||||
export async function sendAdminLoginCode(body: AdminLoginSendRequest, 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/admin-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 sendLoginCode(body: LoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
export type AdminAuthenticatedUser = {
|
||||
"role": "super_admin";
|
||||
"status": "active";
|
||||
"user_id": string;
|
||||
};
|
||||
|
||||
export type AdminLoginCompleteRequest = {
|
||||
"registration_id": string;
|
||||
"verification_code": string;
|
||||
};
|
||||
|
||||
export type AdminLoginCompleteResponse = {
|
||||
"admin": AdminAuthenticatedUser;
|
||||
"audience": "admin";
|
||||
"session_expires_at": string;
|
||||
"status": "authenticated";
|
||||
};
|
||||
|
||||
export type AdminLoginSendRequest = {
|
||||
"email": string;
|
||||
};
|
||||
|
||||
export type AdminSessionResponse = {
|
||||
"acknowledged_private_content_notice_version": string | null;
|
||||
"admin": AdminAuthenticatedUser;
|
||||
"audience": "admin";
|
||||
"authenticated": true;
|
||||
"csrf_token": string;
|
||||
"current_private_content_notice_version": string | null;
|
||||
"expires_at": string;
|
||||
"notice_acknowledged": boolean;
|
||||
};
|
||||
|
||||
export type AuthenticatedUser = {
|
||||
"creator_name": string;
|
||||
"role": "user";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
||||
import { AdminAuthPage } from "./admin-auth.js";
|
||||
import { UserAuthPage } from "./user-auth.js";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
@@ -18,9 +19,12 @@ let authRevision = 0;
|
||||
|
||||
function renderAuthenticationEntry() {
|
||||
authRevision += 1;
|
||||
const authenticationPage = window.location.pathname.startsWith("/admin")
|
||||
? <AdminAuthPage key={authRevision} />
|
||||
: <UserAuthPage key={authRevision} />;
|
||||
appRoot.render(
|
||||
<StrictMode>
|
||||
<UserAuthPage key={authRevision} />
|
||||
{authenticationPage}
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ export function UserAuthPage() {
|
||||
</section>
|
||||
|
||||
<section className="auth-content">
|
||||
<a className="auth-admin-link" href="/admin">管理员登录</a>
|
||||
<a className="auth-admin-link" href="/admin/login">管理员登录</a>
|
||||
<div className="auth-panel">
|
||||
<div className="auth-tabs" role="tablist" aria-label="认证方式">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user