SPB Git

spb/findata

Public
2.8 KB · 100 lines tsx
1"use client";2import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";3import { usePathname, useRouter } from "next/navigation";4import { api, isApiError, setCsrf, type Me } from "./api";56interface AuthState {7  me: Me | null;8  loading: boolean;9  refresh: () => Promise<Me | null>;10  logout: () => Promise<void>;11  isOwner: boolean;12  hasScope: (s: string) => boolean;13}1415const Ctx = createContext<AuthState | null>(null);1617const PUBLIC_PATHS = ["/login"];1819function isPublic(path: string | null): boolean {20  if (!path) return false;21  const p = path.replace(/\/+$/, "") || "/";22  return PUBLIC_PATHS.includes(p);23}2425export function AuthProvider({ children }: { children: ReactNode }) {26  const [me, setMe] = useState<Me | null>(null);27  const [loading, setLoading] = useState(true);28  const pathname = usePathname();29  const router = useRouter();3031  const refresh = useCallback(async (): Promise<Me | null> => {32    try {33      const m = await api.get<Me>("/auth/me");34      setCsrf(m.csrf_token);35      setMe(m);36      return m;37    } catch (e) {38      if (isApiError(e) && e.status === 401) {39        setCsrf(null);40        setMe(null);41        return null;42      }43      // network/other error: keep previous state, but stop loading44      setMe(null);45      return null;46    } finally {47      setLoading(false);48    }49  }, []);5051  useEffect(() => {52    void refresh();53  }, [refresh]);5455  // Redirect rules: signed out → /login/ ; MFA pending → /login/ (step 2) ; signed in on /login → /56  useEffect(() => {57    if (loading) return;58    const pub = isPublic(pathname);59    if (!me && !pub) {60      const next = pathname && pathname !== "/" ? `?next=${encodeURIComponent(pathname)}` : "";61      router.replace(`/login/${next}`);62    } else if (me && !me.mfa_passed && !pub) {63      router.replace("/login/");64    } else if (me && me.mfa_passed && pub) {65      const next = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("next") : null;66      router.replace(next && next.startsWith("/") ? next : "/");67    }68  }, [loading, me, pathname, router]);6970  const logout = useCallback(async () => {71    try {72      await api.post("/auth/logout");73    } catch {74      /* ignore */75    }76    setCsrf(null);77    setMe(null);78    router.replace("/login/");79  }, [router]);8081  const value = useMemo<AuthState>(82    () => ({83      me,84      loading,85      refresh,86      logout,87      isOwner: me?.role === "owner" || (me?.scopes ?? []).includes("admin"),88      hasScope: (s: string) => (me?.scopes ?? []).includes(s) || (me?.scopes ?? []).includes("admin"),89    }),90    [me, loading, refresh, logout],91  );92  return <Ctx.Provider value={value}>{children}</Ctx.Provider>;93}9495export function useAuth(): AuthState {96  const c = useContext(Ctx);97  if (!c) throw new Error("useAuth outside AuthProvider");98  return c;99}100
Files · b31ccfb82046b1137ca27720f0467b178d4cebd6
Menu

Theme