Files
server-26/drb-frontend/components/DiscordServersCard.tsx
T
Logan CusanoandClaude Opus 5.5 32d223e556 S7: OAuth start/options accept system_id; Discord picker QA fixes (server-26#225, #256 #257 #258)
- discord_oauth: POST /discord/oauth/start and GET /options take an optional
  system_id. The system must be the org's own or shared (another org's or
  unknown = 404). Its preferred bot is strict: one that can't connect is a
  404, never a quiet swap to Automatic. No preference = the usual order.
  The picker's "bot for a system" now always sends system_id.
- #257: _channel_state gives a bot whose token Discord rejected its own
  "invalid" state; the picker shows "Radio bot needs replacing" with no
  invite links, instead of "No radio bot".
- #256: Start no longer stays disabled on "Needs fix". It re-checks with
  Discord first (refresh=1) and starts only if the fresh answer allows it.
  "Not a voice channel" still disables Start.
- #258: own tooltip for "Not a voice channel"; "platform admin" -> "DRB
  support"; PreferredBotsCard says plainly that adding your own bot isn't
  self-serve (no support address exists yet, see #47), and a load failure
  gets a plain message + Try again instead of the raw C2 error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:09:28 -04:00

342 lines
15 KiB
TypeScript

"use client";
// Settings → Organization → "Linked Discord servers" (server-26#213 item 1).
// Matches design-canvas/discord-system-mockup.html's Servers panel, minus the
// plan / incident-forum / roles / limits columns: none of those exist
// server-side yet, and a column with nothing behind it is a dead surface.
//
// Two ways to link (server-26#225 S7):
// 1. Connect a Discord server (primary): pick a radio bot, add it in
// Discord, and the same click links the server (per-bot OAuth, S6).
// No main bot needed. Discord sends the browser back to
// /discord/connected, which finishes the link.
// 2. Link code (secondary, collapsed): /drb link, run by someone with
// Manage Server. Needs the main DRB bot in that server.
import { useCallback, useEffect, useRef, useState } from "react";
import { c2api } from "@/lib/c2api";
import { Card, CardHeader } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { parseC2Error, type OrgPreferredBots } from "@/lib/discordPicker";
import { type OAuthStartRequest, OAuthNotConfigured, startDiscordOAuth } from "@/lib/discordOAuth";
type Guild = { guild_id: string; guild_name: string | null; linked_at: string | null; linked_via: string };
const SELECT =
"bg-page border border-line rounded-lg px-2.5 py-1.5 text-ink text-sm focus:outline-none focus:border-accent disabled:opacity-50 max-w-full";
// Option values for the bot picker: "" = let C2 choose, "tok:<id>" = one of
// the org's own bots, "sys:<id>" = whichever bot is preferred for that system.
function startRequest(choice: string): OAuthStartRequest {
const req: OAuthStartRequest = { return_to: "/settings/organization" };
if (choice.startsWith("tok:")) req.token_id = choice.slice(4);
// c2-core resolves the system's preferred bot itself, including a pool bot
// a platform admin pinned (whose token_id the org never sees).
else if (choice.startsWith("sys:")) req.system_id = choice.slice(4);
return req;
}
function BotChoices({ data }: { data: OrgPreferredBots | null }) {
const systems = (data?.systems ?? []).filter((s) => s.preferred_token_id || s.preferred_is_platform_bot);
const bots = data?.bots ?? [];
return (
<>
<option value="">Automatic: your own bot, else a DRB radio bot</option>
{systems.length > 0 && (
<optgroup label="The bot for a system">
{systems.map((s) => (
<option key={s.system_id} value={`sys:${s.system_id}`}>
{s.name}: {s.preferred_name ?? "DRB radio bot"}
</option>
))}
</optgroup>
)}
{bots.length > 0 && (
<optgroup label="Your organization's bots">
{bots.map((b) => (
<option key={b.token_id} value={`tok:${b.token_id}`} disabled={b.valid === false}>
{b.name}{b.valid === false ? " (token rejected)" : ""}
</option>
))}
</optgroup>
)}
</>
);
}
type Code = { code: string; expires_at: string; command: string };
const POLL_MS = 5000;
function shortId(id: string) {
return id.length > 8 ? `${id.slice(0, 2)}…${id.slice(-4)}` : id;
}
function fmtDate(iso: string | null) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function fmtLeft(ms: number) {
const s = Math.max(0, Math.ceil(ms / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isAdmin: boolean }) {
const [guilds, setGuilds] = useState<Guild[] | null>(null);
const [restricted, setRestricted] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [code, setCode] = useState<Code | null>(null);
const [justLinked, setJustLinked] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [now, setNow] = useState(() => Date.now());
const known = useRef<Set<string> | null>(null);
const [choices, setChoices] = useState<OrgPreferredBots | null>(null);
const [choice, setChoice] = useState("");
const [connecting, setConnecting] = useState(false);
// Set after /discord/oauth/start answers 404: no bot that fits has Connect
// turned on yet (a platform admin sets its client secret). The link code is
// then the only way, so it opens.
const [oauthOff, setOauthOff] = useState(false);
const [showCode, setShowCode] = useState(false);
const load = useCallback(async () => {
try {
const res = await c2api.listLinkedDiscordGuilds();
setRestricted(res.unlinked_guilds_restricted);
setLoadError(null);
setGuilds(res.guilds);
return res.guilds;
} catch (err) {
setLoadError(err instanceof Error ? err.message : "Could not load Discord servers.");
return null;
}
}, []);
useEffect(() => {
if (!canEdit) return;
load().then((g) => { if (g) known.current = new Set(g.map((x) => x.guild_id)); });
// Bots to offer. A 403 (platform admin with no org) just leaves "Automatic".
c2api.getOrgPreferredBots().then(setChoices).catch(() => setChoices(null));
}, [canEdit, load]);
async function connect() {
setConnecting(true);
setActionError(null);
setJustLinked(null);
try {
const { url } = await startDiscordOAuth(startRequest(choice));
window.location.assign(url);
// Leave the button in its busy state: the page is navigating to Discord.
} catch (err) {
if (err instanceof OAuthNotConfigured) {
setShowCode(true);
if (choice) {
// Only this bot is off: keep Connect and the picker so Automatic can be tried.
setActionError("That bot can't connect servers yet. Try Automatic, or link with a code below.");
} else {
setOauthOff(true);
setActionError("Connecting with Discord isn't set up yet. Link with a code below instead.");
}
} else {
setActionError(parseC2Error(err).message);
}
setConnecting(false);
}
}
const msLeft = code ? new Date(code.expires_at).getTime() - now : 0;
const live = !!code && msLeft > 0;
// While a code is live: tick the countdown, and poll the list so the page
// notices the moment someone runs /drb link in Discord.
useEffect(() => {
if (!live) return;
const tick = setInterval(() => setNow(Date.now()), 1000);
const poll = setInterval(async () => {
const g = await load();
if (!g) return;
const prev = known.current;
const added = prev ? g.find((x) => !prev.has(x.guild_id)) : undefined;
known.current = new Set(g.map((x) => x.guild_id));
if (added) {
setCode(null);
setJustLinked(added.guild_name || `guild ${shortId(added.guild_id)}`);
}
}, POLL_MS);
return () => { clearInterval(tick); clearInterval(poll); };
}, [live, load]);
async function generate() {
setBusy(true);
setActionError(null);
setJustLinked(null);
setCopied(false);
try {
const res = await c2api.createDiscordLinkCode();
setNow(Date.now());
setCode(res);
} catch (err) {
setActionError(err instanceof Error ? err.message : "Could not generate a code.");
} finally {
setBusy(false);
}
}
async function unlink(g: Guild) {
const name = g.guild_name || `guild ${shortId(g.guild_id)}`;
if (!window.confirm(`Unlink ${name}? Its radio channels stop, and it can't use DRB until it is linked again.`)) {
return;
}
setActionError(null);
try {
await c2api.unlinkDiscordGuild(g.guild_id);
const next = await load();
if (next) known.current = new Set(next.map((x) => x.guild_id));
} catch (err) {
setActionError(err instanceof Error ? err.message : "Could not unlink that server.");
}
}
function copy() {
if (!code) return;
navigator.clipboard?.writeText(code.command).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
return (
<Card padding="none" className="overflow-hidden">
<div className="p-5 pb-0">
<CardHeader
title="Linked Discord servers"
subtitle={<>Add a radio bot to your Discord server. Adding it links the server to this organization.</>}
action={
!oauthOff ? (
<Button size="sm" onClick={connect} disabled={!canEdit || connecting}
title={!canEdit ? "Only the organization owner can link Discord servers" : undefined}>
{connecting ? "Opening Discord…" : "Connect a Discord server"}
</Button>
) : undefined
}
/>
{canEdit && !oauthOff && (
<div className="flex flex-wrap items-center gap-2 mb-4 text-xs text-ink-muted">
<label htmlFor="drb-connect-bot">Radio bot</label>
<select id="drb-connect-bot" value={choice} onChange={(e) => setChoice(e.target.value)} disabled={connecting} className={SELECT}>
<BotChoices data={choices} />
</select>
<span>Discord asks you to pick the server and confirm View Channel, Connect and Speak.</span>
</div>
)}
{canEdit && (
<div className="mb-4">
<button
type="button"
onClick={() => setShowCode((v) => !v)}
aria-expanded={showCode}
className="text-xs text-ink-muted hover:text-ink"
>
{showCode ? "▾" : "▸"} Or link with a code (<span className="font-mono">/drb link</span>, needs the main DRB bot in the server)
</button>
{showCode && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button size="sm" variant={oauthOff ? "primary" : "secondary"} onClick={generate} disabled={busy}>
{busy ? "Generating…" : live ? "New code" : "Generate link code"}
</Button>
<span className="text-xs text-ink-muted">
Someone with Manage Server runs it as <span className="font-mono">/drb link</span> in that Discord server.
</span>
</div>
)}
</div>
)}
</div>
{(code || justLinked || actionError) && (
<div className="mx-5 mb-4 rounded-lg border border-line bg-raised p-4 text-sm">
{actionError && <p className="text-sev-major text-xs">{actionError}</p>}
{justLinked && <p className="text-ink">Linked <b>{justLinked}</b>. Its <span className="font-mono">/join</span>, <span className="font-mono">/leave</span> and <span className="font-mono">/status</span> now act for this organization.</p>}
{code && live && (
<div className="space-y-3">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-mono text-xl font-bold tracking-wider text-ink">{code.code}</span>
<span className="text-xs text-ink-muted">single use · expires in {fmtLeft(msLeft)}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<code className="font-mono text-xs text-ink-2 bg-surface border border-line rounded px-2 py-1 break-all">{code.command}</code>
<Button size="sm" variant="secondary" onClick={copy}>{copied ? "Copied" : "Copy command"}</Button>
</div>
<p className="text-xs text-ink-muted">
Run it in the Discord server you want to link. That server can then use this organization&apos;s
systems and radio channels — nothing from any other organization. Generating a new code cancels this one.
</p>
</div>
)}
{code && !live && <p className="text-xs text-ink-muted">That code expired. Generate a new one.</p>}
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-ink-muted uppercase tracking-wider border-y border-line">
<th className="px-5 py-2.5 text-left font-medium">Server</th>
<th className="px-5 py-2.5 text-left font-medium hidden sm:table-cell">Linked</th>
<th className="px-5 py-2.5 text-left font-medium">Status</th>
<th className="px-5 py-2.5 w-20"></th>
</tr>
</thead>
<tbody>
{!canEdit && (
<tr><td colSpan={4} className="px-5 py-4 text-xs text-ink-muted">Only the organization owner can see and link Discord servers.</td></tr>
)}
{canEdit && guilds === null && !loadError && (
<tr><td colSpan={4} className="px-5 py-4 text-xs text-ink-muted">Loading…</td></tr>
)}
{loadError && (
<tr><td colSpan={4} className="px-5 py-4 text-xs text-sev-major">{loadError}</td></tr>
)}
{guilds?.length === 0 && (
<tr><td colSpan={4} className="px-5 py-4 text-xs text-ink-muted">No Discord servers linked yet.</td></tr>
)}
{guilds?.map((g) => (
<tr key={g.guild_id} className="border-b border-line last:border-0">
<td className="px-5 py-3">
<div className="text-ink font-medium">{g.guild_name || "Unnamed server"}</div>
<div className="text-xs text-ink-muted font-mono" title={g.guild_id}>guild {shortId(g.guild_id)}</div>
</td>
<td className="px-5 py-3 text-xs text-ink-2 hidden sm:table-cell">
{fmtDate(g.linked_at)}
{g.linked_via === "oauth" && <div className="text-ink-muted">by adding a radio bot</div>}
</td>
<td className="px-5 py-3"><Badge tone="success">Linked</Badge></td>
<td className="px-5 py-3 text-right">
{canEdit && (
<button onClick={() => unlink(g)} className="text-xs text-sev-major hover:brightness-110 transition-colors">
Unlink
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-5 py-3 border-t border-line bg-raised text-xs text-ink-muted">
{restricted ? (
<>Unlinked servers see only <b className="text-ink">/drb link</b> and <b className="text-ink">/help</b>. </>
) : isAdmin ? (
<>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </>
) : null}
Every command and autocomplete is scoped to the linked organization — no server sees another
organization&apos;s nodes, systems or bots.
</div>
</Card>
);
}