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
@@ -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'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">
|
||||
|
||||
Reference in New Issue
Block a user