Files
roast_command_center/public/js/admin.js
T
snowspeederandClaude Fable 5 385474ecef
Test and deploy / test-and-deploy (push) Successful in 1m8s
Removable reference curves, audit fixes across pages, shaded Academy art
The planner's Artisan .alog drawer now shows what's attached with a
"Remove reference curve" control (re-rendered per drawer open), so adding
a reference curve is no longer a one-way door; /api/alog shares the 8 MB
body cap so real-sized logs parse instead of failing with "bad_request".

Deep-evaluation fixes: editing a brew of an archived bean no longer
silently detaches the bean; the roasts pending-review poll no longer
wipes in-progress after-roast edits; roasters gain an Edit (rename/model)
action; the gear page refuses to autosave over a failed load; duplicating
a plan carries its custom name; cupping sessions can attach a plan after
creation (ownership-checked PUT + selector); admin user deletion also
refreshes plans/audit; cupping cup-count subtitle stays live; roasts
error-row colspan corrected. Regression tests cover the new cupping PUT
and the /api/alog body cap.

Academy scenes drop the flat paper-cutout look: shared defs provide
radial-gradient shading on every bean/half-bean/particle, flame gradients
with radiant halos, soft ground shadows, and a warm-lit stage background;
fill-shift animations now ride a partial-opacity tint overlay so shading
survives the color change.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-11 16:59:28 -04:00

442 lines
14 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__";
let currentUsers = [];
let planFilterUserId = null;
let currentUserId = null;
function fmtDate(value) {
return value ? new Date(value).toLocaleString() : "—";
}
async function loadMetrics() {
const grid = document.getElementById("metrics");
try {
const { metrics } = await api("/api/admin/metrics");
const tiles = [
["Users", metrics.totalUsers],
["Plans", metrics.totalPlans],
["Plans updated (7d)", metrics.plansUpdatedLast7Days],
["Active sessions", metrics.activeSessions],
];
grid.replaceChildren(
...tiles.map(([label, value]) => {
const tile = document.createElement("div");
tile.className = "stat-tile";
tile.innerHTML = `<div class="stat-tile-label"></div><div class="stat-tile-value"></div>`;
tile.querySelector(".stat-tile-label").textContent = label;
tile.querySelector(".stat-tile-value").textContent = value;
return tile;
}),
);
} catch {
grid.textContent = "";
}
}
async function loadResetLinks() {
try {
const { links } = await api("/api/admin/password-resets");
const card = document.getElementById("reset-links-card");
if (!links.length) {
card.hidden = true;
return;
}
card.hidden = false;
document.getElementById("resets-body").replaceChildren(
...links.map((link) => {
const tr = document.createElement("tr");
const email = document.createElement("td");
email.textContent = link.email;
const url = document.createElement("td");
// Not a clickable <a>: an admin's own session immediately 302s /reset to /app,
// so the link is only useful copied out and handed to the actual user.
const copyBtn = document.createElement("button");
copyBtn.className = "ghost-btn small";
copyBtn.type = "button";
copyBtn.textContent = "Copy link";
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(link.url);
copyBtn.textContent = "Copied!";
} catch {
copyBtn.textContent = link.url;
}
setTimeout(() => (copyBtn.textContent = "Copy link"), 2000);
});
url.append(copyBtn);
const expires = document.createElement("td");
expires.textContent = fmtDate(link.expiresAt);
tr.append(email, url, expires);
return tr;
}),
);
} catch {
/* optional feature; ignore failures */
}
}
function wireSignupToggle() {
const toggle = document.getElementById("signup-toggle");
toggle.addEventListener("change", async () => {
try {
await api("/api/admin/signup-enabled", {
method: "PUT",
body: JSON.stringify({ enabled: toggle.checked }),
});
showToast(toggle.checked ? "Signups enabled." : "Signups disabled.");
} catch (error) {
toggle.checked = !toggle.checked;
showToast(error.message, "fail");
}
});
}
async function loadLlm() {
const select = document.getElementById("llm-model-select");
const note = document.getElementById("llm-model-note");
try {
const { current, models, modelsError } = await api("/api/admin/llm");
const auto = document.createElement("option");
auto.value = "";
auto.textContent = "Auto (first available model)";
select.replaceChildren(
auto,
...models.map((model) => {
const option = document.createElement("option");
option.value = model.key;
option.textContent = `${model.name} (${model.provider})`;
return option;
}),
);
// A previously-saved model that is no longer configured must still show as selected
// rather than silently displaying Auto while the server keeps trying the saved one.
if (current && !models.some((model) => model.key === current)) {
const missing = document.createElement("option");
missing.value = current;
missing.textContent = `${current} (no longer configured)`;
select.append(missing);
}
select.value = current;
note.textContent = modelsError
? "No models are configured on the server — reviews and prefill will fail until one is set up."
: current
? ""
: models.length
? `Auto currently resolves to ${models[0].name} (${models[0].provider}).`
: "";
} catch {
note.textContent = "Could not load LLM settings.";
}
}
function wireLlmSave() {
const button = document.getElementById("llm-model-save");
button.addEventListener("click", async () => {
button.disabled = true;
try {
const model = document.getElementById("llm-model-select").value;
await api("/api/admin/llm", {
method: "PUT",
body: JSON.stringify({ model }),
});
showToast(model ? "LLM model saved." : "LLM model set to auto.");
await loadLlm();
} catch (error) {
showToast(error.message, "fail");
} finally {
button.disabled = false;
}
});
}
function wireBackupImport() {
const input = document.getElementById("backup-import-file");
const note = document.getElementById("backup-note");
input.addEventListener("change", async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
let backup;
try {
backup = JSON.parse(await file.text());
} catch {
note.textContent = "That file is not valid JSON.";
return;
}
if (backup.format !== "roast-planner-backup") {
note.textContent =
"That file is not a Roast Planner backup export (expected the file downloaded by “Export full backup”).";
return;
}
const userCount = backup.tables?.users?.length ?? 0;
if (
!confirm(
`Replace the ENTIRE database with "${file.name}" (${userCount} user${userCount === 1 ? "" : "s"}, exported ${backup.exportedAt ?? "unknown date"})?\n\nEverything currently stored will be deleted. This cannot be undone.`,
)
)
return;
note.textContent = "Importing…";
try {
const result = await api("/api/admin/backup/import", {
method: "POST",
body: JSON.stringify(backup),
});
note.textContent = `Imported: ${Object.entries(result.counts)
.map(([table, count]) => `${table} ${count}`)
.join(", ")}.`;
showToast("Backup imported.");
if (!result.sessionKept) {
alert("The restored data does not include your current session's account — log in again with the restored credentials.");
location.assign("/login");
return;
}
await Promise.all([loadMetrics(), loadUsers(), loadPlans(), loadAudit()]);
} catch (error) {
note.textContent = `Import failed: ${error.message}. Nothing was changed.`;
showToast(error.message, "fail");
}
});
}
function roleBadge(user) {
return user.role === "admin"
? `<span class="badge badge-admin">Admin</span>`
: `<span class="badge badge-user">User</span>`;
}
function statusBadge(user) {
return user.disabled_at
? `<span class="badge badge-disabled">Disabled</span>`
: `<span class="badge badge-current">Active</span>`;
}
async function loadUsers() {
const body = document.getElementById("users-body");
try {
const { users, signupEnabled } = await api("/api/admin/users");
currentUsers = users;
document.getElementById("signup-toggle").checked = signupEnabled;
renderUsers();
} catch {
body.innerHTML = `<tr><td colspan="6" class="empty-state">Could not load users.</td></tr>`;
}
}
function renderUsers() {
const body = document.getElementById("users-body");
const query = document
.getElementById("user-search")
.value.trim()
.toLowerCase();
const filtered = query
? currentUsers.filter((u) => u.email.toLowerCase().includes(query))
: currentUsers;
if (!filtered.length) {
body.innerHTML = `<tr><td colspan="6" class="empty-state">No matching users.</td></tr>`;
return;
}
body.replaceChildren(
...filtered.map((user) => {
const tr = document.createElement("tr");
const email = document.createElement("td");
email.textContent = user.email;
const role = document.createElement("td");
role.innerHTML = roleBadge(user);
const status = document.createElement("td");
status.innerHTML = statusBadge(user);
const count = document.createElement("td");
count.className = "num";
count.textContent = user.plan_count;
const joined = document.createElement("td");
joined.textContent = fmtDate(user.created_at);
const actions = document.createElement("td");
actions.className = "data-table-actions";
const isBootstrapAdmin = user.email === "[email protected]";
const isSelf = user.id === currentUserId;
const viewPlans = document.createElement("button");
viewPlans.className = "ghost-btn small";
viewPlans.type = "button";
viewPlans.textContent = "View plans";
viewPlans.addEventListener("click", () => filterPlansByUser(user));
actions.append(viewPlans);
// The server refuses role/disable/delete on the bootstrap admin and on your own
// account (so an admin can never lock themselves out) — don't offer buttons the
// server will always reject.
if (!isBootstrapAdmin && !isSelf) {
const roleBtn = document.createElement("button");
roleBtn.className = "ghost-btn small";
roleBtn.type = "button";
const promoting = user.role !== "admin";
roleBtn.textContent = promoting ? "Promote" : "Demote";
roleBtn.addEventListener("click", async () => {
if (
promoting &&
!confirm(
`Grant ${user.email} full admin access, including user management and password-reset links?`,
)
)
return;
try {
await api(`/api/admin/users/${user.id}/role`, {
method: "PUT",
body: JSON.stringify({
role: promoting ? "admin" : "user",
}),
});
showToast("Role updated.");
loadUsers();
} catch (error) {
showToast(error.message, "fail");
}
});
const disableBtn = document.createElement("button");
disableBtn.className = "ghost-btn small";
disableBtn.type = "button";
const disabling = !user.disabled_at;
disableBtn.textContent = disabling ? "Disable" : "Enable";
disableBtn.addEventListener("click", async () => {
if (
disabling &&
!confirm(`Disable ${user.email}? This signs them out everywhere immediately.`)
)
return;
try {
await api(`/api/admin/users/${user.id}/disabled`, {
method: "PUT",
body: JSON.stringify({ disabled: disabling }),
});
showToast(disabling ? "User disabled." : "User enabled.");
loadUsers();
} catch (error) {
showToast(error.message, "fail");
}
});
const deleteBtn = document.createElement("button");
deleteBtn.className = "ghost-btn small";
deleteBtn.type = "button";
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", async () => {
if (
!confirm(
`Permanently delete ${user.email} and all of their plans?`,
)
)
return;
try {
await api(`/api/admin/users/${user.id}`, { method: "DELETE" });
showToast("User deleted.");
loadUsers();
loadMetrics();
loadPlans(); // their plans/audit rows are gone too
loadAudit();
} catch (error) {
showToast(error.message, "fail");
}
});
actions.append(roleBtn, disableBtn, deleteBtn);
}
tr.append(email, role, status, count, joined, actions);
return tr;
}),
);
}
async function loadPlans() {
const body = document.getElementById("plans-body");
try {
const url = planFilterUserId
? `/api/admin/plans?user=${encodeURIComponent(planFilterUserId)}`
: "/api/admin/plans";
const { plans } = await api(url);
if (!plans.length) {
body.innerHTML = `<tr><td colspan="3" class="empty-state">No plans.</td></tr>`;
return;
}
body.replaceChildren(
...plans.map((plan) => {
const tr = document.createElement("tr");
const owner = document.createElement("td");
owner.textContent = plan.email;
const title = document.createElement("td");
title.textContent = plan.title || "Untitled plan";
const updated = document.createElement("td");
updated.textContent = fmtDate(plan.updated_at);
tr.append(owner, title, updated);
return tr;
}),
);
} catch {
body.innerHTML = `<tr><td colspan="3" class="empty-state">Could not load plans.</td></tr>`;
}
}
function filterPlansByUser(user) {
planFilterUserId = user.id;
document.getElementById("clear-plan-filter").classList.remove("hidden");
document.getElementById("plans").scrollIntoView({ behavior: "smooth" });
loadPlans();
}
async function loadAudit() {
const body = document.getElementById("audit-body");
try {
const { events } = await api("/api/admin/audit");
if (!events.length) {
body.innerHTML = `<tr><td colspan="4" class="empty-state">No activity yet.</td></tr>`;
return;
}
body.replaceChildren(
...events.map((event) => {
const tr = document.createElement("tr");
const when = document.createElement("td");
when.textContent = fmtDate(event.created_at);
const actor = document.createElement("td");
actor.textContent = event.actor_email || "—";
const action = document.createElement("td");
action.textContent = event.action.replaceAll("_", " ");
const target = document.createElement("td");
target.textContent = event.target || "—";
tr.append(when, actor, action, target);
return tr;
}),
);
} catch {
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load activity.</td></tr>`;
}
}
document.getElementById("btn-logout").addEventListener("click", async () => {
await protectedFetch("/api/auth/logout", { method: "POST" });
location.assign("/");
});
document.getElementById("user-search").addEventListener("input", renderUsers);
document.getElementById("clear-plan-filter").addEventListener("click", () => {
planFilterUserId = null;
document.getElementById("clear-plan-filter").classList.add("hidden");
loadPlans();
});
async function init() {
initSideNav();
const user = await loadNavUser();
if (!user) return;
currentUserId = user.id;
wireSignupToggle();
wireLlmSave();
wireBackupImport();
await Promise.all([
loadMetrics(),
loadResetLinks(),
loadUsers(),
loadPlans(),
loadAudit(),
loadLlm(),
]);
}
init();