- c2-core: POST /discord/link-codes (org owner/admin) mints a single-use,
10-minute code; minting revokes the org's older codes. The bot redeems it
via POST /discord/guilds/{id}/link/redeem (service key) inside one
Firestore transaction, so two servers racing for one code cannot both win
and a server linked to org A refuses org B's code. GET/DELETE
/discord/linked-guilds lists/unlinks the caller's org's servers; unlink
stops that server's streams. Rate-limited (mint per user, redeem per
server and per Discord user); every mint/link/refusal/unlink audited.
- resolve_guild_org: an explicitly unlinked server no longer falls back to
DISCORD_UNLINKED_GUILD_ORG_ID. Linking away from the fallback org stops
the streams it had running for that org.
- Discord bot: /drb link code: (Manage Server, enforced at runtime too);
GatedTree answers every other command in an unlinked server with how to
link (autocomplete returns nothing). With the default fallback org set,
nothing is gated — unchanged behaviour.
- Frontend: "Linked Discord servers" card on Settings -> Organization —
generate code with countdown + copy, polls for the new link, unlink.
- 16 tests: single-use, two-server race, one-server/two-org race, expiry,
revoked/malformed codes, wrong-org takeover, idempotent relink, rate
limits, fallback stop, scoped list, unlink without fallback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
9.2 KiB
TypeScript
216 lines
9.2 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.
|
|
|
|
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";
|
|
|
|
type Guild = { guild_id: string; guild_name: string | null; linked_at: string | null; linked_via: string };
|
|
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 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)); });
|
|
}, [canEdit, load]);
|
|
|
|
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={<>A link code is run as <span className="font-mono">/drb link</span> by someone with Manage Server in that Discord server.</>}
|
|
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>
|
|
}
|
|
/>
|
|
</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'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>
|
|
{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)}</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's nodes, systems or bots.
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|