Test and deploy / test-and-deploy (push) Successful in 1m8s
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]>
442 lines
14 KiB
JavaScript
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();
|