feat: implement TASK-WP1-04 admin security
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user