← back to Hub
components/AuthProvider.tsx
150 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 = 'admin' | 'member' | 'viewer' | null;
interface AuthContextValue {
user: string | null;
role: Role;
isAdmin: boolean;
canWrite: boolean;
canUseAI: boolean;
loading: 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);
/* Fetch session identity (user + capabilities) */
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) {
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 */
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>
);
}
return (
<AuthContext.Provider value={{ user, role, isAdmin, canWrite, canUseAI, loading, 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;
}