// 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 providers = []; // [{name, label, enabled}] from /oauth/config let providersChecked = 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 (!providersChecked) { providersChecked = true; try { const r = await apiFetch(uv("oauth/config")); if (r.ok) providers = ((await r.json()).providers || []).filter((p) => p.enabled); } catch (e) { /* leave them hidden */ } } return currentUser; } // Providers the server can complete a flow with. Empty until refreshUser() has // run, which is why the sign-in modal re-renders its provider row on every open // rather than only when it is first built. function enabledProviders() { return providers; } function isLinked(name) { return !!(currentUser && (currentUser.oauth_providers || []).includes(name)); } // --- 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 = ``; // Google's mark is four fixed brand colours, so unlike the others it does not take // currentColor — hence the white button beneath it, which is also what Google's // branding terms require. const GOOGLE_IC = ``; const PROVIDER_IC = { discord: DISCORD_IC, google: GOOGLE_IC }; // The provider buttons under the sign-in form. Rebuilt on every open because // `providers` is populated asynchronously and may still have been empty when the // modal was first constructed. function providerButtonsHtml() { return enabledProviders().map((p) => ` ${PROVIDER_IC[p.name] || ""}Continue with ${escapeHtml(p.label)} `).join(""); } function buildModal() { modal = document.createElement("div"); modal.className = "mp-overlay acct-overlay"; modal.hidden = true; modal.innerHTML = ` `; 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? ' : 'Already have an account? '; // One label for both modes: the provider signs you in or creates the account, // whichever applies, so making the user pick first would be a false choice. const enabled = enabledProviders(); modal.querySelector(".acct-oauth-row").innerHTML = providerButtonsHtml(); modal.querySelector(".acct-alt").hidden = enabled.length === 0; 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 = ``; 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 = `
  • No alerts yet. Weather notifications will show up here.
  • `; return; } list.innerHTML = notifications.map((n) => `
  • ${escapeHtml(n.title)}
    ${escapeHtml(n.body || "")}
    ${timeAgo(n.created_at)}
  • `).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 = ``; let acctEl = null; // The connected-accounts section of the account popover: one entry per provider // the server offers, plus Discord's DM toggle, which is a delivery setting rather // than an auth one and so only appears once Discord is actually linked. function connectedAccountsHtml() { const linkedCount = (currentUser.oauth_providers || []).length; return enabledProviders().map((p) => { if (!isLinked(p.name)) { return `` + `Link ${escapeHtml(p.label)}`; } const dm = p.name === "discord" ? `` : ""; // With no password and nothing else linked, this is the only way back in and // the server refuses to unlink it — so don't offer a button that can't work. const stuck = currentUser.oauth_only && linkedCount <= 1; return dm + (stuck ? `Signed in with ${escapeHtml(p.label)}` : ``); }).join(""); } 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 = `
    `; 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); el.querySelectorAll(".acct-oauth-unlink").forEach((unlinkBtn) => { unlinkBtn.addEventListener("click", async () => { const provider = unlinkBtn.dataset.provider; unlinkBtn.disabled = true; try { const res = await apiFetch(uv(`oauth/${provider}/unlink`), { method: "POST" }); // 409: the account has no password and this was its last provider, 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) || "That account can't be unlinked — it's the only way to sign in.", 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 = ``; 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); } // --- OAuth outcomes ---------------------------------------------------------- // The link/login callback can only talk back to us through a redirect, so it // lands on ?oauth=&provider=. Each status maps to a message builder // taking the provider's display name; anything unrecognised is treated as a // generic failure rather than shown raw. const OAUTH_STATUS = { linked: [(p) => `${p} connected.`, false], signedin: [(p) => `Signed in with ${p}.`, false], created: [(p) => `Account created with ${p} — welcome to Thermograph.`, false], cancelled: [(p) => `${p} sign-in cancelled.`, false], already: [(p) => `A different ${p} account is already connected here.`, true], taken: [(p) => `That ${p} account is already connected to another Thermograph account.`, true], mismatch: [(p) => `That email belongs to an account connected to a different ${p} ` + "account. Sign in with your password to change it.", true], noemail: [(p) => `${p} didn't share an email address, so there's no account to sign in to.`, true], unverified: [(p) => `Verify your email address with ${p} first, then try again.`, true], inactive: [() => "That account is deactivated.", true], unavailable: [(p) => `${p} sign-in isn't available on this server.`, true], }; function providerLabel(name) { const known = providers.find((p) => p.name === name); if (known) return known.label; // The config probe may have failed, or the provider may have been switched off // between starting the flow and coming back. Title-case the raw name rather // than showing "undefined" in the toast. return name ? name.charAt(0).toUpperCase() + name.slice(1) : "That provider"; } function showOAuthStatus(status, provider) { const label = providerLabel(provider); const entry = OAUTH_STATUS[status]; if (!entry) return showToast(`Something went wrong with ${label}. Please try again.`, true); const [build, isError] = entry; showToast(build(label), 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"); // `discord` is the pre-Google spelling: a backend older than this frontend sends // only that one, so fall back to it and assume the provider it implies. const oauthStatus = params.get("oauth") || params.get("discord"); const oauthProvider = params.get("provider") || (params.get("discord") ? "discord" : ""); // These are one-shot handoffs from a redirect; strip them together so a reload // can't replay any of them, and so they never fight over the URL. if (token || oauthStatus) { ["verify_token", "oauth", "provider", "discord"].forEach((k) => params.delete(k)); 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 (oauthStatus) showOAuthStatus(oauthStatus, oauthProvider); 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); } } })();