SPB Git

spb/findata

Public
3.0 KB · 101 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", "/", "/docs"]; // "/" and "/docs" show public metrics; data pages need a session1819function 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    const onLogin = (pathname ?? "").replace(/\/+$/, "") === "/login";60    if (!me && !pub) {61      const next = pathname && pathname !== "/" ? `?next=${encodeURIComponent(pathname)}` : "";62      router.replace(`/login/${next}`);63    } else if (me && !me.mfa_passed && !onLogin) {64      router.replace("/login/");65    } else if (me && me.mfa_passed && onLogin) {66      const next = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("next") : null;67      router.replace(next && next.startsWith("/") ? next : "/");68    }69  }, [loading, me, pathname, router]);7071  const logout = useCallback(async () => {72    try {73      await api.post("/auth/logout");74    } catch {75      /* ignore */76    }77    setCsrf(null);78    setMe(null);79    router.replace("/login/");80  }, [router]);8182  const value = useMemo<AuthState>(83    () => ({84      me,85      loading,86      refresh,87      logout,88      isOwner: me?.role === "owner" || (me?.scopes ?? []).includes("admin"),89      hasScope: (s: string) => (me?.scopes ?? []).includes(s) || (me?.scopes ?? []).includes("admin"),90    }),91    [me, loading, refresh, logout],92  );93  return <Ctx.Provider value={value}>{children}</Ctx.Provider>;94}9596export function useAuth(): AuthState {97  const c = useContext(Ctx);98  if (!c) throw new Error("useAuth outside AuthProvider");99  return c;100}101
Files · main
Menu

Theme