Add green-bean inventory and cupping features
Ports inventory management and SCA-style cupping scoring from hope_roaster: lot tracking with audit-logged consumption, cupping sessions with server-authoritative scoring and a live radar chart, and links from the planner (draw-from-lot, open-cupping-session). Also fixes the Blend/Single-origin toggle layout, replaces tooltips with an in-context "why" teaching layer, and stages the planner UI into pre-roast vs. post-roast phases.
This commit is contained in:
+321
-39
@@ -1,47 +1,329 @@
|
||||
const csrf = () =>
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((value) => value.startsWith("rp_csrf="))
|
||||
?.split("=")[1] || "";
|
||||
const users = document.querySelector("#users");
|
||||
const plans = document.querySelector("#plans");
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { initSideNav, loadNavUser } from "./nav.js";
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
async function load() {
|
||||
const usersResponse = await fetch("/api/admin/users");
|
||||
if (!usersResponse.ok) return;
|
||||
const body = await usersResponse.json();
|
||||
document.querySelector("#signup").checked = body.signupEnabled;
|
||||
users.replaceChildren(
|
||||
...body.users.map((user) =>
|
||||
Object.assign(document.createElement("li"), {
|
||||
textContent: `${user.email} (${user.role}) — ${user.plan_count} plans`,
|
||||
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;
|
||||
}),
|
||||
),
|
||||
);
|
||||
const plansResponse = await fetch("/api/admin/plans");
|
||||
if (!plansResponse.ok) return;
|
||||
const plansBody = await plansResponse.json();
|
||||
plans.replaceChildren(
|
||||
...plansBody.plans.map((plan) =>
|
||||
Object.assign(document.createElement("li"), {
|
||||
textContent: `${plan.email}: ${plan.plan?.fields?.["0.1"] || "Untitled plan"}`,
|
||||
);
|
||||
} 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
actions.append(roleBtn, disableBtn, deleteBtn);
|
||||
}
|
||||
|
||||
tr.append(email, role, status, count, joined, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
document.querySelector("#save").addEventListener("click", async () => {
|
||||
await fetch("/api/admin/signup-enabled", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-csrf-token": csrf(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: document.querySelector("#signup").checked,
|
||||
}),
|
||||
});
|
||||
await load();
|
||||
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();
|
||||
});
|
||||
|
||||
load();
|
||||
async function init() {
|
||||
initSideNav();
|
||||
const user = await loadNavUser();
|
||||
if (!user) return;
|
||||
currentUserId = user.id;
|
||||
wireSignupToggle();
|
||||
await Promise.all([
|
||||
loadMetrics(),
|
||||
loadResetLinks(),
|
||||
loadUsers(),
|
||||
loadPlans(),
|
||||
loadAudit(),
|
||||
]);
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
Reference in New Issue
Block a user