Files
rootandClaude Opus 5.5 dd0167fe85 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]>
2026-09-28 20:22:27 +08:00

171 lines
5.8 KiB
TypeScript

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";
import { qk } from "~/lib/query-keys.ts";
/** 登录态探测(免鉴权,不触发 401 跳转) */
export function useSession() {
return useQuery({
queryKey: qk.session,
queryFn: ({ signal }) => apiData<SessionInfo>("/auth/session", { signal, noAuthRedirect: true }),
staleTime: 60_000,
retry: 1,
});
}
/** 登录/初始化类请求:返回错误码(由调用方按码分流文案),不跳转 */
async function authPost<T>(path: string, body: unknown): Promise<T> {
const r = await apiFetch<T>(path, { method: "POST", body, noAuthRedirect: true });
if (!r.ok) throw new ApiRequestError(r.error.code, r.error.message);
return r.data;
}
export const login = (body: LoginRequest) => authPost<LoginResult>("/auth/login", body);
export const setupAdmin = (body: AdminSetupRequest) => authPost<SessionInfo>("/auth/setup", body);
export function useLogout() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => authPost<unknown>("/auth/logout", {}),
onSettled: () => {
qc.clear();
},
});
}
export function useChangePassword() {
return useMutation({
mutationFn: (body: PasswordChangeRequest) =>
// 旧密码错误也是 401:留在页面显示字段错误,不当成会话失效跳登录
apiData<PasswordChangeResult>("/auth/password", { method: "POST", body, noAuthRedirect: true }),
});
}
export function useTotpStatus(enabled = true) {
return useQuery({
queryKey: qk.sub("auth", "totp"),
queryFn: ({ signal }) => apiData<TotpStatus>("/auth/totp", { signal }),
enabled,
});
}
export function useTotpAction() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: TotpAction) =>
apiData<TotpActionResult>("/auth/totp", { method: "POST", body, noAuthRedirect: true }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.all("auth") });
},
});
}
/** 待确认的 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") });
},
});
}