"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 deleted = CONFIG.quarantine.deleted_folder;
const parts = steps.map((s) =>
s === "release" ? "released in place" :
s === "move" ? `moved to "${move}"` :
s === "delete" ? `deleted to "${deleted}"` : 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)} | ` +
`${esc(j.folder)} | ${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();