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("/auth/session", { signal, noAuthRedirect: true }), staleTime: 60_000, retry: 1, }); } /** 登录/初始化类请求:返回错误码(由调用方按码分流文案),不跳转 */ async function authPost(path: string, body: unknown): Promise { const r = await apiFetch(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("/auth/login", body); export const setupAdmin = (body: AdminSetupRequest) => authPost("/auth/setup", body); export function useLogout() { const qc = useQueryClient(); return useMutation({ mutationFn: () => authPost("/auth/logout", {}), onSettled: () => { qc.clear(); }, }); } export function useChangePassword() { return useMutation({ mutationFn: (body: PasswordChangeRequest) => // 旧密码错误也是 401:留在页面显示字段错误,不当成会话失效跳登录 apiData("/auth/password", { method: "POST", body, noAuthRedirect: true }), }); } export function useTotpStatus(enabled = true) { return useQuery({ queryKey: qk.sub("auth", "totp"), queryFn: ({ signal }) => apiData("/auth/totp", { signal }), enabled, }); } export function useTotpAction() { const qc = useQueryClient(); return useMutation({ mutationFn: (body: TotpAction) => apiData("/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("/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") }); }, }); }