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

@@ -0,0 +1,205 @@
import type { PasskeyInfo } from "@umt/contracts";
import { Check, KeyRound, LoaderCircle, Pencil, Plus, Trash2, X } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
isPasskeyCancel,
passkeySupported,
useDeletePasskey,
usePasskeys,
useRegisterPasskey,
useRenamePasskey,
} from "~/api/auth.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { SettingsBlock } from "./settings-block.tsx";
// Passkey:登记(可以多把,各自命名)、改名、删除。登录页有 Passkey 时显示"用 Passkey 登录"。
/** 按浏览器平台给个默认名,方便区分 */
function defaultName(): string {
const ua = typeof navigator === "undefined" ? "" : navigator.userAgent;
if (/iPhone|iPad/.test(ua)) return "iPhone / iPad";
if (/Mac OS X/.test(ua)) return "Mac";
if (/Android/.test(ua)) return "Android";
if (/Windows/.test(ua)) return "Windows";
return "Passkey";
}
function PasskeyRow({ item }: { item: PasskeyInfo }) {
const rename = useRenamePasskey();
const del = useDeletePasskey();
const [editing, setEditing] = useState(false);
const [name, setName] = useState(item.name);
const save = async (e: React.FormEvent) => {
e.preventDefault();
const v = name.trim();
if (!v || v === item.name) return setEditing(false);
try {
await rename.mutateAsync({ id: item.id, name: v });
setEditing(false);
} catch (err) {
toast.error(errorMessage(err));
}
};
return (
<li className="flex min-h-12 items-center gap-3 py-2">
<KeyRound className="size-4 shrink-0 text-muted-foreground" />
{editing ? (
<form onSubmit={save} className="flex min-w-0 flex-1 items-center gap-1.5">
<Input
aria-label="Passkey 名称"
value={name}
maxLength={60}
autoFocus
onChange={(e) => setName(e.target.value)}
className="h-8 max-w-64"
/>
<Button type="submit" size="icon-sm" variant="ghost" aria-label="保存名称" disabled={rename.isPending}>
{rename.isPending ? <LoaderCircle className="animate-spin" /> : <Check />}
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="取消改名"
onClick={() => {
setName(item.name);
setEditing(false);
}}
>
<X />
</Button>
</form>
) : (
<div className="min-w-0 flex-1">
<p className="truncate text-caption font-medium">{item.name}</p>
<p className="text-meta text-muted-foreground">
{formatDateTime(item.createdAt)} 登记 ·{" "}
{item.lastUsedAt ? `${formatDateTime(item.lastUsedAt)} 用过` : "还没用过"}
{item.backedUp ? " · 已同步到云端钥匙串" : ""}
</p>
</div>
)}
{editing ? null : (
<div className="flex shrink-0 gap-1">
<Button size="icon-sm" variant="ghost" aria-label={`重命名 ${item.name}`} onClick={() => setEditing(true)}>
<Pencil />
</Button>
<ConfirmDialog
trigger={
<Button size="icon-sm" variant="ghost" aria-label={`删除 ${item.name}`}>
<Trash2 />
</Button>
}
title={`删除 Passkey「${item.name}」?`}
description="删除后这把钥匙不能再登录控制台;设备上的钥匙要在系统设置里另外删。"
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(item.id);
toast.success("Passkey 已删除");
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
</div>
)}
</li>
);
}
export function PasskeyBlock({ disabled }: { disabled: boolean }) {
const list = usePasskeys(!disabled);
const register = useRegisterPasskey();
const [adding, setAdding] = useState(false);
const [name, setName] = useState("");
const supported = passkeySupported();
const items = list.data?.items ?? [];
const add = async (e: React.FormEvent) => {
e.preventDefault();
try {
const r = await register.mutateAsync(name.trim() || defaultName());
toast.success(`已登记「${r.name}」`);
setAdding(false);
setName("");
} catch (err) {
if (isPasskeyCancel(err)) return;
toast.error(errorMessage(err, "登记失败"));
}
};
let body: React.ReactNode;
if (disabled) body = <p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>;
else
body = (
<div className="grid gap-3">
{list.isPending ? (
<p className="text-caption text-muted-foreground">读取中…</p>
) : items.length ? (
<ul className="divide-y divide-border/60">
{items.map((p) => (
<PasskeyRow key={p.id} item={p} />
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">还没有登记 Passkey。</p>
)}
{!supported ? (
<p className="text-meta text-muted-foreground">这个浏览器用不了 Passkey:需要 HTTPS 或 localhost 访问。</p>
) : adding ? (
<form onSubmit={add} className="flex max-w-md flex-wrap items-center gap-2">
<Input
aria-label="新 Passkey 的名称"
placeholder={defaultName()}
value={name}
maxLength={60}
autoFocus
onChange={(e) => setName(e.target.value)}
className="min-w-0 flex-1"
/>
<Button type="submit" size="sm" disabled={register.isPending}>
{register.isPending ? <LoaderCircle className="animate-spin" /> : null}
继续
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setAdding(false)}
disabled={register.isPending}
>
取消
</Button>
</form>
) : (
<div>
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
<Plus />
添加 Passkey
</Button>
</div>
)}
</div>
);
return (
<SettingsBlock
section="account"
anchor="passkeys"
title="Passkey"
description="用指纹、面容或安全钥匙直接登录,不用输密码和验证码。可以登记多把。"
>
{body}
</SettingsBlock>
);
}
@@ -0,0 +1,304 @@
import { RECOVERY_CODE_COUNT, TotpCode } from "@umt/contracts";
import { Copy, Download, LoaderCircle } from "lucide-react";
import QRCodeStyling from "qr-code-styling";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useTotpAction, useTotpSetup, useTotpStatus } from "~/api/auth.ts";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { SettingsBlock } from "./settings-block.tsx";
// 二次验证(TOTP):扫码绑定 → 输入验证码开启 → 显示一次性恢复码;已开启时可重新生成恢复码或关闭。
function QrCode({ value }: { value: string }) {
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = box.current;
if (!el) return;
el.replaceChildren();
const qr = new QRCodeStyling({
width: 176,
height: 176,
type: "svg",
data: value,
margin: 0,
qrOptions: { errorCorrectionLevel: "M" },
// token-allow: 二维码必须深色码点 + 白底,扫码器才认
dotsOptions: { type: "rounded", color: "#18181b" },
// token-allow: 同上
cornersSquareOptions: { type: "extra-rounded", color: "#18181b" },
// token-allow: 同上
backgroundOptions: { color: "#ffffff" },
});
qr.append(el);
}, [value]);
return (
// token-allow: 二维码必须白底(深色主题下也一样)
<div className="w-fit rounded-xl border border-border bg-white p-3">
<div ref={box} role="img" aria-label="TOTP 二维码" className="size-44" />
</div>
);
}
/** 恢复码展示:只此一次;可复制、下载 */
function RecoveryCodes({ codes, onDone }: { codes: string[]; onDone: () => void }) {
const text = `UHD Media Tool 恢复码(每个只能用一次)\n\n${codes.join("\n")}\n`;
const copy = async () => {
try {
await navigator.clipboard.writeText(codes.join("\n"));
toast.success("恢复码已复制");
} catch {
toast.error("复制失败,手动选中复制");
}
};
const download = () => {
const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }));
const a = document.createElement("a");
a.href = url;
a.download = "umt-recovery-codes.txt";
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="grid max-w-md gap-3">
<p className="text-caption">
验证器丢了就用恢复码登录,每个只能用一次。
<span className="text-muted-foreground">它们只显示这一次,存到密码管理器里。</span>
</p>
<ol className="grid grid-cols-2 gap-x-6 gap-y-1 rounded-lg bg-muted px-4 py-3 font-mono text-sm select-all">
{codes.map((c) => (
<li key={c}>{c}</li>
))}
</ol>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={copy}>
<Copy />
复制
</Button>
<Button size="sm" variant="outline" onClick={download}>
<Download />
下载
</Button>
<Button size="sm" onClick={onDone}>
已保存
</Button>
</div>
</div>
);
}
type Mode = "idle" | "setup" | "codes" | "disable" | "regenerate";
export function TotpBlock({ disabled }: { disabled: boolean }) {
const status = useTotpStatus(!disabled);
const action = useTotpAction();
const [mode, setMode] = useState<Mode>("idle");
const setup = useTotpSetup(!disabled && mode === "setup");
const [codes, setCodes] = useState<string[]>([]);
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const enabled = status.data?.enabled === true;
const left = status.data?.recoveryCodesRemaining ?? 0;
const reset = (next: Mode = "idle") => {
setMode(next);
setCode("");
setPassword("");
setError(null);
};
const enable = async (e: React.FormEvent) => {
e.preventDefault();
if (!TotpCode.safeParse(code).success) return setError("验证码是 6 位数字");
setError(null);
try {
const r = await action.mutateAsync({ action: "enable", code });
toast.success("二次验证已开启");
setCodes(r.recoveryCodes ?? []);
reset(r.recoveryCodes?.length ? "codes" : "idle");
} catch (err) {
setError(errorMessage(err));
}
};
const disable = async (e: React.FormEvent) => {
e.preventDefault();
if (!password) return setError("输入当前密码");
const c = code.trim();
if (!/^\d{6}$/.test(c) && c.replace(/[\s-]/g, "").length < 10) return setError("输入 6 位验证码或一个恢复码");
setError(null);
try {
await action.mutateAsync({ action: "disable", password, code: c });
toast.success("二次验证已关闭");
reset();
} catch (err) {
setError(errorMessage(err));
}
};
const regenerate = async (e: React.FormEvent) => {
e.preventDefault();
if (!password) return setError("输入当前密码");
setError(null);
try {
const r = await action.mutateAsync({ action: "recovery_codes", password });
setCodes(r.recoveryCodes ?? []);
reset("codes");
} catch (err) {
setError(errorMessage(err));
}
};
const passwordField = (
<FormField label="当前密码" htmlFor="acc-totp-pwd">
<Input
id="acc-totp-pwd"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</FormField>
);
const cancel = (
<Button type="button" variant="ghost" size="sm" onClick={() => reset()} disabled={action.isPending}>
取消
</Button>
);
const spinner = action.isPending ? <LoaderCircle className="animate-spin" /> : null;
const grouped = setup.data?.secret.replace(/(.{4})/g, "$1 ").trim();
let body: React.ReactNode;
if (disabled) body = <p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>;
else if (mode === "codes") body = <RecoveryCodes codes={codes} onDone={() => reset()} />;
else if (mode === "setup")
body = setup.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" />
生成密钥…
</p>
) : setup.isError ? (
<div className="grid justify-items-start gap-2">
<p className="text-meta text-destructive">{errorMessage(setup.error)}</p>
{cancel}
</div>
) : (
<form onSubmit={enable} noValidate className="grid gap-4 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-start">
<QrCode value={setup.data.otpauthUri} />
<div className="grid max-w-md gap-2">
<p className="text-caption">用验证器(1Password、Google Authenticator 等)扫码,再输入它显示的 6 位验证码。</p>
<details className="text-meta text-muted-foreground">
<summary className="cursor-pointer select-none">扫不了码?手动输入密钥</summary>
<code className="mt-1.5 block rounded-lg bg-muted px-2 py-1.5 font-mono text-sm break-all text-foreground select-all">
{grouped}
</code>
</details>
<FormField label="验证码" htmlFor="acc-totp-code" error={error ?? undefined}>
<Input
id="acc-totp-code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
aria-invalid={!!error}
className="max-w-40 font-mono tracking-[0.3em]"
/>
</FormField>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={action.isPending}>
{spinner}
确认开启
</Button>
{cancel}
</div>
</div>
</form>
);
else if (mode === "disable")
body = (
<form onSubmit={disable} noValidate className="grid max-w-md gap-1">
{passwordField}
<FormField label="验证码或恢复码" htmlFor="acc-totp-code" error={error ?? undefined}>
<Input
id="acc-totp-code"
autoComplete="one-time-code"
maxLength={16}
value={code}
onChange={(e) => setCode(e.target.value)}
aria-invalid={!!error}
/>
</FormField>
<div className="flex gap-2">
<Button type="submit" size="sm" variant="destructive" disabled={action.isPending}>
{spinner}
关闭二次验证
</Button>
{cancel}
</div>
</form>
);
else if (mode === "regenerate")
body = (
<form onSubmit={regenerate} noValidate className="grid max-w-md gap-1">
<p className="mb-1 text-caption text-muted-foreground">旧的恢复码会全部作废。</p>
{passwordField}
{error ? <p className="text-meta text-destructive">{error}</p> : null}
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={action.isPending}>
{spinner}
重新生成
</Button>
{cancel}
</div>
</form>
);
else if (enabled)
body = (
<div className="grid justify-items-start gap-3">
<p className={left <= 2 ? "text-caption text-warning" : "text-caption text-muted-foreground"}>
还剩 {left} 个恢复码(共 {RECOVERY_CODE_COUNT} 个)。{left <= 2 ? "快用完了,重新生成一组。" : ""}
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => reset("regenerate")}>
重新生成恢复码
</Button>
<Button size="sm" variant="ghost" onClick={() => reset("disable")}>
关闭二次验证
</Button>
</div>
</div>
);
else
body = (
<div className="grid justify-items-start gap-2">
<Button size="sm" onClick={() => reset("setup")} disabled={status.isPending}>
开启二次验证
</Button>
</div>
);
return (
<SettingsBlock
section="account"
anchor="totp"
title="二次验证(TOTP)"
description={
enabled
? "登录时除了密码还要输入验证器里的 6 位验证码。关闭需要当前密码。"
: "开启后登录还要输入验证器里的 6 位验证码,同时生成 8 个一次性恢复码。"
}
actions={
disabled || status.isPending ? null : (
<StatusBadge tone={enabled ? "success" : "neutral"}>{enabled ? "已开启" : "未开启"}</StatusBadge>
)
}
>
{body}
</SettingsBlock>
);
}
+6 -134
View File
@@ -1,19 +1,20 @@
import { PasswordChangeRequest, TotpCode } from "@umt/contracts";
import { PasswordChangeRequest } from "@umt/contracts";
import { LoaderCircle, LogOut } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { useChangePassword, useLogout, useSession, useTotpAction, useTotpStatus } from "~/api/auth.ts";
import { useChangePassword, useLogout, useSession } from "~/api/auth.ts";
import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { clearAuth, errorMessage, getToken, LOGIN_PATH, setToken } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { PasskeyBlock } from "./account-passkeys.tsx";
import { TotpBlock } from "./account-totp.tsx";
import { anchorId, SettingsBlock } from "./settings-block.tsx";
// 账户与安全:改密、二次验证(TOTP)、当前会话与本机 Token、快捷键
// 账户与安全:改密、二次验证(TOTP,account-totp.tsx)、Passkey(account-passkeys.tsx)、当前会话与本机 Token、快捷键
const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, {
message: "两次输入的新密码不一致",
@@ -103,136 +104,6 @@ function PasswordBlock({ disabled }: { disabled: boolean }) {
);
}
function TotpBlock({ disabled }: { disabled: boolean }) {
const status = useTotpStatus(!disabled);
const action = useTotpAction();
const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null);
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const enabled = status.data?.enabled === true;
const reset = () => {
setSetup(null);
setCode("");
setPassword("");
setError(null);
};
const begin = async () => {
setError(null);
try {
const r = await action.mutateAsync({ action: "begin" });
if (r.secret && r.otpauthUri) setSetup({ secret: r.secret, uri: r.otpauthUri });
} catch (err) {
setError(errorMessage(err));
}
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!TotpCode.safeParse(code).success) {
setError("验证码为 6 位数字");
return;
}
if (enabled && !password) {
setError("输入当前密码");
return;
}
setError(null);
try {
if (enabled) {
await action.mutateAsync({ action: "disable", password, code });
toast.success("二次验证已关闭");
} else {
await action.mutateAsync({ action: "enable", code });
toast.success("二次验证已开启");
}
reset();
} catch (err) {
setError(errorMessage(err));
}
};
const grouped = setup?.secret.replace(/(.{4})/g, "$1 ").trim();
return (
<SettingsBlock
section="account"
anchor="totp"
title="二次验证(TOTP)"
description={enabled ? "关闭需要当前密码和验证码。" : "开启后登录还要输入验证器里的 6 位验证码。"}
actions={
disabled ? null : status.isPending ? null : (
<StatusBadge tone={enabled ? "success" : "neutral"}>{enabled ? "已开启" : "未开启"}</StatusBadge>
)
}
>
{disabled ? (
<p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>
) : !enabled && !setup ? (
<div className="grid justify-items-start gap-2">
{status.data?.pending ? (
<p className="text-meta text-muted-foreground">有一个未确认的密钥,重新生成会作废它。</p>
) : null}
{error ? <p className="text-meta text-destructive">{error}</p> : null}
<Button size="sm" onClick={begin} disabled={action.isPending || status.isPending}>
{action.isPending ? <LoaderCircle className="animate-spin" /> : null}
生成密钥
</Button>
</div>
) : (
<form onSubmit={submit} noValidate className="grid max-w-md gap-1">
{setup ? (
<div className="mb-2 grid gap-1.5">
<p className="text-meta text-muted-foreground">
在验证器里手动输入密钥,或粘贴 otpauth 链接。密钥只显示这一次。
</p>
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm break-all select-all">{grouped}</code>
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-meta break-all text-muted-foreground select-all">
{setup.uri}
</code>
</div>
) : null}
{enabled ? (
<FormField label="当前密码" htmlFor="acc-totp-pwd">
<Input
id="acc-totp-pwd"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</FormField>
) : null}
<FormField label="验证码" htmlFor="acc-totp-code" error={error ?? undefined}>
<Input
id="acc-totp-code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
aria-invalid={!!error}
/>
</FormField>
<div className="flex gap-2">
<Button type="submit" size="sm" variant={enabled ? "destructive" : "default"} disabled={action.isPending}>
{action.isPending ? <LoaderCircle className="animate-spin" /> : null}
{enabled ? "关闭二次验证" : "确认开启"}
</Button>
{setup ? (
<Button type="button" variant="ghost" size="sm" onClick={reset} disabled={action.isPending}>
取消
</Button>
) : null}
</div>
</form>
)}
</SettingsBlock>
);
}
function SessionBlock() {
const session = useSession();
const logout = useLogout();
@@ -321,6 +192,7 @@ export function AccountSection() {
<div className="grid gap-8">
<PasswordBlock disabled={!cookie} />
<TotpBlock disabled={!cookie} />
<PasskeyBlock disabled={!cookie} />
<SessionBlock />
{/* 快捷键说明自带标题,只包卡片与锚点 */}
<section id={anchorId("account", "shortcuts")} className="pg-slab min-w-0 scroll-mt-24 rounded-xl p-5">