Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4842725a03 | ||
|
|
2a1d52b7af | ||
|
|
c7f985df42 |
@@ -260,16 +260,34 @@ 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.
|
||||
"""
|
||||
text = str(exc)
|
||||
if "404" in text or "not found" in text.lower() or "no longer available" in text.lower():
|
||||
if model not in _dead_models:
|
||||
low = text.lower()
|
||||
|
||||
if "404" in text or "not found" in low or "no longer available" in low:
|
||||
_log_tier_down(where, model, "model is unavailable",
|
||||
"Update CORR_CHEAP_MODEL/CORR_SMART_MODEL in config.py", text)
|
||||
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}")
|
||||
|
||||
|
||||
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}: model {model!r} is unavailable -- the LLM correlation tier "
|
||||
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}"
|
||||
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}"
|
||||
)
|
||||
return
|
||||
logger.warning(f"{where} failed for call {call_id}: {text}")
|
||||
|
||||
|
||||
async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict:
|
||||
|
||||
@@ -105,6 +105,11 @@ export default function LoginPage() {
|
||||
</svg>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<p className="text-center text-xs text-gray-500">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -222,4 +222,35 @@ export const c2api = {
|
||||
// Session recording — called on each explicit sign-in
|
||||
recordSession: () =>
|
||||
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) }),
|
||||
};
|
||||
|
||||
@@ -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