← back to Grant
components/AuthProvider.tsx
197 lines
'use client';
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
ReactNode,
} from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Loader2 } from 'lucide-react';
/* ─── Types ──────────────────────────────────────────────────────────────── */
export type Role = 'owner' | 'admin' | 'member' | null;
export type ViewMode = 'live' | 'admin';
const VIEW_MODE_KEY = 'grant-view-mode';
interface AuthContextValue {
user: string | null;
role: Role;
/** True when the session maps to an owner/admin users-table account. */
isAdmin: boolean;
/** May mutate data (non-AI writes). Read-only guest tiers are false. */
canWrite: boolean;
/** May use AI discover/generate features. */
canUseAI: boolean;
loading: boolean;
/** Current header toggle position. Forced to 'live' for non-admins. */
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
/** Convenience: admin-only controls should render only when this is true. */
showAdmin: boolean;
login: (username: string, password: string) => Promise<{ error?: string }>;
logout: () => Promise<void>;
}
/* ─── Context ────────────────────────────────────────────────────────────── */
const AuthContext = createContext<AuthContextValue | null>(null);
/* ─── Provider ───────────────────────────────────────────────────────────── */
export function AuthProvider({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<string | null>(null);
const [role, setRole] = useState<Role>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [canWrite, setCanWrite] = useState(false);
const [canUseAI, setCanUseAI] = useState(false);
const [loading, setLoading] = useState(true);
// Default to 'admin' (controls visible) for the operator; persisted choice
// is read on mount to avoid a hydration mismatch.
const [viewMode, setViewModeState] = useState<ViewMode>('admin');
/* Restore persisted toggle position on mount */
useEffect(() => {
try {
const saved = localStorage.getItem(VIEW_MODE_KEY);
if (saved === 'live' || saved === 'admin') setViewModeState(saved);
} catch { /* localStorage unavailable */ }
}, []);
const setViewMode = useCallback((mode: ViewMode) => {
setViewModeState(mode);
try { localStorage.setItem(VIEW_MODE_KEY, mode); } catch { /* ignore */ }
}, []);
/* Fetch session identity (user + role + isAdmin) */
const checkSession = useCallback(async (): Promise<boolean> => {
try {
const res = await fetch('/api/auth/session', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
if (data.authenticated) {
setUser(data.user);
setRole(data.role ?? null);
setIsAdmin(Boolean(data.isAdmin));
setCanWrite(Boolean(data.canWrite));
setCanUseAI(Boolean(data.canUseAI));
return true;
}
}
setUser(null); setRole(null); setIsAdmin(false); setCanWrite(false); setCanUseAI(false);
return false;
} catch {
setUser(null); setRole(null); setIsAdmin(false); setCanWrite(false); setCanUseAI(false);
return false;
}
}, []);
/* Check session on mount */
useEffect(() => {
let cancelled = false;
(async () => {
await checkSession();
if (!cancelled) setLoading(false);
})();
return () => { cancelled = true; };
}, [checkSession]);
/* Redirect unauthenticated users away from protected routes */
useEffect(() => {
if (loading) return;
if (!user && pathname !== '/login') {
router.replace('/login');
}
}, [user, loading, pathname, router]);
/* Login */
const login = useCallback(async (username: string, password: string) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (res.ok && data.success) {
// Re-fetch session so role/isAdmin are populated, not just the username.
await checkSession();
router.push('/');
router.refresh();
return {};
}
return { error: data.error ?? 'Login failed' };
}, [router, checkSession]);
/* Logout */
const logout = useCallback(async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
setUser(null); setRole(null); setIsAdmin(false); setCanWrite(false); setCanUseAI(false);
router.replace('/login');
}, [router]);
/* Loading screen -- shown on every route while session is being verified */
if (loading) {
return (
<div
className="min-h-screen flex flex-col items-center justify-center gap-3"
style={{ backgroundColor: 'var(--color-bg)' }}
>
<Loader2
size={28}
className="animate-spin"
style={{ color: 'var(--color-primary)' }}
aria-hidden="true"
/>
<span
className="text-sm"
style={{ color: 'var(--color-text-secondary)' }}
>
Loading...
</span>
</div>
);
}
// Non-admins can never be in admin view, regardless of the persisted choice.
const effectiveMode: ViewMode = isAdmin ? viewMode : 'live';
const showAdmin = isAdmin && effectiveMode === 'admin';
return (
<AuthContext.Provider
value={{
user,
role,
isAdmin,
canWrite,
canUseAI,
loading,
viewMode: effectiveMode,
setViewMode,
showAdmin,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
/* ─── Hook ───────────────────────────────────────────────────────────────── */
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used inside <AuthProvider>');
}
return ctx;
}