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>
This commit is contained in:
Logan Cusano
2026-08-18 20:39:16 -04:00
co-authored by Claude Opus 5
parent 1b4ed0d09c
commit 83416fe169
5 changed files with 232 additions and 21 deletions
+11 -4
View File
@@ -15,15 +15,22 @@ const TABS = [
]; ];
export default function SettingsLayout({ children }: { children: React.ReactNode }) { 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 pathname = usePathname();
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (!loading && !isAdmin) router.replace("/dashboard"); if (!loading && !canAccess) router.replace("/dashboard");
}, [loading, isAdmin, router]); }, [loading, canAccess, router]);
if (loading || !isAdmin) return null; if (loading || !canAccess) return null;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
+158 -1
View File
@@ -3,15 +3,168 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useNodes } from "@/lib/useNodes"; import { useNodes } from "@/lib/useNodes";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import type { UserRecord } from "@/lib/types"; import type { UserRecord } from "@/lib/types";
import { StatusBadge } from "@/components/StatusBadge"; 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 { ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonRow } from "@/components/ui/Skeleton"; import { SkeletonRow } from "@/components/ui/Skeleton";
const UNASSIGNED = "__unassigned__"; 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<EnrollmentToken[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [label, setLabel] = useState("");
const [minting, setMinting] = useState(false);
const [justMinted, setJustMinted] = useState<string | null>(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 (
<Card>
<CardHeader
title="Enrollment tokens"
subtitle="Give a new field node one of these instead of an admin-issued key — it enrolls straight into this org."
/>
{justMinted && (
<div className="bg-indigo-900/30 border border-indigo-700/50 rounded-lg p-3 mb-4">
<p className="text-xs text-indigo-200 font-mono mb-1">
New token — copy it now, it won&apos;t be shown again:
</p>
<p className="text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
<button
type="button"
onClick={() => setJustMinted(null)}
className="text-xs text-indigo-300 hover:text-indigo-200 mt-2 transition-colors"
>
Dismiss
</button>
</div>
)}
{error && <ErrorBanner message={error} />}
{canManage && (
<form onSubmit={handleMint} className="flex flex-wrap gap-2 mb-4">
<input
value={label}
onChange={(e) => 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"
/>
<Button type="submit" size="sm" disabled={minting || !label.trim()}>
{minting ? "Minting…" : "New token"}
</Button>
</form>
)}
{loading ? (
<div className="space-y-2">
<SkeletonRow cols={1} />
</div>
) : tokens.length === 0 ? (
<p className="text-gray-600 text-xs">No enrollment tokens yet.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
<th className="py-2 text-left">Label</th>
<th className="py-2 text-left hidden sm:table-cell">Created</th>
<th className="py-2 text-left">Status</th>
{canManage && <th className="py-2 text-right">Actions</th>}
</tr>
</thead>
<tbody>
{tokens.map((t) => (
<tr key={t.token_id} className="border-b border-gray-800 last:border-0">
<td className="py-2 text-white">{t.label}</td>
<td className="py-2 text-gray-500 text-xs hidden sm:table-cell">
{new Date(t.created_at).toLocaleDateString()}
</td>
<td className="py-2 text-xs">
{t.revoked ? (
<span className="text-gray-600">Revoked</span>
) : (
<span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span>
)}
</td>
{canManage && (
<td className="py-2 text-right">
{!t.revoked && (
<button
onClick={() => handleRevoke(t.token_id)}
className="text-xs text-red-400 hover:text-red-300 transition-colors"
>
Revoke
</button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</Card>
);
}
export default function NodeOwnershipSettingsPage() { export default function NodeOwnershipSettingsPage() {
const { nodes, loading: nodesLoading } = useNodes(); const { nodes, loading: nodesLoading } = useNodes();
const [users, setUsers] = useState<UserRecord[]>([]); const [users, setUsers] = useState<UserRecord[]>([]);
@@ -69,6 +222,9 @@ export default function NodeOwnershipSettingsPage() {
const loading = nodesLoading || loadingUsers; const loading = nodesLoading || loadingUsers;
return ( return (
<div className="space-y-6">
<EnrollmentTokensPanel />
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here. Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
@@ -123,5 +279,6 @@ export default function NodeOwnershipSettingsPage() {
</table> </table>
</Card> </Card>
</div> </div>
</div>
); );
} }
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider";
import { useNodes } from "@/lib/useNodes"; import { useNodes } from "@/lib/useNodes";
import { getCurrentSubscription, getPlan, type Subscription } from "@/lib/billing"; import { getCurrentSubscription, getPlan, type Subscription } from "@/lib/billing";
import { Card, CardHeader } from "@/components/ui/Card"; import { Card, CardHeader } from "@/components/ui/Card";
@@ -21,15 +22,40 @@ function StatTile({ label, value }: { label: string; value: string | number }) {
export default function OrganizationSettingsPage() { export default function OrganizationSettingsPage() {
const { nodes } = useNodes(); const { nodes } = useNodes();
const { isOrgOwner, isAdmin } = useAuth();
const [memberCount, setMemberCount] = useState<number | null>(null); const [memberCount, setMemberCount] = useState<number | null>(null);
const [sub, setSub] = useState<Subscription | null>(null); const [sub, setSub] = useState<Subscription | null>(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<string | null>(null);
const canEdit = isOrgOwner || isAdmin;
useEffect(() => { useEffect(() => {
c2api.listUsers().then((u) => setMemberCount(u.length)).catch(() => setMemberCount(null)); c2api.listUsers().then((u) => setMemberCount(u.length)).catch(() => setMemberCount(null));
getCurrentSubscription().then(setSub); 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; const plan = sub ? getPlan(sub.planId) : null;
return ( return (
@@ -44,17 +70,21 @@ export default function OrganizationSettingsPage() {
<label className="text-xs text-gray-400 block mb-1">Organization name</label> <label className="text-xs text-gray-400 block mb-1">Organization name</label>
<input <input
value={orgName} value={orgName}
disabled={orgLoading || !canEdit}
onChange={(e) => setOrgName(e.target.value)} onChange={(e) => 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"
/> />
</div> </div>
{saveError && <p className="text-red-400 text-xs">{saveError}</p>}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button size="sm" disabled title="Organization profile isn't persisted server-side yet"> <Button
Save changes size="sm"
disabled={orgLoading || !canEdit || saving || !orgName.trim() || orgName.trim() === savedName}
onClick={handleSave}
title={!canEdit ? "Only the organization owner can change this" : undefined}
>
{saving ? "Saving…" : "Save changes"}
</Button> </Button>
<span className="text-xs text-gray-600 font-mono">
Preview only — no backend endpoint stores this yet.
</span>
</div> </div>
</div> </div>
</Card> </Card>
+11 -4
View File
@@ -7,6 +7,7 @@ import { useUnconfiguredNodes } from "@/lib/useNodes";
import { useUnacknowledgedAlerts } from "@/lib/useAlerts"; import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { useTheme } from "@/components/ThemeProvider"; import { useTheme } from "@/components/ThemeProvider";
import { FOUNDING_ORG_ID } from "@/lib/tenancy";
// Links visible to all authenticated roles (viewer+) // Links visible to all authenticated roles (viewer+)
const viewerLinks = [ const viewerLinks = [
@@ -15,9 +16,13 @@ const viewerLinks = [
{ href: "/incidents", label: "Incidents" }, { href: "/incidents", label: "Incidents" },
{ href: "/map", label: "Map" }, { href: "/map", label: "Map" },
{ href: "/alerts", label: "Alerts" }, { 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 // Additional links for operators and admins
const operatorLinks = [ const operatorLinks = [
{ href: "/nodes", label: "Nodes" }, { href: "/nodes", label: "Nodes" },
@@ -25,10 +30,10 @@ const operatorLinks = [
{ href: "/tokens", label: "Tokens" }, { 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 = [ const adminLinks = [
{ href: "/admin", label: "Admin" }, { href: "/admin", label: "Admin" },
{ href: "/settings", label: "Settings" },
]; ];
function SunIcon() { function SunIcon() {
@@ -56,7 +61,7 @@ function MoonIcon() {
} }
export function Nav() { export function Nav() {
const { user, isAdmin, isOperator } = useAuth(); const { user, isAdmin, isOperator, isOrgOwner, orgId } = useAuth();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { nodes: pending } = useUnconfiguredNodes(); const { nodes: pending } = useUnconfiguredNodes();
@@ -68,8 +73,10 @@ export function Nav() {
const allLinks = [ const allLinks = [
...viewerLinks, ...viewerLinks,
...(orgId === FOUNDING_ORG_ID || isAdmin ? [tripsLink] : []),
...(isAdmin || isOperator ? operatorLinks : []), ...(isAdmin || isOperator ? operatorLinks : []),
...(isAdmin ? adminLinks : []), ...(isAdmin ? adminLinks : []),
...(isAdmin || isOrgOwner ? [{ href: "/settings", label: "Settings" }] : []),
]; ];
function navLinkClass(href: string) { function navLinkClass(href: string) {
+10
View File
@@ -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";