import { api, protectedFetch } from "./api.js"; import { initSideNav, loadNavUser } from "./nav.js"; import { showToast } from "./toast.js"; 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 = `
`; 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 : 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" ? `Admin` : `User`; } function statusBadge(user) { return user.disabled_at ? `Disabled` : `Active`; } 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 = `Could not load users.`; } } 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 = `No matching users.`; 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 === "snowspeeder@gmail.com"; 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 = `No plans.`; 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 = `Could not load plans.`; } } 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 = `No activity yet.`; 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 = `Could not load activity.`; } } 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();