feat(web): auth context, login page, route guards, toast/error shell
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
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());
|
||||
|
||||
useEffect(() => {
|
||||
const off = () => setTok(null); // client 的 401 拦截在这里回收 React 状态
|
||||
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) 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);
|
||||
qc.clear();
|
||||
},
|
||||
}),
|
||||
[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 />;
|
||||
}
|
||||
Reference in New Issue
Block a user