Discord admin page: servers, radio channels, radio bots (server-26#213 item 2)

Frontend, built from design-canvas/discord-system-mockup.html ("DRB admin"):
- Settings > Discord (/settings/discord, platform admin only; the tab is
  hidden from org owners because every route behind it is admin-only).
  KPIs + tabs:
  * Radio channels: every discord_streams doc, desired -> actual, the node's
    real error, red after 2 min desired-on without connecting. Stop
    (POST /discord/guilds/{id}/stop), Start for a stopped channel and
    "+ Add radio channel" (POST /discord/streams), with the invite links a
    "no bot in this server" refusal carries.
  * Servers: linked servers plus servers seen running radio while unlinked
    (acting for DISCORD_UNLINKED_GUILD_ORG_ID); Link / Change org
    (PUT /discord/guilds/{id}/link).
  * Radio bots: valid / check_error, guild membership with a per-server
    invite link for each server the bot is missing from, lease (node,
    since, releasing), fan-out, Refresh (POST /tokens/{id}/refresh),
    Remove, Release all leases (POST /tokens/flush), add with optional BYO org.
  * How it works.
- /tokens now renders the same radio-bot table (new token fields + refresh).
- Left out, no backend yet: alert routes, incident forum/roles/plan columns,
  per-channel talkgroup chips, "Generate link code" (item 1).

Backend (routers/discord.py):
- GET /discord/admin/overview (platform admin): guilds, streams with node
  and bot names, orgs, systems, limits. No token strings, no Discord user
  ids, no linking-admin uid.
- PUT /discord/guilds/{id}/link: digits-only id, optional guild_name label,
  and re-linking a server to a different org stops its live channels
  (the reconciler would otherwise keep re-placing them on the old org's
  nodes, using its stored org_id).
- POST /discord/streams: guild_id/channel_id must be digits (the web form
  types them in; the bot always sent snowflakes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 20:05:35 -04:00
co-authored by Claude Opus 5.5
parent e574bd924a
commit d3277c13e1
11 changed files with 1464 additions and 161 deletions
+33 -154
View File
@@ -1,30 +1,26 @@
"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() {
// Platform admin only: every /tokens route is require_admin_token since
// server-26#204 item 6, so an operator here only ever saw a failed fetch.
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(() => {
@@ -33,151 +29,34 @@ export default function TokensPage() {
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(() => { 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) 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>
);
}