From dd0167fe85cae22fdf11bf5f172fa1545aea9b74 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 20:22:27 +0800 Subject: [PATCH] feat(web): TOTP QR code and recovery codes, passkey management and passkey login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/web/app/api/auth.ts | 99 ++++++ .../app/components/console/account-menu.tsx | 156 +-------- .../components/settings/account-passkeys.tsx | 205 ++++++++++++ .../app/components/settings/account-totp.tsx | 304 ++++++++++++++++++ apps/web/app/components/settings/account.tsx | 140 +------- apps/web/app/routes/login.tsx | 143 ++++++-- 6 files changed, 739 insertions(+), 308 deletions(-) create mode 100644 apps/web/app/components/settings/account-passkeys.tsx create mode 100644 apps/web/app/components/settings/account-totp.tsx diff --git a/apps/web/app/api/auth.ts b/apps/web/app/api/auth.ts index d60cb011..02eee202 100644 --- a/apps/web/app/api/auth.ts +++ b/apps/web/app/api/auth.ts @@ -1,13 +1,26 @@ +import { + type AuthenticationResponseJSON, + browserSupportsWebAuthn, + type PublicKeyCredentialCreationOptionsJSON, + type PublicKeyCredentialRequestOptionsJSON, + type RegistrationResponseJSON, + startAuthentication, + startRegistration, +} from "@simplewebauthn/browser"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AdminSetupRequest, LoginRequest, LoginResult, + PasskeyChallenge, + PasskeyInfo, + PasskeyList, PasswordChangeRequest, PasswordChangeResult, SessionInfo, TotpAction, TotpActionResult, + TotpSetup, TotpStatus, } from "@umt/contracts"; import { ApiRequestError, apiData, apiFetch } from "~/lib/api.ts"; @@ -69,3 +82,89 @@ export function useTotpAction() { }, }); } + +/** 待确认的 TOTP 密钥与 otpauth URL(没有就由服务端生成;刷新不作废) */ +export function useTotpSetup(enabled: boolean) { + return useQuery({ + // 不挂在 "auth" 下:开启成功后 invalidate("auth") 不该再去取(已开启会 409);不缓存,每次进入绑定都向服务端要 + // (服务端对未确认的密钥原样返回,刷新不会换码) + queryKey: qk.sub("totp-setup"), + queryFn: ({ signal }) => apiData("/auth/totp/setup", { signal, noAuthRedirect: true }), + enabled, + staleTime: 0, + gcTime: 0, + refetchOnWindowFocus: false, + retry: false, + }); +} + +// ── Passkey ── + +/** 浏览器能不能用 Passkey:要安全上下文(HTTPS 或 localhost)且支持 WebAuthn */ +export function passkeySupported(): boolean { + return typeof window !== "undefined" && window.isSecureContext && browserSupportsWebAuthn(); +} + +/** 用户在系统弹窗里取消 / 超时:不当成错误提示 */ +export function isPasskeyCancel(err: unknown): boolean { + const name = (err as { name?: string; cause?: { name?: string } })?.name; + const cause = (err as { cause?: { name?: string } })?.cause?.name; + return name === "NotAllowedError" || cause === "NotAllowedError" || name === "AbortError"; +} + +/** Passkey 登录:取挑战 → 浏览器签名 → 校验;成功后服务端下发会话 cookie */ +export async function loginWithPasskey(): Promise { + const ch = await authPost("/auth/passkeys/login/options", {}); + const response: AuthenticationResponseJSON = await startAuthentication({ + optionsJSON: ch.options as unknown as PublicKeyCredentialRequestOptionsJSON, + }); + return authPost("/auth/passkeys/login/verify", { challengeId: ch.challengeId, response }); +} + +export function usePasskeys(enabled = true) { + return useQuery({ + queryKey: qk.sub("auth", "passkeys"), + queryFn: ({ signal }) => apiData("/auth/passkeys", { signal }), + enabled, + }); +} + +export function useRegisterPasskey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (name: string) => { + const ch = await apiData("/auth/passkeys/register/options", { method: "POST", body: {} }); + const response: RegistrationResponseJSON = await startRegistration({ + optionsJSON: ch.options as unknown as PublicKeyCredentialCreationOptionsJSON, + }); + return apiData("/auth/passkeys/register/verify", { + method: "POST", + body: { challengeId: ch.challengeId, name, response }, + }); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: qk.all("auth") }); + }, + }); +} + +export function useRenamePasskey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (v: { id: string; name: string }) => + apiData(`/auth/passkeys/${v.id}`, { method: "PATCH", body: { name: v.name } }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.sub("auth", "passkeys") }); + }, + }); +} + +export function useDeletePasskey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiData(`/auth/passkeys/${id}`, { method: "DELETE" }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.all("auth") }); + }, + }); +} diff --git a/apps/web/app/components/console/account-menu.tsx b/apps/web/app/components/console/account-menu.tsx index 65d949b4..f50d6dfa 100644 --- a/apps/web/app/components/console/account-menu.tsx +++ b/apps/web/app/components/console/account-menu.tsx @@ -1,9 +1,10 @@ -import { PasswordChangeRequest, type SessionInfo, TotpCode } from "@umt/contracts"; +import { PasswordChangeRequest, type SessionInfo } from "@umt/contracts"; import { KeyRound, LoaderCircle, LogOut, ShieldCheck, UserRound } from "lucide-react"; import { useState } from "react"; +import { useNavigate } from "react-router"; import { toast } from "sonner"; import { z } from "zod"; -import { useChangePassword, useLogout, useTotpAction, useTotpStatus } from "~/api/auth.ts"; +import { useChangePassword, useLogout } from "~/api/auth.ts"; import { Button } from "~/components/ui/button.tsx"; import { Dialog, @@ -105,156 +106,11 @@ function ChangePasswordDialog({ open, onOpenChange }: { open: boolean; onOpenCha ); } -/** 二次验证(TOTP):未开启 → 生成密钥 → 在验证器里添加 → 输入 6 位码确认;已开启 → 当前密码 + 验证码关闭 */ -function TotpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) { - const status = useTotpStatus(open); - 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(null); - const enabled = status.data?.enabled === true; - - const reset = () => { - setSetup(null); - setCode(""); - setPassword(""); - setError(null); - }; - const close = (o: boolean) => { - if (action.isPending) return; - if (!o) reset(); - onOpenChange(o); - }; - - 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; - } - setError(null); - try { - if (enabled) { - if (!password) { - setError("输入当前密码"); - return; - } - await action.mutateAsync({ action: "disable", password, code }); - toast.success("已关闭二次验证"); - } else { - await action.mutateAsync({ action: "enable", code }); - toast.success("已开启二次验证"); - } - reset(); - onOpenChange(false); - } catch (err) { - setError(errorMessage(err)); - } - }; - - const grouped = setup?.secret.replace(/(.{4})/g, "$1 ").trim(); - - return ( - - - - 二次验证(TOTP) - - {status.isLoading - ? "读取状态…" - : enabled - ? "已开启。关闭需要当前密码和验证码。" - : "未开启。开启后登录还要输入验证器里的 6 位验证码。"} - - - {!enabled && !setup ? ( -
- {status.data?.pending ? ( -

有一个未确认的密钥;重新生成会作废它。

- ) : null} - {error ?

{error}

: null} -
- ) : ( -
- {setup ? ( -
-

- 在验证器里手动输入下面的密钥,或粘贴 otpauth 链接。密钥只显示这一次。 -

- - {grouped} - - - {setup.uri} - -
- ) : null} - {enabled ? ( - - setPassword(e.target.value)} - /> - - ) : null} - - setCode(e.target.value.replace(/\D/g, ""))} - aria-invalid={!!error} - /> - -
- )} - - - {!enabled && !setup ? ( - - ) : ( - - )} - -
-
- ); -} - /** 右上角账户弹层:当前身份、会话到期、改密、二次验证、登出 */ export function AccountMenu({ session }: { session: SessionInfo }) { const [open, setOpen] = useState(false); const [pwdOpen, setPwdOpen] = useState(false); - const [totpOpen, setTotpOpen] = useState(false); + const navigate = useNavigate(); const logout = useLogout(); const actor = session.actor; const isCookie = !!session.expiresAt; @@ -305,7 +161,8 @@ export function AccountMenu({ session }: { session: SessionInfo }) { type="button" onClick={() => { setOpen(false); - setTotpOpen(true); + // 开启时要扫码、保存恢复码,放在设置页的账户与安全里做 + navigate({ pathname: "/console/settings", hash: "#account/totp" }); }} className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left text-body text-foreground hover:bg-foreground/[0.06]" > @@ -329,7 +186,6 @@ export function AccountMenu({ session }: { session: SessionInfo }) { - ); } diff --git a/apps/web/app/components/settings/account-passkeys.tsx b/apps/web/app/components/settings/account-passkeys.tsx new file mode 100644 index 00000000..729f16bf --- /dev/null +++ b/apps/web/app/components/settings/account-passkeys.tsx @@ -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 ( +
  • + + {editing ? ( +
    + setName(e.target.value)} + className="h-8 max-w-64" + /> + + +
    + ) : ( +
    +

    {item.name}

    +

    + {formatDateTime(item.createdAt)} 登记 ·{" "} + {item.lastUsedAt ? `${formatDateTime(item.lastUsedAt)} 用过` : "还没用过"} + {item.backedUp ? " · 已同步到云端钥匙串" : ""} +

    +
    + )} + {editing ? null : ( +
    + + + + + } + 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; + } + }} + /> +
    + )} +
  • + ); +} + +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 =

    当前用 API Key 访问,登录会话才能修改。

    ; + else + body = ( +
    + {list.isPending ? ( +

    读取中…

    + ) : items.length ? ( +
      + {items.map((p) => ( + + ))} +
    + ) : ( +

    还没有登记 Passkey。

    + )} + {!supported ? ( +

    这个浏览器用不了 Passkey:需要 HTTPS 或 localhost 访问。

    + ) : adding ? ( +
    + setName(e.target.value)} + className="min-w-0 flex-1" + /> + + +
    + ) : ( +
    + +
    + )} +
    + ); + + return ( + + {body} + + ); +} diff --git a/apps/web/app/components/settings/account-totp.tsx b/apps/web/app/components/settings/account-totp.tsx new file mode 100644 index 00000000..9af45471 --- /dev/null +++ b/apps/web/app/components/settings/account-totp.tsx @@ -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(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: 二维码必须白底(深色主题下也一样) +
    +
    +
    + ); +} + +/** 恢复码展示:只此一次;可复制、下载 */ +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 ( +
    +

    + 验证器丢了就用恢复码登录,每个只能用一次。 + 它们只显示这一次,存到密码管理器里。 +

    +
      + {codes.map((c) => ( +
    1. {c}
    2. + ))} +
    +
    + + + +
    +
    + ); +} + +type Mode = "idle" | "setup" | "codes" | "disable" | "regenerate"; + +export function TotpBlock({ disabled }: { disabled: boolean }) { + const status = useTotpStatus(!disabled); + const action = useTotpAction(); + const [mode, setMode] = useState("idle"); + const setup = useTotpSetup(!disabled && mode === "setup"); + const [codes, setCodes] = useState([]); + const [code, setCode] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(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 = ( + + setPassword(e.target.value)} + /> + + ); + const cancel = ( + + ); + const spinner = action.isPending ? : null; + const grouped = setup.data?.secret.replace(/(.{4})/g, "$1 ").trim(); + + let body: React.ReactNode; + if (disabled) body =

    当前用 API Key 访问,登录会话才能修改。

    ; + else if (mode === "codes") body = reset()} />; + else if (mode === "setup") + body = setup.isPending ? ( +

    + + 生成密钥… +

    + ) : setup.isError ? ( +
    +

    {errorMessage(setup.error)}

    + {cancel} +
    + ) : ( +
    + +
    +

    用验证器(1Password、Google Authenticator 等)扫码,再输入它显示的 6 位验证码。

    +
    + 扫不了码?手动输入密钥 + + {grouped} + +
    + + setCode(e.target.value.replace(/\D/g, ""))} + aria-invalid={!!error} + className="max-w-40 font-mono tracking-[0.3em]" + /> + +
    + + {cancel} +
    +
    + + ); + else if (mode === "disable") + body = ( +
    + {passwordField} + + setCode(e.target.value)} + aria-invalid={!!error} + /> + +
    + + {cancel} +
    +
    + ); + else if (mode === "regenerate") + body = ( +
    +

    旧的恢复码会全部作废。

    + {passwordField} + {error ?

    {error}

    : null} +
    + + {cancel} +
    +
    + ); + else if (enabled) + body = ( +
    +

    + 还剩 {left} 个恢复码(共 {RECOVERY_CODE_COUNT} 个)。{left <= 2 ? "快用完了,重新生成一组。" : ""} +

    +
    + + +
    +
    + ); + else + body = ( +
    + +
    + ); + + return ( + {enabled ? "已开启" : "未开启"} + ) + } + > + {body} + + ); +} diff --git a/apps/web/app/components/settings/account.tsx b/apps/web/app/components/settings/account.tsx index 70c56c65..a07fb1b6 100644 --- a/apps/web/app/components/settings/account.tsx +++ b/apps/web/app/components/settings/account.tsx @@ -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(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 ( - {enabled ? "已开启" : "未开启"} - ) - } - > - {disabled ? ( -

    当前用 API Key 访问,登录会话才能修改。

    - ) : !enabled && !setup ? ( -
    - {status.data?.pending ? ( -

    有一个未确认的密钥,重新生成会作废它。

    - ) : null} - {error ?

    {error}

    : null} - -
    - ) : ( -
    - {setup ? ( -
    -

    - 在验证器里手动输入密钥,或粘贴 otpauth 链接。密钥只显示这一次。 -

    - {grouped} - - {setup.uri} - -
    - ) : null} - {enabled ? ( - - setPassword(e.target.value)} - /> - - ) : null} - - setCode(e.target.value.replace(/\D/g, ""))} - aria-invalid={!!error} - /> - -
    - - {setup ? ( - - ) : null} -
    -
    - )} -
    - ); -} - function SessionBlock() { const session = useSession(); const logout = useLogout(); @@ -321,6 +192,7 @@ export function AccountSection() {
    + {/* 快捷键说明自带标题,只包卡片与锚点 */}
    diff --git a/apps/web/app/routes/login.tsx b/apps/web/app/routes/login.tsx index 2c278c8d..135d5a65 100644 --- a/apps/web/app/routes/login.tsx +++ b/apps/web/app/routes/login.tsx @@ -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(null); const [fieldErrors, setFieldErrors] = useState>({}); @@ -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 = {}; 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() {
    ) : null} -
    + {showPasskey ? ( +
    + +
    + + 或用密码 + +
    +
    + ) : null} + + {mode === "setup" ? ( ) : null} - {mode === "totp" ? ( + {mode === "totp" && useRecovery ? ( + + setRecovery(e.target.value)} + disabled={disabled} + aria-invalid={!!fieldErrors.recoveryCode} + className="font-mono tracking-widest uppercase" + placeholder="XXXXX-XXXXX" + /> + + ) : null} + {mode === "totp" && !useRecovery ? ( - {mode === "totp" ? ( - +
    + + +
    ) : null} {session.isError ? (