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