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>
This commit is contained in:
Logan Cusano
2026-10-05 02:51:39 -04:00
co-authored by Claude Opus 5.5
parent e54191a5d0
commit dd3e5c9655
8 changed files with 654 additions and 34 deletions
+126 -6
View File
@@ -4,14 +4,64 @@
// 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);
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;
@@ -41,6 +91,14 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
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 {
@@ -58,8 +116,34 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
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) {
setOauthOff(true);
setShowCode(true);
setActionError(
choice
? "That bot can't connect servers yet. Try Automatic, or link with a code below."
: "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;
@@ -126,14 +210,47 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
<div className="p-5 pb-0">
<CardHeader
title="Linked Discord servers"
subtitle={<>A link code is run as <span className="font-mono">/drb link</span> by someone with Manage Server in that Discord server.</>}
subtitle={<>Add a radio bot to your Discord server. Adding it links the server to this organization.</>}
action={
<Button size="sm" onClick={generate} disabled={!canEdit || busy}
title={!canEdit ? "Only the organization owner can link Discord servers" : undefined}>
{busy ? "Generating…" : live ? "New code" : "Generate link code"}
</Button>
!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) && (
@@ -189,7 +306,10 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
<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)}</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 && (
@@ -3,6 +3,8 @@
import { useState } from "react";
import { c2api } from "@/lib/c2api";
import {
DISCORD_UNVERIFIED_GUILD_LIMIT,
POOL_BOT_GUILD_CAP,
type DiscordAdminOverview,
type RadioBot,
type RadioBotAddResult,
@@ -13,12 +15,13 @@ import {
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.
// DELETE /tokens/{id}, POST /tokens/flush, PUT /tokens/{id}/oauth.
function Checks({ t }: { t: RadioBot }) {
if (t.valid === false) {
@@ -46,6 +49,101 @@ function Checks({ t }: { t: RadioBot }) {
);
}
/** "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]));
@@ -54,34 +152,38 @@ function Membership({ t, overview }: { t: RadioBot; overview: DiscordAdminOvervi
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="flex flex-wrap gap-1 max-w-[22rem]">
{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}
<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>
);
}
@@ -286,6 +388,7 @@ export function RadioBotsPanel({
</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">