// 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
// 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; }
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 = `
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? ';
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.