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.
|
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:
|
|
||||||
|
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)
|
_dead_models.add(model)
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{where}: model {model!r} is unavailable -- the LLM correlation tier "
|
f"{where}: {problem} ({model!r}) -- the LLM correlation tier is DISABLED "
|
||||||
f"is DISABLED and every call is falling back to rules-only. Update "
|
f"and every call is falling back to rules-only. {fix}. API said: {text}"
|
||||||
f"CORR_CHEAP_MODEL/CORR_SMART_MODEL in config.py. Google 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:
|
async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict:
|
||||||
|
|||||||
@@ -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'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { 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,15 +42,11 @@ 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);
|
||||||
|
|
||||||
useEffect(() => {
|
async function applyClaims(u: User, forceRefresh: boolean) {
|
||||||
return onAuthStateChanged(auth, async (u) => {
|
const result = await u.getIdTokenResult(forceRefresh);
|
||||||
setUser(u);
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
if (u) {
|
|
||||||
document.cookie = "drb_session=1; path=/; SameSite=Strict";
|
|
||||||
const result = await u.getIdTokenResult(true);
|
|
||||||
const claims = result.claims;
|
const claims = result.claims;
|
||||||
|
|
||||||
// Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean
|
// 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);
|
setRole(effectiveRole);
|
||||||
setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []);
|
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 {
|
} 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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) }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user