import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__"; import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__"; import { showToast } from "./toast.js?v=__ASSET_VERSION__"; function fmtDate(value) { return value ? new Date(value).toLocaleString() : "—"; } function friendlyDevice(userAgent) { if (!userAgent) return "Unknown device"; const browser = /Edg\//.test(userAgent) ? "Edge" : /OPR\//.test(userAgent) ? "Opera" : /Chrome\//.test(userAgent) ? "Chrome" : /CriOS\//.test(userAgent) ? "Chrome" : /Firefox\//.test(userAgent) ? "Firefox" : /Safari\//.test(userAgent) ? "Safari" : "Browser"; const os = /Windows/.test(userAgent) ? "Windows" : /iPhone|iPad/.test(userAgent) ? "iOS" : /Mac OS X/.test(userAgent) ? "macOS" : /Android/.test(userAgent) ? "Android" : /Linux/.test(userAgent) ? "Linux" : ""; return os ? `${browser} on ${os}` : browser; } async function loadProfile(user) { document.getElementById("profile-email").textContent = user.email; document .getElementById("profile-role-admin") .classList.toggle("hidden", user.role !== "admin"); document.getElementById("email-form").email.value = user.email; } async function loadSessions() { const body = document.getElementById("sessions-body"); try { const { sessions } = await api("/api/account/sessions"); if (!sessions.length) { body.innerHTML = `No active sessions.`; return; } body.replaceChildren( ...sessions.map((session) => { const tr = document.createElement("tr"); const device = document.createElement("td"); const label = document.createElement("span"); label.className = "session-device-label"; label.textContent = friendlyDevice(session.userAgent); if (session.userAgent) label.title = session.userAgent; device.append(label); if (session.current) { const badge = document.createElement("span"); badge.className = "badge badge-current"; badge.textContent = "This device"; badge.style.marginLeft = "8px"; device.append(badge); } const lastSeen = document.createElement("td"); lastSeen.textContent = fmtDate(session.lastSeenAt || session.createdAt); const expires = document.createElement("td"); expires.textContent = fmtDate(session.expiresAt); const actions = document.createElement("td"); const revoke = document.createElement("button"); revoke.className = "ghost-btn small"; revoke.type = "button"; revoke.textContent = session.current ? "Sign out" : "Revoke"; revoke.addEventListener("click", async () => { try { await api(`/api/account/sessions/${session.id}`, { method: "DELETE", }); if (session.current) return location.assign("/login"); showToast("Session revoked."); loadSessions(); } catch (error) { showToast(error.message, "fail"); } }); actions.append(revoke); tr.append(device, lastSeen, expires, actions); return tr; }), ); } catch (error) { body.innerHTML = `Could not load sessions.`; } } async function loadPlans() { const body = document.getElementById("plans-body"); try { const response = await fetch("/api/plans"); if (!response.ok) throw new Error("could not load plans"); const { plans } = await response.json(); if (!plans.length) { body.innerHTML = `No saved plans yet.`; return; } body.replaceChildren( ...plans.map((plan) => { const tr = document.createElement("tr"); const title = document.createElement("td"); title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan"; const updated = document.createElement("td"); updated.textContent = fmtDate(plan.updated_at); const actions = document.createElement("td"); actions.className = "data-table-actions"; const open = document.createElement("a"); open.className = "ghost-btn small"; open.href = `/app?plan=${encodeURIComponent(plan.id)}`; open.textContent = "Open"; const duplicate = document.createElement("button"); duplicate.className = "ghost-btn small"; duplicate.type = "button"; duplicate.textContent = "Duplicate"; duplicate.addEventListener("click", async () => { try { await api("/api/plans", { method: "POST", body: JSON.stringify({ plan: plan.plan }), }); showToast("Plan duplicated."); loadPlans(); } catch (error) { showToast(error.message, "fail"); } }); const download = document.createElement("button"); download.className = "ghost-btn small"; download.type = "button"; download.textContent = "Download"; download.addEventListener("click", () => { const blob = new Blob([JSON.stringify(plan.plan, null, 2)], { type: "application/json", }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${(plan.plan?.fields?.["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`; a.click(); URL.revokeObjectURL(a.href); }); const cup = document.createElement("button"); cup.className = "ghost-btn small"; cup.type = "button"; cup.textContent = "Cup"; cup.addEventListener("click", async () => { cup.disabled = true; try { const existing = await api(`/api/cupping?plan=${encodeURIComponent(plan.id)}`); if (existing.sessions?.length) { location.assign(`/cupping?session=${existing.sessions[0].id}`); return; } const created = await api("/api/cupping", { method: "POST", body: JSON.stringify({ roastPlanId: plan.id }), }); location.assign(`/cupping?session=${created.session.id}`); } catch (error) { showToast(error.message, "fail"); cup.disabled = false; } }); const del = document.createElement("button"); del.className = "ghost-btn small"; del.type = "button"; del.textContent = "Delete"; del.addEventListener("click", async () => { if (!confirm("Delete this plan? This cannot be undone.")) return; try { await api(`/api/plans/${plan.id}`, { method: "DELETE" }); showToast("Plan deleted."); loadPlans(); } catch (error) { showToast(error.message, "fail"); } }); actions.append(open, duplicate, download, cup, del); tr.append(title, updated, actions); return tr; }), ); } catch { body.innerHTML = `Could not load plans.`; } } /** Disables `button` for the duration of `run()` so a slow request can't be double-submitted. */ async function guarded(button, run) { if (button.disabled) return; button.disabled = true; try { await run(); } finally { button.disabled = false; } } function wireForms(user) { document.getElementById("email-form").addEventListener("submit", (event) => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.target)); guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => { try { await api("/api/account/email", { method: "PUT", body: JSON.stringify(data) }); showToast("Email updated."); document.getElementById("profile-email").textContent = data.email; event.target.password.value = ""; } catch (error) { showToast( error.code === "email_exists" ? "That email is already in use." : error.message, "fail", ); } }); }); document .getElementById("password-form") .addEventListener("submit", (event) => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.target)); if (data.newPassword !== data.confirmPassword) { showToast("New passwords do not match.", "fail"); return; } guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => { try { await api("/api/account/password", { method: "PUT", body: JSON.stringify(data), }); showToast("Password updated. Other sessions were signed out."); event.target.reset(); loadSessions(); } catch (error) { showToast(error.message, "fail"); } }); }); document .getElementById("btn-revoke-others") .addEventListener("click", (event) => { guarded(event.currentTarget, async () => { try { await api("/api/account/sessions/revoke-others", { method: "POST" }); showToast("Other sessions signed out."); loadSessions(); } catch (error) { showToast(error.message, "fail"); } }); }); document.getElementById("delete-form").addEventListener("submit", (event) => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.target)); if (data.confirmEmail.trim().toLowerCase() !== user.email.toLowerCase()) { showToast("Type your email exactly to confirm.", "fail"); return; } if (!confirm("This permanently deletes your account and plans. Continue?")) return; guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => { try { await api("/api/account", { method: "DELETE", body: JSON.stringify({ password: data.password }), }); location.assign("/"); } catch (error) { showToast( error.code === "last_admin" ? "You are the only administrator — promote another admin first." : error.message, "fail", ); } }); }); } function wirePwaButtons() { let deferredInstallPrompt = null; window.addEventListener("beforeinstallprompt", (event) => { event.preventDefault(); deferredInstallPrompt = event; document.getElementById("btn-install").classList.remove("hidden"); }); document.getElementById("btn-install").addEventListener("click", async () => { if (!deferredInstallPrompt) return; deferredInstallPrompt.prompt(); await deferredInstallPrompt.userChoice; deferredInstallPrompt = null; document.getElementById("btn-install").classList.add("hidden"); }); if ("serviceWorker" in navigator) { const hadController = !!navigator.serviceWorker.controller; navigator.serviceWorker.getRegistration().then((registration) => { if (registration?.waiting) document.getElementById("btn-refresh").classList.remove("hidden"); }); navigator.serviceWorker.addEventListener("controllerchange", () => { if (hadController) location.reload(); }); } document.getElementById("btn-refresh").addEventListener("click", async () => { const registration = await navigator.serviceWorker.getRegistration(); registration?.waiting?.postMessage("SKIP_WAITING"); }); } document.getElementById("btn-logout").addEventListener("click", async () => { await protectedFetch("/api/auth/logout", { method: "POST" }); location.assign("/"); }); async function init() { initSideNav(); const user = await loadNavUser(); if (!user) return; document.getElementById("profile-since").textContent = `Member since ${fmtDate(user.createdAt)}`; await loadProfile(user); wireForms(user); wirePwaButtons(); await Promise.all([loadSessions(), loadPlans()]); } init();