Merge pull request 'Discord admin page + hide admin-only bot pool from operators (#213 items 2, 4)' (#219) from feat/213-discord-admin into main
Build & Deploy / Build & push images (push) Successful in 5m1s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 5s
Build & Deploy / Deploy to VM (push) Successful in 7s
Build & Deploy / Report a failed deploy (push) Skipped

Reviewed-on: #219
This commit was merged in pull request #219.
This commit is contained in:
2026-10-04 22:05:36 -04:00
13 changed files with 1510 additions and 173 deletions
+11 -7
View File
@@ -91,13 +91,17 @@ export default function NetworkPage() {
count={systemsLoading ? null : systems.length}
countLabel={systemsLoading ? "" : "configured"}
/>
<HubCard
href="/tokens"
title="Bot Tokens"
description="Discord bot tokens available for nodes to claim when relaying live audio."
count={null}
countLabel="manage"
/>
{/* The radio-bot pool is platform-admin only (server-26#204 item 6);
an operator following this card only hit a 403 (server-26#213). */}
{isAdmin && (
<HubCard
href="/tokens"
title="Bot Tokens"
description="Discord bot tokens available for nodes to claim when relaying live audio."
count={null}
countLabel="manage"
/>
)}
</div>
</div>
);
+169
View File
@@ -0,0 +1,169 @@
"use client";
// Settings › Discord — the admin page from design-canvas/discord-system-mockup.html
// ("DRB admin" view), server-26#213 item 2. Desired state lives here; nodes
// reconcile and report back. Platform admin only for now: every route behind
// it (GET /discord/admin/overview, /tokens, PUT /discord/guilds/{id}/link) is
// require_admin_token. The mockup's "Alert routes" tab, incident-forum /
// roles / plan columns and per-channel talkgroup chips have no backend yet and
// are left out rather than shown as dead controls.
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { type DiscordAdminOverview, type RadioBot, parseC2Error, streamHealth } from "@/lib/discord";
import { ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { StreamsPanel } from "@/components/discord/StreamsPanel";
import { ServersPanel } from "@/components/discord/ServersPanel";
import { RadioBotsPanel } from "@/components/discord/RadioBotsPanel";
import { Panel } from "@/components/discord/ui";
type Tab = "streams" | "guilds" | "bots" | "how";
const TABS: { key: Tab; label: string }[] = [
{ key: "streams", label: "Radio channels" },
{ key: "guilds", label: "Servers" },
{ key: "bots", label: "Radio bots" },
{ key: "how", label: "How it works" },
];
const POLL_MS = 10_000;
function Kpi({ k, v, s }: { k: string; v: string; s: string }) {
return (
<div className="bg-surface border border-line rounded-xl p-3">
<div className="text-xs text-ink-muted">{k}</div>
<div className="text-xl font-semibold text-ink mt-0.5">{v}</div>
<div className="text-xs text-ink-muted">{s}</div>
</div>
);
}
function HowItWorks() {
const steps = [
["1 · Desired", "/join in Discord or this page writes a discord_streams doc."],
["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server, in one transaction."],
["3 · Reconcile", "A retained MQTT message tells the node its full channel list (token ids, never token strings)."],
["4 · Voice", "The node logs in as the leased bot and plays its PulseAudio capture — never Icecast."],
["5 · Actual", "The node reports each channel's real state; this page and the Discord reply show it."],
];
return (
<Panel
title="How a radio channel stays up"
note="Node reboot, C2 sweep, Discord voice drop: the node re-reads its desired state and reconnects; persistent channels whose node went away are re-placed on another node. A channel desired-on but not connected for 2 minutes turns red on the Radio channels tab."
>
<div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
{steps.map(([t, d]) => (
<div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted">
<b className="block text-ink text-sm mb-1">{t}</b>
{d}
</div>
))}
</div>
</Panel>
);
}
export default function DiscordSettingsPage() {
const { isAdmin, loading: authLoading } = useAuth();
const router = useRouter();
const [tab, setTab] = useState<Tab>("streams");
const [overview, setOverview] = useState<DiscordAdminOverview | null>(null);
const [bots, setBots] = useState<RadioBot[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !isAdmin) router.replace("/settings/organization");
}, [authLoading, isAdmin, router]);
useEffect(() => {
const h = window.location.hash.slice(1);
if (TABS.some((t) => t.key === h)) setTab(h as Tab);
}, []);
const load = useCallback(async () => {
try {
const [o, t] = await Promise.all([c2api.getDiscordAdmin(), c2api.getTokens()]);
setOverview(o);
setBots(t as RadioBot[]);
setError(null);
} catch (err) {
setError(parseC2Error(err).message);
}
}, []);
useEffect(() => {
if (!isAdmin) return;
load();
// Actual state is reported by nodes; poll while the tab is visible.
const id = setInterval(() => {
if (document.visibilityState === "visible") load();
}, POLL_MS);
return () => clearInterval(id);
}, [isAdmin, load]);
// Every hook runs before this guard (React error #310 — see app/nodes/page.tsx).
if (authLoading || !isAdmin) return null;
const linked = overview?.guilds.filter((g) => g.linked).length ?? 0;
const unlinked = (overview?.guilds.length ?? 0) - linked;
const live = overview?.streams.filter((s) => s.desired === "on") ?? [];
const connected = live.filter((s) => streamHealth(s).health === "ok").length;
const stuck = live.filter((s) => streamHealth(s).health === "bad").length;
const leased = bots.filter((b) => b.lease).length;
const byo = bots.filter((b) => b.org_id).length;
const attention = bots.filter((b) => b.valid === false || b.guild_ids == null).length;
return (
<div className="space-y-4">
<div>
<h2 className="text-ink font-semibold text-lg">Discord</h2>
<p className="text-ink-muted text-sm mt-0.5">
Desired state lives here; nodes reconcile and report back. Voice audio comes from each node&apos;s PulseAudio
monitor, never Icecast.
</p>
</div>
{error && <ErrorBanner message={error} />}
{!overview && !error ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)}
</div>
) : overview ? (
<>
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
<Kpi k="Linked servers" v={String(linked)} s={unlinked ? `${unlinked} seen but unlinked` : "all linked"} />
<Kpi k="Radio channels" v={`${connected} / ${live.length}`} s={stuck ? `connected / desired · ${stuck} failing` : "connected / desired"} />
<Kpi k="Radio bots leased" v={`${leased} / ${bots.length}`} s={byo ? `pool + ${byo} BYO` : "platform pool"} />
<Kpi k="Bots needing a check" v={String(attention)} s="rejected or membership unknown" />
</div>
<div className="flex gap-1 border-b border-line overflow-x-auto">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => {
setTab(t.key);
history.replaceState(null, "", `#${t.key}`);
}}
className={`px-3 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${
tab === t.key ? "border-accent text-ink" : "border-transparent text-ink-muted hover:text-ink"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "streams" && <StreamsPanel overview={overview} bots={bots} onChanged={load} />}
{tab === "guilds" && <ServersPanel overview={overview} onChanged={load} />}
{tab === "bots" && <RadioBotsPanel bots={bots} overview={overview} onChanged={load} />}
{tab === "how" && <HowItWorks />}
</>
) : null}
</div>
);
}
+5 -1
View File
@@ -14,6 +14,10 @@ const TABS = [
{ href: "/settings/billing", label: "Billing" },
];
// Platform-admin-only tabs: every route behind them is require_admin_token,
// so an org owner would only see 403s (server-26#213 item 2).
const ADMIN_TABS = [{ href: "/settings/discord", label: "Discord" }];
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
// 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
@@ -40,7 +44,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
/>
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit max-w-full overflow-x-auto">
{TABS.map((t) => (
{[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => (
<Link
key={t.href}
href={t.href}
+14 -2
View File
@@ -1220,13 +1220,16 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
const [tokens, setTokens] = useState<TokenOption[] | null>(null);
const [saving, setSaving] = useState(false);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
if (tokens !== null) return;
try {
const data = await c2api.getTokens();
setTokens(data as TokenOption[]);
} catch {
} catch (err) {
// An error used to render as "No tokens in pool." — a false empty state.
setError(err instanceof Error ? err.message : "Could not load radio bots.");
setTokens([]);
}
}
@@ -1241,6 +1244,8 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
try {
await c2api.setPreferredToken(tokenId, systemId);
setPreferredId(tokenId);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save the preference.");
} finally {
setSaving(false);
}
@@ -1252,6 +1257,8 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
try {
await c2api.setPreferredToken(preferredId, "_none");
setPreferredId(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not clear the preference.");
} finally {
setSaving(false);
}
@@ -1276,6 +1283,8 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
<div className="mt-3 space-y-2 font-mono text-xs">
{tokens === null ? (
<p className="text-gray-600 italic">Loading tokens…</p>
) : error ? (
<p className="text-red-400">{error}</p>
) : tokens.length === 0 ? (
<p className="text-gray-600 italic">No tokens in pool.</p>
) : (
@@ -2016,7 +2025,10 @@ export default function SystemsPage() {
Delete
</button>
</div>
<PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />
{/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only
(server-26#204 item 6), so for anyone else this panel could
neither list the pool nor save a choice (server-26#213 item 4). */}
{isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />}
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
<AreaContextPanel systemId={s.system_id} />
<TalkgroupPendingPanel systemId={s.system_id} />
+38 -157
View File
@@ -1,181 +1,62 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// The radio-bot pool (bot_tokens). Same table as Settings › Discord › Radio
// bots: validity, server membership, invite links, lease, and the
// POST /tokens/{id}/refresh re-check (server-26#213 item 2). Platform admin
// only: every /tokens route is require_admin_token since server-26#204 item 6.
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider";
interface TokenRecord {
token_id: string;
name: string;
token: string; // masked server-side
in_use: boolean;
assigned_node_id: string | null;
assigned_at: string | null;
}
import { type DiscordAdminOverview, type RadioBot, parseC2Error } from "@/lib/discord";
import { PageHeader } from "@/components/ui/PageHeader";
import { LinkButton } from "@/components/ui/Button";
import { ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { RadioBotsPanel } from "@/components/discord/RadioBotsPanel";
export default function TokensPage() {
const { isAdmin, isOperator, loading: authLoading } = useAuth();
const { isAdmin, loading: authLoading } = useAuth();
const router = useRouter();
const [tokens, setTokens] = useState<TokenRecord[]>([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [name, setName] = useState("");
const [token, setToken] = useState("");
const [saving, setSaving] = useState(false);
const [bots, setBots] = useState<RadioBot[] | null>(null);
const [overview, setOverview] = useState<DiscordAdminOverview | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!authLoading && !isAdmin && !isOperator) router.replace("/");
}, [authLoading, isAdmin, isOperator, router]);
if (!authLoading && !isAdmin) router.replace("/network");
}, [authLoading, isAdmin, router]);
const refresh = useCallback(async () => {
try {
const data = await c2api.getTokens();
setTokens(data as TokenRecord[]);
} finally {
setLoading(false);
const t = await c2api.getTokens();
setBots(t as RadioBot[]);
setError(null);
} catch (err) {
setError(parseC2Error(err).message);
setBots((b) => b ?? []);
}
// Server names for the membership column; the table still works without them.
try {
setOverview(await c2api.getDiscordAdmin());
} catch {
/* membership falls back to raw server ids */
}
}, []);
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => { if (isAdmin) refresh(); }, [isAdmin, refresh]);
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
await c2api.addToken({ name, token });
setName("");
setToken("");
setShowAdd(false);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add token.");
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Remove this token from the pool?")) return;
try {
await c2api.deleteToken(id);
await refresh();
} catch (err) {
alert(err instanceof Error ? err.message : "Delete failed.");
}
}
if (authLoading || (!isAdmin && !isOperator)) return null;
if (authLoading || !isAdmin) return null;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white font-mono">Bot Token Pool</h1>
<p className="text-xs text-gray-500 font-mono mt-0.5">
Discord bot tokens assigned to nodes when they join a voice channel.
</p>
</div>
<button
onClick={() => setShowAdd(!showAdd)}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
>
+ Add Token
</button>
</div>
{showAdd && (
<form onSubmit={handleAdd} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
<h3 className="text-white font-semibold text-sm">Add Token</h3>
<div>
<label className="text-xs text-gray-400 block mb-1">Label</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
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"
placeholder="DRB Bot 1"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Discord Bot Token</label>
<input
value={token}
onChange={(e) => setToken(e.target.value)}
required
type="password"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="MTxxxxxxxxxx.Gxxxxx.xxxxxxxxxx"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={saving}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
)}
{loading ? (
<p className="text-gray-600 text-sm font-mono">Loading…</p>
) : tokens.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No tokens in the pool. Add one to enable Discord voice streaming.</p>
) : (
<div className="border border-gray-800 rounded-lg overflow-hidden font-mono">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-900 text-xs text-gray-500 uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Label</th>
<th className="px-4 py-2.5 text-left">Token</th>
<th className="px-4 py-2.5 text-left">Status</th>
<th className="px-4 py-2.5 text-left">Node</th>
<th className="px-4 py-2.5 w-16"></th>
</tr>
</thead>
<tbody>
{tokens.map((t) => (
<tr key={t.token_id} className="border-t border-gray-800 hover:bg-gray-900/50">
<td className="px-4 py-2.5 text-white">{t.name}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs">{t.token}</td>
<td className="px-4 py-2.5">
{t.in_use ? (
<span className="text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded">In use</span>
) : (
<span className="text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">Free</span>
)}
</td>
<td className="px-4 py-2.5 text-gray-500 text-xs">
{t.assigned_node_id ?? "—"}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => handleDelete(t.token_id)}
disabled={t.in_use}
className="text-xs text-red-600 hover:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<PageHeader
title="Radio bots"
description="Discord bot tokens that nodes log in with to carry radio into voice channels."
action={<LinkButton href="/settings/discord" variant="secondary" size="sm">Discord settings</LinkButton>}
/>
{error && <ErrorBanner message={error} />}
{bots === null ? <SkeletonCard /> : <RadioBotsPanel bots={bots} overview={overview} onChanged={refresh} />}
</div>
);
}