- 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]>
171 lines
5.8 KiB
TypeScript
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") });
|
|
},
|
|
});
|
|
}
|