diff --git a/static/account.js b/static/account.js
new file mode 100644
index 0000000..fadc900
--- /dev/null
+++ b/static/account.js
@@ -0,0 +1,342 @@
+// 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
+const authCbs = []; // notified on login/logout so pages can re-gate
+
+// --- shared fetch helper -----------------------------------------------------
+// Base-relative URL (resolves under /thermograph automatically), 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(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; }
+ 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";
+ const nav = brand.querySelector(".view-nav");
+ brand.insertBefore(acctEl, nav); // top-right, just left of the view tabs
+ }
+ 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);
+ 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();
+})();
diff --git a/static/app.js b/static/app.js
index d91de51..b45b532 100644
--- a/static/app.js
+++ b/static/app.js
@@ -1,6 +1,7 @@
// Weekly (map) page. Imports replace the old script-tag ordering contract.
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
import { fmtTemp, onUnitChange } from "./units.js";
+import "./account.js"; // header sign-in entry + notification bell
import { getJSON, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
diff --git a/static/calendar.js b/static/calendar.js
index e104be0..614ca1f 100644
--- a/static/calendar.js
+++ b/static/calendar.js
@@ -3,6 +3,7 @@
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
// The tooltip reads fmtTemp live, so a unit switch shows on the next hover.
import { fmtTemp, onUnitChange } from "./units.js";
+import "./account.js"; // header sign-in entry + notification bell
import { TTL, chunkedFetch, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord,
diff --git a/static/compare.js b/static/compare.js
index 8f65cc5..99dacac 100644
--- a/static/compare.js
+++ b/static/compare.js
@@ -25,6 +25,7 @@ import { MONTHS, pad, monthStart, monthEnd, buildChunks,
allMonths, monthsToMask, maskToMonths,
namePrimary, nameParts } from "./shared.js";
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
+import "./account.js"; // header sign-in entry + notification bell
import { initFilterSheet } from "./filtersheet.js";
const CMP = {
diff --git a/static/day.js b/static/day.js
index 41f0931..b1d3fc5 100644
--- a/static/day.js
+++ b/static/day.js
@@ -3,6 +3,7 @@
// and where the observed values land. Talks to /api/v2/day + /api/v2/geocode.
import { loadLastLocation, saveLastLocation, locHash } from "./nav.js";
import { fmtTemp, onUnitChange } from "./units.js";
+import "./account.js"; // header sign-in entry + notification bell
import { getJSON, TTL, prefetchViews } from "./cache.js";
import { initFindButton, setFindLabel } from "./mappicker.js";
import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, ord, fmtPrecip, fmtWind, fmtHumid,
diff --git a/static/legend.html b/static/legend.html
index 6ccb109..5ffd7b2 100644
--- a/static/legend.html
+++ b/static/legend.html
@@ -87,6 +87,7 @@
+
+