Compare commits

..
3 Commits
Author SHA1 Message Date
Logan CusanoandClaude Opus 5 4842725a03 Escalate a depleted Gemini balance the same way as a dead model ID
Build & Deploy / Build & push images (push) Successful in 4m8s
Build & Deploy / Deploy to VM (push) Successful in 2m2s
Correcting the model IDs got past the 404s and straight into 429 "Your
prepayment credits are depleted" on every call, so the LLM correlation tier is
still down -- same symptom, different cause, and the previous commit would have
logged it as an ordinary per-call WARNING and buried it exactly like the last
one.

An empty balance shares a status code with an ordinary rate limit but is the
opposite kind of problem: a rate limit clears on its own, a dead account never
does. The match is on the billing wording ("credits are depleted",
"prepayment", "billing") rather than on 429, so a burst of rate limiting still
reads as WARNING while an unpayable account escalates to the once-per-model
ERROR that names the fix.

The two escalation paths now share _log_tier_down, which is also where the
once-per-model suppression lives -- this code runs on every call at radio
traffic volume, so an ERROR per call would be its own kind of noise.

38 correlator tests still pass. No new environment variables, so CI deploys
this without an ansible run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:37:27 -04:00
Logan CusanoandClaude Opus 5 2a1d52b7af Add a real signup path instead of the accidental one
SAAS_PLAN.md 2.2: there was no /signup page. The only self-serve path was
Google sign-in on /login, which auto-provisions a Firebase account with no
role or org claim at all - previously that meant "viewer role, full read
access" the moment the AuthProvider cookie logic (previous commit) let it
through. That's closed now regardless; this commit is the other side of it
- giving people an actual way in.

app/signup/page.tsx: email/password (createUserWithEmailAndPassword) or
Google, same visual language as /login. It only creates the Firebase
account - org naming is deliberately not on this page, so every path that
produces an account with no org (this one, and Google-via-/login) converges
on the same next screen.

app/onboarding/page.tsx: that screen. Shown to any signed-in user with no
orgId (ChromeSwitcher's redirect, previous commit), collects an org name,
calls the new c2api.signup() -> POST /auth/signup (routers/links.py,
already shipped), then refreshClaims() to force-refetch the ID token so
orgId picks up immediately and the same redirect effect sends them on to
/dashboard - no manual reload needed.

lib/c2api.ts also gained getOrg/updateOrg and the enrollment-token
mint/list/revoke calls (routers/org.py, already shipped on the backend)
and joinWaitlist (routers/waitlist.py) - none consumed yet, wired in ahead
of the settings/legal commits that use them so this stays one add per
concept rather than scattering client additions across later commits.

/login gained a "Don't have an account? Sign up" link to /signup. This is
signup plumbing, not marketing copy - pricing/plan copy (app/pricing,
lib/billing.ts) is untouched in this pass, that's a separate, still-open
decision (SAAS_PLAN.md section 6).

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:34:04 -04:00
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
13 changed files with 463 additions and 46 deletions
+26 -8
View File
@@ -260,18 +260,36 @@ def _log_llm_failure(where: str, call_id: str, model: str, exc: Exception) -> No
that will never fix itself, so it gets ERROR and says what to do. that will never fix itself, so it gets ERROR and says what to do.
""" """
text = str(exc) text = str(exc)
if "404" in text or "not found" in text.lower() or "no longer available" in text.lower(): low = text.lower()
if model not in _dead_models:
_dead_models.add(model) if "404" in text or "not found" in low or "no longer available" in low:
logger.error( _log_tier_down(where, model, "model is unavailable",
f"{where}: model {model!r} is unavailable -- the LLM correlation tier " "Update CORR_CHEAP_MODEL/CORR_SMART_MODEL in config.py", text)
f"is DISABLED and every call is falling back to rules-only. Update "
f"CORR_CHEAP_MODEL/CORR_SMART_MODEL in config.py. Google said: {text}"
)
return return
# A depleted balance reads as 429, the same status as an ordinary rate limit,
# but it is the opposite kind of problem: a rate limit clears on its own and a
# dead account never does. Matching on the billing wording keeps a burst of
# rate limits at WARNING while an empty account escalates like a bad model ID.
if "credits are depleted" in low or "prepayment" in low or "billing" in low:
_log_tier_down(where, model, "the Gemini account is out of credit",
"Top up billing at https://ai.studio/projects", text)
return
logger.warning(f"{where} failed for call {call_id}: {text}") logger.warning(f"{where} failed for call {call_id}: {text}")
def _log_tier_down(where: str, model: str, problem: str, fix: str, text: str) -> None:
"""ERROR once per model, not once per call — this runs at radio-traffic volume."""
if model in _dead_models:
return
_dead_models.add(model)
logger.error(
f"{where}: {problem} ({model!r}) -- the LLM correlation tier is DISABLED "
f"and every call is falling back to rules-only. {fix}. API said: {text}"
)
async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict: async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict:
""" """
Run the smart tiebreaker (corr_smart_model) when rules and LLM disagree. Run the smart tiebreaker (corr_smart_model) when rules and LLM disagree.
+5
View File
@@ -105,6 +105,11 @@ export default function LoginPage() {
</svg> </svg>
Continue with Google Continue with Google
</button> </button>
<p className="text-center text-xs text-gray-500">
Don&apos;t have an account?{" "}
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
</p>
</div> </div>
</div> </div>
); );
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { Button } from "@/components/ui/Button";
/**
* Shown to any signed-in user with no org_id claim — see ChromeSwitcher's
* no-claim guard (SAAS_PLAN.md B3). Two ways to land here:
* 1. Just created an account via /signup, org name not collected yet.
* 2. Signed in via Google on /login (which auto-creates a Firebase account
* on first use) and was never provisioned into anything.
* Either way, this is the one screen an unprovisioned account can reach,
* and completing it is what POST /auth/signup uses to grant org_id/org_role.
*/
export default function OnboardingPage() {
const { user, loading, orgId, refreshClaims } = useAuth();
const router = useRouter();
const [orgName, setOrgName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (loading) return;
if (!user) {
router.replace("/login");
return;
}
if (orgId) {
router.replace("/dashboard");
}
}, [loading, user, orgId, router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!orgName.trim()) return;
setSubmitting(true);
setError(null);
try {
await c2api.signup(orgName.trim());
// Firebase custom claims only show up in a *freshly fetched* ID token —
// getIdTokenResult(true) inside refreshClaims forces that fetch, then
// AuthProvider's own state (orgId) updates and the effect above
// redirects to /dashboard.
await refreshClaims();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
setSubmitting(false);
}
}
if (loading || !user || orgId) return null;
return (
<div className="max-w-sm mx-auto pt-16">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
<div>
<h1 className="text-white text-lg font-bold">Set up your organization</h1>
<p className="text-gray-400 text-xs mt-2 leading-relaxed">
One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
<input
type="text"
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
required
autoFocus
placeholder="e.g. Riverside County Scanner"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<Button type="submit" disabled={submitting || !orgName.trim()} fullWidth>
{submitting ? "Setting up…" : "Continue"}
</Button>
</form>
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { createUserWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import { auth } from "@/lib/firebase";
import { useRouter } from "next/navigation";
/**
* Self-serve account creation (SAAS_PLAN.md B4). Only creates the Firebase
* user — org naming happens on the next screen, /onboarding, which is also
* where every other no-org-yet path (Google sign-in via /login, etc.) ends
* up. Keeping that step in one shared place means there's exactly one route
* that calls POST /auth/signup.
*/
export default function SignupPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
await createUserWithEmailAndPassword(auth, email, password);
router.push("/onboarding");
} catch (err: unknown) {
const code = (err as { code?: string })?.code;
if (code === "auth/email-already-in-use") {
setError("An account with this email already exists. Try signing in instead.");
} else if (code === "auth/weak-password") {
setError("Password is too weak — use at least 6 characters.");
} else {
setError("Could not create your account. Check your details and try again.");
}
} finally {
setLoading(false);
}
}
async function handleGoogle() {
setLoading(true);
setError(null);
try {
await signInWithPopup(auth, new GoogleAuthProvider());
router.push("/onboarding");
} catch {
setError("Google sign-up failed. Try again.");
} finally {
setLoading(false);
}
}
return (
<div className="max-w-sm mx-auto pt-16">
<Link href="/" className="flex items-center justify-center gap-2 mb-6 font-mono font-bold text-white">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
DRB
</Link>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
<h1 className="text-white text-lg font-bold">Create your account</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{loading ? "Creating account…" : "Create account"}
</button>
</form>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-gray-700" />
<span className="text-xs text-gray-500">or</span>
<div className="flex-1 h-px bg-gray-700" />
</div>
<button
type="button"
onClick={handleGoogle}
disabled={loading}
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-gray-100 disabled:opacity-50 text-gray-900 rounded-lg py-2 text-sm font-semibold transition-colors"
>
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.859-3.048.859-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z" fill="#34A853"/>
<path d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.962L3.964 6.294C4.672 4.169 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
Continue with Google
</button>
<p className="text-center text-xs text-gray-500">
Already have an account?{" "}
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
</p>
</div>
</div>
);
}
+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 (
+31
View File
@@ -222,4 +222,35 @@ export const c2api = {
// Session recording — called on each explicit sign-in // Session recording — called on each explicit sign-in
recordSession: () => recordSession: () =>
request<{ ok: boolean }>("/auth/session", { method: "POST" }), request<{ ok: boolean }>("/auth/session", { method: "POST" }),
// Org provisioning (SAAS_PLAN.md B4) — called once from /onboarding right
// after a Firebase account exists but before it has an org_id claim.
signup: (orgName: string) =>
request<{ org_id: string; org_name: string; already_provisioned: boolean }>("/auth/signup", {
method: "POST",
body: JSON.stringify({ org_name: orgName }),
}),
// Organization profile
getOrg: () =>
request<{ org_id: string; name: string; created_at: string }>("/org"),
updateOrg: (name: string) =>
request<{ ok: boolean; name: string }>("/org", { method: "PATCH", body: JSON.stringify({ name }) }),
// Per-org enrollment tokens (SAAS_PLAN.md B2b)
listEnrollmentTokens: () =>
request<{ token_id: string; label: string; created_at: string; revoked: boolean; uses: number }[]>(
"/org/enrollment-tokens"
),
mintEnrollmentToken: (label: string) =>
request<{ token_id: string; token: string; label: string }>("/org/enrollment-tokens", {
method: "POST",
body: JSON.stringify({ label }),
}),
revokeEnrollmentToken: (tokenId: string) =>
request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }),
// Public waitlist — no auth, see routers/waitlist.py
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
}; };
+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();
} }