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