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 = `
`; 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"); } }); } 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 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(); wireLlmSave(); await Promise.all([ loadMetrics(), loadResetLinks(), loadUsers(), loadPlans(), loadAudit(), loadLlm(), ]); } init();