← back to Norma
components/AuthProvider.tsx
184 lines
'use client';
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
ReactNode,
} from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Loader2 } from 'lucide-react';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface AuthContextValue {
user: string | null;
role: 'admin' | 'staff' | 'intern' | 'pulse' | null;
orgId: string | null;
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<AuthContextValue['role']>(null);
const [orgId, setOrgId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
/* Check session on mount */
useEffect(() => {
let cancelled = false;
async function checkSession() {
try {
const res = await fetch('/api/auth/session', { credentials: 'include' });
if (!cancelled) {
if (res.ok) {
const data = await res.json();
if (data.authenticated) {
setUser(data.user);
setRole(data.role || 'admin');
setOrgId(data.orgId || null);
// Persist role for orgFetch and OrgProvider
if (typeof window !== 'undefined') {
localStorage.setItem('norma-user-role', data.role || 'admin');
if (data.orgId) localStorage.setItem('norma-user-orgid', data.orgId);
}
} else {
setUser(null);
setRole(null);
setOrgId(null);
}
} else {
setUser(null);
setRole(null);
setOrgId(null);
}
}
} catch {
if (!cancelled) {
setUser(null);
setRole(null);
setOrgId(null);
}
} finally {
if (!cancelled) setLoading(false);
}
}
checkSession();
return () => { cancelled = true; };
}, []);
/* Redirect unauthenticated users away from protected routes */
useEffect(() => {
if (loading) return;
// Allow public routes without auth
const publicPaths = ['/login', '/pulse'];
const isPublic = publicPaths.some((p) => pathname.startsWith(p));
if (!user && !isPublic) {
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) {
setUser(username);
const loginRole = data.role || 'admin';
const loginOrgId = data.orgId || null;
setRole(loginRole);
setOrgId(loginOrgId);
// Persist role and orgId for orgFetch and OrgProvider
if (typeof window !== 'undefined') {
localStorage.setItem('norma-user-role', loginRole);
if (loginOrgId) localStorage.setItem('norma-user-orgid', loginOrgId);
else localStorage.removeItem('norma-user-orgid');
}
// Role-based redirect: pulse goes to /pulse, admin/staff go to /
if (loginRole === 'pulse') {
router.push('/pulse');
} else {
router.push('/');
}
router.refresh();
return {};
}
return { error: data.error ?? 'Login failed' };
}, [router]);
/* Logout */
const logout = useCallback(async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
setUser(null);
setRole(null);
setOrgId(null);
if (typeof window !== 'undefined') {
localStorage.removeItem('norma-user-role');
localStorage.removeItem('norma-user-orgid');
}
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>
);
}
return (
<AuthContext.Provider value={{ user, role, orgId, 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;
}