"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:" = one of // the org's own bots, "sys:" = whichever bot is preferred for that system. function startRequest(choice: string, data: OrgPreferredBots | null): OAuthStartRequest { const req: OAuthStartRequest = { return_to: "/settings/organization" }; if (choice.startsWith("tok:")) req.token_id = choice.slice(4); else if (choice.startsWith("sys:")) { const id = choice.slice(4); // The org's own bot preferred for this system: name it by token_id, which // S6 already accepts. Only a platform-pinned pool bot needs system_id. const pref = data?.systems.find((s) => s.system_id === id)?.preferred_token_id; if (pref) req.token_id = pref; else req.system_id = id; } 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 ( <> {systems.length > 0 && ( {systems.map((s) => ( ))} )} {bots.length > 0 && ( {bots.map((b) => ( ))} )} ); } 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(null); const [restricted, setRestricted] = useState(true); const [loadError, setLoadError] = useState(null); const [code, setCode] = useState(null); const [justLinked, setJustLinked] = useState(null); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(null); const [copied, setCopied] = useState(false); const [now, setNow] = useState(() => Date.now()); const known = useRef | null>(null); const [choices, setChoices] = useState(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, choices)); 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 (
Add a radio bot to your Discord server. Adding it links the server to this organization.} action={ !oauthOff ? ( ) : undefined } /> {canEdit && !oauthOff && (
Discord asks you to pick the server and confirm View Channel, Connect and Speak.
)} {canEdit && (
{showCode && (
Someone with Manage Server runs it as /drb link in that Discord server.
)}
)}
{(code || justLinked || actionError) && (
{actionError &&

{actionError}

} {justLinked &&

Linked {justLinked}. Its /join, /leave and /status now act for this organization.

} {code && live && (
{code.code} single use · expires in {fmtLeft(msLeft)}
{code.command}

Run it in the Discord server you want to link. That server can then use this organization's systems and radio channels — nothing from any other organization. Generating a new code cancels this one.

)} {code && !live &&

That code expired. Generate a new one.

}
)}
{!canEdit && ( )} {canEdit && guilds === null && !loadError && ( )} {loadError && ( )} {guilds?.length === 0 && ( )} {guilds?.map((g) => ( ))}
Server Linked Status
Only the organization owner can see and link Discord servers.
Loading…
{loadError}
No Discord servers linked yet.
{g.guild_name || "Unnamed server"}
guild {shortId(g.guild_id)}
{fmtDate(g.linked_at)} {g.linked_via === "oauth" &&
by adding a radio bot
}
Linked {canEdit && ( )}
{restricted ? ( <>Unlinked servers see only /drb link and /help. ) : isAdmin ? ( <>Platform setting: unlinked servers still act for the default organization (DISCORD_UNLINKED_GUILD_ORG_ID) until linked. ) : null} Every command and autocomplete is scoped to the linked organization — no server sees another organization's nodes, systems or bots.
); }