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

+99
View File
@@ -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<TotpSetup>("/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<LoginResult> {
const ch = await authPost<PasskeyChallenge>("/auth/passkeys/login/options", {});
const response: AuthenticationResponseJSON = await startAuthentication({
optionsJSON: ch.options as unknown as PublicKeyCredentialRequestOptionsJSON,
});
return authPost<LoginResult>("/auth/passkeys/login/verify", { challengeId: ch.challengeId, response });
}
export function usePasskeys(enabled = true) {
return useQuery({
queryKey: qk.sub("auth", "passkeys"),
queryFn: ({ signal }) => apiData<PasskeyList>("/auth/passkeys", { signal }),
enabled,
});
}
export function useRegisterPasskey() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (name: string) => {
const ch = await apiData<PasskeyChallenge>("/auth/passkeys/register/options", { method: "POST", body: {} });
const response: RegistrationResponseJSON = await startRegistration({
optionsJSON: ch.options as unknown as PublicKeyCredentialCreationOptionsJSON,
});
return apiData<PasskeyInfo>("/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<PasskeyInfo>(`/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<unknown>(`/auth/passkeys/${id}`, { method: "DELETE" }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.all("auth") });
},
});
}