import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__"; import { applyAvatar, 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.name || 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, // Carry the custom name across — a rename-only plan would otherwise // duplicate as an indistinguishable "Untitled plan". name: plan.name ? `${plan.name} (copy)` : "", }), }); 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.name || 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"); }); } // Profile picture: resized client-side to a small square JPEG data URL before upload, so the // server only ever stores a few tens of KB per user. function wireAvatar(user) { const refresh = (hasAvatar) => { const shownUser = { ...user, hasAvatar }; applyAvatar(document.getElementById("profile-avatar"), shownUser, true); applyAvatar(document.getElementById("nav-user-avatar"), shownUser, true); document.getElementById("avatar-remove").classList.toggle("hidden", !hasAvatar); }; refresh(user.hasAvatar); document.getElementById("avatar-file").addEventListener("change", async (event) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; try { const bitmap = await createImageBitmap(file); const size = 128; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const context = canvas.getContext("2d"); // Cover-crop the centre square so faces aren't squished. const side = Math.min(bitmap.width, bitmap.height); context.drawImage( bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side, 0, 0, size, size, ); const dataUrl = canvas.toDataURL("image/jpeg", 0.85); await api("/api/account/avatar", { method: "PUT", body: JSON.stringify({ dataUrl }), }); showToast("Profile picture updated."); refresh(true); } catch (error) { showToast(error.message || "Could not read that image.", "fail"); } }); document.getElementById("avatar-remove").addEventListener("click", async () => { try { await api("/api/account/avatar", { method: "DELETE" }); showToast("Profile picture removed."); refresh(false); } catch (error) { showToast(error.message, "fail"); } }); } async function loadTokens() { const body = document.getElementById("tokens-body"); try { const { tokens } = await api("/api/tokens"); if (!tokens.length) { body.innerHTML = `No API tokens yet.`; return; } body.replaceChildren( ...tokens.map((token) => { const tr = document.createElement("tr"); const name = document.createElement("td"); name.textContent = token.name || "(unnamed)"; const created = document.createElement("td"); created.textContent = fmtDate(token.createdAt); const used = document.createElement("td"); used.textContent = token.lastUsedAt ? fmtDate(token.lastUsedAt) : "Never"; const actions = document.createElement("td"); actions.className = "data-table-actions"; const revoke = document.createElement("button"); revoke.className = "ghost-btn small"; revoke.type = "button"; revoke.textContent = "Revoke"; revoke.addEventListener("click", async () => { if (!confirm(`Revoke "${token.name || "this token"}"? Anything using it stops working immediately.`)) return; try { await api(`/api/tokens/${token.id}`, { method: "DELETE" }); showToast("Token revoked."); await loadTokens(); } catch (error) { showToast(error.message, "fail"); } }); actions.append(revoke); tr.append(name, created, used, actions); return tr; }), ); } catch { body.innerHTML = `Could not load tokens.`; } } function wireTokenForm() { document.getElementById("token-form").addEventListener("submit", async (event) => { event.preventDefault(); const button = document.getElementById("token-create"); button.disabled = true; try { const created = await api("/api/tokens", { method: "POST", body: JSON.stringify({ name: event.target.name.value.trim() }), }); // Shown exactly once — the server only stores a hash. document.getElementById("token-reveal").textContent = created.token; document.getElementById("token-reveal-wrap").classList.remove("hidden"); event.target.reset(); await loadTokens(); } catch (error) { showToast(error.message, "fail"); } finally { button.disabled = false; } }); } 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(); wireTokenForm(); wireAvatar(user); await Promise.all([loadSessions(), loadPlans(), loadTokens()]); } init();