Files
book-comic-library/web/src/auth/AuthContext.tsx
T

88 lines
2.8 KiB
TypeScript

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { api, getToken, LOGOUT_EVENT, setToken } from "../api/client";
import type { Me } from "../api/types";
interface AuthState {
user: Me | null;
isAdmin: boolean;
ready: boolean;
login: (u: string, p: string) => Promise<void>;
logout: () => void;
}
const Ctx = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const qc = useQueryClient();
const [token, setTok] = useState<string | null>(() => getToken());
// 401 到期登出与显式登出同样清理:跨用户数据不得残留在 query 缓存 / SW CacheStorage
const clearSession = () => {
qc.clear();
if (typeof caches !== "undefined") {
void caches.delete("booklib-api");
void caches.delete("booklib-immutable");
}
};
useEffect(() => {
const off = () => {
setTok(null); // client 的 401 拦截在这里回收 React 状态(token 已由 client.ts 清除)
clearSession();
};
window.addEventListener(LOGOUT_EVENT, off);
return () => window.removeEventListener(LOGOUT_EVENT, off);
}, []);
const meQ = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: !!token, retry: 0 });
useEffect(() => {
if (token && meQ.isError) {
setToken(null); // 非 401 失败也要清持久化 token,避免与 React 状态分叉导致 RequireAuth 永久加载
setTok(null);
}
}, [token, meQ.isError]);
const value = useMemo<AuthState>(
() => ({
user: meQ.data ?? null,
isAdmin: meQ.data?.role === "admin",
ready: !token || !meQ.isPending,
login: async (u, p) => {
const r = await api.login(u, p);
setToken(r.token);
setTok(r.token);
await qc.invalidateQueries({ queryKey: ["me"] });
},
logout: () => {
setToken(null);
setTok(null);
clearSession();
},
}),
[meQ.data, meQ.isPending, meQ.isError, token, qc],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useAuth(): AuthState {
const v = useContext(Ctx);
if (!v) throw new Error("useAuth 必须在 AuthProvider 内使用");
return v;
}
export function RequireAuth({ children }: { children: ReactNode }) {
const { user, ready } = useAuth();
const loc = useLocation();
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
if (!ready || !user) return <div className="grid h-full place-items-center text-zinc-500">加载中…</div>;
return <>{children}</>;
}
export function RequireAdmin({ children }: { children: ReactNode }) {
const { isAdmin } = useAuth();
return isAdmin ? <>{children}</> : <Navigate to="/" replace />;
}