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
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
// The URL Discord sends the browser back to carries a single-use OAuth code.
|
||||
// The page strips it from the address bar on load; no-referrer (also set as a
|
||||
// response header in middleware.ts) covers anything that loads before that.
|
||||
export const metadata: Metadata = {
|
||||
title: "Connect a Discord server",
|
||||
referrer: "no-referrer",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function DiscordConnectedLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
// Discord's OAuth redirect target for "Connect a Discord server"
|
||||
// (server-26#225 S7). Registered as each radio bot app's redirect URI:
|
||||
// https://drb.cusano.net/discord/connected
|
||||
//
|
||||
// middleware.ts lets this path through without the drb_session cookie, for
|
||||
// two reasons: a signed-out visitor would otherwise be redirected to /login
|
||||
// with the query (code + state) dropped; and drb_session is SameSite=Strict,
|
||||
// so even a signed-in user's cookie is not sent on the cross-site redirect
|
||||
// back from discord.com. This page does its own auth check instead.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
import { parseC2Error } from "@/lib/discord";
|
||||
import {
|
||||
OAUTH_CALLBACK_PATH,
|
||||
type OAuthCallback,
|
||||
type OAuthCompleteResult,
|
||||
captureCallback,
|
||||
clearCallback,
|
||||
completeDiscordOAuth,
|
||||
} from "@/lib/discordOAuth";
|
||||
|
||||
type View =
|
||||
| { kind: "working" }
|
||||
| { kind: "none" }
|
||||
| { kind: "denied" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "done"; result: OAuthCompleteResult };
|
||||
|
||||
const SETTINGS = "/settings/organization";
|
||||
|
||||
function nextStepLabel(path: string): string {
|
||||
if (path.startsWith("/nodes/")) return "Add a radio channel on this node";
|
||||
if (path.startsWith("/settings/discord")) return "Back to Discord settings";
|
||||
if (path.startsWith(SETTINGS)) return "Back to organization settings";
|
||||
return "Continue";
|
||||
}
|
||||
|
||||
export default function DiscordConnectedPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [view, setView] = useState<View>({ kind: "working" });
|
||||
const cb = useRef<OAuthCallback | null | undefined>(undefined);
|
||||
const sent = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// First run: stash code/state for this tab and strip them from the
|
||||
// address bar. Later runs (auth settling) read the stashed copy.
|
||||
if (cb.current === undefined) cb.current = captureCallback();
|
||||
const c = cb.current;
|
||||
if (!c) {
|
||||
setView({ kind: "none" });
|
||||
return;
|
||||
}
|
||||
if (c.error) {
|
||||
clearCallback();
|
||||
// Only a short code from the URL is ever echoed, never free text a crafted link could carry.
|
||||
const code = /^[a-z_]{1,40}$/.test(c.error) ? ` (${c.error})` : "";
|
||||
setView(c.error === "access_denied"
|
||||
? { kind: "denied" }
|
||||
: { kind: "error", message: `Discord didn't finish adding the bot${code}. Nothing was changed.` });
|
||||
return;
|
||||
}
|
||||
if (!c.code || !c.state) {
|
||||
clearCallback();
|
||||
setView({ kind: "error", message: "Discord's reply was incomplete. Nothing was changed — start again from Connect a Discord server." });
|
||||
return;
|
||||
}
|
||||
if (loading) return;
|
||||
if (!user) {
|
||||
// Keep the stash; /login sends the user back here once signed in.
|
||||
router.replace(`/login?next=${encodeURIComponent(OAUTH_CALLBACK_PATH)}`);
|
||||
return;
|
||||
}
|
||||
if (sent.current) return; // the code is single use — never send it twice (StrictMode, re-renders)
|
||||
sent.current = true;
|
||||
clearCallback();
|
||||
completeDiscordOAuth(c.code, c.state)
|
||||
.then((result) => setView({ kind: "done", result }))
|
||||
.catch((err) => setView({ kind: "error", message: parseC2Error(err).message }));
|
||||
}, [loading, user, router]);
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto pt-10">
|
||||
<Card padding="lg" className="space-y-4">
|
||||
<h1 className="text-ink text-lg font-semibold">Connect a Discord server</h1>
|
||||
<Body view={view} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ view }: { view: View }) {
|
||||
switch (view.kind) {
|
||||
case "working":
|
||||
return <p className="text-sm text-ink-muted">Linking the server and checking the radio bot's permissions…</p>;
|
||||
case "none":
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-ink-2">There is nothing to finish here. Start from <b className="text-ink">Connect a Discord server</b> in your organization settings.</p>
|
||||
<LinkButton href={SETTINGS} size="sm">Organization settings</LinkButton>
|
||||
</>
|
||||
);
|
||||
case "denied":
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-ink-2"><b className="text-ink">Nothing changed.</b> The bot was not added, and no server was linked.</p>
|
||||
<LinkButton href={SETTINGS} size="sm" variant="secondary">Back to organization settings</LinkButton>
|
||||
</>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-sev-major">{view.message}</p>
|
||||
<p className="text-xs text-ink-muted">If the bot did join the server, it stays there but cannot carry radio until the server is linked. Try Connect again, or use a link code.</p>
|
||||
<LinkButton href={SETTINGS} size="sm" variant="secondary">Back to organization settings</LinkButton>
|
||||
</>
|
||||
);
|
||||
case "done":
|
||||
return <Done r={view.result} />;
|
||||
}
|
||||
}
|
||||
|
||||
function Done({ r }: { r: OAuthCompleteResult }) {
|
||||
const name = r.guild_name || "the server";
|
||||
const next = r.return_to ?? SETTINGS;
|
||||
const checked = r.channels_ready !== null || r.channels_blocked !== null;
|
||||
const blocked = r.channels_blocked ?? 0;
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-ink">
|
||||
Linked <b>{name}</b>{r.bot_name ? <> and added <b>{r.bot_name}</b></> : null}.
|
||||
</p>
|
||||
{checked ? (
|
||||
<p className="text-sm text-ink-2">
|
||||
{r.channels_ready ?? 0} voice channel{r.channels_ready === 1 ? "" : "s"} ready
|
||||
{blocked > 0 && <>, <span className="text-sev-moderate">{blocked} need{blocked === 1 ? "s" : ""} a permission fix</span></>}.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-ink-muted">The bot's channel permissions couldn't be checked yet. The node's Discord picker shows them.</p>
|
||||
)}
|
||||
{blocked > 0 && (
|
||||
<p className="text-xs text-ink-muted">The node's Discord picker names the missing permission on each channel and how to allow it.</p>
|
||||
)}
|
||||
{r.stopped > 0 && (
|
||||
<p className="text-xs text-ink-muted">
|
||||
{r.stopped} radio channel{r.stopped === 1 ? " was" : "s were"} stopped: this server used to act for the default organization.
|
||||
</p>
|
||||
)}
|
||||
<LinkButton href={next} size="sm">{nextStepLabel(next)}</LinkButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user