From 83416fe169bc4488d700133234e5636cb4406268 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Tue, 18 Aug 2026 20:39:16 -0400 Subject: [PATCH] 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 --- drb-frontend/app/settings/layout.tsx | 15 +- drb-frontend/app/settings/nodes/page.tsx | 169 +++++++++++++++++- .../app/settings/organization/page.tsx | 44 ++++- drb-frontend/components/Nav.tsx | 15 +- drb-frontend/lib/tenancy.ts | 10 ++ 5 files changed, 232 insertions(+), 21 deletions(-) create mode 100644 drb-frontend/lib/tenancy.ts diff --git a/drb-frontend/app/settings/layout.tsx b/drb-frontend/app/settings/layout.tsx index 34cf352..744ed72 100644 --- a/drb-frontend/app/settings/layout.tsx +++ b/drb-frontend/app/settings/layout.tsx @@ -15,15 +15,22 @@ const TABS = [ ]; export default function SettingsLayout({ children }: { children: React.ReactNode }) { - const { isAdmin, loading } = useAuth(); + // SAAS_PLAN.md B7: this used to gate on isAdmin (platform admin) alone, + // which meant a paying customer who is their own org's owner couldn't + // reach their own billing/members/node-ownership settings — "admin" here + // conflated "platform operator" with "org owner". isAdmin still passes + // (support/debugging access to any org's settings), but org_role === + // "owner" is now sufficient on its own. + const { isAdmin, isOrgOwner, loading } = useAuth(); + const canAccess = isAdmin || isOrgOwner; const pathname = usePathname(); const router = useRouter(); useEffect(() => { - if (!loading && !isAdmin) router.replace("/dashboard"); - }, [loading, isAdmin, router]); + if (!loading && !canAccess) router.replace("/dashboard"); + }, [loading, canAccess, router]); - if (loading || !isAdmin) return null; + if (loading || !canAccess) return null; return (
diff --git a/drb-frontend/app/settings/nodes/page.tsx b/drb-frontend/app/settings/nodes/page.tsx index a36cf38..dc01f8a 100644 --- a/drb-frontend/app/settings/nodes/page.tsx +++ b/drb-frontend/app/settings/nodes/page.tsx @@ -3,15 +3,168 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useNodes } from "@/lib/useNodes"; +import { useAuth } from "@/components/AuthProvider"; import { c2api } from "@/lib/c2api"; import type { UserRecord } from "@/lib/types"; import { StatusBadge } from "@/components/StatusBadge"; -import { Card } from "@/components/ui/Card"; +import { Card, CardHeader } from "@/components/ui/Card"; +import { Button } from "@/components/ui/Button"; import { ErrorBanner } from "@/components/ui/EmptyState"; import { SkeletonRow } from "@/components/ui/Skeleton"; const UNASSIGNED = "__unassigned__"; +interface EnrollmentToken { + token_id: string; + label: string; + created_at: string; + revoked: boolean; + uses: number; +} + +/** + * SAAS_PLAN.md B2b — per-org enrollment tokens (routers/org.py). This is the + * credential a customer's field node presents to POST /nodes/enroll + * (X-Enrollment-Token) so it lands in THIS org instead of the legacy + * fleet-wide pool. Minting/revoking is owner-only server-side; any org + * member can list (metadata only, the raw token is shown exactly once at + * mint time and never again). + */ +function EnrollmentTokensPanel() { + const { isOrgOwner, isAdmin } = useAuth(); + const canManage = isOrgOwner || isAdmin; + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [label, setLabel] = useState(""); + const [minting, setMinting] = useState(false); + const [justMinted, setJustMinted] = useState(null); + + const load = useCallback(() => { + c2api.listEnrollmentTokens() + .then(setTokens) + .catch((e) => setError(e instanceof Error ? e.message : String(e))) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + async function handleMint(e: React.FormEvent) { + e.preventDefault(); + if (!label.trim()) return; + setMinting(true); + setError(null); + try { + const result = await c2api.mintEnrollmentToken(label.trim()); + setJustMinted(result.token); + setLabel(""); + load(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setMinting(false); + } + } + + async function handleRevoke(tokenId: string) { + try { + await c2api.revokeEnrollmentToken(tokenId); + load(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + return ( + + + + {justMinted && ( +
+

+ New token — copy it now, it won't be shown again: +

+

{justMinted}

+ +
+ )} + + {error && } + + {canManage && ( +
+ setLabel(e.target.value)} + placeholder="Label, e.g. 'node-003 field kit'" + className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500" + /> + +
+ )} + + {loading ? ( +
+ +
+ ) : tokens.length === 0 ? ( +

No enrollment tokens yet.

+ ) : ( + + + + + + + {canManage && } + + + + {tokens.map((t) => ( + + + + + {canManage && ( + + )} + + ))} + +
LabelCreatedStatusActions
{t.label} + {new Date(t.created_at).toLocaleDateString()} + + {t.revoked ? ( + Revoked + ) : ( + Active · {t.uses} use{t.uses !== 1 ? "s" : ""} + )} + + {!t.revoked && ( + + )} +
+ )} +
+ ); +} + export default function NodeOwnershipSettingsPage() { const { nodes, loading: nodesLoading } = useNodes(); const [users, setUsers] = useState([]); @@ -69,12 +222,15 @@ export default function NodeOwnershipSettingsPage() { const loading = nodesLoading || loadingUsers; return ( -
-

- Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here. -

+
+ - {error && } +
+

+ Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here. +

+ + {error && } @@ -122,6 +278,7 @@ export default function NodeOwnershipSettingsPage() {
+
); } diff --git a/drb-frontend/app/settings/organization/page.tsx b/drb-frontend/app/settings/organization/page.tsx index b177773..e74e31d 100644 --- a/drb-frontend/app/settings/organization/page.tsx +++ b/drb-frontend/app/settings/organization/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { c2api } from "@/lib/c2api"; +import { useAuth } from "@/components/AuthProvider"; import { useNodes } from "@/lib/useNodes"; import { getCurrentSubscription, getPlan, type Subscription } from "@/lib/billing"; import { Card, CardHeader } from "@/components/ui/Card"; @@ -21,15 +22,40 @@ function StatTile({ label, value }: { label: string; value: string | number }) { export default function OrganizationSettingsPage() { const { nodes } = useNodes(); + const { isOrgOwner, isAdmin } = useAuth(); const [memberCount, setMemberCount] = useState(null); const [sub, setSub] = useState(null); - const [orgName, setOrgName] = useState("My Organization"); + const [orgName, setOrgName] = useState(""); + const [savedName, setSavedName] = useState(""); + const [orgLoading, setOrgLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const canEdit = isOrgOwner || isAdmin; useEffect(() => { c2api.listUsers().then((u) => setMemberCount(u.length)).catch(() => setMemberCount(null)); getCurrentSubscription().then(setSub); + c2api.getOrg() + .then((org) => { setOrgName(org.name); setSavedName(org.name); }) + .catch(() => {}) + .finally(() => setOrgLoading(false)); }, []); + async function handleSave() { + setSaving(true); + setSaveError(null); + try { + const res = await c2api.updateOrg(orgName.trim()); + setSavedName(res.name); + setOrgName(res.name); + } catch (err) { + setSaveError(err instanceof Error ? err.message : "Could not save."); + } finally { + setSaving(false); + } + } + const plan = sub ? getPlan(sub.planId) : null; return ( @@ -44,17 +70,21 @@ export default function OrganizationSettingsPage() { setOrgName(e.target.value)} - 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" + 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 disabled:opacity-50" />
+ {saveError &&

{saveError}

}
- - - Preview only — no backend endpoint stores this yet. -
diff --git a/drb-frontend/components/Nav.tsx b/drb-frontend/components/Nav.tsx index b79dc01..23895a0 100644 --- a/drb-frontend/components/Nav.tsx +++ b/drb-frontend/components/Nav.tsx @@ -7,6 +7,7 @@ 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 = [ @@ -15,9 +16,13 @@ const viewerLinks = [ { href: "/incidents", label: "Incidents" }, { href: "/map", label: "Map" }, { href: "/alerts", label: "Alerts" }, - { href: "/trips", label: "Trips" }, ]; +// 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" }, @@ -25,10 +30,10 @@ const operatorLinks = [ { href: "/tokens", label: "Tokens" }, ]; -// Admin-only links +// 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" }, - { href: "/settings", label: "Settings" }, ]; function SunIcon() { @@ -56,7 +61,7 @@ function MoonIcon() { } export function Nav() { - const { user, isAdmin, isOperator } = useAuth(); + const { user, isAdmin, isOperator, isOrgOwner, orgId } = useAuth(); const pathname = usePathname(); const router = useRouter(); const { nodes: pending } = useUnconfiguredNodes(); @@ -68,8 +73,10 @@ export function Nav() { const allLinks = [ ...viewerLinks, + ...(orgId === FOUNDING_ORG_ID || isAdmin ? [tripsLink] : []), ...(isAdmin || isOperator ? operatorLinks : []), ...(isAdmin ? adminLinks : []), + ...(isAdmin || isOrgOwner ? [{ href: "/settings", label: "Settings" }] : []), ]; function navLinkClass(href: string) { diff --git a/drb-frontend/lib/tenancy.ts b/drb-frontend/lib/tenancy.ts new file mode 100644 index 0000000..855c192 --- /dev/null +++ b/drb-frontend/lib/tenancy.ts @@ -0,0 +1,10 @@ +/** + * Mirrors drb-c2-core/app/internal/tenancy.py's FOUNDING_ORG_ID — the org + * every pre-tenancy document and every legacy enrollment path resolves + * into. Frontend-side, it's used only to gate the /trips feature (an + * internal utility riding along on this stack, not a tenant-scoped product + * surface — see [[trips-feature-intentional]] and SAAS_PLAN.md B7) to the + * founding org, matching the same restriction the backend already enforces + * in routers/trips.py. + */ +export const FOUNDING_ORG_ID = "founding";