diff --git a/drb-frontend/components/AuthProvider.tsx b/drb-frontend/components/AuthProvider.tsx index 0696013..9481c73 100644 --- a/drb-frontend/components/AuthProvider.tsx +++ b/drb-frontend/components/AuthProvider.tsx @@ -5,6 +5,8 @@ import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/a import { auth } from "@/lib/firebase"; import type { UserRole } from "@/lib/types"; +export type OrgRole = "owner" | "member"; + interface AuthContextType { user: User | null; loading: boolean; @@ -12,7 +14,13 @@ interface AuthContextType { 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({ @@ -22,7 +30,11 @@ const AuthContext = createContext({ isAdmin: false, isOperator: false, ownedNodeIds: [], + orgId: null, + orgRole: null, + isOrgOwner: false, signOut: async () => {}, + refreshClaims: async () => {}, }); export function AuthProvider({ children }: { children: React.ReactNode }) { @@ -30,34 +42,60 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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); - setLoading(false); if (u) { - document.cookie = "drb_session=1; path=/; SameSite=Strict"; - const result = await u.getIdTokenResult(true); - 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[]) ?? []); + await applyClaims(u, true); } else { document.cookie = "drb_session=; path=/; max-age=0"; setRole(null); setOwnedNodeIds([]); + setOrgId(null); + setOrgRole(null); } + setLoading(false); }); }, []); @@ -66,11 +104,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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} ); diff --git a/drb-frontend/components/ChromeSwitcher.tsx b/drb-frontend/components/ChromeSwitcher.tsx index 20034d8..2e9ff24 100644 --- a/drb-frontend/components/ChromeSwitcher.tsx +++ b/drb-frontend/components/ChromeSwitcher.tsx @@ -1,22 +1,49 @@ "use client"; -import { usePathname } from "next/navigation"; +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useAuth } from "@/components/AuthProvider"; import { Nav } from "@/components/Nav"; import { MarketingHeader } from "@/components/marketing/MarketingHeader"; import { MarketingFooter } from "@/components/marketing/MarketingFooter"; // Public marketing surface — exact paths, not prefixes, so e.g. /features/x // (if it ever exists) doesn't accidentally get pulled into marketing chrome. -const MARKETING_PATHS = new Set(["/", "/features", "/pricing", "/faq"]); +// Keep in sync with PUBLIC_PATHS in middleware.ts (that one decides whether +// to redirect at all; this one just picks page chrome). +const MARKETING_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/privacy"]); + +// Pages a signed-in user with no org_id claim must still be able to reach — +// otherwise the redirect below would loop against itself, or lock someone +// out of the one screen (/onboarding) that fixes their account. +const NO_ORG_ALLOWED_PATHS = new Set(["/onboarding", "/login", "/signup", "/profile"]); /** * Picks page chrome by route: the public marketing pages get a full-bleed * layout with their own header/footer, everything else (the authenticated * app, including /login and /settings) keeps the existing app Nav + padded * main container. + * + * Also carries AuthProvider's no-claim guard (SAAS_PLAN.md B3): a signed-in + * Firebase user with no org_id claim is a real session that is nonetheless + * provisioned into nothing — AuthProvider already refuses to set the + * drb_session cookie for them, so middleware.ts's redirect only covers + * "not signed in at all". This effect covers the other case: signed in, no + * org, anywhere in the app — send them to /onboarding rather than letting + * every page's data hooks fail open or silently return nothing. */ export function ChromeSwitcher({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const { user, loading, orgId } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (loading) return; + if (!user) return; // not signed in — middleware.ts already routes this to /login + if (orgId) return; + if (MARKETING_PATHS.has(pathname) || NO_ORG_ALLOWED_PATHS.has(pathname)) return; + router.replace("/onboarding"); + }, [loading, user, orgId, pathname, router]); if (MARKETING_PATHS.has(pathname)) { return ( diff --git a/drb-frontend/lib/useAlerts.ts b/drb-frontend/lib/useAlerts.ts index 7c85d47..5826dd2 100644 --- a/drb-frontend/lib/useAlerts.ts +++ b/drb-frontend/lib/useAlerts.ts @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { onAuthStateChanged } from "firebase/auth"; import { db, auth } from "@/lib/firebase"; +import { useAuth } from "@/components/AuthProvider"; import type { AlertEvent } from "@/lib/types"; const toISO = (v: unknown): string => @@ -14,6 +15,7 @@ export function useAlerts(limitCount = 50) { const [alerts, setAlerts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -26,9 +28,15 @@ export function useAlerts(limitCount = 50) { setLoading(false); return; } + if (!orgId) { + setAlerts([]); + setLoading(false); + return; + } const q = query( collection(db, "alert_events"), + where("org_id", "==", orgId), orderBy("triggered_at", "desc"), limit(limitCount) ); @@ -52,13 +60,14 @@ export function useAlerts(limitCount = 50) { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, [limitCount]); + }, [limitCount, orgId]); return { alerts, loading, error }; } export function useUnacknowledgedAlerts() { const [alerts, setAlerts] = useState([]); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -70,9 +79,14 @@ export function useUnacknowledgedAlerts() { setAlerts([]); return; } + if (!orgId) { + setAlerts([]); + return; + } const q = query( collection(db, "alert_events"), + where("org_id", "==", orgId), where("acknowledged", "==", false), orderBy("triggered_at", "desc"), limit(100) @@ -89,7 +103,7 @@ export function useUnacknowledgedAlerts() { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, []); + }, [orgId]); return alerts; } diff --git a/drb-frontend/lib/useCalls.ts b/drb-frontend/lib/useCalls.ts index e118da6..6af6aa1 100644 --- a/drb-frontend/lib/useCalls.ts +++ b/drb-frontend/lib/useCalls.ts @@ -4,12 +4,14 @@ import { useEffect, useState } from "react"; import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { onAuthStateChanged } from "firebase/auth"; import { db, auth } from "@/lib/firebase"; +import { useAuth } from "@/components/AuthProvider"; import type { CallRecord } from "@/lib/types"; export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) { const [calls, setCalls] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { orgId } = useAuth(); // Stable ms values so the effect dependency doesn't fire on every render const dateFromMs = dateFrom?.getTime(); @@ -26,11 +28,21 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) { setLoading(false); return; } + // No org_id claim yet (still resolving, or genuinely unprovisioned — + // see ChromeSwitcher's no-claim guard) — an unfiltered query here + // would be exactly the cross-tenant read this scoping exists to + // close, so wait rather than fall back to "query everything". + if (!orgId) { + setCalls([]); + setLoading(false); + return; + } const from = dateFromMs != null ? new Date(dateFromMs) : undefined; const to = dateToMs != null ? new Date(dateToMs) : undefined; const constraints = [ + where("org_id", "==", orgId), ...(from ? [where("started_at", ">=", from)] : []), ...(to ? [where("started_at", "<=", to)] : []), orderBy("started_at", "desc"), @@ -52,7 +64,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, [limitCount, dateFromMs, dateToMs]); + }, [limitCount, dateFromMs, dateToMs, orgId]); return { calls, loading, error }; } @@ -60,6 +72,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) { export function useCallsByIncident(incidentId: string | null) { const [calls, setCalls] = useState([]); const [loading, setLoading] = useState(true); + const { orgId } = useAuth(); useEffect(() => { if (!incidentId) { setLoading(false); return; } @@ -68,11 +81,16 @@ export function useCallsByIncident(incidentId: string | null) { const unsubAuth = onAuthStateChanged(auth, (user) => { if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; } if (!user) { setLoading(false); return; } + if (!orgId) { setCalls([]); setLoading(false); return; } const toISO = (v: any): string | null => v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null); - const q = query(collection(db, "calls"), where("incident_ids", "array-contains", incidentId)); + const q = query( + collection(db, "calls"), + where("org_id", "==", orgId), + where("incident_ids", "array-contains", incidentId) + ); unsubFirestore = onSnapshot(q, (snap) => { const docs = snap.docs.map((d) => { const data = d.data(); @@ -85,13 +103,14 @@ export function useCallsByIncident(incidentId: string | null) { }); return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, [incidentId]); + }, [incidentId, orgId]); return { calls, loading }; } export function useActiveCalls() { const [calls, setCalls] = useState([]); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -103,8 +122,12 @@ export function useActiveCalls() { setCalls([]); return; } + if (!orgId) { + setCalls([]); + return; + } - const q = query(collection(db, "calls"), where("status", "==", "active")); + const q = query(collection(db, "calls"), where("org_id", "==", orgId), where("status", "==", "active")); const toISO = (v: any): string | null => v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null); unsubFirestore = onSnapshot(q, (snap) => { @@ -119,7 +142,7 @@ export function useActiveCalls() { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, []); + }, [orgId]); return calls; } diff --git a/drb-frontend/lib/useIncidents.ts b/drb-frontend/lib/useIncidents.ts index f2714f0..55446fa 100644 --- a/drb-frontend/lib/useIncidents.ts +++ b/drb-frontend/lib/useIncidents.ts @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { collection, doc, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { onAuthStateChanged } from "firebase/auth"; import { db, auth } from "@/lib/firebase"; +import { useAuth } from "@/components/AuthProvider"; import type { IncidentRecord } from "@/lib/types"; const toISO = (v: unknown): string => @@ -14,6 +15,7 @@ export function useIncidents(limitCount = 100) { const [incidents, setIncidents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -26,9 +28,15 @@ export function useIncidents(limitCount = 100) { setLoading(false); return; } + if (!orgId) { + setIncidents([]); + setLoading(false); + return; + } const q = query( collection(db, "incidents"), + where("org_id", "==", orgId), orderBy("started_at", "desc"), limit(limitCount) ); @@ -53,7 +61,7 @@ export function useIncidents(limitCount = 100) { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, [limitCount]); + }, [limitCount, orgId]); return { incidents, loading, error }; } @@ -97,6 +105,7 @@ export function useIncident(incidentId: string | null) { export function useActiveIncidents() { const [incidents, setIncidents] = useState([]); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -108,8 +117,12 @@ export function useActiveIncidents() { setIncidents([]); return; } + if (!orgId) { + setIncidents([]); + return; + } - const q = query(collection(db, "incidents"), where("status", "==", "active")); + const q = query(collection(db, "incidents"), where("org_id", "==", orgId), where("status", "==", "active")); unsubFirestore = onSnapshot(q, (snap) => { setIncidents(snap.docs.map((d) => { const data = d.data(); @@ -126,7 +139,7 @@ export function useActiveIncidents() { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, []); + }, [orgId]); return incidents; } diff --git a/drb-frontend/lib/useNodes.ts b/drb-frontend/lib/useNodes.ts index 7f3ba33..b3aca79 100644 --- a/drb-frontend/lib/useNodes.ts +++ b/drb-frontend/lib/useNodes.ts @@ -1,15 +1,17 @@ "use client"; import { useEffect, useState } from "react"; -import { collection, onSnapshot, query, FirestoreError } from "firebase/firestore"; +import { collection, onSnapshot, query, where, FirestoreError } from "firebase/firestore"; import { onAuthStateChanged } from "firebase/auth"; import { db, auth } from "@/lib/firebase"; +import { useAuth } from "@/components/AuthProvider"; import type { NodeRecord } from "@/lib/types"; export function useNodes() { const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -22,8 +24,13 @@ export function useNodes() { setLoading(false); return; } + if (!orgId) { + setNodes([]); + setLoading(false); + return; + } - const q = query(collection(db, "nodes")); + const q = query(collection(db, "nodes"), where("org_id", "==", orgId)); unsubFirestore = onSnapshot(q, (snap) => { setNodes(snap.docs.map((d) => d.data() as NodeRecord)); setLoading(false); @@ -34,7 +41,7 @@ export function useNodes() { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, []); + }, [orgId]); return { nodes, loading, error }; } diff --git a/drb-frontend/lib/useSystems.ts b/drb-frontend/lib/useSystems.ts index fb05613..89a8ac4 100644 --- a/drb-frontend/lib/useSystems.ts +++ b/drb-frontend/lib/useSystems.ts @@ -1,15 +1,17 @@ "use client"; import { useEffect, useState } from "react"; -import { collection, onSnapshot, FirestoreError } from "firebase/firestore"; +import { collection, onSnapshot, query, where, FirestoreError } from "firebase/firestore"; import { onAuthStateChanged } from "firebase/auth"; import { db, auth } from "@/lib/firebase"; +import { useAuth } from "@/components/AuthProvider"; import type { SystemRecord } from "@/lib/types"; export function useSystems() { const [systems, setSystems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { orgId } = useAuth(); useEffect(() => { let unsubFirestore: (() => void) | undefined; @@ -22,8 +24,14 @@ export function useSystems() { setLoading(false); return; } + if (!orgId) { + setSystems([]); + setLoading(false); + return; + } - unsubFirestore = onSnapshot(collection(db, "systems"), (snap) => { + const q = query(collection(db, "systems"), where("org_id", "==", orgId)); + unsubFirestore = onSnapshot(q, (snap) => { setSystems(snap.docs.map((d) => d.data() as SystemRecord)); setLoading(false); }, (err: FirestoreError) => { console.error("useSystems:", err); setError(err.message); setLoading(false); }); @@ -33,7 +41,7 @@ export function useSystems() { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; - }, []); + }, [orgId]); return { systems, loading, error }; } diff --git a/drb-frontend/middleware.ts b/drb-frontend/middleware.ts index 3577c3a..6cff905 100644 --- a/drb-frontend/middleware.ts +++ b/drb-frontend/middleware.ts @@ -3,7 +3,18 @@ import { NextRequest, NextResponse } from "next/server"; // Public marketing pages — no session required. Keep this in sync with // MARKETING_PATHS in components/ChromeSwitcher.tsx (that one picks page // chrome; this one decides whether to redirect at all). -const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq"]); +const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/privacy"]); + +// /signup and /onboarding are deliberately NOT gated by the drb_session +// cookie here, even though they aren't "public" in the sense of not needing +// an account — AuthProvider only sets that cookie once a user has an org_id +// claim (SAAS_PLAN.md B3's no-claim guard), and /onboarding exists +// specifically for a signed-in user who doesn't have one yet. Gating it on +// the same cookie would bounce the exact users who need it back to /login +// before ChromeSwitcher's client-side redirect ever runs. Both pages do +// their own client-side auth check (redirect to /login if genuinely signed +// out) instead. +const NO_SESSION_COOKIE_GATE = new Set(["/signup", "/onboarding"]); // NOTE: this is a UX redirect only, not a security boundary — it just checks // a client-set cookie's presence. Real enforcement is server-side, in @@ -12,7 +23,7 @@ export function middleware(request: NextRequest) { const session = request.cookies.get("drb_session"); const { pathname } = request.nextUrl; - if (PUBLIC_PATHS.has(pathname)) { + if (PUBLIC_PATHS.has(pathname) || NO_SESSION_COOKIE_GATE.has(pathname)) { return NextResponse.next(); }