Files
server-26/drb-frontend/components/AuthProvider.tsx
T
Logan CusanoandClaude Opus 5 c7f985df42 Scope every Firestore hook to org_id and stop granting sessions to nobody's org
Frontend half of SAAS_PLAN.md B2/B3. The backend commits so far (org_id
stamping, Firestore rules) don't protect anything by themselves - every
hook in lib/use*.ts reads Firestore directly from the browser
(onSnapshot(collection(db, ...))), which is why B1's rules commit called
this out as the actual read path in the first place. Until these hooks
filter by org_id, the rules just turn "any signed-in user sees everything"
into "any signed-in user sees nothing" the moment they're deployed, because
nothing supplies the org_id the rules now require.

useCalls (all three exports), useIncidents (useIncidents +
useActiveIncidents), useNodes, useSystems, and useAlerts (both exports) now
pull orgId from AuthProvider and add where("org_id","==",orgId) to their
query. If orgId is falsy - not yet resolved, or the account genuinely has
no org - each hook returns empty rather than falling back to an unfiltered
query, which would silently reopen the exact leak this closes for anyone
whose claim hasn't loaded yet. useIncident/useNodes single-doc-by-id reads
and useTrips are intentionally untouched: single-doc reads are already
covered by the rules directly, and trips has no org_id at all (see the
previous commit's trips.py gating - it's staying founding-org-only via B7,
not becoming tenant-scoped).

AuthProvider grew orgId/orgRole state (read from the org_id/org_role custom
claims POST /auth/signup sets) and a refreshClaims() escape hatch for the
signup flow to force a claims refetch after provisioning. The load-bearing
change is in when it sets the drb_session cookie: only when a claim carries
org_id. A signed-in user with no org - the accidental-signup hole
SAAS_PLAN.md 2.2/2.3 flagged, where Google sign-in on /login auto-creates a
Firebase account with no role or org claim at all - now gets no cookie,
which starts them at "no data, by construction" rather than "viewer role,
full read access" once combined with the rules deployed earlier.

ChromeSwitcher carries the other half of that guard: a signed-in user with
no orgId, anywhere outside the marketing pages, gets redirected to
/onboarding (added to the frontend in the next commit) instead of letting
every page's data hooks just quietly return empty forever. middleware.ts
adds /signup and /onboarding to a new no-cookie-gate list, since
AuthProvider's cookie logic means an unprovisioned user by definition has
no drb_session cookie - gating those two routes on it would bounce exactly
the users who need them back to /login before the client-side redirect
above ever runs. /terms and /privacy (next-next commit) are pre-added to
both PUBLIC_PATHS and ChromeSwitcher's MARKETING_PATHS here so that commit
doesn't need to touch routing files.

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:33:46 -04:00

127 lines
4.0 KiB
TypeScript

"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<void>;
/** Force-refetch the ID token's claims — call after POST /auth/signup so orgId picks up immediately. */
refreshClaims: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
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<User | null>(null);
const [loading, setLoading] = useState(true);
const [role, setRole] = useState<UserRole | null>(null);
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
const [orgId, setOrgId] = useState<string | null>(null);
const [orgRole, setOrgRole] = useState<OrgRole | null>(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 (
<AuthContext.Provider
value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, orgId, orgRole, isOrgOwner, signOut, refreshClaims }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}