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:
Logan Cusano
2026-10-05 02:51:39 -04:00
co-authored by Claude Opus 5.5
parent e54191a5d0
commit dd3e5c9655
8 changed files with 654 additions and 34 deletions
+13 -1
View File
@@ -9,6 +9,18 @@ import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
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() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -31,7 +43,7 @@ export default function LoginPage() {
useEffect(() => {
if (authLoading) return;
if (!user) return;
router.replace(orgId ? "/" : "/onboarding");
router.replace(orgId ? resumePath() ?? "/" : "/onboarding");
}, [authLoading, user, orgId, router]);
async function handleSubmit(e: React.FormEvent) {