| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- "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 =
- `<td>${esc(u.email)}</td>` +
- `<td><select data-uidx="${i}">${SCHEMA.roles.map((r) =>
- `<option value="${r}" ${r === u.role ? "selected" : ""}>${r}</option>`).join("")}</select></td>` +
- `<td><button class="btn small" data-remove="${i}">Remove</button></td>`;
- 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 = `<span>${esc(f)}</span><button data-fi="${i}" title="remove">✕</button>`;
- 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) => `<option value="${esc(f)}" ${f === current ? "selected" : ""}>${esc(f)}</option>`).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) =>
- `<label class="field-inline"><input type="checkbox" data-step="${s}" ${rr.steps.includes(s) ? "checked" : ""}> ${s}</label>`
- ).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 =
- `<td>${j.id}</td><td>${esc(j.user || "-")}</td><td>${esc(j.action)}</td>` +
- // A pending job with run_after is waiting out a Report & Release step delay.
- `<td>${esc(j.folder)}</td><td class="st-${j.status}" title="${esc(j.run_after || "")}">` +
- `${j.status === "pending" && j.run_after ? "waiting" : j.status}</td>` +
- `<td>${j.processed}/${j.total}</td>` +
- `<td>${j.status === "failed" ? `<button class="btn small" data-retry="${j.id}">Retry</button>` : ""}</td>`;
- 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) => `<div class="log-line log-${l.level}">${esc(l.text)}</div>`).join("")
- || '<div class="log-line">(empty)</div>';
- } 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]) =>
- `<a href="#${id}">${label}</a>`).join("");
- }
- function fillOptions(sel, opts, current) {
- sel.innerHTML = opts.map((o) => `<option value="${o}" ${o === current ? "selected" : ""}>${o}</option>`).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();
|