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>
342 lines
14 KiB
TypeScript
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'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>
|
|
);
|
|
}
|