From 1f99485ca3e01a09b87463fd7b2c1d2eac3ee6e8 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sun, 19 Jul 2026 19:26:33 -0700 Subject: [PATCH] Link a Thermograph account to Discord via OAuth2 (#208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a signed-in user connect their Discord account (OAuth2 identify), storing the Discord user id that a later feature (DM alerts) will deliver to. Standard authorization-code flow, all server-side: - backend/discord_link.py: /discord/link/start redirects to Discord's consent screen; /discord/link/callback exchanges the code, reads the Discord user id, and stores it; /discord/unlink forgets it. The `state` is signed with the app auth secret (stdlib hmac, no new dependency) and carries the Thermograph user id, so a callback can't be replayed or bound to another account. Every route requires an active session, so linking acts on whoever is actually logged in. - models.py: User.discord_id (unique, nullable). schemas.py exposes it on UserRead so the frontend can show link state. - app.py: the router under /api/v2/discord. - account.js: a "Link Discord" / "Unlink Discord" control in the account popover. - deploy/migrations/001-user-discord-id.sql: the manual column add for the existing prod accounts DB. This project has no Alembic — create_all only makes missing tables, so an added column needs a hand-applied migration (documented in-file). - env example: THERMOGRAPH_DISCORD_CLIENT_SECRET + the redirect to register. Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8 --- static/account.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/static/account.js b/static/account.js index 94b3f21..a443ae7 100644 --- a/static/account.js +++ b/static/account.js @@ -301,6 +301,9 @@ function renderHeader() { `; @@ -328,6 +331,13 @@ function renderHeader() { } }); el.querySelector(".acct-signout").addEventListener("click", logout); + const unlinkBtn = el.querySelector(".acct-discord-unlink"); + if (unlinkBtn) unlinkBtn.addEventListener("click", async () => { + unlinkBtn.disabled = true; + try { await apiFetch("api/v2/discord/unlink", { method: "POST" }); } catch (e) {} + await refreshUser(); + emitAuth(); // repaint the popover in its unlinked state + }); paintBadge(); paintNotifList(); startNotifPolling();