// 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 = `
Sign in
`;
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.
`).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);
}
}
})();