Files
roast_command_center/public/js/account.js
T
Shane MaynardandClaude Fable 5 38c7d01e03
Test and deploy / test-and-deploy (push) Successful in 1m6s
Add brewing section, plan chat, roaster learning, API tokens, Swagger docs, and full backup
Brewing:
- roasted_beans + brews tables; /beans (bean management with LLM URL
  prefill) and /brews (silhouette brewer picker across immersion/
  percolation/espresso, recipe fields, auto ratio, 0-10 rating, tasting
  notes); bean remaining weight derived from logged brew doses
- Green inventory lot form also prefills from a product URL

Navigation/UX:
- Side nav is now generated from one definition in nav.js, grouped
  Roasting / Brewing / account, consistent on every page

LLM:
- 'Ask the LLM' chat drawer on the planner (stateless /api/plan-chat)
  grounded in the plan, computed ledger, learned pace, and a new
  roaster-behavior profile aggregated from uploaded .alogs
  (/api/roaster-profile: TP lag, phase RoR, median milestone temps)
- The profile also feeds roast reviews and the planner curve's fallback
  milestone temps

API platform:
- User-generated bearer tokens (rpt_…) with account-page management;
  token requests skip CSRF; hand-authored OpenAPI 3 spec at
  /api/openapi.json rendered by self-hosted Swagger UI at /api-docs
- Full-database backup export/import (admin) + per-user data export

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-08 22:37:42 -04:00

420 lines
13 KiB
JavaScript

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 = `<tr><td colspan="4" class="empty-state">No active sessions.</td></tr>`;
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 = `<tr><td colspan="4" class="empty-state">Could not load sessions.</td></tr>`;
}
}
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 = `<tr><td colspan="3" class="empty-state">No saved plans yet.</td></tr>`;
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 = `<tr><td colspan="3" class="empty-state">Could not load plans.</td></tr>`;
}
}
/** 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");
});
}
async function loadTokens() {
const body = document.getElementById("tokens-body");
try {
const { tokens } = await api("/api/tokens");
if (!tokens.length) {
body.innerHTML = `<tr><td colspan="4" class="empty-state">No API tokens yet.</td></tr>`;
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 = `<tr><td colspan="4" class="empty-state">Could not load tokens.</td></tr>`;
}
}
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();
await Promise.all([loadSessions(), loadPlans(), loadTokens()]);
}
init();