S7 review: align OAuth UI with S6's actual contract (server-26#225)

- complete: S6 returns streams_stopped, not stopped — the "N radio
  channels were stopped" note never showed.
- start: bot name is nested as bot.name.
- "The bot for a system" with an org-owned preferred bot now sends
  token_id (S6 accepts it today); system_id only for a platform-pinned
  pool bot, which still needs S6 to accept system_id.
- A 404 for one chosen bot no longer hides Connect and the picker
  (the message told the user to try Automatic, but the button was gone).
- Server count uses GET /tokens' per-bot server_cap when present.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 02:56:05 -04:00
co-authored by Claude Opus 5.5
parent dd3e5c9655
commit eaa0be07c5
4 changed files with 30 additions and 16 deletions
+17 -9
View File
@@ -28,10 +28,17 @@ const SELECT =
// Option values for the bot picker: "" = let C2 choose, "tok:<id>" = one of // 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. // the org's own bots, "sys:<id>" = whichever bot is preferred for that system.
function startRequest(choice: string): OAuthStartRequest { function startRequest(choice: string, data: OrgPreferredBots | null): OAuthStartRequest {
const req: OAuthStartRequest = { return_to: "/settings/organization" }; const req: OAuthStartRequest = { return_to: "/settings/organization" };
if (choice.startsWith("tok:")) req.token_id = choice.slice(4); if (choice.startsWith("tok:")) req.token_id = choice.slice(4);
else if (choice.startsWith("sys:")) req.system_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; return req;
} }
@@ -125,18 +132,19 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
setActionError(null); setActionError(null);
setJustLinked(null); setJustLinked(null);
try { try {
const { url } = await startDiscordOAuth(startRequest(choice)); const { url } = await startDiscordOAuth(startRequest(choice, choices));
window.location.assign(url); window.location.assign(url);
// Leave the button in its busy state: the page is navigating to Discord. // Leave the button in its busy state: the page is navigating to Discord.
} catch (err) { } catch (err) {
if (err instanceof OAuthNotConfigured) { if (err instanceof OAuthNotConfigured) {
setOauthOff(true);
setShowCode(true); setShowCode(true);
setActionError( if (choice) {
choice // Only this bot is off: keep Connect and the picker so Automatic can be tried.
? "That bot can't connect servers yet. Try Automatic, or link with a code below." setActionError("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 {
); setOauthOff(true);
setActionError("Connecting with Discord isn't set up yet. Link with a code below instead.");
}
} else { } else {
setActionError(parseC2Error(err).message); setActionError(parseC2Error(err).message);
} }
@@ -54,11 +54,12 @@ function Checks({ t }: { t: RadioBot }) {
function ServerCount({ t, cap }: { t: RadioBot; cap: number }) { function ServerCount({ t, cap }: { t: RadioBot; cap: number }) {
if (t.guild_ids == null) return null; if (t.guild_ids == null) return null;
const n = t.guild_ids.length; const n = t.guild_ids.length;
const full = t.org_id == null && n >= cap; const limit = t.server_cap ?? cap;
const full = t.org_id == null && n >= limit;
return ( return (
<div className={`text-xs mb-1 ${full || n >= DISCORD_UNVERIFIED_GUILD_LIMIT ? "text-sev-moderate" : "text-ink-muted"}`}> <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 {n} of {DISCORD_UNVERIFIED_GUILD_LIMIT} servers
{full && ` · full at ${cap}: Connect skips it, add another pool bot`} {full && ` · full at ${limit}: Connect skips it, add another pool bot`}
</div> </div>
); );
} }
+2
View File
@@ -30,6 +30,8 @@ export interface RadioBot {
/** "Connect a Discord server" is on for this bot: its OAuth client secret is /** "Connect a Discord server" is on for this bot: its OAuth client secret is
* set server-side (server-26#225 S6). The secret itself is never sent. */ * set server-side (server-26#225 S6). The secret itself is never sent. */
oauth_enabled?: boolean | null; oauth_enabled?: boolean | null;
/** GET /tokens (S6): the pool cap for a DRB pool bot, null for a BYO bot. */
server_cap?: number | null;
} }
export interface RadioBotAddResult { export interface RadioBotAddResult {
+8 -5
View File
@@ -35,7 +35,9 @@ const post = <T,>(path: string, body: object) => send<T>("POST", path, body);
export interface OAuthStartRequest { export interface OAuthStartRequest {
/** One of the org's own (BYO) bots, by token_id. */ /** One of the org's own (BYO) bots, by token_id. */
token_id?: string; token_id?: string;
/** The bot marked preferred for this system (works for a DRB bot a platform admin pinned too). */ /** The bot marked preferred for this system, when it is a DRB pool bot a platform
* admin pinned (its token_id is not shown to the org). NEEDS S6 to accept
* system_id — until it does, c2-core ignores it and picks Automatic. */
system_id?: string; system_id?: string;
/** Kept for the plan's node-page entry point: the preferred bot of this node's system. */ /** Kept for the plan's node-page entry point: the preferred bot of this node's system. */
node_id?: string; node_id?: string;
@@ -52,7 +54,7 @@ export interface OAuthStartResult {
export class OAuthNotConfigured extends Error {} export class OAuthNotConfigured extends Error {}
export async function startDiscordOAuth(req: OAuthStartRequest): Promise<OAuthStartResult> { export async function startDiscordOAuth(req: OAuthStartRequest): Promise<OAuthStartResult> {
let raw: { url?: string; authorize_url?: string; bot_name?: string | null; token_name?: string | null }; let raw: { url?: string; authorize_url?: string; bot?: { name?: string | null } | null; bot_name?: string | null };
try { try {
raw = await post("/discord/oauth/start", req); raw = await post("/discord/oauth/start", req);
} catch (err) { } catch (err) {
@@ -63,7 +65,7 @@ export async function startDiscordOAuth(req: OAuthStartRequest): Promise<OAuthSt
// Only ever send the browser to Discord from here — never to a URL that // Only ever send the browser to Discord from here — never to a URL that
// merely came back in a response body without being Discord's. // merely came back in a response body without being Discord's.
if (!isDiscordAuthorizeUrl(url)) throw new Error("C2 returned an unexpected sign-in link. Nothing was changed."); if (!isDiscordAuthorizeUrl(url)) throw new Error("C2 returned an unexpected sign-in link. Nothing was changed.");
return { url, bot_name: raw.bot_name ?? raw.token_name ?? null }; return { url, bot_name: raw.bot?.name ?? raw.bot_name ?? null };
} }
export function isDiscordAuthorizeUrl(url: string): boolean { export function isDiscordAuthorizeUrl(url: string): boolean {
@@ -104,8 +106,9 @@ export async function completeDiscordOAuth(code: string, state: string): Promise
channels_ready: count(r.channels_ready), channels_ready: count(r.channels_ready),
channels_blocked: count(r.channels_blocked), channels_blocked: count(r.channels_blocked),
return_to: safeReturnTo(str(r.return_to)), return_to: safeReturnTo(str(r.return_to)),
bot_name: str(r.bot_name) ?? str(r.token_name), bot_name: str(r.bot_name),
stopped: count(r.stopped) ?? 0, // S6 answers streams_stopped (a count).
stopped: count(r.streams_stopped) ?? count(r.stopped) ?? 0,
}; };
} }