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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a3681ea698
commit
c7f985df42
@@ -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<CallRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<CallRecord[]>([]);
|
||||
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<CallRecord[]>([]);
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user