"use client"; import { createContext, useContext, useEffect, useState } from "react"; import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth"; import { auth } from "@/lib/firebase"; import type { UserRole } from "@/lib/types"; export type OrgRole = "owner" | "member"; interface AuthContextType { user: User | null; loading: boolean; role: UserRole | null; isAdmin: boolean; isOperator: boolean; ownedNodeIds: string[]; /** Tenant claim — null means this account isn't provisioned into an org yet. */ orgId: string | null; orgRole: OrgRole | null; isOrgOwner: boolean; signOut: () => Promise; /** Force-refetch the ID token's claims — call after POST /auth/signup so orgId picks up immediately. */ refreshClaims: () => Promise; } const AuthContext = createContext({ user: null, loading: true, role: null, isAdmin: false, isOperator: false, ownedNodeIds: [], orgId: null, orgRole: null, isOrgOwner: false, signOut: async () => {}, refreshClaims: async () => {}, }); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [role, setRole] = useState(null); const [ownedNodeIds, setOwnedNodeIds] = useState([]); const [orgId, setOrgId] = useState(null); const [orgRole, setOrgRole] = useState(null); async function applyClaims(u: User, forceRefresh: boolean) { const result = await u.getIdTokenResult(forceRefresh); const claims = result.claims; // Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean let effectiveRole: UserRole = "viewer"; if (claims.role === "admin" || claims.admin) { effectiveRole = "admin"; } else if (claims.role === "operator") { effectiveRole = "operator"; } else if (claims.role === "viewer") { effectiveRole = "viewer"; } setRole(effectiveRole); setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []); // org_id/org_role are set by POST /auth/signup. No org_id claim means // this account was created (e.g. via Google sign-in's implicit account // creation) but never provisioned — see the no-claim guard below, which // is what stops that from being a live data exposure. const claimOrgId = typeof claims.org_id === "string" ? claims.org_id : null; const claimOrgRole = claims.org_role === "owner" || claims.org_role === "member" ? claims.org_role : null; setOrgId(claimOrgId); setOrgRole(claimOrgRole); // drb_session is only a UX redirect signal (middleware.ts), not a // security boundary (see CLAUDE.md) — but it must not be set for an // unprovisioned account, or the middleware will wave them straight into // /dashboard instead of /onboarding. if (claimOrgId) { document.cookie = "drb_session=1; path=/; SameSite=Strict"; } else { document.cookie = "drb_session=; path=/; max-age=0"; } } useEffect(() => { return onAuthStateChanged(auth, async (u) => { setUser(u); if (u) { await applyClaims(u, true); } else { document.cookie = "drb_session=; path=/; max-age=0"; setRole(null); setOwnedNodeIds([]); setOrgId(null); setOrgRole(null); } setLoading(false); }); }, []); async function signOut() { await firebaseSignOut(auth); document.cookie = "drb_session=; path=/; max-age=0"; } async function refreshClaims() { if (auth.currentUser) await applyClaims(auth.currentUser, true); } const isAdmin = role === "admin"; const isOperator = role === "operator"; const isOrgOwner = orgRole === "owner"; return ( {children} ); } export function useAuth() { return useContext(AuthContext); }