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:
co-authored by
Claude Opus 5.5
parent
e54191a5d0
commit
dd3e5c9655
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user