330 lines
10 KiB
JavaScript
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();
|