/* admin.jsx * * Admin-only views. Currently just the cert verification review queue. * * Exposes: * window.AdminCertReviewsPage — the cert review queue * * Auth: the page itself does a client-side check on user.is_admin. The * backend endpoints ALSO check, so a non-admin who guesses the URL gets * 403 from the API even if they bypass the client check. * * Layout: a single list of pending reviews by default, with a filter to * see approved/rejected/all. Each row shows: * - Who declared (email, username) * - What they declared (cert id, issuer, verification id, custom name) * - PDF view link * - Approve / Reject buttons (with notes textarea on reject) */ (function () { "use strict"; const e = React.createElement; function fmtDate(iso) { if (!iso) return "—"; try { return new Date(iso).toLocaleString(); } catch (_) { return iso; } } function ReviewRow({ item, onAction }) { const [notes, setNotes] = React.useState(""); const [showNotes, setShowNotes] = React.useState(false); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(""); async function viewPdf() { try { const resp = await fetch(`/api/user-certs/${encodeURIComponent(item.owned_cert_id)}/pdf`, { headers: { Authorization: `Bearer ${window.AuthStore.token}` }, }); if (!resp.ok) throw new Error(await resp.text() || resp.statusText); const blob = await resp.blob(); const url = URL.createObjectURL(blob); window.open(url, "_blank"); setTimeout(() => URL.revokeObjectURL(url), 60000); } catch (e2) { setErr("Could not load PDF: " + (e2.message || e2)); } } async function decide(action) { if (action === "reject" && !showNotes) { setShowNotes(true); return; } setBusy(true); setErr(""); try { await window.AuthStore.api(`/api/admin/cert-reviews/${encodeURIComponent(item.owned_cert_id)}`, { method: "POST", body: JSON.stringify({ action, notes }), }); onAction && onAction(item.owned_cert_id); } catch (e2) { setErr(e2.message || String(e2)); } finally { setBusy(false); } } const certLabel = (window.CERT_META?.[item.declared_cert_id]?.label) || item.declared_custom_name || item.declared_cert_id || "(unknown cert)"; return e("div", { className: "adm-row" }, e("div", { className: "adm-row-meta" }, e("div", { className: "adm-row-title" }, certLabel, item.declared_cert_id === "other" && e("span", { className: "adm-other" }, " (custom)") ), e("div", { className: "adm-row-userline" }, item.user_email, " · ", e("span", { className: "adm-mono" }, "@", item.user_username), " · ", "uploaded ", fmtDate(item.pdf_uploaded_at) ) ), e("div", { className: "adm-row-declared" }, item.declared_issuer && e("div", null, e("span", { className: "adm-key" }, "Issuer: "), item.declared_issuer), item.declared_verification_id && e("div", null, e("span", { className: "adm-key" }, "Verification ID: "), e("span", { className: "adm-mono" }, item.declared_verification_id) ) ), e("div", { className: "adm-row-actions" }, e("button", { className: "btn btn-ghost btn-sm", onClick: viewPdf }, "View PDF"), e("button", { className: "btn btn-primary btn-sm", onClick: () => decide("approve"), disabled: busy }, "Approve"), e("button", { className: "btn btn-danger btn-sm", onClick: () => decide("reject"), disabled: busy }, showNotes ? "Confirm reject" : "Reject"), ), showNotes && e("div", { className: "adm-row-notes" }, e("textarea", { className: "adm-notes-input", placeholder: "Reason for rejection (visible to user)…", value: notes, onChange: ev => setNotes(ev.target.value), maxLength: 2000, rows: 3, }), e("div", { className: "adm-notes-hint" }, "Click Confirm reject above to submit. Cancel:", " ", e("button", { className: "pf-link-btn", onClick: () => { setShowNotes(false); setNotes(""); } }, "discard") ) ), err && e("div", { className: "adm-row-err" }, err) ); } // ────────────────────────────────────────────────────────── // ReviewQueueTab — the existing review queue, extracted into a tab // ────────────────────────────────────────────────────────── function ReviewQueueTab() { const [status, setStatus] = React.useState("pending"); const [items, setItems] = React.useState([]); const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(""); async function load() { setLoading(true); setErr(""); try { const data = await window.AuthStore.api(`/api/admin/cert-reviews?status=${encodeURIComponent(status)}`); setItems(Array.isArray(data) ? data : []); } catch (e2) { setErr(e2.message || String(e2)); } finally { setLoading(false); } } React.useEffect(() => { load(); }, [status]); function handleAction(ownedCertId) { setItems(items.filter(i => i.owned_cert_id !== ownedCertId)); } return e("div", null, e("div", { className: "adm-filter" }, ["pending", "approved", "rejected", "all"].map(s => e("button", { key: s, className: `adm-filter-btn ${status === s ? "active" : ""}`, onClick: () => setStatus(s), }, s[0].toUpperCase() + s.slice(1)) ) ), err && e("div", { className: "pf-banner-error" }, err), loading && e("div", { className: "adm-loading" }, "Loading…"), !loading && items.length === 0 && e("div", { className: "pf-empty" }, e("div", { className: "pf-empty-title" }, "No ", status, " reviews"), e("p", { className: "pf-empty-body" }, status === "pending" ? "Inbox zero. Nothing waiting for review." : "Nothing in this bucket yet.") ), !loading && items.length > 0 && e("div", { className: "adm-list" }, items.map(it => e(ReviewRow, { key: it.owned_cert_id, item: it, onAction: handleAction, })) ) ); } // ────────────────────────────────────────────────────────── // CustomCertsTab — aggregated demand for certs we don't yet teach // ────────────────────────────────────────────────────────── function CustomCertsTab() { const [items, setItems] = React.useState([]); const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(""); async function load() { setLoading(true); setErr(""); try { const data = await window.AuthStore.api("/api/admin/custom-certs"); setItems(Array.isArray(data) ? data : []); } catch (e2) { setErr(e2.message || String(e2)); } finally { setLoading(false); } } React.useEffect(() => { load(); }, []); const total = items.reduce((acc, r) => acc + r.count, 0); return e("div", null, e("p", { className: "adm-tab-blurb" }, "Names users entered when picking \"Other\" in the cert picker. Sorted by frequency — high-count entries are good candidates for new study tracks." ), err && e("div", { className: "pf-banner-error" }, err), loading && e("div", { className: "adm-loading" }, "Loading…"), !loading && items.length === 0 && e("div", { className: "pf-empty" }, e("div", { className: "pf-empty-title" }, "No custom certs yet"), e("p", { className: "pf-empty-body" }, "When users add a cert that isn't in our picker (using the \"Other\" option), it'll show up here so you can see what to build next.") ), !loading && items.length > 0 && e("div", { className: "adm-custom-wrap" }, e("div", { className: "adm-custom-summary" }, e("span", null, total, " total \"Other\" entries across ", items.length, " unique cert name", items.length === 1 ? "" : "s") ), e("table", { className: "adm-custom-table" }, e("thead", null, e("tr", null, e("th", null, "Cert name"), e("th", { className: "adm-tnum" }, "Count"), e("th", null, "Issuers users entered"), e("th", null, "Sample users") ) ), e("tbody", null, items.map((r, i) => e("tr", { key: i }, e("td", { className: "adm-custom-name" }, r.name), e("td", { className: "adm-tnum" }, r.count), e("td", { className: "adm-custom-issuers" }, r.sample_issuers.length > 0 ? r.sample_issuers.join(", ") : e("span", { className: "adm-muted" }, "—") ), e("td", { className: "adm-custom-users" }, r.sample_users.map((u, j) => e("span", { key: j, className: "adm-mono adm-pill" }, "@", u) ) ) )) ) ) ) ); } // ────────────────────────────────────────────────────────── // AdminCertReviewsPage — tab container // ────────────────────────────────────────────────────────── function AdminCertReviewsBody() { // The actual cert-review UI (tabs + tab content). No .adm-page wrapper // — caller is responsible for that. Auth check still here so the // standalone /admin/cert-reviews page and the dispatcher-wrapped one // both behave identically. const user = window.AuthStore && window.AuthStore.user; const [tab, setTab] = React.useState("queue"); if (!user) { return e("h1", { className: "adm-page-title" }, "Sign in required"); } if (!user.is_admin) { return e(React.Fragment, null, e("h1", { className: "adm-page-title" }, "Forbidden"), e("p", null, "Admin access is required to view this page.") ); } return e(React.Fragment, null, e("div", { className: "adm-page-head" }, e("h1", { className: "adm-page-title" }, "Cert Admin"), e("p", { className: "adm-page-sub" }, tab === "queue" ? "Review uploaded certifications. Approving marks a user's cert as ✓ Verified." : "See what certs users want that aren't in our picker yet.") ), e("div", { className: "adm-tabs" }, e("button", { className: `adm-tabbtn ${tab === "queue" ? "active" : ""}`, onClick: () => setTab("queue") }, "Review queue"), e("button", { className: `adm-tabbtn ${tab === "custom" ? "active" : ""}`, onClick: () => setTab("custom") }, "Custom certs"), ), tab === "queue" && e(ReviewQueueTab), tab === "custom" && e(CustomCertsTab) ); } // Standalone version, kept exported as window.AdminCertReviewsPage for // any callers that still bypass the admin dispatcher. This is the // original public API; behavior unchanged. function AdminCertReviewsPage() { return e("div", { className: "adm-page" }, e(AdminCertReviewsBody) ); } // ═══════════════════════════════════════════════════════════════════════ // V1 ADMIN PANEL // ═══════════════════════════════════════════════════════════════════════ // Five pages: dashboard, users, user detail, requests, audit log. // Each is a function component below; AdminPanel dispatches based on the // window.Router.currentAdminRoute() return value. // // Shared concerns: // - All routes do a client-side is_admin check up top (UX guard only). // - All fetches go through window.AuthStore.api so the bearer token // gets attached automatically. // - Toast errors via setError local state per page; no global error bus. function adminApi(path, opts) { return window.AuthStore.api(path, opts || {}); } function fmtRelative(iso) { if (!iso) return "—"; try { const d = new Date(iso); const diff = (Date.now() - d.getTime()) / 1000; if (diff < 60) return Math.round(diff) + "s ago"; if (diff < 3600) return Math.round(diff / 60) + "m ago"; if (diff < 86400) return Math.round(diff / 3600) + "h ago"; if (diff < 604800) return Math.round(diff / 86400) + "d ago"; return d.toLocaleDateString(); } catch (_) { return iso; } } // Premium expiry helpers — kept module-scope so the user-detail render // can use them inline without re-defining them per render. function _isPremiumExpired(iso) { if (!iso) return false; try { return new Date(iso).getTime() < Date.now(); } catch (_) { return false; } } function _expiryLabel(iso) { if (!iso) return ""; try { const d = new Date(iso); const diff = (d.getTime() - Date.now()) / 1000; // seconds until expiry; negative = past if (diff < 0) { const past = -diff; if (past < 3600) return "Expired " + Math.round(past / 60) + "m ago"; if (past < 86400) return "Expired " + Math.round(past / 3600) + "h ago"; return "Expired " + Math.round(past / 86400) + "d ago"; } if (diff < 3600) return "in " + Math.round(diff / 60) + "m"; if (diff < 86400) return "in " + Math.round(diff / 3600) + "h"; if (diff < 86400 * 60) return "in " + Math.round(diff / 86400) + " days"; return "in " + Math.round(diff / 86400 / 30) + " months"; } catch (_) { return ""; } } // Modal: prompts the admin to choose "Until revoked" or "N days" before // granting premium. Used for both initial grant AND extending/changing // expiration on an already-premium user (the backend allows re-grant). // Quick-pick chips for common durations + a custom input for arbitrary // day counts. Keyboard-friendly: Enter submits, Esc closes. function GrantPremiumModal({ user, busy, onClose, onConfirm }) { const isExtending = !!user.is_premium; // mode: "perpetual" | "duration" const [mode, setMode] = React.useState(isExtending && user.premium_expires_at ? "duration" : "perpetual"); const [days, setDays] = React.useState(30); const [err, setErr] = React.useState(""); const QUICK_PRESETS = [ { label: "7 days", days: 7 }, { label: "30 days", days: 30 }, { label: "90 days", days: 90 }, { label: "1 year", days: 365 }, ]; // Esc to close. Single binding effect. React.useEffect(() => { const onKey = (ev) => { if (ev.key === "Escape" && !busy) onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [busy, onClose]); function submit() { setErr(""); if (mode === "perpetual") { return onConfirm(null); } const n = Number(days); if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) { setErr("Enter a positive whole number of days."); return; } onConfirm(n); } // Friendly preview of when the premium would expire. const preview = (() => { if (mode === "perpetual") return "No expiration. Premium lasts until manually revoked."; const n = Number(days); if (!Number.isFinite(n) || n < 1) return "Enter a number of days."; const expires = new Date(Date.now() + n * 86400000); return "Premium will expire on " + expires.toLocaleString() + "."; })(); return e("div", { className: "adm-modal-backdrop", onClick: (ev) => { if (ev.target.classList.contains("adm-modal-backdrop") && !busy) onClose(); } }, e("div", { className: "adm-modal", role: "dialog", "aria-labelledby": "gpm-title" }, e("div", { className: "adm-modal-head" }, e("h2", { id: "gpm-title", className: "adm-modal-title" }, isExtending ? "Change premium expiration" : "Grant premium" ), e("button", { className: "adm-modal-close", onClick: onClose, "aria-label": "Close", disabled: busy }, "×") ), e("div", { className: "adm-modal-body" }, e("p", { className: "adm-modal-help" }, "Granting to ", e("strong", null, user.email), "."), // Mode selector — two big radio-like buttons. e("div", { className: "adm-mode-row" }, e("button", { className: "adm-mode-btn " + (mode === "perpetual" ? "active" : ""), onClick: () => setMode("perpetual"), disabled: busy, }, e("div", { className: "adm-mode-btn-title" }, "Until revoked"), e("div", { className: "adm-mode-btn-sub" }, "Perpetual. No auto-expiry."), ), e("button", { className: "adm-mode-btn " + (mode === "duration" ? "active" : ""), onClick: () => setMode("duration"), disabled: busy, }, e("div", { className: "adm-mode-btn-title" }, "For a duration"), e("div", { className: "adm-mode-btn-sub" }, "Choose number of days."), ), ), mode === "duration" && e("div", { className: "adm-duration-block" }, e("div", { className: "adm-preset-row" }, QUICK_PRESETS.map(p => e("button", { key: p.days, className: "btn btn-ghost btn-sm", onClick: () => setDays(p.days), disabled: busy, }, p.label)) ), e("div", { className: "adm-days-input-row" }, e("input", { type: "number", min: 1, step: 1, className: "adm-days-input", value: days, onChange: (ev) => setDays(ev.target.value), onKeyDown: (ev) => { if (ev.key === "Enter") submit(); }, disabled: busy, "aria-label": "Number of days", }), e("span", { className: "adm-days-suffix" }, "days"), ), ), e("p", { className: "adm-modal-preview" }, preview), err && e("div", { className: "adm-toast error" }, err), ), e("div", { className: "adm-modal-foot" }, e("button", { className: "btn btn-ghost", onClick: onClose, disabled: busy }, "Cancel"), e("button", { className: "btn btn-primary", onClick: submit, disabled: busy }, busy ? "Granting…" : (isExtending ? "Update expiration" : "Grant premium") ), ) ) ); } // ─── Shared admin layout: nav strip across the top of each page ──────── function AdminNav({ active }) { const items = [ ["dashboard", "Dashboard", "/admin"], ["users", "Users", "/admin/users"], ["requests", "Requests", "/admin/requests"], ["cert-reviews", "Cert Reviews", "/admin/cert-reviews"], ["spotlights", "Spotlights", "/admin/spotlights"], ["audit", "Audit Log", "/admin/audit"], ]; return e("nav", { className: "adm-nav" }, items.map(([k, label, url]) => e("a", { key: k, className: "adm-nav-link " + (active === k ? "active" : ""), href: url, onClick: (ev) => { ev.preventDefault(); // Use history pushState directly to avoid full page reload window.history.pushState({}, "", url); window.dispatchEvent(new PopStateEvent("popstate")); }, }, label) ) ); } // ─── Dashboard ───────────────────────────────────────────────────────── function AdminDashboard() { const [data, setData] = React.useState(null); const [err, setErr] = React.useState(""); React.useEffect(() => { let cancelled = false; adminApi("/api/admin/dashboard") .then(d => { if (!cancelled) setData(d); }) .catch(ex => { if (!cancelled) setErr(ex.message || "Failed to load."); }); return () => { cancelled = true; }; }, []); return e("div", { className: "adm-page" }, e(AdminNav, { active: "dashboard" }), e("h1", { className: "adm-page-title" }, "Admin Dashboard"), err && e("div", { className: "adm-error" }, err), !data && !err && e("div", { className: "adm-loading" }, "Loading…"), data && e("div", { className: "adm-tile-grid" }, e(StatTile, { label: "Total active users", value: data.total_users }), e(StatTile, { label: "Soft-deleted users", value: data.deleted_users }), e(StatTile, { label: "Admins", value: data.admins }), e(StatTile, { label: "Premium users", value: data.premium_users }), e(StatTile, { label: "Signups, last 7d", value: data.signups_7d }), e(StatTile, { label: "Signups, last 30d", value: data.signups_30d }), e(StatTile, { label: "Pending cert reviews", value: data.pending_cert_reviews, href: data.pending_cert_reviews > 0 ? "/admin/cert-reviews" : null }), e(StatTile, { label: "Cert content requests (total)", value: data.cert_request_total, sub: data.cert_request_unique_certs + " unique certs requested", href: data.cert_request_total > 0 ? "/admin/requests" : null }), ) ); } function StatTile({ label, value, sub, href }) { const inner = e("div", { className: "adm-tile" }, e("div", { className: "adm-tile-label" }, label), e("div", { className: "adm-tile-value" }, value), sub && e("div", { className: "adm-tile-sub" }, sub) ); if (!href) return inner; return e("a", { className: "adm-tile-link", href, onClick: (ev) => { ev.preventDefault(); window.history.pushState({}, "", href); window.dispatchEvent(new PopStateEvent("popstate")); } }, inner); } // ─── Users list ──────────────────────────────────────────────────────── function AdminUsers() { const [q, setQ] = React.useState(""); const [page, setPage] = React.useState(1); const [filterKind, setFilterKind] = React.useState("active"); const [data, setData] = React.useState(null); const [err, setErr] = React.useState(""); const [loading, setLoading] = React.useState(true); // Debounced search — wait 300ms after typing stops before firing the query. // Stops us from hammering the API while user is mid-type. React.useEffect(() => { let cancelled = false; setLoading(true); const t = setTimeout(() => { const params = new URLSearchParams(); if (q) params.set("q", q); params.set("page", String(page)); params.set("filter_kind", filterKind); adminApi("/api/admin/users?" + params.toString()) .then(d => { if (!cancelled) { setData(d); setLoading(false); } }) .catch(ex => { if (!cancelled) { setErr(ex.message || "Failed."); setLoading(false); } }); }, 300); return () => { cancelled = true; clearTimeout(t); }; }, [q, page, filterKind]); function openUser(id) { window.history.pushState({}, "", "/admin/user/" + id); window.dispatchEvent(new PopStateEvent("popstate")); } return e("div", { className: "adm-page" }, e(AdminNav, { active: "users" }), e("h1", { className: "adm-page-title" }, "Users"), e("div", { className: "adm-filters" }, e("input", { className: "adm-search", type: "search", placeholder: "Search by email, username, or name…", value: q, onChange: (ev) => { setQ(ev.target.value); setPage(1); }, }), e("select", { className: "adm-filter-select", value: filterKind, onChange: (ev) => { setFilterKind(ev.target.value); setPage(1); }, }, e("option", { value: "active" }, "Active only"), e("option", { value: "deleted" }, "Soft-deleted only"), e("option", { value: "admin" }, "Admins only"), e("option", { value: "premium" }, "Premium only"), e("option", { value: "all" }, "All (incl. deleted)"), ) ), err && e("div", { className: "adm-error" }, err), data && e(React.Fragment, null, e("div", { className: "adm-table-meta" }, loading ? "Loading…" : `${data.total} user${data.total === 1 ? "" : "s"} • page ${data.page}` ), e("div", { className: "adm-table-wrap" }, e("table", { className: "adm-table" }, e("thead", null, e("tr", null, e("th", null, "Email"), e("th", null, "Name"), e("th", null, "Tier"), e("th", null, "Created"), e("th", null, "Last login"), e("th", null, "Status"), )), e("tbody", null, data.users.map(u => e("tr", { key: u.id, className: "adm-table-row" + (u.deleted_at ? " deleted" : ""), onClick: () => openUser(u.id), }, e("td", null, u.email), e("td", null, u.name), e("td", null, u.is_superuser && e("span", { className: "adm-tier adm-tier-su" }, "Superuser"), !u.is_superuser && u.is_admin && e("span", { className: "adm-tier adm-tier-admin" }, "Admin"), u.is_premium && e("span", { className: "adm-tier adm-tier-premium" }, "Premium"), !u.is_admin && !u.is_premium && !u.is_superuser && e("span", { className: "adm-tier-none" }, "—"), ), e("td", null, fmtRelative(u.created_at)), e("td", null, fmtRelative(u.last_login)), e("td", null, u.deleted_at ? e("span", { className: "adm-status-del" }, "Deleted") : e("span", { className: "adm-status-ok" }, "Active")), )) ) ) ), // Simple pagination e("div", { className: "adm-pager" }, e("button", { className: "btn btn-ghost btn-sm", disabled: page <= 1, onClick: () => setPage(page - 1) }, "← Prev"), e("span", { className: "adm-pager-info" }, `Page ${page} of ${Math.max(1, Math.ceil(data.total / data.page_size))}`), e("button", { className: "btn btn-ghost btn-sm", disabled: page * data.page_size >= data.total, onClick: () => setPage(page + 1) }, "Next →"), ) ) ); } // ─── User detail ─────────────────────────────────────────────────────── function AdminUserDetail({ userId }) { const [user, setUser] = React.useState(null); const [err, setErr] = React.useState(""); const [busy, setBusy] = React.useState(false); const [toast, setToast] = React.useState(""); const me = window.AuthStore && window.AuthStore.user; function refresh() { adminApi("/api/admin/users/" + userId) .then(setUser) .catch(ex => setErr(ex.message || "Failed.")); } React.useEffect(() => { refresh(); }, [userId]); // Grant-premium modal state. Null = closed; { defaultMode } = open. // Two modes the user can choose: "perpetual" (until revoked) and // "duration" (N days). When already-premium, the modal is also used // to extend/change the expiration without revoking first. const [grantPremiumOpen, setGrantPremiumOpen] = React.useState(false); async function doAction(path, confirmMsg, body) { if (confirmMsg && !window.confirm(confirmMsg)) return; setBusy(true); try { // POST with body if provided. Server endpoints that take no body // tolerate a null body via FastAPI/pydantic optional models, so // we always JSON.stringify when body is supplied and let the // server parse. const opts = { method: "POST" }; if (body !== undefined) { opts.body = JSON.stringify(body); } await adminApi(path, opts); setToast("Done."); setTimeout(() => setToast(""), 2500); refresh(); } catch (ex) { setErr(ex.message || "Action failed."); setTimeout(() => setErr(""), 4000); } finally { setBusy(false); } } if (err && !user) { return e("div", { className: "adm-page" }, e(AdminNav, { active: "users" }), e("div", { className: "adm-error" }, err)); } if (!user) { return e("div", { className: "adm-page" }, e(AdminNav, { active: "users" }), e("div", { className: "adm-loading" }, "Loading…")); } return e("div", { className: "adm-page" }, e(AdminNav, { active: "users" }), e("button", { className: "btn btn-ghost btn-sm adm-back", onClick: () => { window.history.pushState({}, "", "/admin/users"); window.dispatchEvent(new PopStateEvent("popstate")); }, }, "← Back to users"), toast && e("div", { className: "adm-toast ok" }, toast), err && e("div", { className: "adm-toast error" }, err), e("div", { className: "adm-user-head" }, e("h1", { className: "adm-page-title" }, user.name), e("div", { className: "adm-user-meta" }, e("div", null, e("strong", null, "Email: "), user.email), e("div", null, e("strong", null, "Username: "), user.username), e("div", null, e("strong", null, "ID: "), user.id), e("div", null, e("strong", null, "Created: "), fmtDate(user.created_at)), e("div", null, e("strong", null, "Last login: "), fmtDate(user.last_login)), user.deleted_at && e("div", { className: "adm-deleted-warn" }, e("strong", null, "⚠ Soft-deleted: "), fmtDate(user.deleted_at) ), ), e("div", { className: "adm-tier-badges" }, user.is_superuser && e("span", { className: "adm-tier adm-tier-su" }, "Superuser"), user.is_admin && e("span", { className: "adm-tier adm-tier-admin" }, "Admin"), user.is_premium && e("span", { className: "adm-tier adm-tier-premium" }, "Premium"), user.has_password && e("span", { className: "adm-tier-auth" }, "Password"), user.has_google && e("span", { className: "adm-tier-auth" }, "Google"), ) ), // Action panel e("section", { className: "adm-section" }, e("h2", { className: "adm-h2" }, "Actions"), e("div", { className: "adm-actions" }, // Premium grant/revoke. When NOT premium → green "Grant" opens // the modal that asks for duration. When ALREADY premium → grey // "Extend / change" reopens the same modal to set a new expiry // (server allows re-grant on an already-premium user). Revoke // is a separate destructive button. !user.is_premium && e("button", { className: "btn btn-primary btn-sm", disabled: busy, onClick: () => setGrantPremiumOpen(true), }, "Grant premium"), user.is_premium && e(React.Fragment, null, e("button", { className: "btn btn-ghost btn-sm", disabled: busy, onClick: () => setGrantPremiumOpen(true), }, "Extend / change expiry"), e("button", { className: "btn btn-ghost btn-sm adm-danger", disabled: busy, onClick: () => doAction(`/api/admin/users/${user.id}/revoke-premium`, "Revoke premium for " + user.email + "?"), }, "Revoke premium"), ), user.deleted_at ? e("button", { className: "btn btn-primary btn-sm", disabled: busy, onClick: () => doAction(`/api/admin/users/${user.id}/restore`, "Restore " + user.email + "? A password reset email will be sent.") }, "Restore account") : user.id !== (me && me.id) && !user.is_superuser && e("button", { className: "btn btn-ghost btn-sm adm-danger", disabled: busy, onClick: () => doAction(`/api/admin/users/${user.id}/soft-delete`, "Soft-delete " + user.email + "? They'll have 30 days before purge.") }, "Soft-delete"), !user.deleted_at && user.has_password && e("button", { className: "btn btn-ghost btn-sm", disabled: busy, onClick: () => doAction(`/api/admin/users/${user.id}/send-password-reset`, "Send password reset email to " + user.email + "?") }, "Send password reset"), // Superuser-only admin grant/revoke me && me.is_superuser && !user.deleted_at && ( user.is_admin ? !user.is_superuser && user.id !== me.id && e("button", { className: "btn btn-ghost btn-sm adm-danger", disabled: busy, onClick: () => doAction(`/api/admin/users/${user.id}/revoke-admin`, "Revoke admin from " + user.email + "?") }, "Revoke admin") : e("button", { className: "btn btn-primary btn-sm", disabled: busy, onClick: () => doAction(`/api/admin/users/${user.id}/grant-admin`, "Grant admin to " + user.email + "?") }, "Grant admin") ), ) ), // Grant-premium modal. Open when grantPremiumOpen=true. On submit, // sends { days: N | null } to the grant-premium endpoint and closes. grantPremiumOpen && e(GrantPremiumModal, { user, busy, onClose: () => setGrantPremiumOpen(false), onConfirm: async (days) => { await doAction( `/api/admin/users/${user.id}/grant-premium`, null, // skip native confirm — modal IS the confirm { days: days } // null = perpetual, number = N days ); setGrantPremiumOpen(false); }, }), // Premium grant metadata. Now also shows expiry info — with a // computed "expires in X days" relative timer for at-a-glance // sense of how much time is left. If past expiry, shows "Expired". user.is_premium && e("section", { className: "adm-section" }, e("h2", { className: "adm-h2" }, "Premium info"), e("div", { className: "adm-info-grid" }, e("div", null, e("strong", null, "Granted at: "), fmtDate(user.premium_granted_at)), e("div", null, e("strong", null, "Granted by (admin id): "), user.premium_granted_by || "—"), e("div", null, e("strong", null, "Expires: "), user.premium_expires_at ? e(React.Fragment, null, fmtDate(user.premium_expires_at), " · ", e("span", { className: _isPremiumExpired(user.premium_expires_at) ? "adm-status-del" : "adm-status-ok" }, _expiryLabel(user.premium_expires_at)) ) : e("span", { className: "adm-status-ok" }, "Until revoked"), ), ) ), // Owned certs user.owned_certs && user.owned_certs.length > 0 && e("section", { className: "adm-section" }, e("h2", { className: "adm-h2" }, "Owned certifications (" + user.owned_certs.length + ")"), e("table", { className: "adm-table compact" }, e("thead", null, e("tr", null, e("th", null, "Cert"), e("th", null, "Issuer"), e("th", null, "Earned"), e("th", null, "Expires"), e("th", null, "Status"))), e("tbody", null, user.owned_certs.map(c => e("tr", { key: c.id }, e("td", null, c.cert_id), e("td", null, c.issuer || "—"), e("td", null, c.earned_date || "—"), e("td", null, c.expiration_date || "—"), e("td", null, c.review_status === "approved" ? e("span", { className: "adm-status-ok" }, "✓ Verified") : c.review_status === "pending" ? e("span", { className: "adm-status-pending" }, "Pending review") : c.review_status === "rejected" ? e("span", { className: "adm-status-del" }, "Rejected") : "—" ), ))) ) ), // Data JSON peek — collapsed by default since it's noisy e("section", { className: "adm-section" }, e("h2", { className: "adm-h2" }, "Account data (data_json)"), e("details", { className: "adm-data-details" }, e("summary", null, "Show raw user data"), e("pre", { className: "adm-data-pre" }, JSON.stringify(user.data, null, 2)) ) ) ); } // ─── Requests view ───────────────────────────────────────────────────── function AdminRequests() { const [data, setData] = React.useState(null); const [err, setErr] = React.useState(""); React.useEffect(() => { adminApi("/api/admin/requests") .then(setData) .catch(ex => setErr(ex.message || "Failed.")); }, []); return e("div", { className: "adm-page" }, e(AdminNav, { active: "requests" }), e("h1", { className: "adm-page-title" }, "User Requests"), err && e("div", { className: "adm-error" }, err), !data && !err && e("div", { className: "adm-loading" }, "Loading…"), data && e(React.Fragment, null, e("section", { className: "adm-section" }, e("h2", { className: "adm-h2" }, "Cert content requests (build priority)"), e("p", { className: "adm-help" }, "Ranked by number of users who clicked Request on a cert. Build the top-ranked next."), data.cert_requests.length === 0 ? e("p", { className: "adm-help" }, "No requests yet.") : e("table", { className: "adm-table" }, e("thead", null, e("tr", null, e("th", null, "Cert"), e("th", null, "Requests"))), e("tbody", null, data.cert_requests.map((r, i) => e("tr", { key: i }, e("td", null, e("strong", null, r.cert)), e("td", null, r.count), ))) ) ), data.new_career_requests.length > 0 && e("section", { className: "adm-section" }, e("h2", { className: "adm-h2" }, "Career path requests"), e("p", { className: "adm-help" }, "User-submitted suggestions for new career paths."), e("table", { className: "adm-table" }, e("thead", null, e("tr", null, e("th", null, "Description"), e("th", null, "Count"), e("th", null, "Last requested"))), e("tbody", null, data.new_career_requests.map((r, i) => e("tr", { key: i }, e("td", null, r.description), e("td", null, r.count), e("td", null, fmtRelative(r.last_at)), ))) ) ), data.career_unlock_requests.length > 0 && e("section", { className: "adm-section" }, e("h2", { className: "adm-h2" }, "Career unlock requests (\"Tell us this matters\")"), e("p", { className: "adm-help" }, "Free-text feedback from users on locked careers."), e("table", { className: "adm-table" }, e("thead", null, e("tr", null, e("th", null, "Description"), e("th", null, "Count"))), e("tbody", null, data.career_unlock_requests.map((r, i) => e("tr", { key: i }, e("td", { style: { whiteSpace: "pre-wrap" } }, r.description), e("td", null, r.count), ))) ) ) ) ); } // ─── Audit log ───────────────────────────────────────────────────────── function AdminAuditLogPage() { const [data, setData] = React.useState(null); const [page, setPage] = React.useState(1); const [actionFilter, setActionFilter] = React.useState(""); const [err, setErr] = React.useState(""); React.useEffect(() => { const params = new URLSearchParams(); params.set("page", String(page)); if (actionFilter) params.set("action", actionFilter); adminApi("/api/admin/audit-log?" + params.toString()) .then(setData) .catch(ex => setErr(ex.message || "Failed.")); }, [page, actionFilter]); return e("div", { className: "adm-page" }, e(AdminNav, { active: "audit" }), e("h1", { className: "adm-page-title" }, "Audit Log"), err && e("div", { className: "adm-error" }, err), e("div", { className: "adm-filters" }, e("select", { className: "adm-filter-select", value: actionFilter, onChange: (ev) => { setActionFilter(ev.target.value); setPage(1); } }, e("option", { value: "" }, "All actions"), e("option", { value: "grant_premium" }, "Grant premium"), e("option", { value: "revoke_premium" }, "Revoke premium"), e("option", { value: "soft_delete_user" }, "Soft delete"), e("option", { value: "restore_user" }, "Restore"), e("option", { value: "send_password_reset" }, "Send password reset"), e("option", { value: "grant_admin" }, "Grant admin"), e("option", { value: "revoke_admin" }, "Revoke admin"), ), ), data && e(React.Fragment, null, e("div", { className: "adm-table-meta" }, `${data.total} entries`), e("table", { className: "adm-table" }, e("thead", null, e("tr", null, e("th", null, "When"), e("th", null, "Admin"), e("th", null, "Action"), e("th", null, "Target"), e("th", null, "Details"))), e("tbody", null, data.entries.map(r => e("tr", { key: r.id }, e("td", null, fmtRelative(r.created_at)), e("td", null, r.admin_email), e("td", null, e("code", null, r.action)), e("td", null, r.target_type ? `${r.target_type}#${r.target_id}` : "—"), e("td", { className: "adm-audit-details" }, r.details ? e("code", null, JSON.stringify(r.details)) : "—"), ))) ), e("div", { className: "adm-pager" }, e("button", { className: "btn btn-ghost btn-sm", disabled: page <= 1, onClick: () => setPage(page - 1) }, "← Prev"), e("span", { className: "adm-pager-info" }, `Page ${page} of ${Math.max(1, Math.ceil(data.total / data.page_size))}`), e("button", { className: "btn btn-ghost btn-sm", disabled: page * data.page_size >= data.total, onClick: () => setPage(page + 1) }, "Next →"), ) ) ); } // ─── Spotlights: choose featured cert + career for the home page ──────── function AdminSpotlights() { const [data, setData] = React.useState(null); const [err, setErr] = React.useState(null); const [cert, setCert] = React.useState(""); const [career, setCareer] = React.useState(""); const [saveStatus, setSaveStatus] = React.useState(null); // 'saving' | 'saved' | 'error' const [saveMsg, setSaveMsg] = React.useState(""); React.useEffect(() => { adminApi("/api/spotlights") .then(d => { setData(d || {cert: null, career: null}); setCert(d && d.cert ? d.cert : ""); setCareer(d && d.career ? d.career : ""); }) .catch(e => setErr(e.message || String(e))); }, []); async function save() { setSaveStatus("saving"); setSaveMsg(""); try { const result = await adminApi("/api/admin/spotlights", { method: "PUT", body: JSON.stringify({cert: cert || null, career: career || null}), }); setData(result); setCert(result.cert || ""); setCareer(result.career || ""); setSaveStatus("saved"); setSaveMsg("Saved."); setTimeout(() => { setSaveStatus(null); setSaveMsg(""); }, 2500); } catch (e) { setSaveStatus("error"); setSaveMsg(e.message || "Save failed."); } } const exams = window.EXAMS || []; const certMeta = window.CERT_META || {}; const careers = window.CAREER_PATHS || []; // Cert dropdown: live EXAMS first, then comingSoon entries from CERT_META. const liveCertIds = new Set(exams.map(x => x.id)); const certOptions = [ ...exams.map(x => ({id: x.id, label: x.label})), ...Object.entries(certMeta) .filter(([id, m]) => m && m.comingSoon && !liveCertIds.has(id)) .map(([id, m]) => ({id, label: (m.label || id) + " (coming soon)"})), ]; if (err) return e("div", { className: "adm-page" }, e(AdminNav, { active: "spotlights" }), e("div", { className: "adm-error" }, err)); if (!data) return e("div", { className: "adm-page" }, e(AdminNav, { active: "spotlights" }), e("div", { className: "adm-loading" }, "Loading…")); return e("div", { className: "adm-page" }, e(AdminNav, { active: "spotlights" }), e("h1", { className: "adm-page-title" }, "Home Spotlights"), e("p", { style: {color: "var(--fg-dim)", marginTop: "-8px", marginBottom: "20px"} }, "Pick one cert and one career to feature on the home page (below the Careers / Certifications buttons). Leave either blank to hide that slot."), e("div", { style: {display: "grid", gap: "20px", maxWidth: "560px"} }, e("div", null, e("label", { style: {display: "block", fontSize: "12px", color: "var(--fg-dim)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: "6px"} }, "Featured Certification"), e("select", { value: cert, onChange: ev => setCert(ev.target.value), style: {width: "100%", padding: "8px 10px", background: "var(--input)", border: "1px solid var(--border)", borderRadius: "var(--r-md)", color: "var(--fg)", font: "inherit"}, }, e("option", { value: "" }, "— None —"), certOptions.map(o => e("option", { key: o.id, value: o.id }, o.label)) ) ), e("div", null, e("label", { style: {display: "block", fontSize: "12px", color: "var(--fg-dim)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: "6px"} }, "Featured Career"), e("select", { value: career, onChange: ev => setCareer(ev.target.value), style: {width: "100%", padding: "8px 10px", background: "var(--input)", border: "1px solid var(--border)", borderRadius: "var(--r-md)", color: "var(--fg)", font: "inherit"}, }, e("option", { value: "" }, "— None —"), careers.map(c => e("option", { key: c.id, value: c.id }, c.name || c.id)) ) ), e("div", { style: {display: "flex", gap: "12px", alignItems: "center"} }, e("button", { className: "btn", onClick: save, disabled: saveStatus === "saving", }, saveStatus === "saving" ? "Saving…" : "Save spotlights"), saveMsg && e("span", { style: {color: saveStatus === "error" ? "var(--red, #ff6b6b)" : "var(--green)", fontSize: "13px"}, }, saveMsg), ), e("div", { style: {marginTop: "12px", fontSize: "12px", color: "var(--fg-mute)"} }, "Current values: cert = ", e("code", null, data.cert || "(none)"), ", career = ", e("code", null, data.career || "(none)"), ". Changes are audit-logged." ) ) ); } // ─── Dispatcher ──────────────────────────────────────────────────────── // Router-driven: re-renders on URL change. Returns the right component // for the current /admin/* path. Falls back to a generic forbidden if // user isn't admin (server still enforces). function AdminPanel() { const user = window.AuthStore && window.AuthStore.user; const [routeBump, setRouteBump] = React.useState(0); React.useEffect(() => { const refresh = () => setRouteBump(n => n + 1); window.addEventListener("popstate", refresh); return () => window.removeEventListener("popstate", refresh); }, []); if (!user) { return e("div", { className: "adm-page" }, e("h1", { className: "adm-page-title" }, "Sign in required")); } if (!user.is_admin) { // Mirror the "page not found" treatment — don't reveal admin routes exist return e("div", { className: "adm-page" }, e("h1", { className: "adm-page-title" }, "Page not found"), e("p", null, "The page you're looking for doesn't exist.")); } const path = window.location.pathname; if (path === "/admin" || path === "/admin/") return e(AdminDashboard); if (path === "/admin/users") return e(AdminUsers); if (path === "/admin/requests") return e(AdminRequests); if (path === "/admin/spotlights" || path === "/admin/spotlights/") return e(AdminSpotlights); if (path === "/admin/audit") return e(AdminAuditLogPage); // Cert reviews — renders the legacy cert-review UI with the admin nav // strip on top, all inside ONE .adm-page wrapper so padding is correct. // Uses AdminCertReviewsBody (the un-wrapped inner content) so we don't // get double-nested .adm-page divs. if (path === "/admin/cert-reviews" || path === "/admin/cert-reviews/") { return e("div", { className: "adm-page" }, e(AdminNav, { active: "cert-reviews" }), e(AdminCertReviewsBody) ); } const userMatch = path.match(/^\/admin\/user\/(\d+)$/); if (userMatch) return e(AdminUserDetail, { userId: parseInt(userMatch[1], 10) }); // Fall through — unknown /admin/* path return e("div", { className: "adm-page" }, e(AdminNav, { active: "" }), e("h1", { className: "adm-page-title" }, "Admin page not found"), e("p", null, "No admin page exists at " + path)); } window.AdminPanel = AdminPanel; window.AdminCertReviewsPage = AdminCertReviewsPage; })();