"use client"; 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, 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 [cmdCopied, setCmdCopied] = useState(false); const [tokenCopied, setTokenCopied] = useState(false); // The paste-ready one-shot install command for a fresh Pi. node id and the // MQTT broker host aren't known here — left as placeholders the operator // edits. C2 URL comes from this deployment's own config; the broker is the // documented mqtt. sibling of the api host (install.sh header). const c2Url = (process.env.NEXT_PUBLIC_C2_URL ?? "https://api.example.net").replace(/\/$/, ""); const mqttBroker = (() => { try { return `mqtt.${new URL(c2Url).hostname.replace(/^api\./, "")}`; } catch { return "mqtt.example.net"; } })(); const installCmd = justMinted ? `curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \\ | sudo bash -s -- --token ${justMinted} --node-id node-XXX \\ --c2-url ${c2Url} --mqtt-broker ${mqttBroker}` : ""; 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}

…or run this on a fresh Pi (edit node-XXX and the broker host):

{installCmd}
)} {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 && ( )} ))}
Label Created StatusActions
{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([]); const [loadingUsers, setLoadingUsers] = useState(true); const [error, setError] = useState(null); const [savingNodeId, setSavingNodeId] = useState(null); useEffect(() => { c2api.listUsers() .then(setUsers) .catch((e) => setError(e instanceof Error ? e.message : String(e))) .finally(() => setLoadingUsers(false)); }, []); // Ownership (owned_node_ids) is only meaningful for operators elsewhere in the // app (see Admin → Users); admins already have full access regardless. const assignable = useMemo(() => users.filter((u) => u.role === "operator"), [users]); const ownerByNode = useMemo(() => { const map = new Map(); for (const u of users) { if (u.role !== "operator") continue; for (const nodeId of u.owned_node_ids) map.set(nodeId, u); } return map; }, [users]); const reassign = useCallback(async (nodeId: string, newUid: string) => { setSavingNodeId(nodeId); setError(null); try { const prevOwner = ownerByNode.get(nodeId); // Remove from previous owner, if any and different from the new one. if (prevOwner && prevOwner.uid !== newUid) { const next = prevOwner.owned_node_ids.filter((id) => id !== nodeId); await c2api.updateUser(prevOwner.uid, { owned_node_ids: next }); setUsers((all) => all.map((u) => (u.uid === prevOwner.uid ? { ...u, owned_node_ids: next } : u))); } // Add to new owner, if one was selected. if (newUid !== UNASSIGNED) { const newOwner = users.find((u) => u.uid === newUid); if (newOwner && !newOwner.owned_node_ids.includes(nodeId)) { const next = [...newOwner.owned_node_ids, nodeId]; await c2api.updateUser(newOwner.uid, { owned_node_ids: next }); setUsers((all) => all.map((u) => (u.uid === newOwner.uid ? { ...u, owned_node_ids: next } : u))); } } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSavingNodeId(null); } }, [ownerByNode, users]); 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 && } {loading ? ( Array.from({ length: 3 }).map((_, i) => ) ) : nodes.length === 0 ? ( ) : ( nodes.map((n) => { const owner = ownerByNode.get(n.node_id); return ( ); }) )}
Node Status Owner
No nodes registered yet.
{n.name}

{n.node_id}

); }