admin.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. "use strict";
  2. // Admin panel. Loads redacted config + schema, renders each section, and saves per
  3. // section via PUT /api/admin/config. Secrets are write-only: the password field is only
  4. // sent when the admin types something.
  5. let CONFIG = null; // redacted config
  6. let SCHEMA = null;
  7. let CSRF = null;
  8. let logTimer = null;
  9. const $ = (id) => document.getElementById(id);
  10. async function api(path, opts = {}) {
  11. const res = await fetch(path, {
  12. headers: { "Content-Type": "application/json", ...(CSRF ? { "X-CSRF-Token": CSRF } : {}) },
  13. ...opts,
  14. });
  15. if (res.status === 401) { window.location = "/login"; throw new Error("Not signed in"); }
  16. if (res.status === 403) { throw new Error("Admin access required"); }
  17. const data = await res.json().catch(() => ({}));
  18. if (!res.ok) {
  19. const msg = data.errors ? data.errors.map((e) => `${e.field}: ${e.message}`).join("; ")
  20. : (data.error || `HTTP ${res.status}`);
  21. throw new Error(msg);
  22. }
  23. return data;
  24. }
  25. async function init() {
  26. // Grab CSRF from the quarantine config endpoint (shared session token).
  27. try { CSRF = (await (await fetch("/api/config")).json()).csrf_token; } catch { /* ignore */ }
  28. await load();
  29. wire();
  30. refreshJobs();
  31. refreshLogs();
  32. }
  33. async function load() {
  34. const data = await api("/api/admin/config");
  35. CONFIG = data.config;
  36. SCHEMA = data.schema;
  37. renderAccess();
  38. renderPps();
  39. renderFolders();
  40. renderDefaults();
  41. renderReportRelease();
  42. renderNav();
  43. }
  44. // ---------- Access ----------
  45. function renderAccess() {
  46. const body = $("users-body");
  47. body.innerHTML = "";
  48. (CONFIG.auth.users || []).forEach((u, i) => {
  49. const tr = document.createElement("tr");
  50. tr.innerHTML =
  51. `<td>${esc(u.email)}</td>` +
  52. `<td><select data-uidx="${i}">${SCHEMA.roles.map((r) =>
  53. `<option value="${r}" ${r === u.role ? "selected" : ""}>${r}</option>`).join("")}</select></td>` +
  54. `<td><button class="btn small" data-remove="${i}">Remove</button></td>`;
  55. body.append(tr);
  56. });
  57. body.querySelectorAll("[data-remove]").forEach((b) =>
  58. b.onclick = () => { CONFIG.auth.users.splice(+b.dataset.remove, 1); renderAccess(); });
  59. body.querySelectorAll("[data-uidx]").forEach((s) =>
  60. s.onchange = () => { CONFIG.auth.users[+s.dataset.uidx].role = s.value; });
  61. $("denied-message").value = CONFIG.auth.denied_message || "";
  62. const banner = $("auth-mode-banner");
  63. if (SCHEMA.auth_mode === "static") {
  64. banner.textContent = "Auth mode is STATIC (development). Set [auth] mode = \"oidc\" in the server config for production.";
  65. banner.classList.remove("hidden");
  66. } else banner.classList.add("hidden");
  67. }
  68. function addUser() {
  69. const email = $("new-user-email").value.trim();
  70. if (!email) return;
  71. CONFIG.auth.users = CONFIG.auth.users || [];
  72. CONFIG.auth.users.push({ email, role: $("new-user-role").value });
  73. $("new-user-email").value = "";
  74. renderAccess();
  75. }
  76. // ---------- PPS ----------
  77. function renderPps() {
  78. const p = CONFIG.pps;
  79. $("pps-base_url").value = p.base_url || "";
  80. $("pps-username").value = p.username || "";
  81. $("pps-verify_tls").checked = !!p.verify_tls;
  82. $("pps-timeout").value = p.timeout ?? 120;
  83. $("pps-client_cert").value = p.client_cert || "";
  84. $("pps-client_key").value = p.client_key || "";
  85. $("pps-password").value = "";
  86. $("pps-password-state").textContent = p.password_set ? "Password is set" : "No password set";
  87. }
  88. // ---------- Folders ----------
  89. function renderFolders() {
  90. const list = $("folders-list");
  91. list.innerHTML = "";
  92. (CONFIG.quarantine.folders || []).forEach((f, i) => {
  93. const li = document.createElement("li");
  94. li.className = "chip";
  95. li.innerHTML = `<span>${esc(f)}</span><button data-fi="${i}" title="remove">✕</button>`;
  96. list.append(li);
  97. });
  98. list.querySelectorAll("[data-fi]").forEach((b) =>
  99. b.onclick = () => { CONFIG.quarantine.folders.splice(+b.dataset.fi, 1); renderFolders(); });
  100. fillFolderSelect($("q-default_folder"), CONFIG.quarantine.default_folder);
  101. fillFolderSelect($("q-deleted_folder"), CONFIG.quarantine.deleted_folder);
  102. }
  103. function addFolder() {
  104. const v = $("new-folder").value.trim();
  105. if (!v) return;
  106. CONFIG.quarantine.folders = CONFIG.quarantine.folders || [];
  107. CONFIG.quarantine.folders.push(v);
  108. $("new-folder").value = "";
  109. renderFolders();
  110. renderReportRelease();
  111. }
  112. function fillFolderSelect(sel, current) {
  113. sel.innerHTML = (CONFIG.quarantine.folders || [])
  114. .map((f) => `<option value="${esc(f)}" ${f === current ? "selected" : ""}>${esc(f)}</option>`).join("");
  115. }
  116. // ---------- Defaults ----------
  117. function renderDefaults() {
  118. const q = CONFIG.quarantine;
  119. $("q-default_limit").value = q.default_limit ?? 200;
  120. $("q-default_days_back").value = q.default_days_back ?? 7;
  121. $("q-list_query").value = q.list_query || "";
  122. $("q-chunk_size").value = q.chunk_size ?? 25;
  123. fillOptions($("q-default_sort_field"), SCHEMA.sort_fields, q.default_sort_field);
  124. fillOptions($("q-default_sort_dir"), SCHEMA.sort_dirs, q.default_sort_dir);
  125. }
  126. // ---------- Report & Release ----------
  127. function renderReportRelease() {
  128. const rr = CONFIG.quarantine.report_release || { steps: [], move_target: "" };
  129. const box = $("rr-steps");
  130. box.innerHTML = SCHEMA.steps.map((s) =>
  131. `<label class="field-inline"><input type="checkbox" data-step="${s}" ${rr.steps.includes(s) ? "checked" : ""}> ${s}</label>`
  132. ).join("");
  133. box.querySelectorAll("[data-step]").forEach((c) => c.onchange = updateRrSentence);
  134. fillFolderSelect($("rr-move_target"), rr.move_target);
  135. $("rr-step_delay_seconds").value = rr.step_delay_seconds ?? 60;
  136. updateRrSentence();
  137. }
  138. function currentSteps() {
  139. // Preserve canonical order from the schema.
  140. return SCHEMA.steps.filter((s) => document.querySelector(`[data-step="${s}"]`)?.checked);
  141. }
  142. function updateRrSentence() {
  143. const steps = currentSteps();
  144. const move = $("rr-move_target").value;
  145. const deleted = CONFIG.quarantine.deleted_folder;
  146. const parts = steps.map((s) =>
  147. s === "release" ? "released in place" :
  148. s === "move" ? `moved to "${move}"` :
  149. s === "delete" ? `deleted to "${deleted}"` : s);
  150. $("rr-sentence").textContent = parts.length ? parts.join(" → ") : "(no steps selected)";
  151. }
  152. // ---------- Saving ----------
  153. async function save(section) {
  154. let patch = {};
  155. if (section === "access") {
  156. patch = { auth: { users: CONFIG.auth.users || [], denied_message: $("denied-message").value } };
  157. } else if (section === "pps") {
  158. const pps = {
  159. base_url: $("pps-base_url").value.trim(),
  160. username: $("pps-username").value.trim(),
  161. verify_tls: $("pps-verify_tls").checked,
  162. timeout: +$("pps-timeout").value,
  163. client_cert: $("pps-client_cert").value.trim(),
  164. client_key: $("pps-client_key").value.trim(),
  165. };
  166. if ($("pps-password").value) pps.password = $("pps-password").value; // write-only
  167. patch = { pps };
  168. } else if (section === "folders") {
  169. patch = { quarantine: {
  170. folders: CONFIG.quarantine.folders || [],
  171. default_folder: $("q-default_folder").value,
  172. deleted_folder: $("q-deleted_folder").value,
  173. } };
  174. } else if (section === "defaults") {
  175. patch = { quarantine: {
  176. default_limit: +$("q-default_limit").value,
  177. default_days_back: +$("q-default_days_back").value,
  178. list_query: $("q-list_query").value.trim(),
  179. chunk_size: +$("q-chunk_size").value,
  180. default_sort_field: $("q-default_sort_field").value,
  181. default_sort_dir: $("q-default_sort_dir").value,
  182. } };
  183. } else if (section === "rr") {
  184. patch = { quarantine: { report_release: {
  185. steps: currentSteps(),
  186. move_target: $("rr-move_target").value,
  187. step_delay_seconds: +$("rr-step_delay_seconds").value,
  188. } } };
  189. }
  190. try {
  191. const res = await api("/api/admin/config", { method: "PUT", body: JSON.stringify(patch) });
  192. CONFIG = res.config;
  193. let msg = "Saved.";
  194. if (res.restart_required && res.restart_required.length)
  195. msg += ` Restart required for: ${res.restart_required.join(", ")}.`;
  196. toast(msg, false);
  197. await load();
  198. } catch (err) {
  199. toast(err.message, true);
  200. }
  201. }
  202. // ---------- PPS test ----------
  203. async function testPps() {
  204. $("pps-test-result").textContent = "Testing…";
  205. const body = {
  206. base_url: $("pps-base_url").value.trim(),
  207. username: $("pps-username").value.trim(),
  208. verify_tls: $("pps-verify_tls").checked,
  209. timeout: +$("pps-timeout").value,
  210. client_cert: $("pps-client_cert").value.trim(),
  211. client_key: $("pps-client_key").value.trim(),
  212. };
  213. if ($("pps-password").value) body.password = $("pps-password").value;
  214. try {
  215. const res = await api("/api/admin/pps-test", { method: "POST", body: JSON.stringify(body) });
  216. const el = $("pps-test-result");
  217. el.textContent = res.detail;
  218. el.className = "hint " + (res.ok ? "ok" : "err");
  219. } catch (err) { $("pps-test-result").textContent = err.message; }
  220. }
  221. // ---------- Jobs ----------
  222. async function refreshJobs() {
  223. try {
  224. const data = await api("/api/admin/jobs");
  225. const body = $("jobs-body");
  226. body.innerHTML = "";
  227. (data.jobs || []).forEach((j) => {
  228. const tr = document.createElement("tr");
  229. tr.innerHTML =
  230. `<td>${j.id}</td><td>${esc(j.user || "-")}</td><td>${esc(j.action)}</td>` +
  231. `<td>${esc(j.folder)}</td><td class="st-${j.status}">${j.status}</td>` +
  232. `<td>${j.processed}/${j.total}</td>` +
  233. `<td>${j.status === "failed" ? `<button class="btn small" data-retry="${j.id}">Retry</button>` : ""}</td>`;
  234. body.append(tr);
  235. });
  236. body.querySelectorAll("[data-retry]").forEach((b) =>
  237. b.onclick = async () => { await api(`/api/admin/jobs/${b.dataset.retry}/retry`, { method: "POST" }); refreshJobs(); });
  238. } catch (err) { toast(err.message, true); }
  239. }
  240. // ---------- Logs ----------
  241. async function refreshLogs() {
  242. clearTimeout(logTimer);
  243. try {
  244. const source = $("log-source").value;
  245. const q = encodeURIComponent($("log-filter").value || "");
  246. const data = await api(`/api/admin/logs?source=${source}&lines=300&q=${q}`);
  247. $("log-view").innerHTML = (data.lines || [])
  248. .map((l) => `<div class="log-line log-${l.level}">${esc(l.text)}</div>`).join("")
  249. || '<div class="log-line">(empty)</div>';
  250. } catch (err) { $("log-view").textContent = err.message; }
  251. if ($("log-auto").checked && document.visibilityState === "visible")
  252. logTimer = setTimeout(refreshLogs, 5000);
  253. }
  254. // ---------- misc ----------
  255. function renderNav() {
  256. const secs = [["sec-access","Access"],["sec-pps","PPS"],["sec-folders","Folders"],
  257. ["sec-defaults","Defaults"],["sec-rr","Report & Release"],["sec-jobs","Jobs"],["sec-logs","Logs"]];
  258. $("admin-nav").innerHTML = secs.map(([id,label]) =>
  259. `<a href="#${id}">${label}</a>`).join("");
  260. }
  261. function fillOptions(sel, opts, current) {
  262. sel.innerHTML = opts.map((o) => `<option value="${o}" ${o === current ? "selected" : ""}>${o}</option>`).join("");
  263. }
  264. function wire() {
  265. document.querySelectorAll("[data-save]").forEach((b) => b.onclick = () => save(b.dataset.save));
  266. $("add-user-btn").onclick = addUser;
  267. $("add-folder-btn").onclick = addFolder;
  268. $("pps-test-btn").onclick = testPps;
  269. $("jobs-refresh").onclick = refreshJobs;
  270. $("log-refresh").onclick = refreshLogs;
  271. $("log-source").onchange = refreshLogs;
  272. $("q-deleted_folder")?.addEventListener("change", updateRrSentence);
  273. }
  274. let toastTimer = null;
  275. function toast(msg, isError) {
  276. const t = $("toast");
  277. t.textContent = msg;
  278. t.className = "toast " + (isError ? "err" : "ok");
  279. clearTimeout(toastTimer);
  280. toastTimer = setTimeout(() => t.classList.add("hidden"), 6000);
  281. }
  282. function esc(s) {
  283. return String(s ?? "").replace(/[&<>"']/g, (c) =>
  284. ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
  285. }
  286. init();