Files
roast_command_center/public/js/admin.js
T

330 lines
10 KiB
JavaScript

import { api, protectedFetch } from "./api.js?v=20260730-release2";
import { initSideNav, loadNavUser } from "./nav.js?v=20260730-release2";
import { showToast } from "./toast.js?v=20260730-release2";
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");
}
});
}
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;
}),
);
}
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();
await Promise.all([
loadMetrics(),
loadResetLinks(),
loadUsers(),
loadPlans(),
loadAudit(),
]);
}
init();