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:
Logan Cusano
2026-08-18 20:33:46 -04:00
co-authored by Claude Opus 5
parent a3681ea698
commit c7f985df42
8 changed files with 186 additions and 38 deletions
+63 -18
View File
@@ -5,6 +5,8 @@ import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/a
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import type { UserRole } from "@/lib/types"; import type { UserRole } from "@/lib/types";
export type OrgRole = "owner" | "member";
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
loading: boolean; loading: boolean;
@@ -12,7 +14,13 @@ interface AuthContextType {
isAdmin: boolean; isAdmin: boolean;
isOperator: boolean; isOperator: boolean;
ownedNodeIds: string[]; 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>; 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>({ const AuthContext = createContext<AuthContextType>({
@@ -22,7 +30,11 @@ const AuthContext = createContext<AuthContextType>({
isAdmin: false, isAdmin: false,
isOperator: false, isOperator: false,
ownedNodeIds: [], ownedNodeIds: [],
orgId: null,
orgRole: null,
isOrgOwner: false,
signOut: async () => {}, signOut: async () => {},
refreshClaims: async () => {},
}); });
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
@@ -30,34 +42,60 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [role, setRole] = useState<UserRole | null>(null); const [role, setRole] = useState<UserRole | null>(null);
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]); 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(() => { useEffect(() => {
return onAuthStateChanged(auth, async (u) => { return onAuthStateChanged(auth, async (u) => {
setUser(u); setUser(u);
setLoading(false);
if (u) { if (u) {
document.cookie = "drb_session=1; path=/; SameSite=Strict"; await applyClaims(u, true);
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[]) ?? []);
} else { } else {
document.cookie = "drb_session=; path=/; max-age=0"; document.cookie = "drb_session=; path=/; max-age=0";
setRole(null); setRole(null);
setOwnedNodeIds([]); 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"; document.cookie = "drb_session=; path=/; max-age=0";
} }
async function refreshClaims() {
if (auth.currentUser) await applyClaims(auth.currentUser, true);
}
const isAdmin = role === "admin"; const isAdmin = role === "admin";
const isOperator = role === "operator"; const isOperator = role === "operator";
const isOrgOwner = orgRole === "owner";
return ( return (
<AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}> <AuthContext.Provider
value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, orgId, orgRole, isOrgOwner, signOut, refreshClaims }}
>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+29 -2
View File
@@ -1,22 +1,49 @@
"use client"; "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 { Nav } from "@/components/Nav";
import { MarketingHeader } from "@/components/marketing/MarketingHeader"; import { MarketingHeader } from "@/components/marketing/MarketingHeader";
import { MarketingFooter } from "@/components/marketing/MarketingFooter"; import { MarketingFooter } from "@/components/marketing/MarketingFooter";
// Public marketing surface — exact paths, not prefixes, so e.g. /features/x // Public marketing surface — exact paths, not prefixes, so e.g. /features/x
// (if it ever exists) doesn't accidentally get pulled into marketing chrome. // (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 * Picks page chrome by route: the public marketing pages get a full-bleed
* layout with their own header/footer, everything else (the authenticated * layout with their own header/footer, everything else (the authenticated
* app, including /login and /settings) keeps the existing app Nav + padded * app, including /login and /settings) keeps the existing app Nav + padded
* main container. * 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 }) { export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); 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)) { if (MARKETING_PATHS.has(pathname)) {
return ( return (
+16 -2
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth"; import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase"; import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { AlertEvent } from "@/lib/types"; import type { AlertEvent } from "@/lib/types";
const toISO = (v: unknown): string => const toISO = (v: unknown): string =>
@@ -14,6 +15,7 @@ export function useAlerts(limitCount = 50) {
const [alerts, setAlerts] = useState<AlertEvent[]>([]); const [alerts, setAlerts] = useState<AlertEvent[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -26,9 +28,15 @@ export function useAlerts(limitCount = 50) {
setLoading(false); setLoading(false);
return; return;
} }
if (!orgId) {
setAlerts([]);
setLoading(false);
return;
}
const q = query( const q = query(
collection(db, "alert_events"), collection(db, "alert_events"),
where("org_id", "==", orgId),
orderBy("triggered_at", "desc"), orderBy("triggered_at", "desc"),
limit(limitCount) limit(limitCount)
); );
@@ -52,13 +60,14 @@ export function useAlerts(limitCount = 50) {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, [limitCount]); }, [limitCount, orgId]);
return { alerts, loading, error }; return { alerts, loading, error };
} }
export function useUnacknowledgedAlerts() { export function useUnacknowledgedAlerts() {
const [alerts, setAlerts] = useState<AlertEvent[]>([]); const [alerts, setAlerts] = useState<AlertEvent[]>([]);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -70,9 +79,14 @@ export function useUnacknowledgedAlerts() {
setAlerts([]); setAlerts([]);
return; return;
} }
if (!orgId) {
setAlerts([]);
return;
}
const q = query( const q = query(
collection(db, "alert_events"), collection(db, "alert_events"),
where("org_id", "==", orgId),
where("acknowledged", "==", false), where("acknowledged", "==", false),
orderBy("triggered_at", "desc"), orderBy("triggered_at", "desc"),
limit(100) limit(100)
@@ -89,7 +103,7 @@ export function useUnacknowledgedAlerts() {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, []); }, [orgId]);
return alerts; return alerts;
} }
+28 -5
View File
@@ -4,12 +4,14 @@ import { useEffect, useState } from "react";
import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth"; import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase"; import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { CallRecord } from "@/lib/types"; import type { CallRecord } from "@/lib/types";
export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) { export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
const [calls, setCalls] = useState<CallRecord[]>([]); const [calls, setCalls] = useState<CallRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { orgId } = useAuth();
// Stable ms values so the effect dependency doesn't fire on every render // Stable ms values so the effect dependency doesn't fire on every render
const dateFromMs = dateFrom?.getTime(); const dateFromMs = dateFrom?.getTime();
@@ -26,11 +28,21 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
setLoading(false); setLoading(false);
return; 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 from = dateFromMs != null ? new Date(dateFromMs) : undefined;
const to = dateToMs != null ? new Date(dateToMs) : undefined; const to = dateToMs != null ? new Date(dateToMs) : undefined;
const constraints = [ const constraints = [
where("org_id", "==", orgId),
...(from ? [where("started_at", ">=", from)] : []), ...(from ? [where("started_at", ">=", from)] : []),
...(to ? [where("started_at", "<=", to)] : []), ...(to ? [where("started_at", "<=", to)] : []),
orderBy("started_at", "desc"), orderBy("started_at", "desc"),
@@ -52,7 +64,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, [limitCount, dateFromMs, dateToMs]); }, [limitCount, dateFromMs, dateToMs, orgId]);
return { calls, loading, error }; return { calls, loading, error };
} }
@@ -60,6 +72,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
export function useCallsByIncident(incidentId: string | null) { export function useCallsByIncident(incidentId: string | null) {
const [calls, setCalls] = useState<CallRecord[]>([]); const [calls, setCalls] = useState<CallRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
if (!incidentId) { setLoading(false); return; } if (!incidentId) { setLoading(false); return; }
@@ -68,11 +81,16 @@ export function useCallsByIncident(incidentId: string | null) {
const unsubAuth = onAuthStateChanged(auth, (user) => { const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; } if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user) { setLoading(false); return; } if (!user) { setLoading(false); return; }
if (!orgId) { setCalls([]); setLoading(false); return; }
const toISO = (v: any): string | null => const toISO = (v: any): string | null =>
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : 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) => { unsubFirestore = onSnapshot(q, (snap) => {
const docs = snap.docs.map((d) => { const docs = snap.docs.map((d) => {
const data = d.data(); const data = d.data();
@@ -85,13 +103,14 @@ export function useCallsByIncident(incidentId: string | null) {
}); });
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); }; return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
}, [incidentId]); }, [incidentId, orgId]);
return { calls, loading }; return { calls, loading };
} }
export function useActiveCalls() { export function useActiveCalls() {
const [calls, setCalls] = useState<CallRecord[]>([]); const [calls, setCalls] = useState<CallRecord[]>([]);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -103,8 +122,12 @@ export function useActiveCalls() {
setCalls([]); setCalls([]);
return; 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 => const toISO = (v: any): string | null =>
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null); v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
unsubFirestore = onSnapshot(q, (snap) => { unsubFirestore = onSnapshot(q, (snap) => {
@@ -119,7 +142,7 @@ export function useActiveCalls() {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, []); }, [orgId]);
return calls; return calls;
} }
+16 -3
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { collection, doc, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { collection, doc, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth"; import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase"; import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { IncidentRecord } from "@/lib/types"; import type { IncidentRecord } from "@/lib/types";
const toISO = (v: unknown): string => const toISO = (v: unknown): string =>
@@ -14,6 +15,7 @@ export function useIncidents(limitCount = 100) {
const [incidents, setIncidents] = useState<IncidentRecord[]>([]); const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -26,9 +28,15 @@ export function useIncidents(limitCount = 100) {
setLoading(false); setLoading(false);
return; return;
} }
if (!orgId) {
setIncidents([]);
setLoading(false);
return;
}
const q = query( const q = query(
collection(db, "incidents"), collection(db, "incidents"),
where("org_id", "==", orgId),
orderBy("started_at", "desc"), orderBy("started_at", "desc"),
limit(limitCount) limit(limitCount)
); );
@@ -53,7 +61,7 @@ export function useIncidents(limitCount = 100) {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, [limitCount]); }, [limitCount, orgId]);
return { incidents, loading, error }; return { incidents, loading, error };
} }
@@ -97,6 +105,7 @@ export function useIncident(incidentId: string | null) {
export function useActiveIncidents() { export function useActiveIncidents() {
const [incidents, setIncidents] = useState<IncidentRecord[]>([]); const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -108,8 +117,12 @@ export function useActiveIncidents() {
setIncidents([]); setIncidents([]);
return; 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) => { unsubFirestore = onSnapshot(q, (snap) => {
setIncidents(snap.docs.map((d) => { setIncidents(snap.docs.map((d) => {
const data = d.data(); const data = d.data();
@@ -126,7 +139,7 @@ export function useActiveIncidents() {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, []); }, [orgId]);
return incidents; return incidents;
} }
+10 -3
View File
@@ -1,15 +1,17 @@
"use client"; "use client";
import { useEffect, useState } from "react"; 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 { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase"; import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { NodeRecord } from "@/lib/types"; import type { NodeRecord } from "@/lib/types";
export function useNodes() { export function useNodes() {
const [nodes, setNodes] = useState<NodeRecord[]>([]); const [nodes, setNodes] = useState<NodeRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -22,8 +24,13 @@ export function useNodes() {
setLoading(false); setLoading(false);
return; 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) => { unsubFirestore = onSnapshot(q, (snap) => {
setNodes(snap.docs.map((d) => d.data() as NodeRecord)); setNodes(snap.docs.map((d) => d.data() as NodeRecord));
setLoading(false); setLoading(false);
@@ -34,7 +41,7 @@ export function useNodes() {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, []); }, [orgId]);
return { nodes, loading, error }; return { nodes, loading, error };
} }
+11 -3
View File
@@ -1,15 +1,17 @@
"use client"; "use client";
import { useEffect, useState } from "react"; 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 { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase"; import { db, auth } from "@/lib/firebase";
import { useAuth } from "@/components/AuthProvider";
import type { SystemRecord } from "@/lib/types"; import type { SystemRecord } from "@/lib/types";
export function useSystems() { export function useSystems() {
const [systems, setSystems] = useState<SystemRecord[]>([]); const [systems, setSystems] = useState<SystemRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { orgId } = useAuth();
useEffect(() => { useEffect(() => {
let unsubFirestore: (() => void) | undefined; let unsubFirestore: (() => void) | undefined;
@@ -22,8 +24,14 @@ export function useSystems() {
setLoading(false); setLoading(false);
return; 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)); setSystems(snap.docs.map((d) => d.data() as SystemRecord));
setLoading(false); setLoading(false);
}, (err: FirestoreError) => { console.error("useSystems:", err); setError(err.message); setLoading(false); }); }, (err: FirestoreError) => { console.error("useSystems:", err); setError(err.message); setLoading(false); });
@@ -33,7 +41,7 @@ export function useSystems() {
unsubAuth(); unsubAuth();
if (unsubFirestore) unsubFirestore(); if (unsubFirestore) unsubFirestore();
}; };
}, []); }, [orgId]);
return { systems, loading, error }; return { systems, loading, error };
} }
+13 -2
View File
@@ -3,7 +3,18 @@ import { NextRequest, NextResponse } from "next/server";
// Public marketing pages — no session required. Keep this in sync with // Public marketing pages — no session required. Keep this in sync with
// MARKETING_PATHS in components/ChromeSwitcher.tsx (that one picks page // MARKETING_PATHS in components/ChromeSwitcher.tsx (that one picks page
// chrome; this one decides whether to redirect at all). // 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 // 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 // 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 session = request.cookies.get("drb_session");
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (PUBLIC_PATHS.has(pathname)) { if (PUBLIC_PATHS.has(pathname) || NO_SESSION_COOKIE_GATE.has(pathname)) {
return NextResponse.next(); return NextResponse.next();
} }