Files
server-26/drb-frontend/app/settings/nodes/page.tsx
T
Logan CusanoandClaude Sonnet 5 93fa3a6054 frontend: install command uses the node id from the mint form (node-26#4)
The mint panel's copy command hard-coded --node-id node-XXX. Now the label
just entered (the operator types the node id there — placeholder relabeled
"Node ID, e.g. node-003") is captured on mint and interpolated into the
command: spaces → dashes, non [A-Za-z0-9_-] stripped (install.sh's rule),
falling back to node-XXX only if that yields nothing. The "edit node-XXX"
hint now only shows in the fallback case.

Not typechecked (no node/npm here); one useState<string|null>, one derived
string, a JSX conditional. `next build` in deploy.yml gates it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:13:23 -04:00

342 lines
14 KiB
TypeScript

"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<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 [cmdCopied, setCmdCopied] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false);
// The label the operator typed for the token that was just minted — used as
// the node id in the install command below. Captured on mint because `label`
// itself is cleared afterward.
const [mintedLabel, setMintedLabel] = useState<string | null>(null);
// The paste-ready one-shot install command for a fresh Pi. The node id comes
// from the label just entered (spaces → dashes; install.sh requires
// [A-Za-z0-9_-]); if that yields nothing it falls back to a node-XXX
// placeholder. The MQTT broker host is the documented mqtt.<domain> sibling
// of the api host (install.sh header) — a DNS assumption the operator checks.
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 nodeIdForCmd =
(mintedLabel ?? "").trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9_-]/g, "") || "node-XXX";
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 ${nodeIdForCmd} \\
--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);
setMintedLabel(label.trim());
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>
<div className="flex items-start gap-2">
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
<button
type="button"
onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
setTokenCopied(true); setTimeout(() => setTokenCopied(false), 2000);
})}
className="text-xs text-indigo-300 hover:text-indigo-200 px-2 py-1.5 transition-colors shrink-0"
>
{tokenCopied ? "Copied" : "Copy"}
</button>
</div>
<p className="text-xs text-indigo-200 font-mono mt-3 mb-1">
…or run this on a fresh Pi{" "}
{nodeIdForCmd === "node-XXX"
? <>(edit <span className="text-indigo-100">node-XXX</span> and check the broker host)</>
: <>(check the broker host)</>}:
</p>
<div className="flex items-start gap-2">
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre>
<button
type="button"
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
setCmdCopied(true); setTimeout(() => setCmdCopied(false), 2000);
})}
className="text-xs text-indigo-300 hover:text-indigo-200 px-2 py-1.5 transition-colors shrink-0"
>
{cmdCopied ? "Copied" : "Copy"}
</button>
</div>
<button
type="button"
onClick={() => { setJustMinted(null); setMintedLabel(null); }}
className="text-xs text-indigo-300 hover:text-indigo-200 mt-3 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="Node ID, e.g. node-003"
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() {
const { nodes, loading: nodesLoading } = useNodes();
const [users, setUsers] = useState<UserRecord[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [error, setError] = useState<string | null>(null);
const [savingNodeId, setSavingNodeId] = useState<string | null>(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<string, UserRecord>();
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 (
<div className="space-y-6">
<EnrollmentTokensPanel />
<div className="space-y-4">
<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.
</p>
{error && <ErrorBanner message={error} />}
<Card padding="none" className="overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
<th className="px-4 py-3 text-left">Node</th>
<th className="px-4 py-3 text-left hidden sm:table-cell">Status</th>
<th className="px-4 py-3 text-left">Owner</th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
) : nodes.length === 0 ? (
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-600 text-sm">No nodes registered yet.</td></tr>
) : (
nodes.map((n) => {
const owner = ownerByNode.get(n.node_id);
return (
<tr key={n.node_id} className="border-b border-gray-800 last:border-0">
<td className="px-4 py-3">
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
{n.name}
</Link>
<p className="text-gray-600 text-xs font-mono">{n.node_id}</p>
</td>
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
<td className="px-4 py-3">
<select
value={owner?.uid ?? UNASSIGNED}
disabled={savingNodeId === n.node_id}
onChange={(e) => reassign(n.node_id, e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]"
>
<option value={UNASSIGNED}>Unassigned</option>
{assignable.map((u) => (
<option key={u.uid} value={u.uid}>{u.display_name || u.email}</option>
))}
</select>
</td>
</tr>
);
})
)}
</tbody>
</table>
</Card>
</div>
</div>
);
}