← 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;
}