Discord: Connect a Discord server — OAuth UI (#225 S7) — merge after S6 #259
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,18 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { describeAuthError } from "@/lib/authErrors";
|
import { describeAuthError } from "@/lib/authErrors";
|
||||||
|
|
||||||
|
// Pages that may send a signed-out user here and expect to be resumed after
|
||||||
|
// sign-in, via /login?next=<path>. An allowlist, not "any relative path", so
|
||||||
|
// this can never become an open redirect. /discord/connected: the OAuth
|
||||||
|
// callback keeps its code/state in sessionStorage across this detour.
|
||||||
|
const RESUMABLE = new Set(["/discord/connected"]);
|
||||||
|
|
||||||
|
function resumePath(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const next = new URLSearchParams(window.location.search).get("next");
|
||||||
|
return next && RESUMABLE.has(next) ? next : null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -31,7 +43,7 @@ export default function LoginPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
router.replace(orgId ? "/" : "/onboarding");
|
router.replace(orgId ? resumePath() ?? "/" : "/onboarding");
|
||||||
}, [authLoading, user, orgId, router]);
|
}, [authLoading, user, orgId, router]);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
|||||||
@@ -4,14 +4,64 @@
|
|||||||
// Matches design-canvas/discord-system-mockup.html's Servers panel, minus the
|
// Matches design-canvas/discord-system-mockup.html's Servers panel, minus the
|
||||||
// plan / incident-forum / roles / limits columns: none of those exist
|
// plan / incident-forum / roles / limits columns: none of those exist
|
||||||
// server-side yet, and a column with nothing behind it is a dead surface.
|
// 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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import { Card, CardHeader } from "@/components/ui/Card";
|
import { Card, CardHeader } from "@/components/ui/Card";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { Button } from "@/components/ui/Button";
|
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 };
|
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:<id>" = one of
|
||||||
|
// the org's own bots, "sys:<id>" = whichever bot is preferred for that system.
|
||||||
|
function startRequest(choice: string): OAuthStartRequest {
|
||||||
|
const req: OAuthStartRequest = { return_to: "/settings/organization" };
|
||||||
|
if (choice.startsWith("tok:")) req.token_id = choice.slice(4);
|
||||||
|
else if (choice.startsWith("sys:")) req.system_id = choice.slice(4);
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<option value="">Automatic: your own bot, else a DRB radio bot</option>
|
||||||
|
{systems.length > 0 && (
|
||||||
|
<optgroup label="The bot for a system">
|
||||||
|
{systems.map((s) => (
|
||||||
|
<option key={s.system_id} value={`sys:${s.system_id}`}>
|
||||||
|
{s.name}: {s.preferred_name ?? "DRB radio bot"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{bots.length > 0 && (
|
||||||
|
<optgroup label="Your organization's bots">
|
||||||
|
{bots.map((b) => (
|
||||||
|
<option key={b.token_id} value={`tok:${b.token_id}`} disabled={b.valid === false}>
|
||||||
|
{b.name}{b.valid === false ? " (token rejected)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
type Code = { code: string; expires_at: string; command: string };
|
type Code = { code: string; expires_at: string; command: string };
|
||||||
|
|
||||||
const POLL_MS = 5000;
|
const POLL_MS = 5000;
|
||||||
@@ -41,6 +91,14 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const known = useRef<Set<string> | null>(null);
|
const known = useRef<Set<string> | null>(null);
|
||||||
|
const [choices, setChoices] = useState<OrgPreferredBots | null>(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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -58,8 +116,34 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
load().then((g) => { if (g) known.current = new Set(g.map((x) => x.guild_id)); });
|
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]);
|
}, [canEdit, load]);
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
setConnecting(true);
|
||||||
|
setActionError(null);
|
||||||
|
setJustLinked(null);
|
||||||
|
try {
|
||||||
|
const { url } = await startDiscordOAuth(startRequest(choice));
|
||||||
|
window.location.assign(url);
|
||||||
|
// Leave the button in its busy state: the page is navigating to Discord.
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof OAuthNotConfigured) {
|
||||||
|
setOauthOff(true);
|
||||||
|
setShowCode(true);
|
||||||
|
setActionError(
|
||||||
|
choice
|
||||||
|
? "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 {
|
||||||
|
setActionError(parseC2Error(err).message);
|
||||||
|
}
|
||||||
|
setConnecting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const msLeft = code ? new Date(code.expires_at).getTime() - now : 0;
|
const msLeft = code ? new Date(code.expires_at).getTime() - now : 0;
|
||||||
const live = !!code && msLeft > 0;
|
const live = !!code && msLeft > 0;
|
||||||
|
|
||||||
@@ -126,14 +210,47 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
|
|||||||
<div className="p-5 pb-0">
|
<div className="p-5 pb-0">
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="Linked Discord servers"
|
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.</>}
|
subtitle={<>Add a radio bot to your Discord server. Adding it links the server to this organization.</>}
|
||||||
action={
|
action={
|
||||||
<Button size="sm" onClick={generate} disabled={!canEdit || busy}
|
!oauthOff ? (
|
||||||
title={!canEdit ? "Only the organization owner can link Discord servers" : undefined}>
|
<Button size="sm" onClick={connect} disabled={!canEdit || connecting}
|
||||||
{busy ? "Generating…" : live ? "New code" : "Generate link code"}
|
title={!canEdit ? "Only the organization owner can link Discord servers" : undefined}>
|
||||||
</Button>
|
{connecting ? "Opening Discord…" : "Connect a Discord server"}
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{canEdit && !oauthOff && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-4 text-xs text-ink-muted">
|
||||||
|
<label htmlFor="drb-connect-bot">Radio bot</label>
|
||||||
|
<select id="drb-connect-bot" value={choice} onChange={(e) => setChoice(e.target.value)} disabled={connecting} className={SELECT}>
|
||||||
|
<BotChoices data={choices} />
|
||||||
|
</select>
|
||||||
|
<span>Discord asks you to pick the server and confirm View Channel, Connect and Speak.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCode((v) => !v)}
|
||||||
|
aria-expanded={showCode}
|
||||||
|
className="text-xs text-ink-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
{showCode ? "▾" : "▸"} Or link with a code (<span className="font-mono">/drb link</span>, needs the main DRB bot in the server)
|
||||||
|
</button>
|
||||||
|
{showCode && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<Button size="sm" variant={oauthOff ? "primary" : "secondary"} onClick={generate} disabled={busy}>
|
||||||
|
{busy ? "Generating…" : live ? "New code" : "Generate link code"}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-ink-muted">
|
||||||
|
Someone with Manage Server runs it as <span className="font-mono">/drb link</span> in that Discord server.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(code || justLinked || actionError) && (
|
{(code || justLinked || actionError) && (
|
||||||
@@ -189,7 +306,10 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
|
|||||||
<div className="text-ink font-medium">{g.guild_name || "Unnamed server"}</div>
|
<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>
|
<div className="text-xs text-ink-muted font-mono" title={g.guild_id}>guild {shortId(g.guild_id)}</div>
|
||||||
</td>
|
</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 text-xs text-ink-2 hidden sm:table-cell">
|
||||||
|
{fmtDate(g.linked_at)}
|
||||||
|
{g.linked_via === "oauth" && <div className="text-ink-muted">by adding a radio bot</div>}
|
||||||
|
</td>
|
||||||
<td className="px-5 py-3"><Badge tone="success">Linked</Badge></td>
|
<td className="px-5 py-3"><Badge tone="success">Linked</Badge></td>
|
||||||
<td className="px-5 py-3 text-right">
|
<td className="px-5 py-3 text-right">
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import {
|
import {
|
||||||
|
DISCORD_UNVERIFIED_GUILD_LIMIT,
|
||||||
|
POOL_BOT_GUILD_CAP,
|
||||||
type DiscordAdminOverview,
|
type DiscordAdminOverview,
|
||||||
type RadioBot,
|
type RadioBot,
|
||||||
type RadioBotAddResult,
|
type RadioBotAddResult,
|
||||||
@@ -13,12 +15,13 @@ import {
|
|||||||
shortId,
|
shortId,
|
||||||
} from "@/lib/discord";
|
} from "@/lib/discord";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { clearBotOAuthSecret, setBotOAuthSecret } from "@/lib/discordOAuth";
|
||||||
import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
|
import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
|
||||||
|
|
||||||
// Radio bots = the bot_tokens pool (routers/tokens.py). Used by the Discord
|
// 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
|
// admin page's "Radio bots" tab and by /tokens. Every action here is a real
|
||||||
// platform-admin route: POST /tokens, POST /tokens/{id}/refresh,
|
// 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 }) {
|
function Checks({ t }: { t: RadioBot }) {
|
||||||
if (t.valid === false) {
|
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 }) {
|
function Membership({ t, overview }: { t: RadioBot; overview: DiscordAdminOverview | null }) {
|
||||||
if (t.guild_ids == null) return <span className={DIM}>unknown — refresh</span>;
|
if (t.guild_ids == null) return <span className={DIM}>unknown — refresh</span>;
|
||||||
const known = new Map((overview?.guilds ?? []).map((g) => [g.guild_id, g]));
|
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(
|
const missing = (overview?.guilds ?? []).filter(
|
||||||
(g) => !members.includes(g.guild_id) && (t.org_id == null || g.acting_org_id === t.org_id),
|
(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>;
|
if (!members.length && !missing.length) return <span className={DIM}>no servers</span>;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1 max-w-[22rem]">
|
<div className="max-w-[22rem]">
|
||||||
{members.map((gid) => (
|
<ServerCount t={t} cap={cap} />
|
||||||
<span key={gid} className="text-[11px] rounded px-1.5 py-px bg-raised border border-line text-ink-2">
|
<div className="flex flex-wrap gap-1">
|
||||||
{known.has(gid) ? guildLabel(known.get(gid)!) : `server ${shortId(gid)}`}
|
{members.map((gid) => (
|
||||||
</span>
|
<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)}`}
|
||||||
{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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -286,6 +388,7 @@ export function RadioBotsPanel({
|
|||||||
</td>
|
</td>
|
||||||
<td className={TD}>
|
<td className={TD}>
|
||||||
<Checks t={t} />
|
<Checks t={t} />
|
||||||
|
<ConnectSetting t={t} onChanged={onChanged} />
|
||||||
</td>
|
</td>
|
||||||
<td className={`${TD} text-right whitespace-nowrap`}>
|
<td className={`${TD} text-right whitespace-nowrap`}>
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export interface RadioBot {
|
|||||||
in_use?: boolean;
|
in_use?: boolean;
|
||||||
assigned_node_id?: string | null;
|
assigned_node_id?: string | null;
|
||||||
preferred_for_system_id?: string | null;
|
preferred_for_system_id?: string | null;
|
||||||
|
/** "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. */
|
||||||
|
oauth_enabled?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RadioBotAddResult {
|
export interface RadioBotAddResult {
|
||||||
@@ -93,9 +96,16 @@ export interface DiscordAdminOverview {
|
|||||||
node_max_streams: number;
|
node_max_streams: number;
|
||||||
unlinked_guild_org_id: string | null;
|
unlinked_guild_org_id: string | null;
|
||||||
unlinked_guild_org_name: string | null;
|
unlinked_guild_org_name: string | null;
|
||||||
|
/** Connect stops picking a DRB pool bot at this many servers (owner ruling Q3). */
|
||||||
|
pool_bot_guild_cap?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Discord's hard limit for an unverified bot. */
|
||||||
|
export const DISCORD_UNVERIFIED_GUILD_LIMIT = 100;
|
||||||
|
/** settings.discord_pool_bot_guild_cap's default — used when the overview doesn't send it. */
|
||||||
|
export const POOL_BOT_GUILD_CAP = 90;
|
||||||
|
|
||||||
/** A desired-on channel that has not reached "connected" for this long turns red. */
|
/** A desired-on channel that has not reached "connected" for this long turns red. */
|
||||||
export const STUCK_AFTER_MS = 2 * 60 * 1000;
|
export const STUCK_AFTER_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// "Connect a Discord server" — per-bot OAuth (server-26#225 S6 backend, S7 UI).
|
||||||
|
// Kept out of c2api.ts on purpose (plan S7): this is the only browser code that
|
||||||
|
// handles a Discord authorization code, and it should stay small and readable.
|
||||||
|
//
|
||||||
|
// Flow: an org owner picks a radio bot → POST /discord/oauth/start returns
|
||||||
|
// Discord's authorize URL → the owner adds the bot in Discord → Discord sends
|
||||||
|
// the browser to /discord/connected?code=…&state=… → that page calls
|
||||||
|
// POST /discord/oauth/complete. The bot's client secret never reaches the
|
||||||
|
// browser: the code exchange happens in c2-core.
|
||||||
|
|
||||||
|
import { auth } from "@/lib/firebase";
|
||||||
|
|
||||||
|
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
|
/** Same error format as c2api's request(), so lib/discord.parseC2Error reads it. */
|
||||||
|
async function send<T>(method: "POST" | "PUT" | "DELETE", path: string, body?: object): Promise<T> {
|
||||||
|
const user = auth.currentUser;
|
||||||
|
const token = user ? await user.getIdToken() : null;
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
...(body ? { body: JSON.stringify(body) } : {}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`);
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = <T,>(path: string, body: object) => send<T>("POST", path, body);
|
||||||
|
|
||||||
|
/** Which bot to invite. Empty = c2-core picks (org's own bot, else a DRB bot with room). */
|
||||||
|
export interface OAuthStartRequest {
|
||||||
|
/** One of the org's own (BYO) bots, by token_id. */
|
||||||
|
token_id?: string;
|
||||||
|
/** The bot marked preferred for this system (works for a DRB bot a platform admin pinned too). */
|
||||||
|
system_id?: string;
|
||||||
|
/** Kept for the plan's node-page entry point: the preferred bot of this node's system. */
|
||||||
|
node_id?: string;
|
||||||
|
/** Where the result card's button goes. Relative path only; c2-core re-checks it. */
|
||||||
|
return_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthStartResult {
|
||||||
|
url: string;
|
||||||
|
bot_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status 404 from /oauth/start: no bot that fits has Connect turned on (no client secret set). */
|
||||||
|
export class OAuthNotConfigured extends Error {}
|
||||||
|
|
||||||
|
export async function startDiscordOAuth(req: OAuthStartRequest): Promise<OAuthStartResult> {
|
||||||
|
let raw: { url?: string; authorize_url?: string; bot_name?: string | null; token_name?: string | null };
|
||||||
|
try {
|
||||||
|
raw = await post("/discord/oauth/start", req);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && /^C2 API error 404:/.test(err.message)) throw new OAuthNotConfigured(err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const url = raw.url ?? raw.authorize_url ?? "";
|
||||||
|
// 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.
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDiscordAuthorizeUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.protocol === "https:" && (u.hostname === "discord.com" || u.hostname === "discordapp.com") &&
|
||||||
|
u.pathname.replace(/\/+$/, "").endsWith("/oauth2/authorize");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthCompleteResult {
|
||||||
|
guild_id: string | null;
|
||||||
|
guild_name: string | null;
|
||||||
|
/** Voice channels at least one radio bot can join right now. null = the check didn't finish in time. */
|
||||||
|
channels_ready: number | null;
|
||||||
|
/** Voice channels where every bot is missing a permission. */
|
||||||
|
channels_blocked: number | null;
|
||||||
|
return_to: string | null;
|
||||||
|
bot_name: string | null;
|
||||||
|
/** Streams stopped because this server used to act for another (fallback) org. */
|
||||||
|
stopped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(v: unknown): number | null {
|
||||||
|
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||||
|
if (Array.isArray(v)) return v.length;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeDiscordOAuth(code: string, state: string): Promise<OAuthCompleteResult> {
|
||||||
|
const r = await post<Record<string, unknown>>("/discord/oauth/complete", { code, state });
|
||||||
|
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
||||||
|
return {
|
||||||
|
guild_id: str(r.guild_id),
|
||||||
|
guild_name: str(r.guild_name),
|
||||||
|
channels_ready: count(r.channels_ready),
|
||||||
|
channels_blocked: count(r.channels_blocked),
|
||||||
|
return_to: safeReturnTo(str(r.return_to)),
|
||||||
|
bot_name: str(r.bot_name) ?? str(r.token_name),
|
||||||
|
stopped: count(r.stopped) ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A same-site path like "/nodes/abc" — never "//host", a scheme, or a backslash trick. */
|
||||||
|
export function safeReturnTo(p: string | null | undefined): string | null {
|
||||||
|
if (!p || p.length > 200) return null;
|
||||||
|
return /^\/(?![/\\])[A-Za-z0-9/_\-.?=&%]*$/.test(p) ? p : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the callback's code/state, held across a sign-in detour --------------
|
||||||
|
|
||||||
|
/** The page Discord redirects to. Registered as the redirect URI on each bot app. */
|
||||||
|
export const OAUTH_CALLBACK_PATH = "/discord/connected";
|
||||||
|
|
||||||
|
const STASH_KEY = "drb_discord_oauth_cb";
|
||||||
|
const STASH_TTL_MS = 10 * 60 * 1000; // c2-core's state lives 10 min (discord_oauth_state_ttl_seconds)
|
||||||
|
|
||||||
|
export interface OAuthCallback {
|
||||||
|
code: string | null;
|
||||||
|
state: string | null;
|
||||||
|
/** Discord's error param, e.g. "access_denied" when the user pressed Cancel. */
|
||||||
|
error: string | null;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the callback params off the URL (if any), stash them in this tab, and
|
||||||
|
* strip them from the address bar so they never end up in history or a Referer. */
|
||||||
|
export function captureCallback(): OAuthCallback | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const q = new URLSearchParams(window.location.search);
|
||||||
|
const code = q.get("code");
|
||||||
|
const state = q.get("state");
|
||||||
|
const error = q.get("error");
|
||||||
|
if (code || state || error) {
|
||||||
|
const cb: OAuthCallback = { code, state, error, at: Date.now() };
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STASH_KEY, JSON.stringify(cb));
|
||||||
|
} catch {
|
||||||
|
// Private mode / storage blocked: we still have it in memory for this load.
|
||||||
|
}
|
||||||
|
window.history.replaceState(null, "", OAUTH_CALLBACK_PATH);
|
||||||
|
return cb;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STASH_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const cb = JSON.parse(raw) as OAuthCallback;
|
||||||
|
if (typeof cb?.at !== "number" || Date.now() - cb.at > STASH_TTL_MS) {
|
||||||
|
sessionStorage.removeItem(STASH_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cb;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCallback(): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(STASH_KEY);
|
||||||
|
} catch {
|
||||||
|
// nothing to clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- platform admin: a bot's OAuth client secret ---------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /tokens/{id}/oauth (platform admin). Write-only: c2-core checks the
|
||||||
|
* secret with Discord (client-credentials grant) and stores it server-side
|
||||||
|
* in bot_oauth/{token_id}; it is never returned, logged, or shown again.
|
||||||
|
*/
|
||||||
|
export function setBotOAuthSecret(tokenId: string, clientSecret: string): Promise<{ ok?: boolean; oauth_enabled?: boolean }> {
|
||||||
|
return send("PUT", `/tokens/${encodeURIComponent(tokenId)}/oauth`, { client_secret: clientSecret });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /tokens/{id}/oauth (platform admin): deletes the stored secret; Connect is off for that bot. */
|
||||||
|
export function clearBotOAuthSecret(tokenId: string): Promise<unknown> {
|
||||||
|
return send("DELETE", `/tokens/${encodeURIComponent(tokenId)}/oauth`);
|
||||||
|
}
|
||||||
@@ -14,7 +14,15 @@ const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/
|
|||||||
// before ChromeSwitcher's client-side redirect ever runs. Both pages do
|
// before ChromeSwitcher's client-side redirect ever runs. Both pages do
|
||||||
// their own client-side auth check (redirect to /login if genuinely signed
|
// their own client-side auth check (redirect to /login if genuinely signed
|
||||||
// out) instead.
|
// out) instead.
|
||||||
const NO_SESSION_COOKIE_GATE = new Set(["/signup", "/onboarding"]);
|
//
|
||||||
|
// /discord/connected is Discord's OAuth redirect target (server-26#225 S7).
|
||||||
|
// Redirecting it to /login would drop the query — the single-use code and
|
||||||
|
// state — and the cookie is SameSite=Strict, so it is NOT sent on the
|
||||||
|
// cross-site redirect back from discord.com even for a signed-in user. The
|
||||||
|
// page stashes code/state, sends a signed-out user to /login?next=…, and
|
||||||
|
// resumes when they come back.
|
||||||
|
const OAUTH_CALLBACK_PATH = "/discord/connected";
|
||||||
|
const NO_SESSION_COOKIE_GATE = new Set(["/signup", "/onboarding", OAUTH_CALLBACK_PATH]);
|
||||||
|
|
||||||
// NOTE: this is a UX redirect only, not a security boundary — it just checks
|
// NOTE: this is a UX redirect only, not a security boundary — it just checks
|
||||||
// a client-set cookie's presence. Real enforcement is server-side, in
|
// a client-set cookie's presence. Real enforcement is server-side, in
|
||||||
@@ -23,6 +31,13 @@ export function middleware(request: NextRequest) {
|
|||||||
const session = request.cookies.get("drb_session");
|
const session = request.cookies.get("drb_session");
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (pathname === OAUTH_CALLBACK_PATH) {
|
||||||
|
// The URL carries an OAuth code until the page strips it: never leak it in a Referer.
|
||||||
|
const res = NextResponse.next();
|
||||||
|
res.headers.set("Referrer-Policy", "no-referrer");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
if (PUBLIC_PATHS.has(pathname) || NO_SESSION_COOKIE_GATE.has(pathname)) {
|
if (PUBLIC_PATHS.has(pathname) || NO_SESSION_COOKIE_GATE.has(pathname)) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user