All checks were successful
PR build (required check) / changes (pull_request) Successful in 8s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m11s
PR build (required check) / build-backend (pull_request) Successful in 1m35s
PR build (required check) / gate (pull_request) Successful in 1s
Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
539 lines
24 KiB
JavaScript
539 lines
24 KiB
JavaScript
// Accounts: the header "Sign in / account" entry, the auth modal, and the shared
|
|
// helpers other modules (cache.js, mappicker.js, subscriptions.js, compare.js,
|
|
// digest.js) use to talk to backend — API calls and page/asset links to
|
|
// backend-owned paths (the interactive tool pages, /alerts, /digest, …) alike.
|
|
//
|
|
// Auth is an HttpOnly cookie; credentials: "include" (not "same-origin") so it
|
|
// still rides along when this script itself is loaded cross-origin (repo-split
|
|
// Stage 5 — a frontend SSR service on a different origin than backend, proven
|
|
// via a genuinely cross-origin LAN-dev run). Loaded on every page the way
|
|
// units.js is (imported by each page's entry module).
|
|
|
|
let currentUser = null; // {id, email, display_name} or null
|
|
let discordEnabled = false; // is Discord configured on the server?
|
|
let discordChecked = false; // have we asked yet? (once per page load)
|
|
const authCbs = []; // notified on login/logout so pages can re-gate
|
|
|
|
// backend's own origin+base (e.g. "https://thermograph.org/thermograph", or
|
|
// "http://192.168.1.5:8137/thermograph" when frontend and backend are on
|
|
// genuinely different origins) — derived from this module's own URL, since
|
|
// account.js (like every other frontend/*.js file) is always served BY
|
|
// backend, never by the frontend SSR service. Keeping the full origin (not
|
|
// just the path, as this used to) is what makes fetch()/href targets built
|
|
// from it resolve to backend even when this script was loaded from a page on
|
|
// a different origin (a frontend_ssr-rendered page in the cross-origin case).
|
|
export const APP_BASE = new URL(".", import.meta.url).href.replace(/\/$/, "");
|
|
export const u = (path) => `${APP_BASE}/${String(path).replace(/^\//, "")}`;
|
|
|
|
// Single pin point for the backend content/data API version every fetch below
|
|
// targets — was ~30 hardcoded "api/v2/..." literals spread across ~10 files
|
|
// with no one place to bump. Bump only in lockstep with a verified backend
|
|
// /api/version check: this frontend and its backend now deploy asynchronously
|
|
// (repo-split), so an unverified bump here would just turn every API call
|
|
// into a 404 the moment it ships ahead of a backend that doesn't speak it yet.
|
|
export const API_VERSION = "v2"; // bump only in lockstep with a verified backend /api/version check
|
|
|
|
// Turns a bare resource path ("grade", "users/me") — or one a caller already
|
|
// prefixed with a (possibly stale) version ("api/v2/place") — into a full
|
|
// request URL pinned to API_VERSION. Accepting the already-prefixed form too
|
|
// means a copy-pasted literal from an old call site normalizes instead of
|
|
// silently double-prefixing.
|
|
export const uv = (path) =>
|
|
u(`api/${API_VERSION}/${String(path).replace(/^\/?(api\/v\d+\/)?/, "")}`);
|
|
|
|
// --- shared fetch helper -----------------------------------------------------
|
|
// `url` must already be a full request URL — build it with uv() so every call
|
|
// is pinned to API_VERSION. Cookie sent, and a constant X-TG-Auth header the
|
|
// server can require as cheap CSRF defense (still meaningful cross-origin:
|
|
// forging it needs a preflight-clearing request we never grant, same as
|
|
// same-origin).
|
|
export async function apiFetch(url, { method = "GET", json, form } = {}) {
|
|
const opts = { method, credentials: "include", headers: { "X-TG-Auth": "1" } };
|
|
if (json !== undefined) {
|
|
opts.headers["Content-Type"] = "application/json";
|
|
opts.body = JSON.stringify(json);
|
|
} else if (form !== undefined) {
|
|
opts.headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
opts.body = new URLSearchParams(form).toString();
|
|
}
|
|
return fetch(url, opts);
|
|
}
|
|
|
|
// Parse a JSON response, throwing a friendly Error on failure. fastapi-users
|
|
// returns machine codes ("LOGIN_BAD_CREDENTIALS") or {detail:{reason}} for
|
|
// password rejects; map the common ones to human text.
|
|
async function readJson(res) {
|
|
let data = null;
|
|
try { data = await res.json(); } catch (e) { /* empty body (e.g. 204) */ }
|
|
if (!res.ok) {
|
|
const detail = data && data.detail;
|
|
let msg;
|
|
if (detail && typeof detail === "object" && detail.reason) msg = detail.reason;
|
|
else if (detail === "LOGIN_BAD_CREDENTIALS") msg = "Wrong email or password.";
|
|
else if (detail === "REGISTER_USER_ALREADY_EXISTS") msg = "That email is already registered.";
|
|
else if (detail === "VERIFY_USER_BAD_TOKEN") msg = "That verification link is invalid or expired.";
|
|
else if (detail === "VERIFY_USER_ALREADY_VERIFIED") msg = "This account is already verified.";
|
|
else if (typeof detail === "string") msg = detail.replace(/_/g, " ").toLowerCase();
|
|
else msg = `Request failed (${res.status}).`;
|
|
throw new Error(msg);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
export function getUser() { return currentUser; }
|
|
export function onAuthChange(cb) { authCbs.push(cb); }
|
|
function emitAuth() { authCbs.forEach((cb) => { try { cb(currentUser); } catch (e) {} }); }
|
|
|
|
async function refreshUser() {
|
|
try {
|
|
const res = await apiFetch(uv("users/me"));
|
|
currentUser = res.ok ? await res.json() : null;
|
|
} catch (e) { currentUser = null; }
|
|
// Learn once whether Discord is configured, so we only offer "Link Discord" and
|
|
// "Continue with Discord" when they'll actually work. Asked regardless of sign-in
|
|
// state: the sign-in modal needs the answer precisely when nobody is signed in.
|
|
if (!discordChecked) {
|
|
discordChecked = true;
|
|
try {
|
|
const r = await apiFetch(uv("discord/config"));
|
|
if (r.ok) discordEnabled = (await r.json()).enabled === true;
|
|
} catch (e) { /* leave it hidden */ }
|
|
}
|
|
return currentUser;
|
|
}
|
|
|
|
// --- auth calls --------------------------------------------------------------
|
|
async function login(email, password) {
|
|
// fastapi-users login is an OAuth2 form: username=email, password.
|
|
const res = await apiFetch(uv("auth/login"), { method: "POST", form: { username: email, password } });
|
|
await readJson(res); // throws on bad creds; 204 body is empty
|
|
}
|
|
async function register(email, password) {
|
|
const res = await apiFetch(uv("auth/register"), { method: "POST", json: { email, password } });
|
|
await readJson(res);
|
|
}
|
|
async function verifyEmail(token) {
|
|
const res = await apiFetch(uv("auth/verify"), { method: "POST", json: { token } });
|
|
await readJson(res);
|
|
}
|
|
export async function logout() {
|
|
try { await apiFetch(uv("auth/logout"), { method: "POST" }); } catch (e) {}
|
|
currentUser = null;
|
|
renderHeader();
|
|
emitAuth();
|
|
}
|
|
|
|
// --- auth modal --------------------------------------------------------------
|
|
let modal = null, mode = "login";
|
|
|
|
// Discord's brand mark ("Clyde"), inline like the other icons here so it needs no
|
|
// extra request and inherits currentColor.
|
|
const DISCORD_IC = `<svg viewBox="0 0 24 18" fill="currentColor" aria-hidden="true"><path d="M20.32 1.51A19.79 19.79 0 0 0 15.43 0a13.9 13.9 0 0 0-.63 1.28 18.4 18.4 0 0 0-5.6 0A13.9 13.9 0 0 0 8.57 0 19.74 19.74 0 0 0 3.68 1.51C.57 6.15-.28 10.68.14 15.14a19.9 19.9 0 0 0 6.05 3.05c.49-.66.92-1.37 1.3-2.11a12.9 12.9 0 0 1-2.05-.98c.17-.13.34-.26.5-.4a14.2 14.2 0 0 0 12.12 0c.16.14.33.27.5.4-.65.38-1.34.71-2.05.98.37.74.81 1.45 1.3 2.11a19.87 19.87 0 0 0 6.05-3.05c.49-5.17-.84-9.67-3.54-13.63ZM8.02 12.4c-1.18 0-2.15-1.08-2.15-2.41S6.82 7.58 8.02 7.58s2.17 1.09 2.15 2.41c0 1.33-.95 2.41-2.15 2.41Zm7.96 0c-1.18 0-2.15-1.08-2.15-2.41s.95-2.41 2.15-2.41 2.17 1.09 2.15 2.41c0 1.33-.95 2.41-2.15 2.41Z"/></svg>`;
|
|
|
|
function buildModal() {
|
|
modal = document.createElement("div");
|
|
modal.className = "mp-overlay acct-overlay";
|
|
modal.hidden = true;
|
|
modal.innerHTML = `
|
|
<div class="mp-modal acct-modal" role="dialog" aria-modal="true" aria-label="Account">
|
|
<div class="mp-head">
|
|
<h2 class="acct-title">Sign in</h2>
|
|
<button type="button" class="mp-close" aria-label="Close">×</button>
|
|
</div>
|
|
<form class="acct-form" autocomplete="on">
|
|
<label>Email
|
|
<input type="email" name="email" required autocomplete="email" placeholder="you@example.com" />
|
|
</label>
|
|
<label>Password
|
|
<input type="password" name="password" required minlength="8" autocomplete="current-password" placeholder="At least 8 characters" />
|
|
</label>
|
|
<p class="acct-error" role="alert" hidden></p>
|
|
<button type="submit" class="acct-submit">Sign in</button>
|
|
<div class="acct-alt" hidden>
|
|
<span class="acct-or">or</span>
|
|
<a class="acct-discord-login" href="${uv("discord/login/start")}">
|
|
${DISCORD_IC}<span>Continue with Discord</span>
|
|
</a>
|
|
</div>
|
|
<p class="acct-switch"></p>
|
|
</form>
|
|
</div>`;
|
|
document.body.appendChild(modal);
|
|
|
|
modal.querySelector(".mp-close").onclick = closeAuth;
|
|
modal.addEventListener("pointerdown", (e) => { if (e.target === modal) closeAuth(); });
|
|
document.addEventListener("keydown", (e) => { if (!modal.hidden && e.key === "Escape") closeAuth(); });
|
|
|
|
const form = modal.querySelector(".acct-form");
|
|
form.addEventListener("submit", onSubmit);
|
|
modal.querySelector(".acct-switch").addEventListener("click", (e) => {
|
|
const b = e.target.closest("button");
|
|
if (b) setMode(mode === "login" ? "register" : "login");
|
|
});
|
|
setMode("login");
|
|
}
|
|
|
|
function setMode(m) {
|
|
mode = m;
|
|
const isLogin = m === "login";
|
|
modal.querySelector(".acct-title").textContent = isLogin ? "Sign in" : "Create account";
|
|
modal.querySelector(".acct-submit").textContent = isLogin ? "Sign in" : "Create account";
|
|
modal.querySelector('input[name="password"]').setAttribute(
|
|
"autocomplete", isLogin ? "current-password" : "new-password");
|
|
modal.querySelector(".acct-switch").innerHTML = isLogin
|
|
? 'Need an account? <button type="button">Create one</button>'
|
|
: 'Already have an account? <button type="button">Sign in</button>';
|
|
// One label for both modes: Discord signs you in or creates the account,
|
|
// whichever applies, so making the user pick first would be a false choice.
|
|
modal.querySelector(".acct-alt").hidden = !discordEnabled;
|
|
showError("");
|
|
}
|
|
|
|
function showError(msg) {
|
|
const el = modal.querySelector(".acct-error");
|
|
el.textContent = msg || "";
|
|
el.hidden = !msg;
|
|
}
|
|
|
|
async function onSubmit(e) {
|
|
e.preventDefault();
|
|
const email = modal.querySelector('input[name="email"]').value.trim();
|
|
const password = modal.querySelector('input[name="password"]').value;
|
|
const btn = modal.querySelector(".acct-submit");
|
|
btn.disabled = true;
|
|
showError("");
|
|
try {
|
|
if (mode === "register") { await register(email, password); }
|
|
await login(email, password); // register flow signs in right after
|
|
await refreshUser();
|
|
closeAuth();
|
|
renderHeader();
|
|
emitAuth();
|
|
} catch (err) {
|
|
showError(err.message || "Something went wrong.");
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
export function openAuth(startMode = "login") {
|
|
if (!modal) buildModal();
|
|
setMode(startMode);
|
|
modal.querySelector('input[name="email"]').value = "";
|
|
modal.querySelector('input[name="password"]').value = "";
|
|
modal.hidden = false;
|
|
modal.querySelector('input[name="email"]').focus();
|
|
}
|
|
function closeAuth() { if (modal) modal.hidden = true; }
|
|
|
|
// --- notifications (header bell) ---------------------------------------------
|
|
const BELL_IC = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>`;
|
|
|
|
let notifications = [];
|
|
let unreadCount = 0;
|
|
let notifTimer = null;
|
|
let notifPolling = false;
|
|
|
|
// Backoff: a sustained outage shouldn't keep hitting the backend every 2 min
|
|
// forever. Doubles on each consecutive failure, capped, resets on success.
|
|
const NOTIF_POLL_BASE_MS = 120000; // refresh unread every 2 min when healthy
|
|
const NOTIF_POLL_MAX_MS = 960000; // cap at 16 min
|
|
let notifPollDelay = NOTIF_POLL_BASE_MS;
|
|
let notifFailStreak = 0;
|
|
|
|
function timeAgo(sec) {
|
|
const d = Date.now() / 1000 - sec;
|
|
if (d < 60) return "just now";
|
|
if (d < 3600) return `${Math.floor(d / 60)}m ago`;
|
|
if (d < 86400) return `${Math.floor(d / 3600)}h ago`;
|
|
return `${Math.floor(d / 86400)}d ago`;
|
|
}
|
|
|
|
async function loadNotifications() {
|
|
try {
|
|
const res = await apiFetch(uv("notifications?limit=50"));
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
notifications = data.notifications || [];
|
|
unreadCount = data.unread_count || 0;
|
|
paintBadge();
|
|
paintNotifList();
|
|
notifFailStreak = 0;
|
|
notifPollDelay = NOTIF_POLL_BASE_MS;
|
|
} catch (e) {
|
|
// offline / backend blip — leave prior state, back off the poll interval
|
|
notifFailStreak++;
|
|
notifPollDelay = Math.min(NOTIF_POLL_BASE_MS * 2 ** notifFailStreak, NOTIF_POLL_MAX_MS);
|
|
}
|
|
}
|
|
|
|
function paintBadge() {
|
|
if (!acctEl) return;
|
|
const badge = acctEl.querySelector(".notif-badge");
|
|
if (!badge) return;
|
|
badge.textContent = unreadCount > 99 ? "99+" : String(unreadCount);
|
|
badge.hidden = unreadCount === 0;
|
|
}
|
|
|
|
function paintNotifList() {
|
|
if (!acctEl) return;
|
|
const list = acctEl.querySelector(".notif-list");
|
|
if (!list) return;
|
|
if (!notifications.length) {
|
|
list.innerHTML = `<li class="notif-empty muted">No alerts yet. Weather notifications will show up here.</li>`;
|
|
return;
|
|
}
|
|
list.innerHTML = notifications.map((n) => `
|
|
<li class="notif-item ${n.read_at ? "" : "is-unread"}" data-id="${n.id}">
|
|
<div class="notif-item-title">${escapeHtml(n.title)}</div>
|
|
<div class="notif-item-body">${escapeHtml(n.body || "")}</div>
|
|
<div class="notif-item-time muted">${timeAgo(n.created_at)}</div>
|
|
</li>`).join("");
|
|
list.querySelectorAll(".notif-item.is-unread").forEach((li) => {
|
|
li.addEventListener("click", () => markRead(Number(li.dataset.id), li));
|
|
});
|
|
}
|
|
|
|
async function markRead(id, li) {
|
|
try {
|
|
const res = await apiFetch(uv(`notifications/${id}/read`), { method: "POST" });
|
|
if (!res.ok) return;
|
|
const n = notifications.find((x) => x.id === id);
|
|
if (n && !n.read_at) { n.read_at = Date.now() / 1000; unreadCount = Math.max(0, unreadCount - 1); }
|
|
li.classList.remove("is-unread");
|
|
paintBadge();
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function markAllRead() {
|
|
try {
|
|
const res = await apiFetch(uv("notifications/read-all"), { method: "POST" });
|
|
if (!res.ok) return;
|
|
notifications.forEach((n) => { n.read_at = n.read_at || Date.now() / 1000; });
|
|
unreadCount = 0;
|
|
paintBadge();
|
|
paintNotifList();
|
|
} catch (e) {}
|
|
}
|
|
|
|
function startNotifPolling() {
|
|
if (notifPolling) return;
|
|
notifPolling = true;
|
|
// Recursive setTimeout, not setInterval -- the delay is variable (backoff),
|
|
// so the next tick has to be scheduled after each fetch settles rather than
|
|
// fixed up front.
|
|
const tick = async () => {
|
|
await loadNotifications();
|
|
if (!notifPolling) return; // stopped while the fetch was in flight
|
|
notifTimer = setTimeout(tick, notifPollDelay);
|
|
};
|
|
tick();
|
|
}
|
|
function stopNotifPolling() {
|
|
notifPolling = false;
|
|
if (notifTimer) { clearTimeout(notifTimer); notifTimer = null; }
|
|
notifications = []; unreadCount = 0;
|
|
notifFailStreak = 0; notifPollDelay = NOTIF_POLL_BASE_MS;
|
|
}
|
|
|
|
// --- header entry ------------------------------------------------------------
|
|
const USER_IC = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
|
|
|
|
let acctEl = null;
|
|
|
|
function ensureAcctEl() {
|
|
const brand = document.querySelector(".brand");
|
|
if (!brand) return null;
|
|
if (!acctEl) {
|
|
acctEl = document.createElement("div");
|
|
acctEl.className = "acct";
|
|
// The account/bell live inside the nav menu panel: on desktop the panel is
|
|
// display:contents so they render inline top-right; on phones they stack
|
|
// inside the hamburger dropdown. Fall back to a brand sibling if unwrapped.
|
|
const panel = brand.querySelector(".nav-panel");
|
|
if (panel) panel.appendChild(acctEl);
|
|
else brand.insertBefore(acctEl, brand.querySelector(".view-nav"));
|
|
}
|
|
return acctEl;
|
|
}
|
|
|
|
function renderHeader() {
|
|
const el = ensureAcctEl();
|
|
if (!el) return;
|
|
if (currentUser) {
|
|
const name = currentUser.display_name || currentUser.email;
|
|
el.innerHTML = `
|
|
<div class="notif">
|
|
<button type="button" class="acct-btn notif-btn" aria-haspopup="true" aria-expanded="false" aria-label="Notifications">
|
|
${BELL_IC}<span class="notif-badge" hidden>0</span>
|
|
</button>
|
|
<div class="notif-pop" hidden>
|
|
<div class="notif-head">
|
|
<span>Notifications</span>
|
|
<button type="button" class="notif-readall">Mark all read</button>
|
|
</div>
|
|
<ul class="notif-list"></ul>
|
|
<a href="${APP_BASE}/alerts" class="notif-manage">Manage alerts →</a>
|
|
</div>
|
|
</div>
|
|
<div class="acct-menu">
|
|
<button type="button" class="acct-btn" aria-haspopup="true" aria-expanded="false" aria-label="Account: ${escapeHtml(name)}">
|
|
${USER_IC}<span class="acct-name">${escapeHtml(name)}</span>
|
|
</button>
|
|
<div class="acct-pop" hidden>
|
|
<a href="${APP_BASE}/alerts" class="acct-pop-link">My alerts</a>
|
|
${currentUser.discord_id
|
|
? `<button type="button" class="acct-pop-link acct-discord-dm">${currentUser.discord_dm ? "Discord alerts: on" : "Discord alerts: off"}</button>`
|
|
// A Discord-created account has no password, so Discord is its only
|
|
// way in and the server refuses to unlink it — don't offer the button.
|
|
+ (currentUser.discord_only
|
|
? '<span class="acct-pop-note muted">Signed in with Discord</span>'
|
|
: '<button type="button" class="acct-pop-link acct-discord-unlink">Unlink Discord</button>')
|
|
: (discordEnabled ? `<a href="${uv("discord/link/start")}" class="acct-pop-link">Link Discord</a>` : "")}
|
|
<button type="button" class="acct-pop-link acct-signout">Sign out</button>
|
|
</div>
|
|
</div>`;
|
|
const btn = el.querySelector(".acct-btn:not(.notif-btn)");
|
|
const pop = el.querySelector(".acct-pop");
|
|
btn.addEventListener("click", () => {
|
|
const open = pop.hidden;
|
|
pop.hidden = !open;
|
|
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
|
});
|
|
// Notification bell dropdown.
|
|
const nBtn = el.querySelector(".notif-btn");
|
|
const nPop = el.querySelector(".notif-pop");
|
|
nBtn.addEventListener("click", () => {
|
|
const open = nPop.hidden;
|
|
nPop.hidden = !open;
|
|
nBtn.setAttribute("aria-expanded", open ? "true" : "false");
|
|
if (open) loadNotifications(); // refresh on open
|
|
});
|
|
el.querySelector(".notif-readall").addEventListener("click", markAllRead);
|
|
document.addEventListener("click", (e) => {
|
|
if (!el.contains(e.target)) {
|
|
pop.hidden = true; btn.setAttribute("aria-expanded", "false");
|
|
nPop.hidden = true; nBtn.setAttribute("aria-expanded", "false");
|
|
}
|
|
});
|
|
el.querySelector(".acct-signout").addEventListener("click", logout);
|
|
const unlinkBtn = el.querySelector(".acct-discord-unlink");
|
|
if (unlinkBtn) unlinkBtn.addEventListener("click", async () => {
|
|
unlinkBtn.disabled = true;
|
|
try {
|
|
const res = await apiFetch(uv("discord/unlink"), { method: "POST" });
|
|
// 409: the account was created with Discord and has no password, so
|
|
// unlinking would lock it out. Say why instead of appearing to do nothing.
|
|
if (res.status === 409) {
|
|
const body = await res.json().catch(() => null);
|
|
showToast((body && body.detail) || "Discord can't be unlinked from this account.", true);
|
|
unlinkBtn.disabled = false;
|
|
return;
|
|
}
|
|
} catch (e) {}
|
|
await refreshUser();
|
|
emitAuth(); // repaint the popover in its unlinked state
|
|
});
|
|
const dmBtn = el.querySelector(".acct-discord-dm");
|
|
if (dmBtn) dmBtn.addEventListener("click", async () => {
|
|
dmBtn.disabled = true;
|
|
try {
|
|
await apiFetch(uv("discord/dm"), { method: "POST", json: { enabled: !currentUser.discord_dm } });
|
|
} catch (e) {}
|
|
await refreshUser();
|
|
emitAuth(); // repaint with the new on/off label
|
|
});
|
|
paintBadge();
|
|
paintNotifList();
|
|
startNotifPolling();
|
|
} else {
|
|
stopNotifPolling();
|
|
el.innerHTML = `<button type="button" class="acct-btn acct-signin" aria-label="Sign in">${USER_IC}<span>Sign in</span></button>`;
|
|
el.querySelector(".acct-signin").addEventListener("click", () => openAuth("login"));
|
|
}
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, (c) =>
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
}
|
|
|
|
// --- toast ---------------------------------------------------------------
|
|
// One-off status messages (e.g. "email verified") that aren't tied to the auth
|
|
// modal, which may not even be open. Success/failure share the same neutral
|
|
// style — the text says which, not the color; isError only changes the
|
|
// live-region role (alert = assertive, status = polite).
|
|
let toastTimer = null;
|
|
|
|
function showToast(msg, isError = false) {
|
|
let el = document.querySelector(".acct-toast");
|
|
if (!el) {
|
|
el = document.createElement("div");
|
|
el.className = "acct-toast";
|
|
document.body.appendChild(el);
|
|
}
|
|
el.textContent = msg;
|
|
el.setAttribute("role", isError ? "alert" : "status");
|
|
el.hidden = false;
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
|
|
}
|
|
|
|
// --- Discord OAuth outcomes --------------------------------------------------
|
|
// The link/login callback can only talk back to us through a redirect, so it
|
|
// lands on ?discord=<status>. Each status maps to [message, isError]; anything
|
|
// unrecognised is treated as a generic failure rather than shown raw.
|
|
const DISCORD_STATUS = {
|
|
linked: ["Discord connected.", false],
|
|
signedin: ["Signed in with Discord.", false],
|
|
created: ["Account created with Discord — welcome to Thermograph.", false],
|
|
cancelled: ["Discord sign-in cancelled.", false],
|
|
taken: ["That Discord account is already connected to another Thermograph account.", true],
|
|
mismatch: ["That email belongs to an account connected to a different Discord account. "
|
|
+ "Sign in with your password to change it.", true],
|
|
noemail: ["Discord didn't share an email address, so there's no account to sign in to.", true],
|
|
unverified: ["Verify your email address with Discord first, then try again.", true],
|
|
inactive: ["That account is deactivated.", true],
|
|
unavailable: ["Discord sign-in isn't available on this server.", true],
|
|
};
|
|
|
|
function showDiscordStatus(status) {
|
|
const [msg, isError] = DISCORD_STATUS[status]
|
|
|| ["Something went wrong with Discord. Please try again.", true];
|
|
showToast(msg, isError);
|
|
}
|
|
|
|
// --- boot --------------------------------------------------------------------
|
|
(async function initAccount() {
|
|
ensureAcctEl();
|
|
renderHeader(); // paint the signed-out state immediately
|
|
await refreshUser(); // then confirm session from the cookie
|
|
renderHeader();
|
|
emitAuth();
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
const token = params.get("verify_token");
|
|
// Both of these are one-shot handoffs from a redirect; strip them together so a
|
|
// reload can't replay either, and so the two never fight over the URL.
|
|
const discordStatus = params.get("discord");
|
|
if (token || discordStatus) {
|
|
params.delete("verify_token");
|
|
params.delete("discord");
|
|
const clean = location.pathname + (params.toString() ? `?${params}` : "") + location.hash;
|
|
history.replaceState(null, "", clean);
|
|
}
|
|
// refreshUser() above already ran, and the callback's redirect carried the new
|
|
// session cookie, so the header is painted signed-in before this toast lands.
|
|
if (discordStatus) showDiscordStatus(discordStatus);
|
|
if (token) {
|
|
try {
|
|
await verifyEmail(token);
|
|
await refreshUser();
|
|
renderHeader();
|
|
emitAuth();
|
|
showToast("Email verified — you're all set.");
|
|
} catch (err) {
|
|
showToast(err.message || "Verification link is invalid or expired.", true);
|
|
}
|
|
}
|
|
})();
|