// Accounts: the header "Sign in / account" entry, the auth modal, and the shared // helpers other modules (subscriptions.js) use to talk to authed endpoints. // // Auth is a same-origin HttpOnly cookie, so the frontend stores no token — it just // sends `credentials: same-origin` and asks `GET users/me` who it is. 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 linking 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 // The app's base path (e.g. "/thermograph"), derived from this module's own URL // — it's always served from `${base}/account.js`, so this resolves correctly on // both the depth-1 interactive pages and the deep SEO URLs (/climate/{slug}/…), // where a directory-relative "api/v2/…" would otherwise resolve wrong and 404. const APP_BASE = new URL(".", import.meta.url).pathname.replace(/\/$/, ""); const u = (path) => `${APP_BASE}/${String(path).replace(/^\//, "")}`; // --- shared fetch helper ----------------------------------------------------- // Absolute app-base URL (so it works from any page depth), cookie sent, and a // constant X-TG-Auth header the server can require as cheap CSRF defense (trivial // same-origin, impossible cross-site without a preflight we never grant). export async function apiFetch(path, { method = "GET", json, form } = {}) { const opts = { method, credentials: "same-origin", 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(u(path), 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 (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("api/v2/users/me"); currentUser = res.ok ? await res.json() : null; } catch (e) { currentUser = null; } // Learn once whether Discord linking is configured, so the account menu only // offers "Link Discord" when it will actually work (checked lazily, and only // for a signed-in user — the menu never shows it to anyone else). if (currentUser && !discordChecked) { discordChecked = true; try { const r = await apiFetch("api/v2/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("api/v2/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("api/v2/auth/register", { method: "POST", json: { email, password } }); await readJson(res); } export async function logout() { try { await apiFetch("api/v2/auth/logout", { method: "POST" }); } catch (e) {} currentUser = null; renderHeader(); emitAuth(); } // --- auth modal -------------------------------------------------------------- let modal = null, mode = "login"; 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? '; 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; 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("api/v2/notifications?limit=50"); if (!res.ok) return; const data = await res.json(); notifications = data.notifications || []; unreadCount = data.unread_count || 0; paintBadge(); paintNotifList(); } catch (e) { /* offline — leave prior state */ } } 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(`api/v2/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("api/v2/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() { loadNotifications(); if (notifTimer) return; notifTimer = setInterval(loadNotifications, 120000); // refresh unread every 2 min } function stopNotifPolling() { if (notifTimer) { clearInterval(notifTimer); notifTimer = null; } notifications = []; unreadCount = 0; } // --- header entry ------------------------------------------------------------ const USER_IC = ``; 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 = `
    `; 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 { await apiFetch("api/v2/discord/unlink", { method: "POST" }); } 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("api/v2/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])); } // --- boot -------------------------------------------------------------------- (async function initAccount() { ensureAcctEl(); renderHeader(); // paint the signed-out state immediately await refreshUser(); // then confirm session from the cookie renderHeader(); emitAuth(); })();