feat(web): TOTP QR code and recovery codes, passkey management and passkey login
- Account & security: TOTP setup renders the otpauth URL as a QR code (qr-code-styling) with the secret as a fallback, shows the 8 recovery codes once after enabling (copy / download), and offers regenerate and disable (verification code or recovery code) when enabled. - New Passkey block: add (named, @simplewebauthn/browser), rename, delete; hidden outside a secure context. - Login page: "用 Passkey 登录" when a passkey is registered and the browser supports it; the TOTP step can switch to a recovery code. - Account menu links to the settings block instead of its own TOTP dialog, so enabling always shows the recovery codes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b0e5d539ed
commit
dd0167fe85
6 files changed
+739
-308
No files matched your search
+119
-24
@@ -1,10 +1,11 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AdminSetupRequest, LoginRequest, TotpCode } from "@umt/contracts";
|
||||
import { AdminSetupRequest, LoginRequest, RecoveryCode, TotpCode } from "@umt/contracts";
|
||||
import {
|
||||
AlertCircle,
|
||||
Clapperboard,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Fingerprint,
|
||||
KeyRound,
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
import { type FormEvent, type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { login, setupAdmin, useSession } from "~/api/auth.ts";
|
||||
import { isPasskeyCancel, login, loginWithPasskey, passkeySupported, setupAdmin, useSession } from "~/api/auth.ts";
|
||||
import PrismLeak from "~/components/prism-leak.tsx";
|
||||
import { ThemeToggle } from "~/components/theme-toggle.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
@@ -41,7 +42,7 @@ const SetupForm = AdminSetupRequest.extend({ confirm: z.string() }).refine((v) =
|
||||
});
|
||||
|
||||
/** 后端错误码 → 文案(不直接透传后端 message,setup token 类除外) */
|
||||
function errorText(err: unknown, mode: Mode): string {
|
||||
function errorText(err: unknown, mode: Mode | "passkey" | "recovery"): string {
|
||||
if (!(err instanceof ApiRequestError)) return "请求失败,稍后重试";
|
||||
switch (err.code) {
|
||||
case "rate_limited":
|
||||
@@ -49,6 +50,8 @@ function errorText(err: unknown, mode: Mode): string {
|
||||
case "upstream_unavailable":
|
||||
return "连不上服务端,确认它在运行";
|
||||
case "unauthorized":
|
||||
if (mode === "passkey") return "Passkey 校验没通过,换一把或用密码登录";
|
||||
if (mode === "recovery") return "恢复码错误或已用过";
|
||||
if (mode === "totp") return "验证码错误或已用过,等下一个验证码再试";
|
||||
if (mode === "setup") return "初始化口令无效,用服务端日志里最新的一条";
|
||||
return "用户名或密码错误";
|
||||
@@ -57,6 +60,9 @@ function errorText(err: unknown, mode: Mode): string {
|
||||
return "输入格式不正确";
|
||||
case "conflict":
|
||||
return "管理员已初始化,直接登录";
|
||||
case "precondition_failed":
|
||||
if (mode === "passkey") return "登录挑战已过期,再点一次";
|
||||
return err.message || "登录失败";
|
||||
default:
|
||||
return err.message || "登录失败";
|
||||
}
|
||||
@@ -93,6 +99,13 @@ export default function LoginPage() {
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [setupToken, setSetupToken] = useState("");
|
||||
const [totp, setTotp] = useState("");
|
||||
/** 二次验证这一步改用恢复码(验证器不在手边) */
|
||||
const [useRecovery, setUseRecovery] = useState(false);
|
||||
const [recovery, setRecovery] = useState("");
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
// 安全上下文 + 支持 WebAuthn 才显示 Passkey 入口(挂载后再判断,避免与首屏渲染不一致)
|
||||
const [canPasskey, setCanPasskey] = useState(false);
|
||||
useEffect(() => setCanPasskey(passkeySupported()), []);
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
@@ -147,13 +160,26 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = { username, password, totpCode: mode === "totp" ? totp : undefined };
|
||||
const parsed = (mode === "totp" ? LoginRequest.extend({ totpCode: TotpCode }) : LoginRequest).safeParse(body);
|
||||
const recoveryStep = mode === "totp" && useRecovery;
|
||||
const body = {
|
||||
username,
|
||||
password,
|
||||
totpCode: mode === "totp" && !useRecovery ? totp : undefined,
|
||||
recoveryCode: recoveryStep ? recovery.trim() : undefined,
|
||||
};
|
||||
const schema =
|
||||
mode !== "totp"
|
||||
? LoginRequest
|
||||
: useRecovery
|
||||
? LoginRequest.extend({ recoveryCode: RecoveryCode })
|
||||
: LoginRequest.extend({ totpCode: TotpCode });
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const fe: Record<string, string> = {};
|
||||
for (const i of parsed.error.issues) fe[String(i.path[0])] ??= i.message;
|
||||
if (fe.username) fe.username = "输入用户名";
|
||||
if (fe.password) fe.password = "输入密码";
|
||||
if (fe.recoveryCode) fe.recoveryCode = "恢复码是 10 位字母数字";
|
||||
setFieldErrors(fe);
|
||||
return;
|
||||
}
|
||||
@@ -171,21 +197,38 @@ export default function LoginPage() {
|
||||
setMode("setup");
|
||||
setError("管理员尚未初始化");
|
||||
} else {
|
||||
setError(errorText(err, mode));
|
||||
setError(errorText(err, recoveryStep ? "recovery" : mode));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPasskey = async () => {
|
||||
if (loading || passkeyLoading) return;
|
||||
setError(null);
|
||||
setPasskeyLoading(true);
|
||||
try {
|
||||
const r = await loginWithPasskey();
|
||||
if (r.status === "ok") await done();
|
||||
} catch (err) {
|
||||
if (!isPasskeyCancel(err)) setError(errorText(err, "passkey"));
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const probing = session.isPending;
|
||||
const disabled = probing || loading;
|
||||
const disabled = probing || loading || passkeyLoading;
|
||||
const showPasskey = mode === "login" && canPasskey && session.data?.passkeyEnabled === true;
|
||||
const title = mode === "setup" ? "初始化管理员" : mode === "totp" ? "二次验证" : "登录控制台";
|
||||
const subtitle =
|
||||
mode === "setup"
|
||||
? "首次启动:设置唯一的管理员账号。"
|
||||
: mode === "totp"
|
||||
? "输入身份验证器里的 6 位验证码。"
|
||||
? useRecovery
|
||||
? "输入一个没用过的恢复码,用过即作废。"
|
||||
: "输入身份验证器里的 6 位验证码。"
|
||||
: "使用管理员账号登录,会话 12 小时有效。";
|
||||
|
||||
return (
|
||||
@@ -244,7 +287,20 @@ export default function LoginPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mt-5 space-y-3" onSubmit={onSubmit} noValidate>
|
||||
{showPasskey ? (
|
||||
<div className="mt-5 grid gap-3">
|
||||
<Button type="button" size="lg" className="w-full" onClick={onPasskey} disabled={disabled}>
|
||||
{passkeyLoading ? <LoaderCircle className="animate-spin" /> : <Fingerprint />}用 Passkey 登录
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 text-meta text-muted-foreground">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
或用密码
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className={cn(showPasskey ? "mt-3" : "mt-5", "space-y-3")} onSubmit={onSubmit} noValidate>
|
||||
{mode === "setup" ? (
|
||||
<Field id="setupToken" label="初始化口令" error={fieldErrors.setupToken}>
|
||||
<IconInput
|
||||
@@ -311,7 +367,23 @@ export default function LoginPage() {
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{mode === "totp" ? (
|
||||
{mode === "totp" && useRecovery ? (
|
||||
<Field id="recovery" label="恢复码" error={fieldErrors.recoveryCode}>
|
||||
<IconInput
|
||||
id="recovery"
|
||||
icon={KeyRound}
|
||||
autoComplete="off"
|
||||
maxLength={16}
|
||||
value={recovery}
|
||||
onChange={(e) => setRecovery(e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!fieldErrors.recoveryCode}
|
||||
className="font-mono tracking-widest uppercase"
|
||||
placeholder="XXXXX-XXXXX"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{mode === "totp" && !useRecovery ? (
|
||||
<Field id="totp" label="验证码" error={fieldErrors.totpCode}>
|
||||
<IconInput
|
||||
ref={totpRef}
|
||||
@@ -340,24 +412,47 @@ export default function LoginPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="lg" className="w-full" disabled={disabled}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
variant={showPasskey ? "outline" : "default"}
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
>
|
||||
{loading || probing ? <LoaderCircle className="animate-spin" /> : null}
|
||||
{mode === "setup" ? "初始化并登录" : mode === "totp" ? "验证" : "登录"}
|
||||
</Button>
|
||||
{mode === "totp" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setTotp("");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
返回修改账号
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setUseRecovery((v) => !v);
|
||||
setTotp("");
|
||||
setRecovery("");
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
}}
|
||||
>
|
||||
{useRecovery ? "改用验证码" : "用恢复码"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setTotp("");
|
||||
setRecovery("");
|
||||
setUseRecovery(false);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
返回修改账号
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{session.isError ? (
|
||||
<p className="text-center text-meta text-muted-foreground">
|
||||
|
||||
Reference in new issue
Block a user