From be79499635555c9dc6a4059660106677bdc5dd5e Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 23 Aug 2026 02:28:02 -0400 Subject: [PATCH] Give the nav's dead links somewhere to land Three of the app's routes were referenced but never existed, so the redesign's navigation pointed at 404s from several directions. /dashboard was the post-login and fallback redirect target in nine places -- login, onboarding, middleware, the admin/nodes/systems/tokens/settings guards, and the marketing header -- but app/dashboard/ was never created. Signing in normally dropped the user on a 404. The real signed-in home is "/", which app/page.tsx already renders as LiveView for an authed user with an org, and which the nav labels "Live"; all nine now point there. Nav also linked /watch and /network, neither of which existed. /watch is the alerts screen under its redesign name, so it re-exports app/alerts/page.tsx and /alerts stays reachable for old links. /network is new: the "my equipment" hub the redesign moved /nodes, /systems and /tokens behind and then never built, which had left /systems and /tokens with no entry point in the UI at all. Its hooks all run before the admin/operator guard, per d041c86. Separately, the admin page's guard read isAdmin without authLoading, so every cold load of /admin -- typed URL, hard refresh, bookmark -- redirected away while the Firebase claims were still resolving. Admin was only reachable by clicking through from an already-mounted page. Now it waits, like every other guarded route does. And /incidents no longer lies about an empty list: a failed Firestore query leaves `incidents` empty just as a quiet night does, and the page was printing "No incidents recorded yet" over the top of a missing-composite-index error. useIncidents already returned `error`; the page just ignored it. It now renders an ErrorBanner instead, so the undeployed indexes in server-26#13 read as a failure rather than as silence on the radio. Closes server-26#30, server-26#31. server-26#13 stays open -- the rules and indexes still have to be pushed to the live project by hand. Co-Authored-By: Claude Opus 5 --- drb-frontend/app/admin/page.tsx | 12 +- drb-frontend/app/incidents/page.tsx | 15 ++- drb-frontend/app/login/page.tsx | 4 +- drb-frontend/app/network/page.tsx | 104 ++++++++++++++++++ drb-frontend/app/nodes/page.tsx | 2 +- drb-frontend/app/onboarding/page.tsx | 2 +- drb-frontend/app/settings/layout.tsx | 2 +- drb-frontend/app/systems/page.tsx | 2 +- drb-frontend/app/tokens/page.tsx | 2 +- drb-frontend/app/watch/page.tsx | 5 + .../components/marketing/MarketingHeader.tsx | 4 +- drb-frontend/middleware.ts | 2 +- 12 files changed, 140 insertions(+), 16 deletions(-) create mode 100644 drb-frontend/app/network/page.tsx create mode 100644 drb-frontend/app/watch/page.tsx diff --git a/drb-frontend/app/admin/page.tsx b/drb-frontend/app/admin/page.tsx index 41464bc..f2de6ba 100644 --- a/drb-frontend/app/admin/page.tsx +++ b/drb-frontend/app/admin/page.tsx @@ -1062,14 +1062,20 @@ const TAB_LABELS: { key: AdminTab; label: string }[] = [ ]; export default function AdminPage() { - const { user, isAdmin } = useAuth(); + const { user, isAdmin, loading: authLoading } = useAuth(); const router = useRouter(); const [tab, setTab] = useState("features"); + // Wait for the claims to resolve before deciding. isAdmin is false for the + // first render of every cold load (typed URL, hard refresh, bookmark) while + // AuthProvider fetches the ID token, so a guard that ignores authLoading + // redirects the admin off their own page every time and only ever lets them + // in via an in-app link. Same shape as /nodes, /systems and /settings. useEffect(() => { - if (!isAdmin) router.replace("/dashboard"); - }, [isAdmin, router]); + if (!authLoading && !isAdmin) router.replace("/"); + }, [authLoading, isAdmin, router]); + if (authLoading) return null; if (!isAdmin) return null; // Users/Audit tabs benefit from full width; everything else is narrow diff --git a/drb-frontend/app/incidents/page.tsx b/drb-frontend/app/incidents/page.tsx index f041488..7908013 100644 --- a/drb-frontend/app/incidents/page.tsx +++ b/drb-frontend/app/incidents/page.tsx @@ -10,7 +10,7 @@ import type { IncidentRecord } from "@/lib/types"; import { PageHeader } from "@/components/ui/PageHeader"; import { Button } from "@/components/ui/Button"; import { Badge } from "@/components/ui/Badge"; -import { EmptyState } from "@/components/ui/EmptyState"; +import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState"; import { SkeletonCard } from "@/components/ui/Skeleton"; import { isKnownSeverity, severityRank } from "@/lib/severity"; import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark"; @@ -166,7 +166,7 @@ function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (bo export default function IncidentsPage() { const { isAdmin } = useAuth(); - const { incidents, loading } = useIncidents(); + const { incidents, loading, error } = useIncidents(); const activeCalls = useActiveCalls(); const [showCreate, setShowCreate] = useState(false); const [severityFilter, setSeverityFilter] = useState("all"); @@ -275,7 +275,16 @@ export default function IncidentsPage() { ))} - {filtered.length === 0 && ( + {/* An empty list is only news when the query actually succeeded. + A failed Firestore query (missing composite index, denied rules) + also leaves `incidents` empty, and rendering "no incidents + recorded yet" over the top of it told the operator the radio was + quiet when the page had simply failed to load — server-26#13. */} + {filtered.length === 0 && error && ( + + )} + + {filtered.length === 0 && !error && ( { if (authLoading) return; if (!user) return; - router.replace(orgId ? "/dashboard" : "/onboarding"); + router.replace(orgId ? "/" : "/onboarding"); }, [authLoading, user, orgId, router]); async function handleSubmit(e: React.FormEvent) { diff --git a/drb-frontend/app/network/page.tsx b/drb-frontend/app/network/page.tsx new file mode 100644 index 0000000..f207686 --- /dev/null +++ b/drb-frontend/app/network/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +// The nav's "Network" destination (components/Nav.tsx) — "my equipment". +// The redesign added the link but never the route, so it 404'd and the three +// screens behind it (/nodes, /systems, /tokens) had no entry point in the nav +// at all. This is the hub: it counts what's there, surfaces nodes that still +// need configuring, and hands off to the existing pages. + +import { useEffect } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/components/AuthProvider"; +import { useNodes } from "@/lib/useNodes"; +import { useSystems } from "@/lib/useSystems"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Card } from "@/components/ui/Card"; +import { Badge } from "@/components/ui/Badge"; + +function HubCard({ + href, + title, + description, + count, + countLabel, + badge, +}: { + href: string; + title: string; + description: string; + count: number | null; + countLabel: string; + badge?: React.ReactNode; +}) { + return ( + + +
+

{title}

+ {badge} +
+

{description}

+

+ {count === null ? "—" : count} + {countLabel} +

+
+ + ); +} + +export default function NetworkPage() { + const { isAdmin, isOperator, loading: authLoading } = useAuth(); + const router = useRouter(); + const { nodes, loading: nodesLoading } = useNodes(); + const { systems, loading: systemsLoading } = useSystems(); + + useEffect(() => { + if (!authLoading && !isAdmin && !isOperator) router.replace("/"); + }, [authLoading, isAdmin, isOperator, router]); + + // Every hook runs before this guard — see the note in app/nodes/page.tsx. + if (authLoading || (!isAdmin && !isOperator)) return null; + + const pending = nodes.filter((n) => !n.configured); + const online = nodes.filter((n) => n.status === "online" || n.status === "recording"); + + return ( +
+ + +
+ 0 ? ( + {pending.length} need setup + ) : undefined + } + /> + + +
+
+ ); +} diff --git a/drb-frontend/app/nodes/page.tsx b/drb-frontend/app/nodes/page.tsx index 3478b87..f125cbe 100644 --- a/drb-frontend/app/nodes/page.tsx +++ b/drb-frontend/app/nodes/page.tsx @@ -16,7 +16,7 @@ export default function NodesPage() { const { systems } = useSystems(); useEffect(() => { - if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard"); + if (!authLoading && !isAdmin && !isOperator) router.replace("/"); }, [authLoading, isAdmin, isOperator, router]); const [configNode, setConfigNode] = useState(null); diff --git a/drb-frontend/app/onboarding/page.tsx b/drb-frontend/app/onboarding/page.tsx index 45a3945..789d5a1 100644 --- a/drb-frontend/app/onboarding/page.tsx +++ b/drb-frontend/app/onboarding/page.tsx @@ -29,7 +29,7 @@ export default function OnboardingPage() { return; } if (orgId) { - router.replace("/dashboard"); + router.replace("/"); } }, [loading, user, orgId, router]); diff --git a/drb-frontend/app/settings/layout.tsx b/drb-frontend/app/settings/layout.tsx index 744ed72..2b16ed7 100644 --- a/drb-frontend/app/settings/layout.tsx +++ b/drb-frontend/app/settings/layout.tsx @@ -27,7 +27,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode const router = useRouter(); useEffect(() => { - if (!loading && !canAccess) router.replace("/dashboard"); + if (!loading && !canAccess) router.replace("/"); }, [loading, canAccess, router]); if (loading || !canAccess) return null; diff --git a/drb-frontend/app/systems/page.tsx b/drb-frontend/app/systems/page.tsx index 9643032..11d728b 100644 --- a/drb-frontend/app/systems/page.tsx +++ b/drb-frontend/app/systems/page.tsx @@ -1185,7 +1185,7 @@ export default function SystemsPage() { const { systems, loading } = useSystems(); useEffect(() => { - if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard"); + if (!authLoading && !isAdmin && !isOperator) router.replace("/"); }, [authLoading, isAdmin, isOperator, router]); const [editing, setEditing] = useState(null); diff --git a/drb-frontend/app/tokens/page.tsx b/drb-frontend/app/tokens/page.tsx index f016d94..9a6611f 100644 --- a/drb-frontend/app/tokens/page.tsx +++ b/drb-frontend/app/tokens/page.tsx @@ -26,7 +26,7 @@ export default function TokensPage() { const [error, setError] = useState(null); useEffect(() => { - if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard"); + if (!authLoading && !isAdmin && !isOperator) router.replace("/"); }, [authLoading, isAdmin, isOperator, router]); const refresh = useCallback(async () => { diff --git a/drb-frontend/app/watch/page.tsx b/drb-frontend/app/watch/page.tsx new file mode 100644 index 0000000..3833909 --- /dev/null +++ b/drb-frontend/app/watch/page.tsx @@ -0,0 +1,5 @@ +// The nav's "Watch" destination (components/Nav.tsx). The redesign renamed +// Alerts to Watch but never added the route, so the nav link 404'd. The screen +// itself is unchanged — it still lives in app/alerts/page.tsx, which stays +// reachable so old links and bookmarks keep working. +export { default } from "../alerts/page"; diff --git a/drb-frontend/components/marketing/MarketingHeader.tsx b/drb-frontend/components/marketing/MarketingHeader.tsx index 63d7006..77bd9ff 100644 --- a/drb-frontend/components/marketing/MarketingHeader.tsx +++ b/drb-frontend/components/marketing/MarketingHeader.tsx @@ -41,7 +41,7 @@ export function MarketingHeader() {
{!loading && user ? ( - Go to dashboard + Go to dashboard ) : ( <> Sign in @@ -81,7 +81,7 @@ export function MarketingHeader() { ))}
{!loading && user ? ( - Go to dashboard + Go to dashboard ) : ( <> Sign in diff --git a/drb-frontend/middleware.ts b/drb-frontend/middleware.ts index bbe1f29..1ea913b 100644 --- a/drb-frontend/middleware.ts +++ b/drb-frontend/middleware.ts @@ -28,7 +28,7 @@ export function middleware(request: NextRequest) { } if (pathname === "/login") { - if (session) return NextResponse.redirect(new URL("/dashboard", request.url)); + if (session) return NextResponse.redirect(new URL("/", request.url)); return NextResponse.next(); }