"use strict"; // Admin panel. Loads redacted config + schema, renders each section, and saves per // section via PUT /api/admin/config. Secrets are write-only: the password field is only // sent when the admin types something. let CONFIG = null; // redacted config let SCHEMA = null; let CSRF = null; let logTimer = null; const $ = (id) => document.getElementById(id); async function api(path, opts = {}) { const res = await fetch(path, { headers: { "Content-Type": "application/json", ...(CSRF ? { "X-CSRF-Token": CSRF } : {}) }, ...opts, }); if (res.status === 401) { window.location = "/login"; throw new Error("Not signed in"); } if (res.status === 403) { throw new Error("Admin access required"); } const data = await res.json().catch(() => ({})); if (!res.ok) { const msg = data.errors ? data.errors.map((e) => `${e.field}: ${e.message}`).join("; ") : (data.error || `HTTP ${res.status}`); throw new Error(msg); } return data; } async function init() { // Grab CSRF from the quarantine config endpoint (shared session token). try { CSRF = (await (await fetch("/api/config")).json()).csrf_token; } catch { /* ignore */ } await load(); wire(); refreshJobs(); refreshLogs(); } async function load() { const data = await api("/api/admin/config"); CONFIG = data.config; SCHEMA = data.schema; renderAccess(); renderPps(); renderFolders(); renderDefaults(); renderReportRelease(); renderNav(); } // ---------- Access ---------- function renderAccess() { const body = $("users-body"); body.innerHTML = ""; (CONFIG.auth.users || []).forEach((u, i) => { const tr = document.createElement("tr"); tr.innerHTML = `${esc(u.email)}` + `` + ``; body.append(tr); }); body.querySelectorAll("[data-remove]").forEach((b) => b.onclick = () => { CONFIG.auth.users.splice(+b.dataset.remove, 1); renderAccess(); }); body.querySelectorAll("[data-uidx]").forEach((s) => s.onchange = () => { CONFIG.auth.users[+s.dataset.uidx].role = s.value; }); $("denied-message").value = CONFIG.auth.denied_message || ""; const banner = $("auth-mode-banner"); if (SCHEMA.auth_mode === "static") { banner.textContent = "Auth mode is STATIC (development). Set [auth] mode = \"oidc\" in the server config for production."; banner.classList.remove("hidden"); } else banner.classList.add("hidden"); } function addUser() { const email = $("new-user-email").value.trim(); if (!email) return; CONFIG.auth.users = CONFIG.auth.users || []; CONFIG.auth.users.push({ email, role: $("new-user-role").value }); $("new-user-email").value = ""; renderAccess(); } // ---------- PPS ---------- function renderPps() { const p = CONFIG.pps; $("pps-base_url").value = p.base_url || ""; $("pps-username").value = p.username || ""; $("pps-verify_tls").checked = !!p.verify_tls; $("pps-timeout").value = p.timeout ?? 120; $("pps-client_cert").value = p.client_cert || ""; $("pps-client_key").value = p.client_key || ""; $("pps-password").value = ""; $("pps-password-state").textContent = p.password_set ? "Password is set" : "No password set"; } // ---------- Folders ---------- function renderFolders() { const list = $("folders-list"); list.innerHTML = ""; (CONFIG.quarantine.folders || []).forEach((f, i) => { const li = document.createElement("li"); li.className = "chip"; li.innerHTML = `${esc(f)}`; list.append(li); }); list.querySelectorAll("[data-fi]").forEach((b) => b.onclick = () => { CONFIG.quarantine.folders.splice(+b.dataset.fi, 1); renderFolders(); }); fillFolderSelect($("q-default_folder"), CONFIG.quarantine.default_folder); fillFolderSelect($("q-deleted_folder"), CONFIG.quarantine.deleted_folder); } function addFolder() { const v = $("new-folder").value.trim(); if (!v) return; CONFIG.quarantine.folders = CONFIG.quarantine.folders || []; CONFIG.quarantine.folders.push(v); $("new-folder").value = ""; renderFolders(); renderReportRelease(); } function fillFolderSelect(sel, current) { sel.innerHTML = (CONFIG.quarantine.folders || []) .map((f) => ``).join(""); } // ---------- Defaults ---------- function renderDefaults() { const q = CONFIG.quarantine; $("q-default_limit").value = q.default_limit ?? 200; $("q-default_days_back").value = q.default_days_back ?? 7; $("q-list_query").value = q.list_query || ""; $("q-chunk_size").value = q.chunk_size ?? 25; fillOptions($("q-default_sort_field"), SCHEMA.sort_fields, q.default_sort_field); fillOptions($("q-default_sort_dir"), SCHEMA.sort_dirs, q.default_sort_dir); } // ---------- Report & Release ---------- function renderReportRelease() { const rr = CONFIG.quarantine.report_release || { steps: [], move_target: "" }; const box = $("rr-steps"); box.innerHTML = SCHEMA.steps.map((s) => `` ).join(""); box.querySelectorAll("[data-step]").forEach((c) => c.onchange = updateRrSentence); fillFolderSelect($("rr-move_target"), rr.move_target); $("rr-step_delay_seconds").value = rr.step_delay_seconds ?? 60; updateRrSentence(); } function currentSteps() { // Preserve canonical order from the schema. return SCHEMA.steps.filter((s) => document.querySelector(`[data-step="${s}"]`)?.checked); } function updateRrSentence() { const steps = currentSteps(); const move = $("rr-move_target").value; const parts = steps.map((s) => s === "release" ? "released in place" : s === "move" ? `moved to "${move}"` : s); $("rr-sentence").textContent = parts.length ? parts.join(" → ") : "(no steps selected)"; } // ---------- Saving ---------- async function save(section) { let patch = {}; if (section === "access") { patch = { auth: { users: CONFIG.auth.users || [], denied_message: $("denied-message").value } }; } else if (section === "pps") { const pps = { base_url: $("pps-base_url").value.trim(), username: $("pps-username").value.trim(), verify_tls: $("pps-verify_tls").checked, timeout: +$("pps-timeout").value, client_cert: $("pps-client_cert").value.trim(), client_key: $("pps-client_key").value.trim(), }; if ($("pps-password").value) pps.password = $("pps-password").value; // write-only patch = { pps }; } else if (section === "folders") { patch = { quarantine: { folders: CONFIG.quarantine.folders || [], default_folder: $("q-default_folder").value, deleted_folder: $("q-deleted_folder").value, } }; } else if (section === "defaults") { patch = { quarantine: { default_limit: +$("q-default_limit").value, default_days_back: +$("q-default_days_back").value, list_query: $("q-list_query").value.trim(), chunk_size: +$("q-chunk_size").value, default_sort_field: $("q-default_sort_field").value, default_sort_dir: $("q-default_sort_dir").value, } }; } else if (section === "rr") { patch = { quarantine: { report_release: { steps: currentSteps(), move_target: $("rr-move_target").value, step_delay_seconds: +$("rr-step_delay_seconds").value, } } }; } try { const res = await api("/api/admin/config", { method: "PUT", body: JSON.stringify(patch) }); CONFIG = res.config; let msg = "Saved."; if (res.restart_required && res.restart_required.length) msg += ` Restart required for: ${res.restart_required.join(", ")}.`; toast(msg, false); await load(); } catch (err) { toast(err.message, true); } } // ---------- PPS test ---------- async function testPps() { $("pps-test-result").textContent = "Testing…"; const body = { base_url: $("pps-base_url").value.trim(), username: $("pps-username").value.trim(), verify_tls: $("pps-verify_tls").checked, timeout: +$("pps-timeout").value, client_cert: $("pps-client_cert").value.trim(), client_key: $("pps-client_key").value.trim(), }; if ($("pps-password").value) body.password = $("pps-password").value; try { const res = await api("/api/admin/pps-test", { method: "POST", body: JSON.stringify(body) }); const el = $("pps-test-result"); el.textContent = res.detail; el.className = "hint " + (res.ok ? "ok" : "err"); } catch (err) { $("pps-test-result").textContent = err.message; } } // ---------- Jobs ---------- async function refreshJobs() { try { const data = await api("/api/admin/jobs"); const body = $("jobs-body"); body.innerHTML = ""; (data.jobs || []).forEach((j) => { const tr = document.createElement("tr"); tr.innerHTML = `${j.id}${esc(j.user || "-")}${esc(j.action)}` + // A pending job with run_after is waiting out a Report & Release step delay. `${esc(j.folder)}` + `${j.status === "pending" && j.run_after ? "waiting" : j.status}` + `${j.processed}/${j.total}` + `${j.status === "failed" ? `` : ""}`; body.append(tr); }); body.querySelectorAll("[data-retry]").forEach((b) => b.onclick = async () => { await api(`/api/admin/jobs/${b.dataset.retry}/retry`, { method: "POST" }); refreshJobs(); }); } catch (err) { toast(err.message, true); } } // ---------- Logs ---------- async function refreshLogs() { clearTimeout(logTimer); try { const source = $("log-source").value; const q = encodeURIComponent($("log-filter").value || ""); const data = await api(`/api/admin/logs?source=${source}&lines=300&q=${q}`); $("log-view").innerHTML = (data.lines || []) .map((l) => `
${esc(l.text)}
`).join("") || '
(empty)
'; } catch (err) { $("log-view").textContent = err.message; } if ($("log-auto").checked && document.visibilityState === "visible") logTimer = setTimeout(refreshLogs, 5000); } // ---------- misc ---------- function renderNav() { const secs = [["sec-access","Access"],["sec-pps","PPS"],["sec-folders","Folders"], ["sec-defaults","Defaults"],["sec-rr","Report & Release"],["sec-jobs","Jobs"],["sec-logs","Logs"]]; $("admin-nav").innerHTML = secs.map(([id,label]) => `${label}`).join(""); } function fillOptions(sel, opts, current) { sel.innerHTML = opts.map((o) => ``).join(""); } function wire() { document.querySelectorAll("[data-save]").forEach((b) => b.onclick = () => save(b.dataset.save)); $("add-user-btn").onclick = addUser; $("add-folder-btn").onclick = addFolder; $("pps-test-btn").onclick = testPps; $("jobs-refresh").onclick = refreshJobs; $("log-refresh").onclick = refreshLogs; $("log-source").onchange = refreshLogs; $("q-deleted_folder")?.addEventListener("change", updateRrSentence); } let toastTimer = null; function toast(msg, isError) { const t = $("toast"); t.textContent = msg; t.className = "toast " + (isError ? "err" : "ok"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.add("hidden"), 6000); } function esc(s) { return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } init();