Files
server-26/drb-frontend/components/discord/RadioBotsPanel.tsx
T
Logan CusanoandClaude Opus 5.5 dd3e5c9655 Discord: "Connect a Discord server" OAuth UI (server-26#225 S7)
- /discord/connected: Discord's OAuth redirect target. Stashes code/state
  in sessionStorage, strips them from the URL (replaceState), no-referrer
  (metadata + response header), sends a signed-out user to
  /login?next=/discord/connected and resumes, access_denied = "Nothing
  changed", result card "Linked X: N ready, M need a fix".
- middleware.ts: the callback path joins NO_SESSION_COOKIE_GATE (plan
  finding 2). Also needed for signed-in users: drb_session is
  SameSite=Strict, so it is not sent on the cross-site redirect back from
  discord.com.
- /login honours ?next= for an allowlist of one path (no open redirect).
- DiscordServersCard: Connect is the primary action with a radio-bot
  picker (automatic / the bot for a system / the org's own bots); the
  /drb link code moves to a collapsed secondary option and opens itself
  when /discord/oauth/start answers 404.
- RadioBotsPanel: write-only client-secret field per bot (PUT/DELETE
  /tokens/{id}/oauth), "Connect on/off", "N of 100 servers" with the
  pool cap (90) warning.
- lib/discordOAuth.ts: the S6 client, kept out of c2api.ts; only ever
  navigates to a discord.com /oauth2/authorize URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:51:39 -04:00

429 lines
16 KiB
TypeScript

"use client";
import { useState } from "react";
import { c2api } from "@/lib/c2api";
import {
DISCORD_UNVERIFIED_GUILD_LIMIT,
POOL_BOT_GUILD_CAP,
type DiscordAdminOverview,
type RadioBot,
type RadioBotAddResult,
fmtWhen,
guildLabel,
inviteForGuild,
parseC2Error,
shortId,
} from "@/lib/discord";
import { Button } from "@/components/ui/Button";
import { clearBotOAuthSecret, setBotOAuthSecret } from "@/lib/discordOAuth";
import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
// Radio bots = the bot_tokens pool (routers/tokens.py). Used by the Discord
// admin page's "Radio bots" tab and by /tokens. Every action here is a real
// platform-admin route: POST /tokens, POST /tokens/{id}/refresh,
// DELETE /tokens/{id}, POST /tokens/flush, PUT /tokens/{id}/oauth.
function Checks({ t }: { t: RadioBot }) {
if (t.valid === false) {
return (
<div>
<Status health="bad">Token rejected by Discord</Status>
{t.check_error && <div className={`${DIM} mt-1 max-w-[16rem]`}>{t.check_error}</div>}
</div>
);
}
if (t.valid) {
return (
<div>
<Status health="ok">Token valid</Status>
<div className={`${DIM} mt-1`}>checked {fmtWhen(t.validated_at)}</div>
{t.check_error && <div className={`${DIM} mt-1 max-w-[16rem]`}>last check failed: {t.check_error}</div>}
</div>
);
}
return (
<div>
<Status health="warn">Not checked yet</Status>
{t.check_error && <div className={`${DIM} mt-1 max-w-[16rem]`}>{t.check_error}</div>}
</div>
);
}
/** "N of 100 servers" — Discord caps an unverified bot at 100; Connect stops
* picking a pool bot at the cap (owner ruling Q3), so a full one needs a sibling. */
function ServerCount({ t, cap }: { t: RadioBot; cap: number }) {
if (t.guild_ids == null) return null;
const n = t.guild_ids.length;
const full = t.org_id == null && n >= cap;
return (
<div className={`text-xs mb-1 ${full || n >= DISCORD_UNVERIFIED_GUILD_LIMIT ? "text-sev-moderate" : "text-ink-muted"}`}>
{n} of {DISCORD_UNVERIFIED_GUILD_LIMIT} servers
{full && ` · full at ${cap}: Connect skips it, add another pool bot`}
</div>
);
}
/**
* The bot app's OAuth client secret, which turns on "Connect a Discord server"
* for it (server-26#225 S6/S7, owner ruling Q1: per-bot OAuth). Write-only:
* c2-core checks it with Discord and stores it server-side; this form never
* reads it back, and the field is cleared as soon as it is sent.
*/
function ConnectSetting({ t, onChanged }: { t: RadioBot; onChanged: () => Promise<void> | void }) {
const [open, setOpen] = useState(false);
const [secret, setSecret] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const on = !!t.oauth_enabled;
async function save(e: React.FormEvent) {
e.preventDefault();
const value = secret.trim();
setSecret("");
if (!value) return;
setSaving(true);
setError(null);
try {
await setBotOAuthSecret(t.token_id, value);
setOpen(false);
await onChanged();
} catch (err) {
setError(parseC2Error(err).message);
} finally {
setSaving(false);
}
}
async function turnOff() {
if (!confirm(`Turn off Connect for ${t.name}? Its stored client secret is deleted. Plain invite links keep working.`)) return;
setSaving(true);
setError(null);
try {
await clearBotOAuthSecret(t.token_id);
await onChanged();
} catch (err) {
setError(parseC2Error(err).message);
} finally {
setSaving(false);
}
}
return (
<div className="mt-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Status health={on ? "ok" : "off"}>{on ? "Connect on" : "Connect off"}</Status>
<button type="button" className="text-xs text-accent hover:underline" onClick={() => { setOpen((v) => !v); setError(null); }}>
{open ? "Cancel" : on ? "Replace secret" : "Set client secret"}
</button>
{on && !open && (
<button type="button" disabled={saving} className="text-xs text-sev-major hover:underline disabled:opacity-40" onClick={turnOff}>
Turn off
</button>
)}
</div>
{open && (
<form onSubmit={save} className="mt-1.5 flex flex-wrap items-center gap-1.5 max-w-[18rem]">
<input
value={secret}
onChange={(e) => setSecret(e.target.value)}
type="password"
autoComplete="off"
spellCheck={false}
aria-label={`OAuth client secret for ${t.name}`}
placeholder="Client secret"
className={`${INPUT} font-mono flex-1 min-w-0`}
/>
<Button type="submit" size="sm" disabled={saving || !secret.trim()}>{saving ? "Checking…" : "Save"}</Button>
<p className={`${DIM} w-full`}>
From the Discord Developer Portal → this bot&apos;s app → OAuth2. Checked with Discord, stored server-side, never shown again.
</p>
</form>
)}
{error && <div className="text-xs text-sev-major mt-1 max-w-[18rem]">{error}</div>}
</div>
);
}
function Membership({ t, overview }: { t: RadioBot; overview: DiscordAdminOverview | null }) {
if (t.guild_ids == null) return <span className={DIM}>unknown — refresh</span>;
const known = new Map((overview?.guilds ?? []).map((g) => [g.guild_id, g]));
const members = t.guild_ids;
// Servers this bot could serve (its own org's for a BYO bot, every one for the pool) but is not in.
const missing = (overview?.guilds ?? []).filter(
(g) => !members.includes(g.guild_id) && (t.org_id == null || g.acting_org_id === t.org_id),
);
const cap = overview?.limits.pool_bot_guild_cap ?? POOL_BOT_GUILD_CAP;
if (!members.length && !missing.length) return <span className={DIM}>no servers</span>;
return (
<div className="max-w-[22rem]">
<ServerCount t={t} cap={cap} />
<div className="flex flex-wrap gap-1">
{members.map((gid) => (
<span key={gid} className="text-[11px] rounded px-1.5 py-px bg-raised border border-line text-ink-2">
{known.has(gid) ? guildLabel(known.get(gid)!) : `server ${shortId(gid)}`}
</span>
))}
{missing.map((g) => {
const href = inviteForGuild(t.invite_url, g.guild_id);
const label = `${guildLabel(g)} ✕`;
return href ? (
<a
key={g.guild_id}
href={href}
target="_blank"
rel="noopener noreferrer"
title={`Not in ${guildLabel(g)} — invite it`}
className="text-[11px] rounded px-1.5 py-px border border-dashed border-sev-moderate text-sev-moderate hover:underline"
>
{label}
</a>
) : (
<span key={g.guild_id} className="text-[11px] rounded px-1.5 py-px border border-dashed border-sev-moderate text-sev-moderate">
{label}
</span>
);
})}
</div>
</div>
);
}
function AddBotForm({
overview,
onDone,
onCancel,
}: {
overview: DiscordAdminOverview | null;
onDone: (r: RadioBotAddResult) => void;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const [token, setToken] = useState("");
const [owner, setOwner] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const r = await c2api.addToken({ name: name.trim(), token: token.trim(), org_id: owner || null });
onDone(r);
} catch (err) {
setError(parseC2Error(err).message);
} finally {
setSaving(false);
}
}
return (
<form onSubmit={submit} className="px-4 py-4 border-b border-line grid gap-3 sm:grid-cols-3 bg-raised">
<Field label="Label">
<input value={name} onChange={(e) => setName(e.target.value)} required className={INPUT} placeholder="DRB Radio 2" />
</Field>
<Field label="Discord bot token" hint="Checked with Discord before it is saved.">
<input
value={token}
onChange={(e) => setToken(e.target.value)}
required
type="password"
autoComplete="off"
className={`${INPUT} font-mono`}
/>
</Field>
<Field label="Who can use it" hint="A BYO bot is only ever leased to its own org.">
<select value={owner} onChange={(e) => setOwner(e.target.value)} className={INPUT}>
<option value="">Platform pool</option>
{(overview?.orgs ?? []).map((o) => (
<option key={o.org_id} value={o.org_id}>BYO · {o.name}</option>
))}
</select>
</Field>
{error && <p className="sm:col-span-3 text-sev-major text-xs">{error}</p>}
<div className="sm:col-span-3 flex gap-2">
<Button type="submit" size="sm" disabled={saving}>{saving ? "Checking with Discord…" : "Add bot"}</Button>
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
</div>
</form>
);
}
export function RadioBotsPanel({
bots,
overview,
onChanged,
}: {
bots: RadioBot[];
overview: DiscordAdminOverview | null;
onChanged: () => Promise<void> | void;
}) {
const [adding, setAdding] = useState(false);
const [added, setAdded] = useState<RadioBotAddResult | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const orgName = (id: string | null | undefined) =>
(overview?.orgs ?? []).find((o) => o.org_id === id)?.name ?? id ?? "";
const nodeName = (id: string) => overview?.node_names?.[id] ?? id;
const systemName = (id: string) => (overview?.systems ?? []).find((s) => s.system_id === id)?.name ?? id;
const fanOut = overview?.limits.node_max_streams ?? 4;
async function act(key: string, fn: () => Promise<unknown>) {
setBusy(key);
setError(null);
try {
await fn();
await onChanged();
} catch (err) {
setError(parseC2Error(err).message);
} finally {
setBusy(null);
}
}
const leased = bots.filter((b) => b.lease).length;
return (
<Panel
title="Radio bots"
description="Each bot can be in one voice channel per server, in many servers at once."
action={
<>
{leased > 0 && (
<Button
size="sm"
variant="ghost"
disabled={busy !== null}
onClick={() => {
if (!confirm(`Force-release all ${leased} leased radio bot(s)? Their nodes log the bots out; persistent channels are re-placed.`)) return;
act("flush", () => c2api.flushTokens());
}}
>
Release all leases
</Button>
)}
<Button size="sm" variant="secondary" onClick={() => { setAdding((v) => !v); setAdded(null); }}>
+ Add bot token
</Button>
</>
}
note={
<>
<b className="text-ink">Release is ordered:</b> the node stops the bot&apos;s gateway session, then acks, then the
token returns to the pool — a token is never live on two nodes. Invite links ask for View Channel, Connect and Speak only.
</>
}
>
{adding && (
<AddBotForm
overview={overview}
onCancel={() => setAdding(false)}
onDone={async (r) => {
setAdding(false);
setAdded(r);
await onChanged();
}}
/>
)}
{added && (
<div className="px-4 py-3 border-b border-line text-xs text-ink-2 flex flex-wrap items-center gap-2">
<span>
Added <b className="text-ink">{added.name}</b>
{added.valid ? ` · token valid · in ${added.guild_ids?.length ?? 0} server(s)` : " · Discord could not be reached, membership unknown"}
{added.check_error ? ` (${added.check_error})` : ""}.
</span>
{added.invite_url && (
<a href={added.invite_url} target="_blank" rel="noopener noreferrer" className="text-accent underline">
Invite it to a server
</a>
)}
<button className="ml-auto text-ink-muted hover:text-ink" onClick={() => setAdded(null)} aria-label="Dismiss">✕</button>
</div>
)}
{error && <p className="px-4 py-2 text-sev-major text-xs border-b border-line">{error}</p>}
{bots.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-muted">No radio bots yet. Add one to let nodes carry radio into Discord voice channels.</p>
) : (
<Table head={["Bot", "Lease", "Voice now", "Member of", "Checks", ""]}>
{bots.map((t) => {
const streamsHere = t.lease?.streams?.length ?? 0;
return (
<tr key={t.token_id}>
<td className={TD}>
<b>{t.name}</b>
<div className={DIM}>{t.org_id ? `BYO · ${orgName(t.org_id)} only` : "platform pool"}</div>
<div className={`${DIM} font-mono`}>{t.token}</div>
{t.preferred_for_system_id && (
<div className={DIM}>preferred for {systemName(t.preferred_for_system_id)}</div>
)}
</td>
<td className={TD}>
{t.lease ? (
<>
{nodeName(t.lease.node_id)}
<div className={DIM}>
{t.lease.releasing ? "releasing — waiting for the node to log out" : `since ${fmtWhen(t.lease.since)}`}
</div>
</>
) : (
<span className={DIM}>free</span>
)}
</td>
<td className={TD}>
{t.lease ? (
<>
{streamsHere} server{streamsHere === 1 ? "" : "s"}
<div className="h-1.5 w-28 bg-raised rounded mt-1 overflow-hidden">
<i className="block h-full bg-accent" style={{ width: `${Math.min(100, (streamsHere / fanOut) * 100)}%` }} />
</div>
<div className={DIM}>{streamsHere} / {fanOut} per node</div>
</>
) : (
<span className={DIM}>—</span>
)}
</td>
<td className={TD}>
<Membership t={t} overview={overview} />
</td>
<td className={TD}>
<Checks t={t} />
<ConnectSetting t={t} onChanged={onChanged} />
</td>
<td className={`${TD} text-right whitespace-nowrap`}>
<div className="flex flex-col items-end gap-1">
<Button
size="sm"
variant="secondary"
disabled={busy !== null}
onClick={() => act(`r:${t.token_id}`, () => c2api.refreshToken(t.token_id))}
>
{busy === `r:${t.token_id}` ? "Checking…" : "Refresh"}
</Button>
{t.invite_url && (
<a href={t.invite_url} target="_blank" rel="noopener noreferrer" className="text-xs text-accent hover:underline">
Invite link
</a>
)}
<button
disabled={busy !== null || !!t.lease}
title={t.lease ? "Leased to a node — stop its radio channels first" : undefined}
onClick={() => {
if (!confirm(`Remove ${t.name} from the pool?`)) return;
act(`d:${t.token_id}`, () => c2api.deleteToken(t.token_id));
}}
className="text-xs text-sev-major hover:underline disabled:opacity-30 disabled:no-underline disabled:cursor-not-allowed"
>
Remove
</button>
</div>
</td>
</tr>
);
})}
</Table>
)}
</Panel>
);
}