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
@@ -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<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>({
|
||||
@@ -22,7 +30,11 @@ const AuthContext = createContext<AuthContextType>({
|
||||
isAdmin: false,
|
||||
isOperator: false,
|
||||
ownedNodeIds: [],
|
||||
orgId: null,
|
||||
orgRole: null,
|
||||
isOrgOwner: false,
|
||||
signOut: async () => {},
|
||||
refreshClaims: async () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
@@ -30,15 +42,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
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);
|
||||
|
||||
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);
|
||||
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
|
||||
@@ -53,11 +61,41 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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 (
|
||||
<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}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<AlertEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<AlertEvent[]>([]);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<IncidentRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<IncidentRecord[]>([]);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<NodeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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<SystemRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user