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:
rootandClaude Opus 5.5 committed 2026-09-28 20:22:27 +08:00
1 parent b0e5d539ed
commit dd0167fe85
6 files changed
+739 -308

No files matched your search

+119 -24
View File
@@ -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">