@nordbyte/nordrelay 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +4 -0
  2. package/dist/access/audit-log.js +30 -13
  3. package/dist/channels/discord/discord-bot.js +12 -27
  4. package/dist/channels/shared/channel-bridge-controller.js +1 -1
  5. package/dist/channels/shared/channel-prompt-queue.js +37 -0
  6. package/dist/channels/shared/channel-turn-service.js +23 -9
  7. package/dist/channels/slack/slack-bot.js +12 -15
  8. package/dist/channels/telegram/bot.js +18 -4
  9. package/dist/core/pagination.js +22 -0
  10. package/dist/peers/peer-store.js +16 -0
  11. package/dist/peers/peer-types.js +19 -0
  12. package/dist/peers/peer-web-proxy-contract.js +2 -0
  13. package/dist/runtime/relay-external-activity-monitor.js +15 -0
  14. package/dist/runtime/relay-queue-service.js +1 -0
  15. package/dist/runtime/relay-runtime-dashboard.js +3 -0
  16. package/dist/runtime/relay-runtime-helpers.js +3 -0
  17. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +14 -10
  18. package/dist/runtime/relay-runtime-sessions.js +8 -0
  19. package/dist/runtime/relay-runtime-trace.js +92 -0
  20. package/dist/runtime/relay-runtime-updates-jobs.js +11 -5
  21. package/dist/runtime/relay-runtime.js +16 -6
  22. package/dist/state/prompt-store.js +13 -1
  23. package/dist/web/web-api-contract.js +2 -0
  24. package/dist/web/web-dashboard-access-routes.js +15 -12
  25. package/dist/web/web-dashboard-artifact-routes.js +6 -2
  26. package/dist/web/web-dashboard-assets.js +1 -0
  27. package/dist/web/web-dashboard-pages.js +58 -20
  28. package/dist/web/web-dashboard-peer-routes.js +19 -0
  29. package/dist/web/web-dashboard-runtime-routes.js +8 -1
  30. package/dist/web/web-dashboard-session-routes.js +17 -12
  31. package/dist/web/web-dashboard-ui.js +46 -10
  32. package/dist/web/web-performance.js +2 -0
  33. package/dist/web/web-state.js +33 -4
  34. package/dist/webui-assets/dashboard.css +227 -39
  35. package/dist/webui-assets/dashboard.js +728 -58
  36. package/package.json +4 -2
  37. package/plugins/nordrelay/scripts/nordrelay.mjs +333 -8
  38. package/plugins/nordrelay/scripts/service-installer.mjs +183 -0
  39. package/scripts/postinstall.mjs +122 -0
@@ -9,6 +9,7 @@
9
9
  { path: "/api/progress", methods: ["GET"] },
10
10
  { path: "/api/metrics", methods: ["GET"] },
11
11
  { path: "/api/jobs", methods: ["GET"] },
12
+ { path: "/api/trace", methods: ["GET"] },
12
13
  { re: /^\/api\/jobs\/[^\/]+\/log$/, methods: ["GET"] },
13
14
  { re: /^\/api\/jobs\/[^\/]+\/action$/, methods: ["POST"] },
14
15
  { path: "/api/active-sessions", methods: ["GET"] },
@@ -35,6 +36,7 @@
35
36
  { path: "/api/peers/global-sessions", methods: ["GET"] },
36
37
  { re: /^\/api\/peers\/invitations\/[^\/]+$/, methods: ["DELETE"] },
37
38
  { re: /^\/api\/peers\/[^\/]+\/repin$/, methods: ["POST"] },
39
+ { re: /^\/api\/peers\/[^\/]+\/rotate$/, methods: ["POST"] },
38
40
  { re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
39
41
  { re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
40
42
  { re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
@@ -166,7 +168,7 @@
166
168
  const peerId = selectedPeerTarget();
167
169
  if (!peerId || peerId === "local") return false;
168
170
  if (!path.startsWith("/api/")) return false;
169
- return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || path === "/api/peers/discover" || path === "/api/peers/discovery-jobs" || path === "/api/peers/global-sessions" || path === "/api/peers/identity/backup" || path === "/api/peers/identity/restore" || path === "/api/settings/wizard/test" || /^\/api\/peers\/discovery-jobs\//.test(path) || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || /^\/api\/peers\/[^/]+\/repin$/.test(path) || isLocalAdminApi(path));
171
+ return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || path === "/api/peers/discover" || path === "/api/peers/discovery-jobs" || path === "/api/peers/global-sessions" || path === "/api/peers/identity/backup" || path === "/api/peers/identity/restore" || path === "/api/settings/wizard/test" || /^\/api\/peers\/discovery-jobs\//.test(path) || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || /^\/api\/peers\/[^/]+\/repin$/.test(path) || /^\/api\/peers\/[^/]+\/rotate$/.test(path) || isLocalAdminApi(path));
170
172
  }
171
173
  function isLocalAdminApi(path) {
172
174
  return path === "/api/permissions" || path === "/api/settings" || path === "/api/audit" || path === "/api/locks" || path === "/api/users" || path === "/api/groups" || path === "/api/telegram-chats" || path === "/api/discord-channels" || path === "/api/slack-channels" || /^\/api\/users\//.test(path) || /^\/api\/groups\//.test(path) || /^\/api\/telegram-chats\//.test(path) || /^\/api\/discord-channels\//.test(path) || /^\/api\/slack-channels\//.test(path);
@@ -250,6 +252,8 @@
250
252
  }
251
253
  const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, csrfToken: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, settingsWizard: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, stickyToastActive: false, stickyToastText: "", cliStatusActive: false, webMirror: null, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, toolsVisible: false, agentUpdateJobs: [], sessionsRequestId: 0, chatHistoryRequestId: 0, chatRenderVersion: 0, activeSessions: null, peers: null, peerInviteSecrets: {}, peerProbeResult: null, peerDiscoveryJobs: [], selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
252
254
  globalThis.NORDRELAY_WEBUI_RUNTIME_STATE = state;
255
+ const PAGE_LABELS = { overview: "Overview", chat: "Chat", sessions: "Sessions", queue: "Queue", tasks: "Tasks", metrics: "Metrics", activity: "Activity", trace: "Trace", artifacts: "Artifacts", adapters: "Adapters", peers: "Peers", access: "Users", version: "Version", settings: "Settings", logs: "Logs", diagnostics: "Diagnostics" };
256
+ const NAV_OPEN_STORAGE_KEY = "nordrelayNavOpenSections";
253
257
  function toast(msg, options = {}) {
254
258
  const el = document.getElementById("toast");
255
259
  const text = String(msg ?? "");
@@ -349,6 +353,7 @@
349
353
  el.hidden = !allowed;
350
354
  el.disabled = !allowed;
351
355
  });
356
+ syncNavSections();
352
357
  const currentButton = document.querySelector('nav button[data-page="' + cssEscape(state.currentPage) + '"]');
353
358
  if (currentButton && currentButton.hidden) {
354
359
  const first = [...document.querySelectorAll("nav button[data-page]")].find((b) => !b.hidden);
@@ -371,7 +376,7 @@
371
376
  ["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn,#createSlackChannelBtn", "users.write"],
372
377
  ["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke],[data-peer-invite-delete]", "peers.write"],
373
378
  ["#checkPeerReachabilityBtn,#discoverPeersBtn,#cancelPeerDiscoveryBtn,[data-peer-probe]", "peers.connect"],
374
- ["#exportPeerIdentityBtn,#restorePeerIdentityBtn,[data-peer-repin]", "peers.write"],
379
+ ["#exportPeerIdentityBtn,#restorePeerIdentityBtn,[data-peer-repin],[data-peer-rotate]", "peers.write"],
375
380
  ["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
376
381
  ["[data-switch]", "sessions.write"],
377
382
  ["[data-queue],[data-q]", "queue.write"],
@@ -385,6 +390,59 @@
385
390
  if (!can(permission)) el.title = "Permission required: " + permission;
386
391
  }));
387
392
  }
393
+ function readOpenNavSections() {
394
+ try {
395
+ const raw = localStorage.getItem(NAV_OPEN_STORAGE_KEY);
396
+ if (!raw) return null;
397
+ const parsed = JSON.parse(raw);
398
+ return Array.isArray(parsed) ? new Set(parsed.filter(Boolean)) : null;
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+ function writeOpenNavSections() {
404
+ const open = [...document.querySelectorAll("[data-nav-section]")].filter((section) => section.dataset.navOpen === "true").map((section) => section.dataset.navSection).filter(Boolean);
405
+ localStorage.setItem(NAV_OPEN_STORAGE_KEY, JSON.stringify(open));
406
+ }
407
+ function setNavSectionOpen(sectionId, open, options = {}) {
408
+ const section = document.querySelector('[data-nav-section="' + cssEscape(sectionId) + '"]');
409
+ if (!section) return;
410
+ const items = section.querySelector(".nav-section-items");
411
+ const toggle = section.querySelector("[data-nav-toggle]");
412
+ section.dataset.navOpen = open ? "true" : "false";
413
+ if (items) items.hidden = !open;
414
+ if (toggle) toggle.setAttribute("aria-expanded", open ? "true" : "false");
415
+ if (options.persist !== false) writeOpenNavSections();
416
+ }
417
+ function sectionForPage(name) {
418
+ const button = document.querySelector('nav button[data-page="' + cssEscape(name) + '"]');
419
+ return button?.closest("[data-nav-section]")?.dataset.navSection || "";
420
+ }
421
+ function openSectionForPage(name, options = {}) {
422
+ const sectionId = sectionForPage(name);
423
+ if (sectionId) setNavSectionOpen(sectionId, true, options);
424
+ }
425
+ function syncNavSections() {
426
+ document.querySelectorAll("[data-nav-section]").forEach((section) => {
427
+ const visiblePages = [...section.querySelectorAll("button[data-page]")].filter((button) => !button.hidden);
428
+ const hasVisiblePages = visiblePages.length > 0;
429
+ section.hidden = !hasVisiblePages;
430
+ const active = visiblePages.some((button) => button.dataset.page === state.currentPage);
431
+ section.classList.toggle("active", active);
432
+ section.querySelector("[data-nav-toggle]")?.classList.toggle("active", active);
433
+ if (active && section.dataset.navOpen !== "true") setNavSectionOpen(section.dataset.navSection, true, { persist: false });
434
+ });
435
+ }
436
+ function initNavSections() {
437
+ const saved = readOpenNavSections();
438
+ document.querySelectorAll("[data-nav-section]").forEach((section) => {
439
+ const sectionId = section.dataset.navSection;
440
+ const open = saved ? saved.has(sectionId) : section.dataset.navDefaultOpen === "true";
441
+ setNavSectionOpen(sectionId, open, { persist: false });
442
+ });
443
+ openSectionForPage(state.currentPage, { persist: false });
444
+ syncNavSections();
445
+ }
388
446
  function modelLabel(m) {
389
447
  const meta = [m.contextWindow ? compactNum(m.contextWindow) : "", m.supportsImages === true ? "img" : m.supportsImages === false ? "text" : "", m.supportsThinking === true ? "think" : ""].filter(Boolean).join(" ");
390
448
  return (m.displayName || m.slug) + (meta ? " \xB7 " + meta : "");
@@ -427,9 +485,11 @@
427
485
  }
428
486
  function page(name) {
429
487
  state.currentPage = name;
430
- document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("active", b.dataset.page === name));
488
+ openSectionForPage(name);
489
+ document.querySelectorAll("nav button[data-page]").forEach((b) => b.classList.toggle("active", b.dataset.page === name));
490
+ syncNavSections();
431
491
  document.querySelectorAll(".page").forEach((p) => p.classList.toggle("active", p.id === "page-" + name));
432
- document.getElementById("pageTitle").textContent = name[0].toUpperCase() + name.slice(1);
492
+ document.getElementById("pageTitle").textContent = PAGE_LABELS[name] || name[0].toUpperCase() + name.slice(1);
433
493
  document.getElementById("sidebar").classList.remove("open");
434
494
  void reloadCurrentPage().catch((err) => toast(err.message || String(err)));
435
495
  }
@@ -446,6 +506,7 @@
446
506
  if (name === "diagnostics") await loadDiagnostics();
447
507
  if (name === "artifacts") await loadArtifacts();
448
508
  if (name === "activity") await loadActivity();
509
+ if (name === "trace") renderTracePlaceholder();
449
510
  if (name === "tasks") await loadTasks();
450
511
  if (name === "metrics") await loadMetrics();
451
512
  if (name === "adapters") await loadAdapterHealth();
@@ -453,9 +514,15 @@
453
514
  if (name === "access") await loadAccess();
454
515
  if (name === "version") await loadVersion();
455
516
  }
456
- document.querySelectorAll("nav button").forEach((b) => b.onclick = () => page(b.dataset.page));
517
+ document.querySelectorAll("nav button[data-page]").forEach((b) => b.onclick = () => page(b.dataset.page));
518
+ document.querySelectorAll("[data-nav-toggle]").forEach((b) => b.onclick = () => {
519
+ const sectionId = b.dataset.navToggle;
520
+ const section = document.querySelector('[data-nav-section="' + cssEscape(sectionId) + '"]');
521
+ setNavSectionOpen(sectionId, section?.dataset.navOpen !== "true");
522
+ syncNavSections();
523
+ });
524
+ initNavSections();
457
525
  document.getElementById("menuBtn").onclick = () => document.getElementById("sidebar").classList.toggle("open");
458
- document.getElementById("refreshBtn").onclick = () => loadBootstrap();
459
526
  document.getElementById("themeBtn").onclick = toggleTheme;
460
527
  document.getElementById("toggleToolsBtn").onclick = toggleTools;
461
528
  document.getElementById("logoutBtn").onclick = () => safe(async () => {
@@ -517,6 +584,49 @@
517
584
  };
518
585
  }
519
586
  const sessionsPager = createPaginator("sessionsPager", () => loadSessions(false), 50);
587
+ function createCursorPager(containerId, onChange) {
588
+ const container = document.getElementById(containerId);
589
+ return {
590
+ stack: [],
591
+ cursor: null,
592
+ nextCursor: null,
593
+ hasNext: false,
594
+ total: 0,
595
+ reset() {
596
+ this.stack = [];
597
+ this.cursor = null;
598
+ this.nextCursor = null;
599
+ this.hasNext = false;
600
+ this.total = 0;
601
+ },
602
+ render(meta = {}) {
603
+ if (!container) return;
604
+ this.nextCursor = meta.nextCursor || null;
605
+ this.hasNext = Boolean(meta.hasNext);
606
+ this.total = Number(meta.total || 0);
607
+ container.innerHTML = "<span>" + esc(this.total ? this.total + " total" : "") + '</span><div class="pager-actions"><button data-cursor-action="prev" ' + (!this.stack.length ? "disabled" : "") + '>Previous</button><button data-cursor-action="next" ' + (!this.hasNext ? "disabled" : "") + ">Next</button></div>";
608
+ const prev = container.querySelector('[data-cursor-action="prev"]');
609
+ const next = container.querySelector('[data-cursor-action="next"]');
610
+ prev.onclick = () => {
611
+ if (this.stack.length) {
612
+ this.cursor = this.stack.pop() || null;
613
+ onChange();
614
+ }
615
+ };
616
+ next.onclick = () => {
617
+ if (this.hasNext && this.nextCursor) {
618
+ this.stack.push(this.cursor);
619
+ this.cursor = this.nextCursor;
620
+ onChange();
621
+ }
622
+ };
623
+ }
624
+ };
625
+ }
626
+ const activityPager = createCursorPager("activityPager", () => loadActivity(false));
627
+ const auditPager = createCursorPager("auditPager", () => loadAudit(false));
628
+ const artifactPager = createCursorPager("artifactPager", () => loadArtifacts(false));
629
+ const jobsPager = createCursorPager("jobsPager", () => loadTasks(false));
520
630
  async function loadBootstrap() {
521
631
  const local = await api("/api/bootstrap", { local: true });
522
632
  state.auth = local.auth || null;
@@ -1542,7 +1652,8 @@
1542
1652
  };
1543
1653
  function renderQueue(queue, paused) {
1544
1654
  document.getElementById("queueStatus").textContent = paused ? "Paused" : "Running";
1545
- document.getElementById("queueList").innerHTML = (queue || []).map((q, i) => '<div class="item queue-item" draggable="true" data-queue-id="' + attr(q.id) + '"><strong>' + esc(i + 1 + ". " + q.id + " - " + q.description) + "</strong><small>Created " + fmtDate(q.createdAt) + " / attempts " + q.attempts + (q.lastError ? " / " + esc(q.lastError) : "") + '</small><div class="row"><button data-q="run" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="top" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Top</button><button data-q="up" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Up</button><button data-q="down" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Down</button><button data-q="cancel" data-id="' + q.id + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>';
1655
+ document.getElementById("queueList").innerHTML = (queue || []).map((q, i) => '<div class="item queue-item" draggable="true" data-queue-id="' + attr(q.id) + '"><strong>' + esc(i + 1 + ". " + q.id + " - " + q.description) + "</strong><small>Created " + fmtDate(q.createdAt) + " / attempts " + q.attempts + (q.correlationId ? ' / CID: <button type="button" class="copy-id" data-copy-id="' + attr(q.correlationId) + '">' + esc(q.correlationId) + "</button>" : "") + (q.lastError ? " / " + esc(q.lastError) : "") + '</small><div class="row"><button data-q="run" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="top" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Top</button><button data-q="up" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Up</button><button data-q="down" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Down</button><button data-q="cancel" data-id="' + q.id + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>';
1656
+ document.querySelectorAll("#queueList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Correlation ID copied"));
1546
1657
  document.querySelectorAll("[data-q]").forEach((b) => b.onclick = () => safe(async () => {
1547
1658
  if (!can("queue.write")) {
1548
1659
  toast("Permission required: queue.write");
@@ -1587,12 +1698,14 @@
1587
1698
  const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.queue }) });
1588
1699
  renderQueue(r.queue, r.paused);
1589
1700
  }));
1590
- async function loadArtifacts() {
1701
+ async function loadArtifacts(reset = true) {
1702
+ if (reset) artifactPager.reset();
1591
1703
  setLoading("artifactList", "Loading artifacts...");
1592
1704
  document.getElementById("artifactPreview").innerHTML = "";
1593
- const data = await api("/api/artifacts");
1705
+ const data = await api("/api/artifacts", { query: { limit: 50, cursor: artifactPager.cursor || void 0 } });
1594
1706
  state.artifactReports = data.reports || [];
1595
1707
  renderArtifacts();
1708
+ artifactPager.render(data.pagination || {});
1596
1709
  }
1597
1710
  function artifactMatches(a, kind, query) {
1598
1711
  const name = (a.name || a.relativePath || "").toLowerCase();
@@ -1601,7 +1714,7 @@
1601
1714
  if (kind === "docs") return !/\\.(png|jpe?g|gif|webp|svg)$/i.test(name);
1602
1715
  return true;
1603
1716
  }
1604
- document.getElementById("reloadArtifactsBtn").onclick = loadArtifacts;
1717
+ document.getElementById("reloadArtifactsBtn").onclick = () => loadArtifacts(true);
1605
1718
  document.getElementById("artifactSearch").oninput = renderArtifacts;
1606
1719
  document.getElementById("artifactKind").onchange = renderArtifacts;
1607
1720
  document.getElementById("deleteSelectedArtifactsBtn").onclick = () => safe(async () => {
@@ -1717,18 +1830,22 @@
1717
1830
  throw err;
1718
1831
  }
1719
1832
  }
1720
- async function loadTasks() {
1833
+ async function loadTasks(reset = true) {
1834
+ if (reset) jobsPager.reset();
1721
1835
  setLoading("tasksList", "Loading tasks...");
1722
- const [d, jobs] = await Promise.all([api("/api/tasks"), api("/api/jobs")]);
1836
+ const [d, jobs] = await Promise.all([api("/api/tasks"), api("/api/jobs", { query: { limit: 100, cursor: jobsPager.cursor || void 0 } })]);
1723
1837
  renderTasks(d, jobs);
1838
+ jobsPager.render(jobs?.pagination || {});
1724
1839
  }
1725
1840
  function taskCard(t, title) {
1726
1841
  if (!t) return '<div class="item"><strong>' + esc(title) + "</strong><small>Idle</small></div>";
1727
1842
  const tools = (t.tools || []).map((x) => x.name + " x" + x.count).join(", ") || "-";
1728
- return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small><small>" + esc("Elapsed " + fmtDuration(t.durationMs) + " / current " + (t.currentTool || "-") + " / last " + (t.lastTool || "-")) + "</small><small>" + esc("Tools: " + tools + " / output chars " + (t.outputChars || 0)) + "</small><small>" + esc(t.prompt || t.detail || "") + "</small></div>";
1843
+ return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small>" + (t.correlationId ? '<small>CID: <button type="button" class="copy-id" data-copy-id="' + attr(t.correlationId) + '">' + esc(t.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(t.correlationId) + '">Trace</button></small>' : "") + "<small>" + esc("Elapsed " + fmtDuration(t.durationMs) + " / current " + (t.currentTool || "-") + " / last " + (t.lastTool || "-")) + "</small><small>" + esc("Tools: " + tools + " / output chars " + (t.outputChars || 0)) + "</small><small>" + esc(t.prompt || t.detail || "") + "</small></div>";
1729
1844
  }
1730
1845
  function renderTasks(d, jobs) {
1731
- document.getElementById("tasksList").innerHTML = '<div class="task-grid">' + taskCard(d.current, "Current web turn") + taskCard(d.external, "External CLI turn") + '</div><h2 class="task-section-title">Unified jobs</h2><div class="list">' + renderUnifiedJobs(jobs?.jobs || []) + '</div><h2 class="task-section-title">Queue</h2><div class="list">' + ((d.queue || []).map((q) => '<div class="item"><strong>' + esc(q.id + " \xB7 " + q.description) + "</strong><small>" + esc(fmtDate(q.createdAt) + " / attempts " + q.attempts) + '</small><div class="row"><button data-q="run" data-id="' + attr(q.id) + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="cancel" data-id="' + attr(q.id) + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>') + '</div><h2 class="task-section-title">Recent turns</h2><div class="list">' + ((d.recent || []).map((e) => '<div class="item"><strong>' + esc(e.status + " / " + e.source + " / " + e.type) + "</strong><small>" + esc(fmtDate(e.timestamp) + " / " + (e.threadId || "-")) + "</small><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("") || '<div class="item">No recent tasks.</div>') + "</div>";
1846
+ document.getElementById("tasksList").innerHTML = '<div class="task-grid">' + taskCard(d.current, "Current web turn") + taskCard(d.external, "External CLI turn") + '</div><h2 class="task-section-title">Unified jobs</h2><div class="list">' + renderUnifiedJobs(jobs?.jobs || []) + '</div><h2 class="task-section-title">Queue</h2><div class="list">' + ((d.queue || []).map((q) => '<div class="item"><strong>' + esc(q.id + " \xB7 " + q.description) + "</strong><small>" + esc(fmtDate(q.createdAt) + " / attempts " + q.attempts) + (q.correlationId ? " / CID: " : "") + (q.correlationId ? '<button type="button" class="copy-id" data-copy-id="' + attr(q.correlationId) + '">' + esc(q.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(q.correlationId) + '">Trace</button>' : "") + '</small><div class="row"><button data-q="run" data-id="' + attr(q.id) + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="cancel" data-id="' + attr(q.id) + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>') + '</div><h2 class="task-section-title">Recent turns</h2><div class="list">' + ((d.recent || []).map((e) => '<div class="item"><strong>' + esc(e.status + " / " + e.source + " / " + e.type) + "</strong><small>" + esc(fmtDate(e.timestamp) + " / " + (e.threadId || "-")) + (e.correlationId ? " / CID: " : "") + (e.correlationId ? '<button type="button" class="copy-id" data-copy-id="' + attr(e.correlationId) + '">' + esc(e.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(e.correlationId) + '">Trace</button>' : "") + "</small><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("") || '<div class="item">No recent tasks.</div>') + "</div>";
1847
+ document.querySelectorAll("#tasksList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Correlation ID copied"));
1848
+ document.querySelectorAll("#tasksList [data-trace-id]").forEach((b) => b.onclick = () => openTrace(b.dataset.traceId || ""));
1732
1849
  document.querySelectorAll("#tasksList [data-q]").forEach((b) => b.onclick = () => safe(async () => {
1733
1850
  if (!can("queue.write")) {
1734
1851
  toast("Permission required: queue.write");
@@ -1745,7 +1862,7 @@
1745
1862
  return jobs.map((job) => {
1746
1863
  const retryPermission = jobActionPermission(job, "retry");
1747
1864
  const cancelPermission = jobActionPermission(job, "cancel");
1748
- return '<div class="item"><strong>' + esc(job.title) + ' <span class="adapter-status ' + esc(jobStatusClass(job.status)) + '">' + esc(job.status) + "</span></strong><small>" + esc([job.kind, job.source, job.agentLabel || job.agentId, fmtDate(job.startedAt)].filter(Boolean).join(" / ")) + "</small>" + (job.owner ? "<small>" + esc("Owner: " + (job.owner.label || job.owner.username || job.owner.id || "-")) + "</small>" : "") + (job.threadId ? "<small>" + esc("Thread: " + job.threadId) + "</small>" : "") + (job.summary ? "<small>" + esc(short(job.summary, 300)) + "</small>" : "") + (job.logTail ? '<pre class="update-log">' + esc(short(job.logTail, 1200)) + "</pre>" : "") + '<div class="row">' + (job.canReadLog ? '<button class="secondary" data-job-log="' + attr(job.id) + '">Log</button>' : "") + (job.canRetry ? '<button class="secondary" data-job-action="retry" data-job-permission="' + attr(retryPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(retryPermission) + ">Retry</button>" : "") + (job.canCancel ? '<button class="danger" data-job-action="cancel" data-job-permission="' + attr(cancelPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(cancelPermission) + ">Cancel</button>" : "") + "</div></div>";
1865
+ return '<div class="item"><strong>' + esc(job.title) + ' <span class="adapter-status ' + esc(jobStatusClass(job.status)) + '">' + esc(job.status) + "</span></strong><small>" + esc([job.kind, job.source, job.agentLabel || job.agentId, fmtDate(job.startedAt)].filter(Boolean).join(" / ")) + "</small>" + (job.correlationId ? '<small>CID: <button type="button" class="copy-id" data-copy-id="' + attr(job.correlationId) + '">' + esc(job.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(job.correlationId) + '">Trace</button></small>' : "") + (job.owner ? "<small>" + esc("Owner: " + (job.owner.label || job.owner.username || job.owner.id || "-")) + "</small>" : "") + (job.threadId ? "<small>" + esc("Thread: " + job.threadId) + "</small>" : "") + (job.summary ? "<small>" + esc(short(job.summary, 300)) + "</small>" : "") + (job.logTail ? '<pre class="update-log">' + esc(short(job.logTail, 1200)) + "</pre>" : "") + '<div class="row">' + (job.canReadLog ? '<button class="secondary" data-job-log="' + attr(job.id) + '">Log</button>' : "") + (job.canRetry ? '<button class="secondary" data-job-action="retry" data-job-permission="' + attr(retryPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(retryPermission) + ">Retry</button>" : "") + (job.canCancel ? '<button class="danger" data-job-action="cancel" data-job-permission="' + attr(cancelPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(cancelPermission) + ">Cancel</button>" : "") + "</div></div>";
1749
1866
  }).join("") || '<div class="item">No jobs.</div>';
1750
1867
  }
1751
1868
  function jobActionPermission(job, action) {
@@ -1773,7 +1890,7 @@
1773
1890
  }
1774
1891
  }));
1775
1892
  }
1776
- document.getElementById("reloadTasksBtn").onclick = () => loadTasks();
1893
+ document.getElementById("reloadTasksBtn").onclick = () => loadTasks(true);
1777
1894
  async function loadMetrics() {
1778
1895
  setLoading("metricsPanel", "Loading metrics...");
1779
1896
  const d = await api("/api/metrics");
@@ -1850,13 +1967,15 @@
1850
1967
  }
1851
1968
  document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
1852
1969
  function activityQuery() {
1853
- return { source: val("activitySource"), category: val("activityCategory"), status: val("activityStatus"), limit: val("activityLimit") || "100", actor: val("activityActor") || void 0, agent: val("activityAgent") || "all", thread: val("activityThread") || void 0, workspace: val("activityWorkspace") || void 0, type: val("activityType") || void 0, since: val("activitySince") || void 0 };
1970
+ return { source: val("activitySource"), category: val("activityCategory"), status: val("activityStatus"), limit: val("activityLimit") || "100", cursor: activityPager.cursor || void 0, actor: val("activityActor") || void 0, agent: val("activityAgent") || "all", thread: val("activityThread") || void 0, workspace: val("activityWorkspace") || void 0, type: val("activityType") || void 0, since: val("activitySince") || void 0 };
1854
1971
  }
1855
- async function loadActivity() {
1972
+ async function loadActivity(reset = true) {
1973
+ if (reset) activityPager.reset();
1856
1974
  setLoading("activityList", "Loading activity...");
1857
1975
  const data = await api("/api/activity", { query: activityQuery() });
1858
1976
  state.activityEvents = data.events || [];
1859
1977
  renderActivity(state.activityEvents);
1978
+ activityPager.render(data.pagination || {});
1860
1979
  }
1861
1980
  function activityWorkspace(e) {
1862
1981
  const active = state.snapshot?.session;
@@ -1872,6 +1991,7 @@
1872
1991
  const actor = activityActorText(e);
1873
1992
  const parts = [];
1874
1993
  if (actor) parts.push("User: " + esc(actor));
1994
+ if (e.correlationId) parts.push('CID: <button type="button" class="copy-id" data-copy-id="' + attr(e.correlationId) + '">' + esc(e.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(e.correlationId) + '">Trace</button>');
1875
1995
  if (e.threadId) parts.push('<button type="button" class="copy-id" data-copy-id="' + attr(e.threadId) + '">' + esc(e.threadId) + "</button>");
1876
1996
  if (workspace) parts.push(esc(workspace));
1877
1997
  if (duration) parts.push(esc(duration));
@@ -1883,9 +2003,10 @@
1883
2003
  return '<div class="item"><strong><span class="chip ' + (e.status === "failed" ? "error" : e.status === "queued" ? "warn" : "") + '">' + esc(e.status) + "</span>" + esc([fmtDate(e.timestamp), e.source, e.category, e.type].filter(Boolean).join(" | ")) + "</strong><small>" + esc(short(e.prompt || e.detail || "", 220)) + "</small>" + (meta ? "<small>" + meta + "</small>" : "") + "</div>";
1884
2004
  }).join("") || '<div class="item">No activity.</div>';
1885
2005
  document.querySelectorAll("#activityList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Thread ID copied"));
2006
+ document.querySelectorAll("#activityList [data-trace-id]").forEach((b) => b.onclick = () => openTrace(b.dataset.traceId || ""));
1886
2007
  }
1887
- document.getElementById("loadActivityBtn").onclick = () => loadActivity();
1888
- document.getElementById("activitySince").onchange = () => loadActivity();
2008
+ document.getElementById("loadActivityBtn").onclick = () => loadActivity(true);
2009
+ document.getElementById("activitySince").onchange = () => loadActivity(true);
1889
2010
  document.getElementById("exportActivityBtn").onclick = () => {
1890
2011
  const rows = (state.activityEvents || []).map((e) => [e.timestamp, e.source, e.category || "", e.status, e.type, activityActorText(e), e.agentId || "", e.threadId || "", activityWorkspace(e), e.prompt || e.detail || ""].join("\\t")).join("\\n");
1891
2012
  const blob = new Blob([rows], { type: "text/tab-separated-values" });
@@ -1895,23 +2016,63 @@
1895
2016
  a.click();
1896
2017
  URL.revokeObjectURL(a.href);
1897
2018
  };
2019
+ function renderTracePlaceholder() {
2020
+ const target = document.getElementById("traceDetail");
2021
+ if (target && !target.innerHTML) target.innerHTML = '<div class="item">Enter a correlation ID or open Trace from Activity/Tasks.</div>';
2022
+ }
2023
+ async function openTrace(correlationId) {
2024
+ document.getElementById("traceCorrelationId").value = correlationId;
2025
+ page("trace");
2026
+ await loadTrace(correlationId);
2027
+ }
2028
+ async function loadTrace(correlationId = val("traceCorrelationId")) {
2029
+ if (!correlationId) {
2030
+ renderTracePlaceholder();
2031
+ return;
2032
+ }
2033
+ setLoading("traceDetail", "Loading trace...");
2034
+ const data = await api("/api/trace", { query: { correlationId } });
2035
+ renderTrace(data);
2036
+ }
2037
+ function renderTrace(data) {
2038
+ const s = data.summary || {};
2039
+ const rows = [["Correlation ID", data.correlationId], ["Status", s.status], ["Started", fmtDate(s.startedAt)], ["Updated", fmtDate(s.updatedAt)], ["Agent", s.agentId], ["Thread", s.threadId], ["Workspace", s.workspace], ["Sources", (s.sources || []).join(", ")]];
2040
+ const timeline = (data.timeline || []).map((item) => '<div class="item"><strong>' + esc(fmtDate(item.at) + " | " + item.source + " | " + (item.status || item.type)) + "</strong><small>" + esc([item.title, item.agentId, item.threadId, item.workspace].filter(Boolean).join(" | ")) + "</small>" + (item.detail ? "<small>" + esc(short(item.detail, 500)) + "</small>" : "") + "</div>").join("") || '<div class="item">No events for this correlation ID.</div>';
2041
+ document.getElementById("traceDetail").innerHTML = card("Trace summary", rows) + '<h2 class="task-section-title">Timeline</h2>' + timeline;
2042
+ }
2043
+ document.getElementById("loadTraceBtn").onclick = () => loadTrace();
2044
+ document.getElementById("traceCorrelationId").addEventListener("keydown", (e) => {
2045
+ if (e.key === "Enter") loadTrace();
2046
+ });
1898
2047
  async function loadSettings() {
1899
2048
  state.settingsWizard = null;
1900
- document.getElementById("settingsTabs").style.display = "";
2049
+ document.getElementById("settingsTabHeader").style.display = "";
2050
+ document.getElementById("settingsSubnav").style.display = "";
2051
+ document.getElementById("settingsActions").style.display = "";
1901
2052
  setLoading("settingsForm", "Loading settings...");
1902
2053
  const data = await api("/api/settings");
1903
2054
  state.settings = data.settings;
1904
2055
  renderSettings();
1905
2056
  }
1906
- const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Slack", "Operations", "Artifacts", "Workspace", "Peers", "Voice", "Dashboard"];
1907
- const agentSettingGroups = ["Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"];
1908
- function orderedSettingsGroups(groups) {
1909
- const known = settingsGroupOrder.filter((name) => groups[name]);
1910
- const extra = Object.keys(groups).filter((name) => !settingsGroupOrder.includes(name)).sort();
1911
- return known.concat(extra);
1912
- }
1913
- function agentSettingsNav(current) {
1914
- return '<div class="agent-settings-nav"><strong>Agent settings</strong>' + agentSettingGroups.map((name) => '<button type="button" data-setting-tab="' + attr(name) + '" class="' + (name === current ? "active" : "") + '">' + esc(name) + "</button>").join("") + "</div>";
2057
+ const settingsCategoryDefinitions = [
2058
+ { id: "agents", label: "Agents", groups: ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"] },
2059
+ { id: "chat", label: "Chat", groups: ["Telegram", "Discord", "Slack"] },
2060
+ { id: "operations", label: "Operations", groups: ["Operations", "Artifacts", "Peers", "Voice"] },
2061
+ { id: "workspace", label: "Workspace", groups: ["Workspace"] },
2062
+ { id: "dashboard", label: "Dashboard", groups: ["Dashboard"] }
2063
+ ];
2064
+ function settingsCategories(groups) {
2065
+ const used = /* @__PURE__ */ new Set();
2066
+ const categories = settingsCategoryDefinitions.map((def) => {
2067
+ const available = def.groups.filter((name) => groups[name]);
2068
+ available.forEach((name) => used.add(name));
2069
+ return available.length ? { id: def.id, label: def.label, groups: available, count: available.reduce((sum, name) => sum + groups[name].length, 0) } : null;
2070
+ }).filter(Boolean);
2071
+ Object.keys(groups).filter((name) => !used.has(name)).sort().forEach((name) => categories.push({ id: "extra:" + name, label: name, groups: [name], count: groups[name].length }));
2072
+ return categories;
2073
+ }
2074
+ function settingsCategoryForGroup(categories, group) {
2075
+ return categories.find((category) => category.groups.includes(group)) || categories[0];
1915
2076
  }
1916
2077
  function settingHelp(s) {
1917
2078
  return s.help ? '<span class="setting-info" tabindex="0" role="img" aria-label="' + attr(s.help) + '" title="' + attr(s.help) + '">i</span>' : "";
@@ -1919,23 +2080,44 @@
1919
2080
  function settingLabel(s) {
1920
2081
  return '<label class="setting-label"><span>' + esc(s.label) + "</span>" + settingHelp(s) + "</label>";
1921
2082
  }
2083
+ function settingCategoryButton(category, activeCategory) {
2084
+ const active = category.id === activeCategory?.id;
2085
+ return '<button type="button" role="tab" aria-selected="' + (active ? "true" : "false") + '" tabindex="' + (active ? "0" : "-1") + '" data-setting-category="' + attr(category.id) + '" class="' + (active ? "active" : "") + '">' + esc(category.label + " (" + category.count + ")") + "</button>";
2086
+ }
2087
+ function renderSettingsSubnav(category, groups) {
2088
+ const target = document.getElementById("settingsSubnav");
2089
+ if (!target) return;
2090
+ if (!category || category.groups.length < 2) {
2091
+ target.hidden = true;
2092
+ target.innerHTML = "";
2093
+ return;
2094
+ }
2095
+ target.hidden = false;
2096
+ target.innerHTML = "<label><span>" + esc(category.label) + ' section</span><select id="settingsSubgroupSelect">' + category.groups.map((name) => '<option value="' + attr(name) + '" ' + (name === state.settingsGroup ? "selected" : "") + ">" + esc(name + " (" + groups[name].length + ")") + "</option>").join("") + "</select></label>";
2097
+ document.getElementById("settingsSubgroupSelect").onchange = (e) => {
2098
+ state.settingsGroup = e.target.value;
2099
+ renderSettings();
2100
+ };
2101
+ }
2102
+ function bindSettingsTabs(categories) {
2103
+ document.querySelectorAll("#settingsTabs [data-setting-category]").forEach((b) => b.onclick = () => {
2104
+ const category = categories.find((item) => item.id === b.dataset.settingCategory);
2105
+ if (!category) return;
2106
+ if (!category.groups.includes(state.settingsGroup)) state.settingsGroup = category.groups[0];
2107
+ renderSettings();
2108
+ });
2109
+ }
1922
2110
  function renderSettings() {
1923
2111
  const groups = {};
1924
2112
  state.settings.forEach((s) => (groups[s.group] ??= []).push(s));
1925
- const names = orderedSettingsGroups(groups);
1926
- if (!state.settingsGroup || !groups[state.settingsGroup]) state.settingsGroup = groups.Agents ? "Agents" : names[0];
1927
- document.getElementById("settingsTabs").innerHTML = names.map((name) => '<button data-setting-tab="' + attr(name) + '" class="' + (name === state.settingsGroup ? "active" : "") + '">' + esc(name) + " (" + groups[name].length + ")</button>").join("");
1928
- document.querySelectorAll("[data-setting-tab]").forEach((b) => b.onclick = () => {
1929
- state.settingsGroup = b.dataset.settingTab;
1930
- renderSettings();
1931
- });
2113
+ const categories = settingsCategories(groups);
2114
+ if (!state.settingsGroup || !groups[state.settingsGroup]) state.settingsGroup = groups.Agents ? "Agents" : categories[0]?.groups[0];
2115
+ const activeCategory = settingsCategoryForGroup(categories, state.settingsGroup);
2116
+ document.getElementById("settingsTabs").innerHTML = categories.map((category) => settingCategoryButton(category, activeCategory)).join("");
2117
+ renderSettingsSubnav(activeCategory, groups);
1932
2118
  const items = groups[state.settingsGroup] || [];
1933
- const nav = state.settingsGroup === "Agents" || agentSettingGroups.includes(state.settingsGroup) ? agentSettingsNav(state.settingsGroup) : "";
1934
- document.getElementById("settingsForm").innerHTML = '<div class="settings-section"><h2>' + esc(state.settingsGroup || "Settings") + '</h2><div id="settingsRestartBanner"></div>' + nav + items.map((s) => '<div class="setting" data-setting-box="' + attr(s.key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '">' + settingLabel(s) + settingInput(s) + "<small>" + esc(s.key) + " - " + esc(s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + (s.configured ? " Saved in env file." : " Using default.") + '</small><div class="setting-actions"><button type="button" class="secondary" data-reset-setting="' + attr(s.key) + '">Use default</button>' + (s.kind === "secret" ? '<button type="button" class="secondary" data-reveal-setting="' + attr(s.key) + '">Reveal/replace</button>' : "") + '</div><div class="setting-error"></div></div>').join("") + "</div>";
1935
- document.querySelectorAll("[data-setting-tab]").forEach((b) => b.onclick = () => {
1936
- state.settingsGroup = b.dataset.settingTab;
1937
- renderSettings();
1938
- });
2119
+ document.getElementById("settingsForm").innerHTML = '<div class="settings-section"><h2>' + esc(state.settingsGroup || "Settings") + '</h2><div id="settingsRestartBanner"></div>' + items.map((s) => '<div class="setting" data-setting-box="' + attr(s.key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '">' + settingLabel(s) + settingInput(s) + "<small>" + esc(s.key) + " - " + esc(s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + (s.configured ? " Saved in env file." : " Using default.") + '</small><div class="setting-actions"><button type="button" class="secondary" data-reset-setting="' + attr(s.key) + '">Use default</button>' + (s.kind === "secret" ? '<button type="button" class="secondary" data-reveal-setting="' + attr(s.key) + '">Reveal/replace</button>' : "") + '</div><div class="setting-error"></div></div>').join("") + "</div>";
2120
+ bindSettingsTabs(categories);
1939
2121
  bindSettingsUx();
1940
2122
  }
1941
2123
  function settingAttrs(s, original) {
@@ -2415,9 +2597,10 @@
2415
2597
  loadLocks();
2416
2598
  });
2417
2599
  function auditQuery() {
2418
- return { limit: val("auditLimit") || "50", channel: val("auditChannel") || "all", category: val("auditCategory") || "all", status: val("auditStatus") || "all", actor: val("auditActor") || void 0, agent: val("auditAgent") || "all", thread: val("auditThread") || void 0, workspace: val("auditWorkspace") || void 0, since: val("auditSince") || void 0 };
2600
+ return { limit: val("auditLimit") || "50", cursor: auditPager.cursor || void 0, channel: val("auditChannel") || "all", category: val("auditCategory") || "all", status: val("auditStatus") || "all", actor: val("auditActor") || void 0, agent: val("auditAgent") || "all", thread: val("auditThread") || void 0, workspace: val("auditWorkspace") || void 0, since: val("auditSince") || void 0 };
2419
2601
  }
2420
- async function loadAudit() {
2602
+ async function loadAudit(reset = true) {
2603
+ if (reset) auditPager.reset();
2421
2604
  if (!can("audit.read")) {
2422
2605
  document.getElementById("auditList").innerHTML = '<div class="item">Permission required: audit.read</div>';
2423
2606
  return;
@@ -2425,6 +2608,7 @@
2425
2608
  const d = await api("/api/audit", { query: auditQuery() });
2426
2609
  state.auditEvents = d.events || [];
2427
2610
  renderAudit(state.auditEvents);
2611
+ auditPager.render(d.pagination || {});
2428
2612
  }
2429
2613
  function renderAudit(events) {
2430
2614
  document.getElementById("auditList").innerHTML = (events || []).map((e) => {
@@ -2433,7 +2617,7 @@
2433
2617
  return '<div class="item"><strong>' + esc([fmtDate(e.timestamp), e.channelId, e.status, e.category, e.action].filter(Boolean).join(" | ")) + "</strong><small>" + esc("Actor: " + actor) + "</small>" + (meta ? "<small>" + esc(meta) + "</small>" : "") + "<small>" + esc(e.description || e.detail || "") + "</small></div>";
2434
2618
  }).join("") || '<div class="item">No audit events.</div>';
2435
2619
  }
2436
- document.getElementById("loadAuditBtn").onclick = () => loadAudit();
2620
+ document.getElementById("loadAuditBtn").onclick = () => loadAudit(true);
2437
2621
  document.getElementById("exportAuditBtn").onclick = () => {
2438
2622
  const events = state.auditEvents || [];
2439
2623
  const text = events.map((e) => [fmtDate(e.timestamp), e.channelId, e.status, e.category, e.action, e.actor?.label || e.actor?.username || e.actor?.id || e.actorId || "system", e.contextKey, e.agentId || "", e.threadId || "", e.workspace || "", e.description || e.detail || ""].join("\\t")).join("\\n");
@@ -2454,22 +2638,32 @@
2454
2638
  if (document.getElementById("logFollow").checked) document.getElementById("logs").scrollTop = document.getElementById("logs").scrollHeight;
2455
2639
  }
2456
2640
  document.getElementById("loadLogsBtn").onclick = loadLogs;
2457
- function logLevelOf(line) {
2458
- if (line.includes(" ERROR ")) return "ERROR";
2459
- if (line.includes(" WARN ")) return "WARN";
2460
- if (line.includes(" INFO ")) return "INFO";
2461
- return "";
2641
+ function explicitLogLevelOf(line) {
2642
+ const m = String(line || "").match(/^\s*(?:\[[^\]]+\]|\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\s+[+-]\d{2}:?\d{2})?)?\s*(ERROR|WARN|INFO)\b/);
2643
+ return m ? m[1] : "";
2462
2644
  }
2463
2645
  function logTimeOf(line) {
2464
- const m = line.match(/^(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})/);
2646
+ const text = String(line || "");
2647
+ const m = text.match(/^\s*\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})(?:\s+[+-]\d{2}:?\d{2})?\]?/);
2465
2648
  return m ? new Date(m[1].replace(" ", "T")).getTime() : 0;
2466
2649
  }
2650
+ function parsedLogLines() {
2651
+ let currentLevel = "INFO";
2652
+ let currentTime = 0;
2653
+ return state.logsPlain.split(/\n/).filter((line) => line.length > 0).map((line) => {
2654
+ const explicit = explicitLogLevelOf(line);
2655
+ if (explicit) currentLevel = explicit;
2656
+ const time = logTimeOf(line);
2657
+ if (time) currentTime = time;
2658
+ return { line, level: currentLevel, time: currentTime };
2659
+ });
2660
+ }
2467
2661
  function renderLogs() {
2468
2662
  const level = val("logLevel");
2469
2663
  const query = val("logSearch").toLowerCase();
2470
2664
  const since = val("logSince") ? new Date(val("logSince")).getTime() : 0;
2471
- const lines = state.logsPlain.split(/\\n/).filter((line) => line.length > 0 && (level === "all" || line.includes(level)) && (!query || line.toLowerCase().includes(query)) && (!since || !logTimeOf(line) || logTimeOf(line) >= since));
2472
- document.getElementById("logs").innerHTML = lines.map((line) => '<span class="log-line ' + logLevelOf(line) + '">' + esc(line) + "</span>").join("") || "(empty)";
2665
+ const lines = parsedLogLines().filter((entry) => (level === "all" || entry.level === level) && (!query || entry.line.toLowerCase().includes(query)) && (!since || !entry.time || entry.time >= since));
2666
+ document.getElementById("logs").innerHTML = lines.map((entry) => '<span class="log-line ' + entry.level + '">' + esc(entry.line) + "</span>").join("") || "(empty)";
2473
2667
  }
2474
2668
  document.getElementById("logLevel").onchange = renderLogs;
2475
2669
  document.getElementById("logSearch").oninput = renderLogs;
@@ -2728,6 +2922,22 @@
2728
2922
  }
2729
2923
  });
2730
2924
  });
2925
+ document.addEventListener("click", (e) => {
2926
+ const b = e.target.closest?.("[data-peer-rotate]");
2927
+ if (!b) return;
2928
+ safe(async () => {
2929
+ if (!can("peers.write")) {
2930
+ toast("Permission required: peers.write");
2931
+ return;
2932
+ }
2933
+ if (confirm("Create a new pairing invite for this peer using the current scopes?")) {
2934
+ const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerRotate) + "/rotate", { method: "POST", body: JSON.stringify({ expiresMinutes: 10 }), local: true });
2935
+ if (r.invitation?.id) state.peerInviteSecrets[r.invitation.id] = { code: r.code || "", command: r.command || "" };
2936
+ toast("Rotation invite created. Pairing details are shown under Open invitations.", { duration: 8e3 });
2937
+ loadPeers();
2938
+ }
2939
+ });
2940
+ });
2731
2941
  document.getElementById("updateBtn").onclick = () => safe(async () => {
2732
2942
  if (!can("updates.run")) {
2733
2943
  toast("Permission required: updates.run");
@@ -2914,11 +3124,13 @@
2914
3124
  }
2915
3125
  function peerCard(p) {
2916
3126
  const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
3127
+ const trust = p.trustStatus || "trusted";
3128
+ const trustClass = trust === "trusted" ? "enabled" : trust === "tls-unpinned" ? "planned" : "disabled";
2917
3129
  const health = p.remoteStatus || p.lastSeenAt ? "Health: " + (p.remoteStatus || "seen") + (p.lastLatencyMs !== void 0 ? " / " + p.lastLatencyMs + "ms" : "") + (p.remoteVersion ? " / v" + p.remoteVersion : "") : "Health: unchecked";
2918
3130
  const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
2919
3131
  const effective = "Effective access: " + (p.scopes || []).length + " scope(s), agents " + ((p.allowedAgents || []).join(", ") || "all") + ", workspaces " + ((p.allowedWorkspaceRoots || []).join(", ") || "all");
2920
3132
  const history = (p.healthHistory || []).slice(-5).reverse().map((h) => "<small>" + esc(fmtDate(h.checkedAt) + " | " + h.status + (h.latencyMs !== void 0 ? " | " + h.latencyMs + "ms" : "") + (h.error ? " | " + h.error : "")) + "</small>").join("");
2921
- return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong>" + (p.group ? "<small>" + esc("Group: " + p.group) + "</small>" : "") + "<small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + "<small>" + esc("Direction: " + p.direction + " / scopes " + (p.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((p.allowedAgents || []).join(", ") || "all")) + "</small><small>" + esc("Workspaces: " + ((p.allowedWorkspaceRoots || []).join(", ") || "all")) + "</small>" + (aliases ? "<small>" + esc("Aliases: " + aliases) + "</small>" : "") + "<small>" + esc(effective) + "</small><small>" + esc(health) + "</small>" + (p.lastCheckedAt ? "<small>" + esc("Checked: " + fmtDate(p.lastCheckedAt)) + "</small>" : "") + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + (history ? '<details class="peer-health-history"><summary>Health history (' + (p.healthHistory || []).length + ")</summary>" + history + "</details>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-probe="' + attr(p.id) + '"' + disabledAttr("peers.connect") + '>Probe this node</button><button class="secondary" data-peer-repin="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Re-pin TLS</button><button class="secondary" data-peer-edit="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Edit</button><button class="secondary" data-peer-toggle="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">" + (p.enabled ? "Disable" : "Enable") + '</button><button class="danger" data-peer-revoke="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">Revoke</button></div></div>";
3133
+ return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + '</span> <span class="adapter-status ' + trustClass + '">' + esc(trust) + "</span>" + selected + "</strong>" + (p.group ? "<small>" + esc("Group: " + p.group) + "</small>" : "") + "<small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + (p.trustWarnings && p.trustWarnings.length ? peerWarningsHtml(p.trustWarnings, "Trust warning") : "") + "<small>" + esc("Direction: " + p.direction + " / scopes " + (p.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((p.allowedAgents || []).join(", ") || "all")) + "</small><small>" + esc("Workspaces: " + ((p.allowedWorkspaceRoots || []).join(", ") || "all")) + "</small>" + (aliases ? "<small>" + esc("Aliases: " + aliases) + "</small>" : "") + "<small>" + esc(effective) + "</small><small>" + esc(health) + "</small>" + (p.lastCheckedAt ? "<small>" + esc("Checked: " + fmtDate(p.lastCheckedAt)) + "</small>" : "") + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + (history ? '<details class="peer-health-history"><summary>Health history (' + (p.healthHistory || []).length + ")</summary>" + history + "</details>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-probe="' + attr(p.id) + '"' + disabledAttr("peers.connect") + '>Probe this node</button><button class="secondary" data-peer-repin="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Trust TLS</button><button class="secondary" data-peer-rotate="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Rotate</button><button class="secondary" data-peer-edit="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Edit</button><button class="secondary" data-peer-toggle="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">" + (p.enabled ? "Disable" : "Enable") + '</button><button class="danger" data-peer-revoke="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">Revoke</button></div></div>";
2922
3134
  }
2923
3135
  function openPeerDialog(p) {
2924
3136
  const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
@@ -3008,6 +3220,459 @@
3008
3220
  }
3009
3221
  }));
3010
3222
  }
3223
+ const ACCESS_USER_PAGE_SIZE = 20;
3224
+ const ACCESS_PERMISSION_GROUPS = [
3225
+ ["Overview", ["inspect"]],
3226
+ ["Sessions", ["sessions.read", "sessions.write", "prompt.send", "prompt.abort", "queue.read", "queue.write"]],
3227
+ ["Files", ["files.read", "files.write"]],
3228
+ ["Operations", ["settings.read", "settings.write", "auth.manage", "diagnostics.read", "logs.read", "logs.clear", "updates.run", "system.restart"]],
3229
+ ["Users and audit", ["users.read", "users.write", "audit.read"]],
3230
+ ["Peers", ["peers.read", "peers.write", "peers.connect"]]
3231
+ ];
3232
+ function ensureAccessUiState() {
3233
+ if (!state.userFilters) state.userFilters = { query: "", status: "all", group: "all", identity: "all" };
3234
+ if (!state.userPage) state.userPage = 1;
3235
+ if (!state.userPageSize) state.userPageSize = ACCESS_USER_PAGE_SIZE;
3236
+ if (!state.userDetailAudit) state.userDetailAudit = {};
3237
+ }
3238
+ function groupIdsForUser(u) {
3239
+ return (u.groups || []).map((g) => g.id);
3240
+ }
3241
+ function groupNamesForUser(u) {
3242
+ return (u.groups || []).map((g) => g.name).join(", ") || "-";
3243
+ }
3244
+ function hasUserIdentity(u, kind) {
3245
+ if (kind === "telegram") return (u.telegramIdentities || []).length > 0;
3246
+ if (kind === "discord") return (u.discordIdentities || []).length > 0;
3247
+ if (kind === "slack") return (u.slackIdentities || []).length > 0;
3248
+ if (kind === "web") return (u.webSessions || []).length > 0;
3249
+ if (kind === "unlinked") return !hasUserIdentity(u, "telegram") && !hasUserIdentity(u, "discord") && !hasUserIdentity(u, "slack");
3250
+ return true;
3251
+ }
3252
+ function userSearchText(u) {
3253
+ return [u.displayName, u.email, u.id, groupNamesForUser(u), (u.telegramIdentities || []).map((i) => [i.telegramUserId, i.username].join(" ")).join(" "), (u.discordIdentities || []).map((i) => [i.discordUserId, i.username, i.globalName].join(" ")).join(" "), (u.slackIdentities || []).map((i) => [i.slackUserId, i.teamId, i.username, i.realName].join(" ")).join(" ")].join(" ").toLowerCase();
3254
+ }
3255
+ function filteredUsers() {
3256
+ ensureAccessUiState();
3257
+ const filters = state.userFilters;
3258
+ const query = (filters.query || "").toLowerCase().trim();
3259
+ return (state.userManagement?.users || []).filter((u) => {
3260
+ if (filters.status === "active" && !u.active) return false;
3261
+ if (filters.status === "disabled" && u.active) return false;
3262
+ if (filters.group && filters.group !== "all" && !groupIdsForUser(u).includes(filters.group)) return false;
3263
+ if (filters.identity && filters.identity !== "all" && !hasUserIdentity(u, filters.identity)) return false;
3264
+ if (query && !userSearchText(u).includes(query)) return false;
3265
+ return true;
3266
+ });
3267
+ }
3268
+ function renderUserGroupFilter() {
3269
+ const select = document.getElementById("userGroupFilter");
3270
+ if (!select) return;
3271
+ const current = state.userFilters?.group || "all";
3272
+ select.innerHTML = '<option value="all">All groups</option>' + (state.userManagement?.groups || []).map((g) => '<option value="' + attr(g.id) + '">' + esc(g.name) + "</option>").join("");
3273
+ select.value = current;
3274
+ }
3275
+ function bindAccessFilters() {
3276
+ ensureAccessUiState();
3277
+ const search = document.getElementById("userSearch");
3278
+ if (search && !search.dataset.bound) {
3279
+ search.dataset.bound = "true";
3280
+ search.oninput = () => {
3281
+ state.userFilters.query = search.value;
3282
+ state.userPage = 1;
3283
+ renderUsersList();
3284
+ };
3285
+ }
3286
+ if (search) search.value = state.userFilters.query || "";
3287
+ const status = document.getElementById("userStatusFilter");
3288
+ if (status && !status.dataset.bound) {
3289
+ status.dataset.bound = "true";
3290
+ status.onchange = () => {
3291
+ state.userFilters.status = status.value;
3292
+ state.userPage = 1;
3293
+ renderUsersList();
3294
+ };
3295
+ }
3296
+ if (status) status.value = state.userFilters.status || "all";
3297
+ const group = document.getElementById("userGroupFilter");
3298
+ if (group && !group.dataset.bound) {
3299
+ group.dataset.bound = "true";
3300
+ group.onchange = () => {
3301
+ state.userFilters.group = group.value;
3302
+ state.userPage = 1;
3303
+ renderUsersList();
3304
+ };
3305
+ }
3306
+ if (group) group.value = state.userFilters.group || "all";
3307
+ const identity = document.getElementById("userIdentityFilter");
3308
+ if (identity && !identity.dataset.bound) {
3309
+ identity.dataset.bound = "true";
3310
+ identity.onchange = () => {
3311
+ state.userFilters.identity = identity.value;
3312
+ state.userPage = 1;
3313
+ renderUsersList();
3314
+ };
3315
+ }
3316
+ if (identity) identity.value = state.userFilters.identity || "all";
3317
+ const groupSearch = document.getElementById("groupSearch");
3318
+ if (groupSearch && !groupSearch.dataset.bound) {
3319
+ groupSearch.dataset.bound = "true";
3320
+ groupSearch.oninput = () => renderGroupsList();
3321
+ }
3322
+ const telegramSearch = document.getElementById("telegramChatSearch");
3323
+ if (telegramSearch && !telegramSearch.dataset.bound) {
3324
+ telegramSearch.dataset.bound = "true";
3325
+ telegramSearch.oninput = () => renderTelegramChats();
3326
+ }
3327
+ }
3328
+ function switchAccessTabV2(tab) {
3329
+ state.accessTab = tab || "users";
3330
+ document.querySelectorAll("[data-access-tab]").forEach((b) => {
3331
+ const active = b.dataset.accessTab === state.accessTab;
3332
+ b.classList.toggle("active", active);
3333
+ b.setAttribute("aria-selected", active ? "true" : "false");
3334
+ b.tabIndex = active ? 0 : -1;
3335
+ });
3336
+ document.querySelectorAll("[data-access-tab-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.accessTabPanel === state.accessTab));
3337
+ bindAccessFilters();
3338
+ }
3339
+ function bindAccessTabsV2() {
3340
+ document.querySelectorAll("[data-access-tab]").forEach((b) => b.onclick = () => switchAccessTabV2(b.dataset.accessTab));
3341
+ bindAccessFilters();
3342
+ const discordSearch = document.getElementById("discordChannelSearch");
3343
+ if (discordSearch && !discordSearch.dataset.bound) {
3344
+ discordSearch.dataset.bound = "true";
3345
+ discordSearch.oninput = () => renderDiscordChannelsV2();
3346
+ }
3347
+ const slackSearch = document.getElementById("slackChannelSearch");
3348
+ if (slackSearch && !slackSearch.dataset.bound) {
3349
+ slackSearch.dataset.bound = "true";
3350
+ slackSearch.oninput = () => renderSlackChannelsV2();
3351
+ }
3352
+ }
3353
+ function renderUserManagementV2(d) {
3354
+ ensureAccessUiState();
3355
+ state.userManagement = d;
3356
+ renderUserGroupFilter();
3357
+ bindAccessFilters();
3358
+ renderUsersList();
3359
+ renderGroupsList();
3360
+ renderTelegramChats();
3361
+ renderDiscordChannelsV2(d.discordChannels || []);
3362
+ renderSlackChannelsV2(d.slackChannels || []);
3363
+ bindUserButtonsV2();
3364
+ bindSlackUserButtons();
3365
+ bindAccessCopyButtons();
3366
+ applyPermissions();
3367
+ }
3368
+ function userIdentityChips(u) {
3369
+ const chips = [];
3370
+ const telegram = (u.telegramIdentities || []).length;
3371
+ const discord = (u.discordIdentities || []).length;
3372
+ const slack = (u.slackIdentities || []).length;
3373
+ const web = (u.webSessions || []).length;
3374
+ if (telegram) chips.push('<span class="chip">Telegram ' + telegram + "</span>");
3375
+ if (discord) chips.push('<span class="chip">Discord ' + discord + "</span>");
3376
+ if (slack) chips.push('<span class="chip">Slack ' + slack + "</span>");
3377
+ if (web) chips.push('<span class="chip">Web ' + web + "</span>");
3378
+ return chips.join("") || '<span class="chip">No chat identity</span>';
3379
+ }
3380
+ function effectivePermissions(u) {
3381
+ return Array.from(new Set((u.groups || []).flatMap((g) => g.permissions || []))).sort();
3382
+ }
3383
+ function scopedUnion(u, key) {
3384
+ const groups = u.groups || [];
3385
+ if (groups.some((g) => (g[key] || []).length === 0)) return ["all"];
3386
+ return Array.from(new Set(groups.flatMap((g) => g[key] || []))).sort();
3387
+ }
3388
+ function userCard(u) {
3389
+ const groups = (u.groups || []).map((g) => '<span class="chip">' + esc(g.name) + "</span>").join("") || '<span class="chip">No groups</span>';
3390
+ const perms = effectivePermissions(u);
3391
+ return '<div class="item user-card"><div class="user-card-main"><div><strong>' + esc(u.displayName) + ' <span class="adapter-status ' + (u.active ? "enabled" : "disabled") + '">' + (u.active ? "active" : "disabled") + "</span></strong><small>" + esc(u.email) + '</small><small class="access-id-row">User ID: ' + accessCopyButton(u.id, "User ID copied") + '</small></div><div class="user-card-meta">' + groups + userIdentityChips(u) + "</div></div><small>" + esc(perms.length + " permissions \xB7 agents " + scopedUnion(u, "agentIds").join(", ") + " \xB7 workspaces " + scopedUnion(u, "workspaceRoots").join(", ")) + '</small><div class="row"><button data-user-detail="' + attr(u.id) + '">Details</button><button class="secondary" data-user-edit="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-user-toggle="' + attr(u.id) + '"' + disabledAttr("users.write") + ">" + (u.active ? "Disable" : "Enable") + "</button></div></div>";
3392
+ }
3393
+ function renderUsersList() {
3394
+ const target = document.getElementById("accessPanel");
3395
+ if (!target) return;
3396
+ const users = filteredUsers();
3397
+ const pages = Math.max(1, Math.ceil(users.length / (state.userPageSize || ACCESS_USER_PAGE_SIZE)));
3398
+ if (state.userPage > pages) state.userPage = pages;
3399
+ const start = (state.userPage - 1) * (state.userPageSize || ACCESS_USER_PAGE_SIZE);
3400
+ const pageUsers = users.slice(start, start + (state.userPageSize || ACCESS_USER_PAGE_SIZE));
3401
+ target.innerHTML = pageUsers.map(userCard).join("") || '<div class="item">No users match the current filters.</div>';
3402
+ renderUsersPager(users.length, pages);
3403
+ bindUserButtonsV2();
3404
+ bindAccessCopyButtons();
3405
+ applyPermissions();
3406
+ }
3407
+ function renderUsersPager(total, pages) {
3408
+ const pager = document.getElementById("usersPager");
3409
+ if (!pager) return;
3410
+ const start = total ? (state.userPage - 1) * (state.userPageSize || ACCESS_USER_PAGE_SIZE) + 1 : 0;
3411
+ const end = Math.min(total, state.userPage * (state.userPageSize || ACCESS_USER_PAGE_SIZE));
3412
+ pager.innerHTML = "<span>" + esc(start + "-" + end + " of " + total + " users \xB7 page " + state.userPage + " of " + pages) + '</span><div class="pager-actions"><button data-user-page="prev" ' + (state.userPage <= 1 ? "disabled" : "") + '>Previous</button><button data-user-page="next" ' + (state.userPage >= pages ? "disabled" : "") + ">Next</button></div>";
3413
+ pager.querySelector('[data-user-page="prev"]').onclick = () => {
3414
+ if (state.userPage > 1) {
3415
+ state.userPage -= 1;
3416
+ renderUsersList();
3417
+ }
3418
+ };
3419
+ pager.querySelector('[data-user-page="next"]').onclick = () => {
3420
+ if (state.userPage < pages) {
3421
+ state.userPage += 1;
3422
+ renderUsersList();
3423
+ }
3424
+ };
3425
+ }
3426
+ function renderGroupsList() {
3427
+ const target = document.getElementById("groupsList");
3428
+ if (!target) return;
3429
+ const query = (document.getElementById("groupSearch")?.value || "").toLowerCase();
3430
+ const groups = (state.userManagement?.groups || []).filter((g) => !query || [g.name, g.id, g.description, (g.permissions || []).join(" ")].join(" ").toLowerCase().includes(query));
3431
+ target.innerHTML = groups.map((g) => {
3432
+ const users = (state.userManagement?.users || []).filter((u) => groupIdsForUser(u).includes(g.id)).length;
3433
+ return '<div class="item group-card"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>" + esc(users + " user(s) \xB7 " + (g.permissions || []).length + " permission(s)") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Channels: " + esc(["Telegram " + (csv(g.telegramChatIds) || "all"), "Discord " + (discordScopeLabel(g.discordChannelIds) || "all"), "Slack " + (slackScopeLabel(g.slackChannelIds) || "all")].join(" \xB7 ")) + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>";
3434
+ }).join("") || '<div class="item">No groups match the current filters.</div>';
3435
+ bindUserButtonsV2();
3436
+ applyPermissions();
3437
+ }
3438
+ function renderTelegramChats(chats = state.userManagement?.telegramChats || []) {
3439
+ const target = document.getElementById("telegramChatsList");
3440
+ if (!target) return;
3441
+ const query = (document.getElementById("telegramChatSearch")?.value || "").toLowerCase();
3442
+ const filtered = (chats || []).filter((c) => !query || [c.title, c.chatId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
3443
+ target.innerHTML = filtered.map((c) => '<div class="item"><strong>' + esc(c.title || String(c.chatId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + '</span></strong><small class="access-id-row">Chat ID: ' + accessCopyButton(String(c.chatId), "Telegram chat ID copied") + "</small><small>" + esc("Type: " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-chat-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-chat-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Telegram group chats registered.</div>';
3444
+ bindUserButtonsV2();
3445
+ bindAccessCopyButtons();
3446
+ applyPermissions();
3447
+ }
3448
+ function renderDiscordChannelsV2(channels = state.userManagement?.discordChannels || []) {
3449
+ const target = document.getElementById("discordChannelsList");
3450
+ if (!target) return;
3451
+ const query = (document.getElementById("discordChannelSearch")?.value || "").toLowerCase();
3452
+ const filtered = (channels || []).filter((c) => !query || [c.title, c.channelId, c.guildId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
3453
+ target.innerHTML = filtered.map((c) => '<div class="item"><strong>' + esc(c.title || String(c.channelId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + '</span></strong><small class="access-id-row">Channel ID: ' + accessCopyButton(c.channelId, "Discord channel ID copied") + '</small><small class="access-id-row">Guild ID: ' + accessCopyButton(c.guildId || "", "Discord guild ID copied") + "</small><small>" + esc("Type: " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-discord-channel-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-discord-channel-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Discord channels registered.</div>';
3454
+ bindDiscordChannelButtons();
3455
+ bindAccessCopyButtons();
3456
+ applyPermissions();
3457
+ }
3458
+ function renderSlackChannelsV2(channels = state.userManagement?.slackChannels || []) {
3459
+ const target = document.getElementById("slackChannelsList");
3460
+ if (!target) return;
3461
+ const query = (document.getElementById("slackChannelSearch")?.value || "").toLowerCase();
3462
+ const filtered = (channels || []).filter((c) => !query || [c.title, c.channelId, c.teamId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
3463
+ target.innerHTML = filtered.map((c) => '<div class="item"><strong>' + esc(c.title || String(c.channelId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + '</span></strong><small class="access-id-row">Channel ID: ' + accessCopyButton(c.channelId, "Slack channel ID copied") + '</small><small class="access-id-row">Team ID: ' + accessCopyButton(c.teamId || "", "Slack team ID copied") + "</small><small>" + esc("Type: " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-slack-channel-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-slack-channel-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Slack channels registered.</div>';
3464
+ bindSlackChannelButtons();
3465
+ bindAccessCopyButtons();
3466
+ applyPermissions();
3467
+ }
3468
+ function bindUserButtonsV2() {
3469
+ document.querySelectorAll("[data-user-detail]").forEach((b) => b.onclick = () => safe(() => openUserDetail(b.dataset.userDetail)));
3470
+ document.querySelectorAll("[data-user-edit]").forEach((b) => b.onclick = () => {
3471
+ const u = (state.userManagement?.users || []).find((x) => x.id === b.dataset.userEdit);
3472
+ if (u) openUserDialog(u);
3473
+ });
3474
+ document.querySelectorAll("[data-user-toggle]").forEach((b) => b.onclick = () => safe(async () => {
3475
+ const u = (state.userManagement?.users || []).find((x) => x.id === b.dataset.userToggle);
3476
+ if (!u) return;
3477
+ await api("/api/users/" + encodeURIComponent(u.id), { method: "PATCH", body: JSON.stringify({ active: !u.active }) });
3478
+ toast("User updated");
3479
+ loadAccess();
3480
+ }));
3481
+ document.querySelectorAll("[data-user-code]").forEach((b) => b.onclick = () => safe(() => showLinkCodeDialog("telegram", b.dataset.userCode)));
3482
+ document.querySelectorAll("[data-user-link]").forEach((b) => b.onclick = () => openTelegramLinkDialog(b.dataset.userLink));
3483
+ document.querySelectorAll("[data-user-discord-code]").forEach((b) => b.onclick = () => safe(() => showLinkCodeDialog("discord", b.dataset.userDiscordCode)));
3484
+ document.querySelectorAll("[data-user-discord-link]").forEach((b) => b.onclick = () => openDiscordLinkDialog(b.dataset.userDiscordLink));
3485
+ document.querySelectorAll("[data-user-slack-code]").forEach((b) => b.onclick = () => safe(() => showLinkCodeDialog("slack", b.dataset.userSlackCode)));
3486
+ document.querySelectorAll("[data-user-slack-link]").forEach((b) => b.onclick = () => openSlackLinkDialog(b.dataset.userSlackLink));
3487
+ document.querySelectorAll("[data-user-password]").forEach((b) => b.onclick = () => openPasswordDialog(b.dataset.userPassword));
3488
+ document.querySelectorAll("[data-user-revoke]").forEach((b) => b.onclick = () => safe(async () => {
3489
+ if (confirm("Revoke all web sessions for this user?")) {
3490
+ await api("/api/users/" + encodeURIComponent(b.dataset.userRevoke) + "/sessions", { method: "DELETE" });
3491
+ toast("Sessions revoked");
3492
+ loadAccess();
3493
+ }
3494
+ }));
3495
+ document.querySelectorAll("[data-user-session-revoke]").forEach((b) => b.onclick = () => safe(async () => {
3496
+ if (confirm("Revoke this web session?")) {
3497
+ await api("/api/users/" + encodeURIComponent(b.dataset.user) + "/sessions/" + encodeURIComponent(b.dataset.userSessionRevoke), { method: "DELETE" });
3498
+ toast("Session revoked");
3499
+ await loadAccess();
3500
+ await openUserDetail(b.dataset.user);
3501
+ }
3502
+ }));
3503
+ document.querySelectorAll("[data-telegram-unlink]").forEach((b) => b.onclick = () => safe(async () => {
3504
+ if (confirm("Unlink this Telegram identity?")) {
3505
+ await api("/api/users/" + encodeURIComponent(b.dataset.telegramUser) + "/telegram/" + encodeURIComponent(b.dataset.telegramUnlink), { method: "DELETE" });
3506
+ toast("Telegram unlinked");
3507
+ loadAccess();
3508
+ }
3509
+ }));
3510
+ document.querySelectorAll("[data-discord-unlink]").forEach((b) => b.onclick = () => safe(async () => {
3511
+ if (confirm("Unlink this Discord identity?")) {
3512
+ await api("/api/users/" + encodeURIComponent(b.dataset.discordUser) + "/discord/" + encodeURIComponent(b.dataset.discordUnlink), { method: "DELETE" });
3513
+ toast("Discord unlinked");
3514
+ loadAccess();
3515
+ }
3516
+ }));
3517
+ document.querySelectorAll("[data-slack-unlink]").forEach((b) => b.onclick = () => safe(async () => {
3518
+ if (confirm("Unlink this Slack identity?")) {
3519
+ await api("/api/users/" + encodeURIComponent(b.dataset.slackUser) + "/slack/" + encodeURIComponent(b.dataset.slackUnlink), { method: "DELETE" });
3520
+ toast("Slack unlinked");
3521
+ loadAccess();
3522
+ }
3523
+ }));
3524
+ document.querySelectorAll("[data-group-edit]").forEach((b) => b.onclick = () => {
3525
+ const g = (state.userManagement?.groups || []).find((x) => x.id === b.dataset.groupEdit);
3526
+ if (g) openGroupDialogV2(g);
3527
+ });
3528
+ document.querySelectorAll("[data-chat-edit]").forEach((b) => b.onclick = () => {
3529
+ const c = (state.userManagement?.telegramChats || []).find((x) => x.id === b.dataset.chatEdit);
3530
+ if (c) openChatDialog(c);
3531
+ });
3532
+ document.querySelectorAll("[data-chat-toggle]").forEach((b) => b.onclick = () => safe(async () => {
3533
+ const c = (state.userManagement?.telegramChats || []).find((x) => x.id === b.dataset.chatToggle);
3534
+ if (!c) return;
3535
+ await api("/api/telegram-chats/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
3536
+ toast("Chat updated");
3537
+ loadAccess();
3538
+ }));
3539
+ document.querySelectorAll("[data-discord-channel-edit]").forEach((b) => b.onclick = () => {
3540
+ const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelEdit);
3541
+ if (c) openDiscordChannelDialog(c);
3542
+ });
3543
+ document.querySelectorAll("[data-discord-channel-toggle]").forEach((b) => b.onclick = () => safe(async () => {
3544
+ const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelToggle);
3545
+ if (!c) return;
3546
+ await api("/api/discord-channels/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
3547
+ toast("Discord channel updated");
3548
+ loadAccess();
3549
+ }));
3550
+ bindAccessCopyButtons();
3551
+ applyPermissions();
3552
+ }
3553
+ function userDetailTab(label, id, active) {
3554
+ return '<button type="button" data-user-detail-tab="' + attr(id) + '" class="' + (active ? "active" : "") + '">' + esc(label) + "</button>";
3555
+ }
3556
+ function userDetailPanel(id, active, body) {
3557
+ return '<div class="user-detail-panel ' + (active ? "active" : "") + '" data-user-detail-panel="' + attr(id) + '">' + body + "</div>";
3558
+ }
3559
+ async function openUserDetail(userId) {
3560
+ const user = (state.userManagement?.users || []).find((u) => u.id === userId);
3561
+ if (!user) return;
3562
+ state.activeUserDetailId = userId;
3563
+ renderUserDetail(user);
3564
+ document.getElementById("userDetailDialog").showModal();
3565
+ if (can("audit.read")) {
3566
+ const data = await api("/api/audit", { query: { limit: 100 } });
3567
+ state.userDetailAudit[userId] = (data.events || []).filter((e) => userAuditMatches(user, e)).slice(0, 15);
3568
+ if (state.activeUserDetailId === userId) renderUserDetail(user);
3569
+ }
3570
+ }
3571
+ function userAuditMatches(user, e) {
3572
+ const text = [e.actor?.id, e.actor?.label, e.actor?.username, e.actorId, e.actorRole, e.description, e.detail].join(" ").toLowerCase();
3573
+ const ids = [user.id, user.email, user.displayName].concat((user.telegramIdentities || []).map((i) => String(i.telegramUserId)), (user.discordIdentities || []).map((i) => i.discordUserId), (user.slackIdentities || []).map((i) => i.slackUserId)).filter(Boolean).map((x) => String(x).toLowerCase());
3574
+ return ids.some((id) => text.includes(id));
3575
+ }
3576
+ function renderUserDetail(user) {
3577
+ const audit = state.userDetailAudit?.[user.id];
3578
+ const profile = card("Profile", [["Email", user.email], ["User ID", user.id], ["Status", user.active ? "active" : "disabled"], ["Created", fmtDate(user.createdAt)], ["Updated", fmtDate(user.updatedAt)], ["Last login", fmtDate(user.lastLoginAt)], ["Web sessions", (user.webSessions || []).length]]);
3579
+ const groups = (user.groups || []).map((g) => uiItem(g.name, { badge: g.system ? { text: "system", status: "disabled" } : null, rows: [["Description", g.description], ["Permissions", (g.permissions || []).length], ["Agent scope", csv(g.agentIds) || "all"], ["Workspace scope", csv(g.workspaceRoots) || "all"]] })).join("") || uiEmpty("No groups.");
3580
+ const identities = identityDetailHtml(user);
3581
+ const sessions = (user.webSessions || []).map((s) => '<div class="item"><strong>' + esc("Session " + s.id) + "</strong><small>" + esc("Created: " + fmtDate(s.createdAt)) + "</small><small>" + esc("Last seen: " + fmtDate(s.lastSeenAt)) + "</small><small>" + esc("Expires: " + fmtDate(s.expiresAt)) + '</small><div class="row"><button class="danger" data-user="' + attr(user.id) + '" data-user-session-revoke="' + attr(s.id) + '"' + disabledAttr("users.write") + ">Revoke</button></div></div>").join("") || uiEmpty("No active web sessions.");
3582
+ const effective = effectiveAccessHtml(user);
3583
+ const auditHtml = !can("audit.read") ? uiEmpty("Permission required: audit.read") : audit ? audit.map((e) => '<div class="item"><strong>' + esc([fmtDate(e.timestamp), e.status, e.action].join(" | ")) + "</strong><small>" + esc(e.description || e.detail || "") + "</small></div>").join("") || uiEmpty("No user-related audit events found.") : loadingHtml("Loading user audit...");
3584
+ const tabs = userDetailTab("Profile", "profile", true) + userDetailTab("Groups", "groups", false) + userDetailTab("Identities", "identities", false) + userDetailTab("Web sessions", "sessions", false) + userDetailTab("Effective access", "effective", false) + userDetailTab("Audit", "audit", false);
3585
+ document.getElementById("userDetail").innerHTML = "<h2>" + esc(user.displayName) + "</h2><p>" + esc(user.email) + '</p><div class="tabs user-detail-tabs">' + tabs + "</div>" + userDetailPanel("profile", true, profile + '<div class="row"><button data-user-edit="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Edit user</button><button class="secondary" data-user-password="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Set password</button><button class="secondary" data-user-toggle="' + attr(user.id) + '"' + disabledAttr("users.write") + ">" + (user.active ? "Disable" : "Enable") + "</button></div>") + userDetailPanel("groups", false, '<div class="list">' + groups + "</div>") + userDetailPanel("identities", false, identities) + userDetailPanel("sessions", false, '<div class="list">' + sessions + '</div><div class="row"><button class="danger" data-user-revoke="' + attr(user.id) + '"' + disabledAttr("users.write") + ">Revoke all sessions</button></div>") + userDetailPanel("effective", false, effective) + userDetailPanel("audit", false, '<div class="list">' + auditHtml + "</div>");
3586
+ bindUserDetailTabs();
3587
+ bindUserButtonsV2();
3588
+ bindAccessCopyButtons();
3589
+ }
3590
+ function bindUserDetailTabs() {
3591
+ document.querySelectorAll("[data-user-detail-tab]").forEach((b) => b.onclick = () => {
3592
+ document.querySelectorAll("[data-user-detail-tab]").forEach((x) => x.classList.toggle("active", x === b));
3593
+ document.querySelectorAll("[data-user-detail-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.userDetailPanel === b.dataset.userDetailTab));
3594
+ });
3595
+ }
3596
+ function identityDetailHtml(user) {
3597
+ const telegram = (user.telegramIdentities || []).map((t) => '<div class="item"><strong>Telegram <span class="adapter-status ' + (t.active ? "enabled" : "disabled") + '">' + (t.active ? "active" : "disabled") + '</span></strong><small class="access-id-row">User ID: ' + accessCopyButton(String(t.telegramUserId), "Telegram user ID copied") + "</small>" + (t.username ? "<small>" + esc("@" + t.username) + "</small>" : "") + '<div class="row"><button class="secondary" data-telegram-user="' + attr(user.id) + '" data-telegram-unlink="' + attr(t.id) + '"' + disabledAttr("users.write") + ">Unlink</button></div></div>").join("");
3598
+ const discord = (user.discordIdentities || []).map((i) => '<div class="item"><strong>Discord <span class="adapter-status ' + (i.active ? "enabled" : "disabled") + '">' + (i.active ? "active" : "disabled") + '</span></strong><small class="access-id-row">User ID: ' + accessCopyButton(i.discordUserId, "Discord user ID copied") + "</small>" + (i.username ? "<small>" + esc("@" + i.username) + "</small>" : "") + (i.globalName ? "<small>" + esc(i.globalName) + "</small>" : "") + '<div class="row"><button class="secondary" data-discord-user="' + attr(user.id) + '" data-discord-unlink="' + attr(i.id) + '"' + disabledAttr("users.write") + ">Unlink</button></div></div>").join("");
3599
+ const slack = (user.slackIdentities || []).map((i) => '<div class="item"><strong>Slack <span class="adapter-status ' + (i.active ? "enabled" : "disabled") + '">' + (i.active ? "active" : "disabled") + '</span></strong><small class="access-id-row">User ID: ' + accessCopyButton(i.slackUserId, "Slack user ID copied") + "</small>" + (i.teamId ? '<small class="access-id-row">Team ID: ' + accessCopyButton(i.teamId, "Slack team ID copied") + "</small>" : "") + (i.username ? "<small>" + esc("@" + i.username) + "</small>" : "") + (i.realName ? "<small>" + esc(i.realName) + "</small>" : "") + '<div class="row"><button class="secondary" data-slack-user="' + attr(user.id) + '" data-slack-unlink="' + attr(i.id) + '"' + disabledAttr("users.write") + ">Unlink</button></div></div>").join("");
3600
+ return '<div class="row identity-actions"><button class="secondary" data-user-code="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Telegram code</button><button class="secondary" data-user-link="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Link Telegram ID</button><button class="secondary" data-user-discord-code="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Discord code</button><button class="secondary" data-user-discord-link="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Link Discord ID</button><button class="secondary" data-user-slack-code="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Slack code</button><button class="secondary" data-user-slack-link="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Link Slack ID</button></div><div class="list">' + (telegram + discord + slack || uiEmpty("No chat identities linked.")) + "</div>";
3601
+ }
3602
+ function effectiveAccessHtml(user) {
3603
+ const perms = effectivePermissions(user);
3604
+ const permissionRows = ACCESS_PERMISSION_GROUPS.map(([name, items]) => [name, items.filter((p) => perms.includes(p)).join(", ") || "-"]);
3605
+ const channelRows = [["Agents", scopedUnion(user, "agentIds").join(", ")], ["Workspaces", scopedUnion(user, "workspaceRoots").join(", ")], ["Telegram chat scope", scopedUnion(user, "telegramChatIds").join(", ")], ["Discord channel scope", scopedUnion(user, "discordChannelIds").join(", ")], ["Slack channel scope", scopedUnion(user, "slackChannelIds").join(", ")]];
3606
+ return '<div class="access-effective-grid">' + card("Permissions", permissionRows) + card("Scopes", channelRows) + "</div>";
3607
+ }
3608
+ async function showLinkCodeDialog(channel, userId) {
3609
+ const path = "/api/users/" + encodeURIComponent(userId) + "/" + channel;
3610
+ const data = await api(path, { method: "POST", body: JSON.stringify({ createCode: true }) });
3611
+ const linkCode = data.linkCode || {};
3612
+ const label = channel[0].toUpperCase() + channel.slice(1);
3613
+ showAccessInfoDialog(label + " link code", "<p>Send this code with the " + esc(label) + ' bot/app link command for this user. The code expires automatically.</p><div class="peer-invite-details"><small>Link code</small><button type="button" class="copy-id peer-invite-command" data-access-copy="' + attr(linkCode.code || "") + '" data-access-copy-label="' + attr(label + " link code copied") + '">' + esc(linkCode.code || "") + "</button><small>" + esc("Expires: " + fmtDate(linkCode.expiresAt)) + "</small></div>");
3614
+ }
3615
+ function showAccessInfoDialog(title, body) {
3616
+ const dialog = document.getElementById("adminDialog");
3617
+ document.getElementById("adminDialogTitle").textContent = title;
3618
+ document.getElementById("adminDialogBody").innerHTML = body;
3619
+ document.getElementById("adminDialogSubmit").textContent = "Close";
3620
+ document.getElementById("adminDialogCancel").onclick = () => dialog.close();
3621
+ document.getElementById("adminDialogForm").onsubmit = (e) => {
3622
+ e.preventDefault();
3623
+ dialog.close();
3624
+ };
3625
+ bindAccessCopyButtons();
3626
+ dialog.showModal();
3627
+ }
3628
+ function permissionCheckboxes(selected = []) {
3629
+ const selectedSet = new Set(selected);
3630
+ const used = new Set(ACCESS_PERMISSION_GROUPS.flatMap(([, items]) => items));
3631
+ const extra = (state.userManagement?.permissions || []).filter((p) => !used.has(p)).sort();
3632
+ const groups = ACCESS_PERMISSION_GROUPS.concat(extra.length ? [["Other", extra]] : []);
3633
+ return groups.map(([name, items]) => '<fieldset class="permission-section"><legend>' + esc(name) + "</legend>" + (items || []).map((p) => '<label class="checkbox"><input type="checkbox" data-group-permission="' + attr(p) + '" value="' + attr(p) + '" ' + (selectedSet.has(p) ? "checked" : "") + "> " + esc(p) + "</label>").join("") + "</fieldset>").join("");
3634
+ }
3635
+ function checkboxScope(title, items, selected, attrName, emptyText) {
3636
+ const selectedSet = new Set((selected || []).map(String));
3637
+ return '<div class="scope-section full-span"><strong>' + esc(title) + '</strong><small>Leave every box unchecked to allow all.</small><div class="permission-grid">' + ((items || []).map((item) => '<label class="checkbox"><input type="checkbox" ' + attrName + '="' + attr(item.value) + '" value="' + attr(item.value) + '" ' + (selectedSet.has(String(item.value)) ? "checked" : "") + "> " + esc(item.label) + "</label>").join("") || "<small>" + esc(emptyText || "No options available.") + "</small>") + "</div></div>";
3638
+ }
3639
+ function availableAgentScopeItems() {
3640
+ const labels = { codex: "Codex", pi: "Pi", hermes: "Hermes", openclaw: "OpenClaw", "claude-code": "Claude Code" };
3641
+ const ids = Array.from(/* @__PURE__ */ new Set([...state.enabledAgents || [], "codex", "pi", "hermes", "openclaw", "claude-code"]));
3642
+ return ids.map((id) => ({ value: id, label: labels[id] || id }));
3643
+ }
3644
+ function telegramScopeItems() {
3645
+ return (state.userManagement?.telegramChats || []).map((c) => ({ value: String(c.chatId), label: (c.title || c.chatId) + " / " + (c.type || "-") }));
3646
+ }
3647
+ function discordScopeItems() {
3648
+ return (state.userManagement?.discordChannels || []).map((c) => ({ value: c.channelId, label: discordChannelLabel(c) + " / " + (c.guildId || "DM") }));
3649
+ }
3650
+ function slackScopeItems() {
3651
+ return (state.userManagement?.slackChannels || []).map((c) => ({ value: c.channelId, label: slackChannelLabel(c) + " / " + (c.teamId || "team default") }));
3652
+ }
3653
+ function checkedValues(selector) {
3654
+ return Array.from(document.querySelectorAll(selector + ":checked")).map((el) => el.value).filter(Boolean);
3655
+ }
3656
+ function linesToList(text) {
3657
+ return String(text || "").split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
3658
+ }
3659
+ function openGroupDialogV2(g) {
3660
+ const permissions = g?.permissions || ["inspect", "sessions.read"];
3661
+ const body = '<label>Name<input id="dlgGroupName" value="' + attr(g?.name || "") + '" ' + (g?.system ? "disabled" : "") + '></label><label>Description<input id="dlgGroupDescription" value="' + attr(g?.description || "") + '"></label><label class="full-span">Workspace scope<textarea id="dlgWorkspaceRoots" rows="4" placeholder="One workspace root per line. Empty means all.">' + esc((g?.workspaceRoots || []).join("\n")) + "</textarea></label>" + checkboxScope("Agent scope", availableAgentScopeItems(), g?.agentIds || [], "data-scope-agent", "No agents available.") + checkboxScope("Telegram chat scope", telegramScopeItems(), (g?.telegramChatIds || []).map(String), "data-scope-telegram", "No Telegram chats registered.") + checkboxScope("Discord channel scope", discordScopeItems(), g?.discordChannelIds || [], "data-scope-discord", "No Discord channels registered.") + checkboxScope("Slack channel scope", slackScopeItems(), g?.slackChannelIds || [], "data-scope-slack", "No Slack channels registered.") + '<strong class="full-span">Permissions</strong><div class="permission-category-grid full-span">' + permissionCheckboxes(permissions) + "</div>";
3662
+ adminDialog(g ? "Edit group" : "Create group", body, async () => {
3663
+ const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: checkedValues("[data-group-permission]"), agentIds: checkedValues("[data-scope-agent]"), workspaceRoots: linesToList(val("dlgWorkspaceRoots")), telegramChatIds: checkedValues("[data-scope-telegram]").map(Number).filter(Number.isInteger), discordChannelIds: checkedValues("[data-scope-discord]"), slackChannelIds: checkedValues("[data-scope-slack]") };
3664
+ await api(g ? "/api/groups/" + encodeURIComponent(g.id) : "/api/groups", { method: g ? "PATCH" : "POST", body: JSON.stringify(payload) });
3665
+ toast(g ? "Group updated" : "Group created");
3666
+ });
3667
+ }
3668
+ switchAccessTab = switchAccessTabV2;
3669
+ bindAccessTabs = bindAccessTabsV2;
3670
+ renderUserManagement = renderUserManagementV2;
3671
+ renderDiscordChannels = renderDiscordChannelsV2;
3672
+ renderSlackChannels = renderSlackChannelsV2;
3673
+ bindUserButtons = bindUserButtonsV2;
3674
+ openGroupDialog = openGroupDialogV2;
3675
+ document.getElementById("closeUserDetailBtn").onclick = () => document.getElementById("userDetailDialog").close();
3011
3676
  const SETUP_WIZARDS = {
3012
3677
  telegram: {
3013
3678
  id: "telegram",
@@ -3107,9 +3772,14 @@
3107
3772
  state.settingsWizard = { home: true };
3108
3773
  renderSettingsWizardHome();
3109
3774
  }
3775
+ function setSettingsChromeVisible(visible) {
3776
+ document.getElementById("settingsTabHeader").style.display = visible ? "" : "none";
3777
+ document.getElementById("settingsSubnav").style.display = visible ? "" : "none";
3778
+ document.getElementById("settingsActions").style.display = visible ? "" : "none";
3779
+ }
3110
3780
  function closeSettingsWizard() {
3111
3781
  state.settingsWizard = null;
3112
- document.getElementById("settingsTabs").style.display = "";
3782
+ setSettingsChromeVisible(true);
3113
3783
  renderSettings();
3114
3784
  }
3115
3785
  function wizardRequiredValuePresent(key) {
@@ -3123,7 +3793,7 @@
3123
3793
  return (wizard.required || []).filter((key) => !wizardRequiredValuePresent(key));
3124
3794
  }
3125
3795
  function renderSettingsWizardHome() {
3126
- document.getElementById("settingsTabs").style.display = "none";
3796
+ setSettingsChromeVisible(false);
3127
3797
  const cards = Object.values(SETUP_WIZARDS).map((wizard) => {
3128
3798
  const missing = wizardMissingRequired(wizard);
3129
3799
  const docs = wizardLinkList(wizard.docs);
@@ -3161,7 +3831,7 @@
3161
3831
  return;
3162
3832
  }
3163
3833
  const step = wizard.steps[state.settingsWizard.step] || wizard.steps[0];
3164
- document.getElementById("settingsTabs").style.display = "none";
3834
+ setSettingsChromeVisible(false);
3165
3835
  document.getElementById("settingsForm").innerHTML = '<div class="settings-wizard"><div class="wizard-header"><div><h2>' + esc(wizard.label) + " setup wizard</h2><p>" + esc(wizard.description) + '</p></div><button type="button" class="secondary" id="wizardHomeBtn">Wizard home</button></div>' + renderWizardProgress(wizard) + '<div class="wizard-step"><h3>' + esc(step.title) + "</h3><p>" + esc(step.body) + "</p>" + wizardLinkList(step.links) + '<div id="wizardRestartBanner"></div><div class="settings-grid">' + step.settings.map(renderWizardSetting).join("") + '</div><div id="wizardErrors" class="wizard-errors"></div><div id="wizardTestResult" class="wizard-test-result"></div><div class="wizard-actions"><button type="button" id="wizardPrevBtn" class="secondary">Back</button><button type="button" id="wizardNextBtn">' + esc(state.settingsWizard.step === wizard.steps.length - 1 ? "Review" : "Next") + '</button><button type="button" id="wizardTestBtn" class="secondary">Test setup</button><button type="button" id="wizardSaveBtn">Save wizard settings</button><button type="button" id="wizardSaveRestartBtn" class="secondary"' + disabledAttr("system.restart") + '>Save and restart</button></div><div class="setting-help">After transport is configured, link users and register allowed chats or channels in the Users page.</div></div></div>';
3166
3836
  bindWizardUx();
3167
3837
  renderWizardValidation();