Files
server-26/drb-frontend/components/Nav.tsx
T
Logan CusanoandClaude Opus 5 83416fe169 Split platform-admin from org-owner, hide Trips from non-founding orgs
SAAS_PLAN.md B7. "admin" meant two different things before this: platform
operator (SAAS_PLAN.md's own framing) and, by accident of how
app/settings/layout.tsx was gated, the only role that could ever reach an
org's own billing/members/node-ownership settings. A paying customer who is
their own org's owner couldn't reach their own Settings page - the gate
checked isAdmin, which only platform admins ever have.

settings/layout.tsx now admits org_role === "owner" as well as platform
admins (isAdmin stays valid too, for support access to any org's
settings). Nav.tsx shows the Settings link on the same condition, and moves
Admin (the platform-operator screens: feature flags, users, audit,
correlation debug) out of the customer-facing link group entirely - it was
already gated server-side, this is just the nav no longer implying it's
part of the product.

Trips - an internal utility feature riding along on this stack, not a
tenant-scoped product surface (see [[trips-feature-intentional]]) - drops
out of the customer-facing viewer link group and only shows for the
founding org (new lib/tenancy.ts mirrors app/internal/tenancy.py's
FOUNDING_ORG_ID) or a platform admin, matching the mutation-route gating
routers/trips.py already got in the backend tenancy commit. Reads stay
open to any signed-in user, same as before - trips' own visibility model
(public/private per trip) predates and is unrelated to org tenancy, and
restricting it further wasn't asked for.

Also closes two DEFERRED.md items now that they have somewhere to write to:
app/settings/organization's "Save changes" button now actually calls
c2api.getOrg()/updateOrg() (routers/org.py, shipped in the backend tenancy
commit) instead of being permanently disabled. app/settings/nodes gained an
EnrollmentTokensPanel (mint/list/revoke against the same commit's
/org/enrollment-tokens routes) - without this, B2b's whole point (a
customer enrolls their own node with their own token instead of an
admin-issued key) had no way to actually be used outside a raw API call.

Left alone, and written up as new DEFERRED.md entries instead of guessed
at: node/system *write* routes (approve, create, delete) stay
platform-admin-only rather than being loosened to org owner/operator - a
real gap per SAAS_PLAN.md 2.4, but a separate authorization design that the
plan's 12-item build order doesn't enumerate. And settings/members +
settings/nodes' ownership table both still call GET /admin/users
(platform-admin-only) - a pure org owner who reaches the page via this
commit's gate will get 403s from it. Today's only real user is also a
platform admin, so this is invisible until a second, non-admin org owner
exists.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:39:16 -04:00

196 lines
7.6 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useUnconfiguredNodes } from "@/lib/useNodes";
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
import { useAuth } from "@/components/AuthProvider";
import { useTheme } from "@/components/ThemeProvider";
import { FOUNDING_ORG_ID } from "@/lib/tenancy";
// Links visible to all authenticated roles (viewer+)
const viewerLinks = [
{ href: "/dashboard", label: "Dashboard" },
{ href: "/calls", label: "Calls" },
{ href: "/incidents", label: "Incidents" },
{ href: "/map", label: "Map" },
{ href: "/alerts", label: "Alerts" },
];
// Trips is an internal utility feature, not a tenant-scoped product surface
// (see [[trips-feature-intentional]] and SAAS_PLAN.md B7) — shown only to
// the founding org, matching routers/trips.py's own gating.
const tripsLink = { href: "/trips", label: "Trips" };
// Additional links for operators and admins
const operatorLinks = [
{ href: "/nodes", label: "Nodes" },
{ href: "/systems", label: "Systems" },
{ href: "/tokens", label: "Tokens" },
];
// Platform-admin-only link. Settings is handled separately below — it's
// customer-facing for org owners too, not admin-only (SAAS_PLAN.md B7).
const adminLinks = [
{ href: "/admin", label: "Admin" },
];
function SunIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
);
}
function MoonIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
);
}
export function Nav() {
const { user, isAdmin, isOperator, isOrgOwner, orgId } = useAuth();
const pathname = usePathname();
const router = useRouter();
const { nodes: pending } = useUnconfiguredNodes();
const unackedAlerts = useUnacknowledgedAlerts();
const { theme, toggle } = useTheme();
const [mobileOpen, setMobileOpen] = useState(false);
if (!user) return null;
const allLinks = [
...viewerLinks,
...(orgId === FOUNDING_ORG_ID || isAdmin ? [tripsLink] : []),
...(isAdmin || isOperator ? operatorLinks : []),
...(isAdmin ? adminLinks : []),
...(isAdmin || isOrgOwner ? [{ href: "/settings", label: "Settings" }] : []),
];
function navLinkClass(href: string) {
return `text-sm font-mono transition-colors shrink-0 ${
pathname.startsWith(href) ? "text-white" : "text-gray-500 hover:text-gray-300"
}`;
}
return (
<nav className="sticky top-0 z-40 border-b border-gray-800 bg-gray-950/95 backdrop-blur">
{/* Main bar */}
<div className="px-4 md:px-6 py-3 flex items-center gap-4 md:gap-6">
<span className="font-mono font-bold text-white tracking-tight shrink-0">DRB</span>
{/* Desktop links */}
<div className="hidden md:flex items-center gap-6 overflow-x-auto">
{allLinks.map(({ href, label }) => (
<Link key={href} href={href} className={navLinkClass(href)}>
{label}
{label === "Nodes" && pending.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
{pending.length}
</span>
)}
{label === "Alerts" && unackedAlerts.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
{unackedAlerts.length}
</span>
)}
</Link>
))}
</div>
<div className="ml-auto flex items-center gap-3 shrink-0">
{/* Theme toggle */}
<button
onClick={toggle}
className="text-gray-500 hover:text-gray-300 transition-colors"
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
{/* Profile avatar (desktop) */}
<button
onClick={() => router.push("/profile")}
className={`hidden md:flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold transition-colors ${
pathname.startsWith("/profile")
? "bg-indigo-600 text-white"
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
}`}
title="Profile"
>
{(user?.displayName || user?.email || "?")[0].toUpperCase()}
</button>
{/* Hamburger (mobile) */}
<button
onClick={() => setMobileOpen((v) => !v)}
className="md:hidden text-gray-400 hover:text-gray-200 transition-colors p-1"
aria-label="Toggle menu"
>
{mobileOpen ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
)}
</button>
</div>
</div>
{/* Mobile drawer */}
{mobileOpen && (
<div className="md:hidden border-t border-gray-800 bg-gray-950 px-4 py-3 flex flex-col gap-1">
{allLinks.map(({ href, label }) => (
<Link
key={href}
href={href}
onClick={() => setMobileOpen(false)}
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
pathname.startsWith(href) ? "text-white" : "text-gray-500"
}`}
>
{label}
{label === "Nodes" && pending.length > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
{pending.length}
</span>
)}
{label === "Alerts" && unackedAlerts.length > 0 && (
<span className="inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
{unackedAlerts.length}
</span>
)}
</Link>
))}
<div className="border-t border-gray-800 pt-3 mt-1">
<Link
href="/profile"
onClick={() => setMobileOpen(false)}
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
pathname.startsWith("/profile") ? "text-white" : "text-gray-500"
}`}
>
Profile
</Link>
</div>
</div>
)}
</nav>
);
}