@nordbyte/nordrelay 0.7.0 → 0.8.0
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.
- package/.env.example +35 -0
- package/README.md +109 -49
- package/dist/activity-events.js +2 -2
- package/dist/adapter-conformance.js +61 -0
- package/dist/bot.js +18 -31
- package/dist/channel-adapter.js +33 -6
- package/dist/channel-command-catalog.js +6 -0
- package/dist/channel-command-core.js +60 -0
- package/dist/channel-command-service.js +20 -4
- package/dist/channel-mirror-registry.js +9 -2
- package/dist/channel-prompt-engine.js +177 -0
- package/dist/channel-turn-lifecycle.js +73 -0
- package/dist/config-metadata.js +67 -8
- package/dist/config.js +48 -1
- package/dist/context-key.js +32 -0
- package/dist/discord-bot.js +99 -327
- package/dist/index.js +9 -0
- package/dist/metrics.js +2 -0
- package/dist/peer-client.js +33 -1
- package/dist/peer-readiness.js +77 -0
- package/dist/peer-runtime-service.js +22 -0
- package/dist/peer-store.js +13 -0
- package/dist/relay-runtime-helpers.js +3 -1
- package/dist/relay-runtime.js +7 -0
- package/dist/settings-wizard-test.js +216 -0
- package/dist/slack-artifacts.js +165 -0
- package/dist/slack-bot.js +1461 -0
- package/dist/slack-channel-runtime.js +147 -0
- package/dist/slack-command-surface.js +46 -0
- package/dist/slack-diagnostics.js +116 -0
- package/dist/slack-rate-limit.js +139 -0
- package/dist/user-management-crypto.js +38 -0
- package/dist/user-management-normalize.js +188 -0
- package/dist/user-management-types.js +1 -0
- package/dist/user-management.js +193 -196
- package/dist/web-api-contract.js +8 -0
- package/dist/web-dashboard-access-routes.js +62 -0
- package/dist/web-dashboard-assets.js +1 -0
- package/dist/web-dashboard-pages.js +14 -4
- package/dist/web-dashboard-peer-routes.js +32 -11
- package/dist/web-dashboard.js +34 -0
- package/dist/web-state.js +2 -2
- package/dist/webui-assets/dashboard.css +193 -0
- package/dist/webui-assets/dashboard.js +544 -144
- package/package.json +3 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +101 -10
|
@@ -20,10 +20,13 @@
|
|
|
20
20
|
{ re: /^\/api\/agent-update\/[^\/]+\/input$/, methods: ["POST"] },
|
|
21
21
|
{ re: /^\/api\/agent-update\/[^\/]+\/cancel$/, methods: ["POST"] },
|
|
22
22
|
{ path: "/api/adapters/health", methods: ["GET"] },
|
|
23
|
+
{ path: "/api/adapters/conformance", methods: ["GET"] },
|
|
23
24
|
{ path: "/api/peers", methods: ["GET", "POST"] },
|
|
24
25
|
{ path: "/api/peers/invite", methods: ["POST"] },
|
|
25
26
|
{ path: "/api/peers/pair", methods: ["POST"] },
|
|
27
|
+
{ path: "/api/peers/probe", methods: ["POST"] },
|
|
26
28
|
{ path: "/api/peers/global-sessions", methods: ["GET"] },
|
|
29
|
+
{ re: /^\/api\/peers\/invitations\/[^\/]+$/, methods: ["DELETE"] },
|
|
27
30
|
{ re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
|
|
28
31
|
{ re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
|
|
29
32
|
{ re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
|
|
@@ -38,18 +41,23 @@
|
|
|
38
41
|
{ re: /^\/api\/users\/[^\/]+\/telegram\/[^\/]+$/, methods: ["DELETE"] },
|
|
39
42
|
{ re: /^\/api\/users\/[^\/]+\/discord$/, methods: ["POST"] },
|
|
40
43
|
{ re: /^\/api\/users\/[^\/]+\/discord\/[^\/]+$/, methods: ["DELETE"] },
|
|
44
|
+
{ re: /^\/api\/users\/[^\/]+\/slack$/, methods: ["POST"] },
|
|
45
|
+
{ re: /^\/api\/users\/[^\/]+\/slack\/[^\/]+$/, methods: ["DELETE"] },
|
|
41
46
|
{ path: "/api/groups", methods: ["GET", "POST"] },
|
|
42
47
|
{ re: /^\/api\/groups\/[^\/]+$/, methods: ["PATCH"] },
|
|
43
48
|
{ path: "/api/telegram-chats", methods: ["GET", "POST"] },
|
|
44
49
|
{ re: /^\/api\/telegram-chats\/[^\/]+$/, methods: ["PATCH"] },
|
|
45
50
|
{ path: "/api/discord-channels", methods: ["GET", "POST"] },
|
|
46
51
|
{ re: /^\/api\/discord-channels\/[^\/]+$/, methods: ["PATCH"] },
|
|
52
|
+
{ path: "/api/slack-channels", methods: ["GET", "POST"] },
|
|
53
|
+
{ re: /^\/api\/slack-channels\/[^\/]+$/, methods: ["PATCH"] },
|
|
47
54
|
{ path: "/api/audit", methods: ["GET"] },
|
|
48
55
|
{ path: "/api/locks", methods: ["GET", "POST", "DELETE"] },
|
|
49
56
|
{ path: "/api/auth/status", methods: ["GET"] },
|
|
50
57
|
{ path: "/api/auth/login", methods: ["POST"] },
|
|
51
58
|
{ path: "/api/auth/logout", methods: ["POST"] },
|
|
52
59
|
{ path: "/api/settings", methods: ["GET", "PATCH"] },
|
|
60
|
+
{ path: "/api/settings/wizard/test", methods: ["POST"] },
|
|
53
61
|
{ path: "/api/control-options", methods: ["GET"] },
|
|
54
62
|
{ path: "/api/sessions", methods: ["GET"] },
|
|
55
63
|
{ path: "/api/sessions/new", methods: ["POST"] },
|
|
@@ -140,10 +148,10 @@
|
|
|
140
148
|
const peerId = selectedPeerTarget();
|
|
141
149
|
if (!peerId || peerId === "local") return false;
|
|
142
150
|
if (!path.startsWith("/api/")) return false;
|
|
143
|
-
return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || isLocalAdminApi(path));
|
|
151
|
+
return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || isLocalAdminApi(path));
|
|
144
152
|
}
|
|
145
153
|
function isLocalAdminApi(path) {
|
|
146
|
-
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" || /^\/api\/users\//.test(path) || /^\/api\/groups\//.test(path) || /^\/api\/telegram-chats\//.test(path) || /^\/api\/discord-channels\//.test(path);
|
|
154
|
+
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);
|
|
147
155
|
}
|
|
148
156
|
function selectedPeerTarget() {
|
|
149
157
|
const runtimeState = (
|
|
@@ -222,7 +230,7 @@
|
|
|
222
230
|
throw new Error("Unsupported WebUI API method: " + method + " " + path);
|
|
223
231
|
}
|
|
224
232
|
}
|
|
225
|
-
const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0, activeSessions: null, peers: null, selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
|
|
233
|
+
const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, settingsWizard: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0, activeSessions: null, peers: null, peerInviteSecrets: {}, peerProbeResult: null, selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
|
|
226
234
|
globalThis.NORDRELAY_WEBUI_RUNTIME_STATE = state;
|
|
227
235
|
function toast(msg, options = {}) {
|
|
228
236
|
const el = document.getElementById("toast");
|
|
@@ -320,18 +328,20 @@
|
|
|
320
328
|
["#abortBtn", "prompt.abort"],
|
|
321
329
|
["#clearChatBtn", "sessions.write"],
|
|
322
330
|
["#saveSettingsBtn", "settings.write"],
|
|
331
|
+
["#settingsWizardBtn", "settings.write"],
|
|
323
332
|
["#restartBtn", "system.restart"],
|
|
324
333
|
["#updateBtn", "updates.run"],
|
|
325
334
|
["#clearLogsBtn", "logs.clear"],
|
|
326
|
-
["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn", "users.write"],
|
|
327
|
-
["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke]", "peers.write"],
|
|
335
|
+
["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn,#createSlackChannelBtn", "users.write"],
|
|
336
|
+
["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke],[data-peer-invite-delete]", "peers.write"],
|
|
337
|
+
["#checkPeerReachabilityBtn,[data-peer-probe]", "peers.connect"],
|
|
328
338
|
["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
|
|
329
339
|
["[data-switch]", "sessions.write"],
|
|
330
340
|
["[data-queue],[data-q]", "queue.write"],
|
|
331
341
|
["[data-del-art],#deleteSelectedArtifactsBtn", "files.write"],
|
|
332
342
|
["[data-auth-login],[data-auth-logout]", "auth.manage"],
|
|
333
343
|
["[data-update-agent],[data-update-send],[data-update-cancel],[data-update-delete-log]", "updates.run"],
|
|
334
|
-
["[data-user-edit],[data-user-toggle],[data-user-code],[data-user-link],[data-user-discord-code],[data-user-discord-link],[data-user-password],[data-user-revoke],[data-telegram-unlink],[data-discord-unlink],[data-group-edit],[data-chat-edit],[data-chat-toggle],[data-discord-channel-edit],[data-discord-channel-toggle]", "users.write"]
|
|
344
|
+
["[data-user-edit],[data-user-toggle],[data-user-code],[data-user-link],[data-user-discord-code],[data-user-discord-link],[data-user-slack-code],[data-user-slack-link],[data-user-password],[data-user-revoke],[data-telegram-unlink],[data-discord-unlink],[data-slack-unlink],[data-group-edit],[data-chat-edit],[data-chat-toggle],[data-discord-channel-edit],[data-discord-channel-toggle],[data-slack-channel-edit],[data-slack-channel-toggle]", "users.write"]
|
|
335
345
|
];
|
|
336
346
|
disableMap.forEach(([selector, permission]) => document.querySelectorAll(selector).forEach((el) => {
|
|
337
347
|
el.disabled = !can(permission);
|
|
@@ -565,6 +575,7 @@
|
|
|
565
575
|
if (source === "cli") return "CLI";
|
|
566
576
|
if (source === "telegram") return "Telegram";
|
|
567
577
|
if (source === "discord") return "Discord";
|
|
578
|
+
if (source === "slack") return "Slack";
|
|
568
579
|
if (source === "web") return "WebUI";
|
|
569
580
|
return source || "-";
|
|
570
581
|
}
|
|
@@ -1241,49 +1252,9 @@
|
|
|
1241
1252
|
if (kind === "docs") return !/\\.(png|jpe?g|gif|webp|svg)$/i.test(name);
|
|
1242
1253
|
return true;
|
|
1243
1254
|
}
|
|
1244
|
-
function renderArtifacts() {
|
|
1245
|
-
const query = (document.getElementById("artifactSearch").value || "").toLowerCase();
|
|
1246
|
-
const kind = document.getElementById("artifactKind").value;
|
|
1247
|
-
const reports = state.artifactReports || [];
|
|
1248
|
-
document.getElementById("artifactList").innerHTML = reports.map((r) => {
|
|
1249
|
-
const files = (r.artifacts || []).filter((a) => artifactMatches(a, kind, query));
|
|
1250
|
-
if (files.length === 0) return "";
|
|
1251
|
-
const gallery = files.map((a) => {
|
|
1252
|
-
const href = "/api/artifacts/file?turnId=" + encodeURIComponent(r.turnId) + "&path=" + encodeURIComponent(a.relativePath);
|
|
1253
|
-
const img = /\\.(png|jpe?g|gif|webp|svg)$/i.test(a.name) ? '<img src="' + href + '">' : "<pre>" + esc(a.name.split(".").pop() || "file") + "</pre>";
|
|
1254
|
-
return '<div class="artifact-card"><label><input type="checkbox" data-artifact-select="' + attr(r.turnId) + '" ' + (state.selectedArtifactTurns.has(r.turnId) ? "checked" : "") + "> " + esc(short(a.name, 32)) + "</label>" + img + "<small>" + esc(fmtBytes(a.sizeBytes)) + '</small><div class="row"><a href="' + href + '">Open</a><button class="secondary" data-preview-turn="' + attr(r.turnId) + '" data-preview-path="' + attr(a.relativePath) + '">Preview</button></div></div>';
|
|
1255
|
-
}).join("");
|
|
1256
|
-
return '<div class="item"><strong>' + esc(r.turnId) + " - " + files.length + "/" + r.fileCount + " files - " + fmtBytes(r.totalSizeBytes) + "</strong><small>" + fmtDate(r.updatedAt) + " / " + esc(r.source || "turn") + '</small><div class="row"><a href="/api/artifacts/zip?turnId=' + encodeURIComponent(r.turnId) + '">Download ZIP</a><button data-del-art="' + esc(r.turnId) + '" class="danger"' + disabledAttr("files.write") + '>Delete</button></div><div class="gallery">' + gallery + "</div></div>";
|
|
1257
|
-
}).join("") || '<div class="item">No artifacts.</div>';
|
|
1258
|
-
document.querySelectorAll("[data-artifact-select]").forEach((c) => c.onchange = () => {
|
|
1259
|
-
if (c.checked) state.selectedArtifactTurns.add(c.dataset.artifactSelect);
|
|
1260
|
-
else state.selectedArtifactTurns.delete(c.dataset.artifactSelect);
|
|
1261
|
-
});
|
|
1262
|
-
document.querySelectorAll("[data-del-art]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1263
|
-
if (!can("files.write")) {
|
|
1264
|
-
toast("Permission required: files.write");
|
|
1265
|
-
return;
|
|
1266
|
-
}
|
|
1267
|
-
if (confirm("Delete artifact turn " + b.dataset.delArt + "?")) {
|
|
1268
|
-
await api("/api/artifacts", { method: "DELETE", query: { turnId: b.dataset.delArt } });
|
|
1269
|
-
state.selectedArtifactTurns.delete(b.dataset.delArt);
|
|
1270
|
-
loadArtifacts();
|
|
1271
|
-
}
|
|
1272
|
-
}));
|
|
1273
|
-
document.querySelectorAll("[data-preview-turn]").forEach((b) => b.onclick = () => safe(() => previewArtifact(b.dataset.previewTurn, b.dataset.previewPath)));
|
|
1274
|
-
applyPermissions();
|
|
1275
|
-
}
|
|
1276
1255
|
document.getElementById("reloadArtifactsBtn").onclick = loadArtifacts;
|
|
1277
1256
|
document.getElementById("artifactSearch").oninput = renderArtifacts;
|
|
1278
1257
|
document.getElementById("artifactKind").onchange = renderArtifacts;
|
|
1279
|
-
document.getElementById("zipSelectedArtifactsBtn").onclick = () => {
|
|
1280
|
-
const turnIds = [...state.selectedArtifactTurns];
|
|
1281
|
-
if (turnIds.length === 0) {
|
|
1282
|
-
toast("No artifact turns selected");
|
|
1283
|
-
return;
|
|
1284
|
-
}
|
|
1285
|
-
turnIds.forEach((turnId) => window.open("/api/artifacts/zip?turnId=" + encodeURIComponent(turnId), "_blank"));
|
|
1286
|
-
};
|
|
1287
1258
|
document.getElementById("deleteSelectedArtifactsBtn").onclick = () => safe(async () => {
|
|
1288
1259
|
if (!can("files.write")) {
|
|
1289
1260
|
toast("Permission required: files.write");
|
|
@@ -1304,26 +1275,6 @@
|
|
|
1304
1275
|
function highlightCode(text) {
|
|
1305
1276
|
return esc(text).replace(/\\b(import|export|const|let|function|return|if|else|for|while|class|interface|type|async|await)\\b/g, '<span class="chip">$1</span>');
|
|
1306
1277
|
}
|
|
1307
|
-
async function previewArtifact(turnId, path) {
|
|
1308
|
-
const target = document.getElementById("artifactPreview");
|
|
1309
|
-
target.innerHTML = '<div class="panel">' + loadingHtml("Loading preview...") + "</div>";
|
|
1310
|
-
target.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
1311
|
-
try {
|
|
1312
|
-
const data = await api("/api/artifacts/preview", { query: { turnId, path } });
|
|
1313
|
-
if (data.kind === "image") {
|
|
1314
|
-
target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + '</h2><img src="/api/artifacts/file?turnId=' + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path) + '"></div>';
|
|
1315
|
-
return;
|
|
1316
|
-
}
|
|
1317
|
-
if (data.kind === "text") {
|
|
1318
|
-
target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + " " + fmtBytes(data.sizeBytes) + "</h2><pre>" + highlightCode(data.text || "") + "</pre>" + (data.truncated ? "<small>Preview truncated.</small>" : "") + "</div>";
|
|
1319
|
-
return;
|
|
1320
|
-
}
|
|
1321
|
-
target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + "</h2><p>" + esc(data.detail || "Preview unavailable") + "</p></div>";
|
|
1322
|
-
} catch (err) {
|
|
1323
|
-
target.innerHTML = '<div class="panel"><h2>Preview failed</h2><p>' + esc(err.message || String(err)) + "</p></div>";
|
|
1324
|
-
throw err;
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
1278
|
function isRemotePeerTarget() {
|
|
1328
1279
|
return Boolean(state.selectedPeer && state.selectedPeer !== "local");
|
|
1329
1280
|
}
|
|
@@ -1539,7 +1490,8 @@
|
|
|
1539
1490
|
}
|
|
1540
1491
|
function renderMetrics(d) {
|
|
1541
1492
|
const adapters = d.adapters || {};
|
|
1542
|
-
|
|
1493
|
+
const adapterCards = Object.entries(adapters).map(([name, rate]) => card(name.charAt(0).toUpperCase() + name.slice(1) + " rate limits", rateRows("", rate).map(([k, v]) => [String(k).trim(), v]))).join("");
|
|
1494
|
+
document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + adapterCards + "</div>";
|
|
1543
1495
|
}
|
|
1544
1496
|
document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
|
|
1545
1497
|
function activityQuery() {
|
|
@@ -1557,7 +1509,7 @@
|
|
|
1557
1509
|
}
|
|
1558
1510
|
function activityActorText(e) {
|
|
1559
1511
|
const a = e.actor || {};
|
|
1560
|
-
return a.label || a.username || a.id || ({ web: "Web user", telegram: "Telegram user", discord: "Discord user", cli: "CLI", system: "System" }[a.channel] || "System");
|
|
1512
|
+
return a.label || a.username || a.id || ({ web: "Web user", telegram: "Telegram user", discord: "Discord user", slack: "Slack user", cli: "CLI", system: "System" }[a.channel] || "System");
|
|
1561
1513
|
}
|
|
1562
1514
|
function activityMetaHtml(e) {
|
|
1563
1515
|
const workspace = activityWorkspace(e);
|
|
@@ -1589,12 +1541,14 @@
|
|
|
1589
1541
|
URL.revokeObjectURL(a.href);
|
|
1590
1542
|
};
|
|
1591
1543
|
async function loadSettings() {
|
|
1544
|
+
state.settingsWizard = null;
|
|
1545
|
+
document.getElementById("settingsTabs").style.display = "";
|
|
1592
1546
|
setLoading("settingsForm", "Loading settings...");
|
|
1593
1547
|
const data = await api("/api/settings");
|
|
1594
1548
|
state.settings = data.settings;
|
|
1595
1549
|
renderSettings();
|
|
1596
1550
|
}
|
|
1597
|
-
const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Operations", "Artifacts", "Workspace", "Peers", "Voice", "Dashboard"];
|
|
1551
|
+
const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Slack", "Operations", "Artifacts", "Workspace", "Peers", "Voice", "Dashboard"];
|
|
1598
1552
|
const agentSettingGroups = ["Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"];
|
|
1599
1553
|
function orderedSettingsGroups(groups) {
|
|
1600
1554
|
const known = settingsGroupOrder.filter((name) => groups[name]);
|
|
@@ -1733,7 +1687,7 @@
|
|
|
1733
1687
|
return '<div class="permission-grid full-span">' + (state.userManagement?.permissions || []).map((p) => '<label class="checkbox"><input type="checkbox" data-permission-choice="' + attr(p) + '" ' + (selectedSet.has(p) ? "checked" : "") + "> " + esc(p) + "</label>").join("") + "</div>";
|
|
1734
1688
|
}
|
|
1735
1689
|
function selectedValues(selector) {
|
|
1736
|
-
return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.dataset.discordChannelChoice || x.value).filter(Boolean);
|
|
1690
|
+
return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.dataset.discordChannelChoice || x.dataset.slackChannelChoice || x.value).filter(Boolean);
|
|
1737
1691
|
}
|
|
1738
1692
|
function csv(values = []) {
|
|
1739
1693
|
return (values || []).join(", ");
|
|
@@ -1742,6 +1696,7 @@
|
|
|
1742
1696
|
const dialog = document.getElementById("adminDialog");
|
|
1743
1697
|
document.getElementById("adminDialogTitle").textContent = title;
|
|
1744
1698
|
document.getElementById("adminDialogBody").innerHTML = body;
|
|
1699
|
+
document.getElementById("adminDialogSubmit").textContent = "Save";
|
|
1745
1700
|
document.getElementById("adminDialogCancel").onclick = () => dialog.close();
|
|
1746
1701
|
document.getElementById("adminDialogForm").onsubmit = (e) => safe(async () => {
|
|
1747
1702
|
e.preventDefault();
|
|
@@ -1762,10 +1717,15 @@
|
|
|
1762
1717
|
}
|
|
1763
1718
|
function bindAccessTabs() {
|
|
1764
1719
|
document.querySelectorAll("[data-access-tab]").forEach((b) => b.onclick = () => switchAccessTab(b.dataset.accessTab));
|
|
1765
|
-
const
|
|
1766
|
-
if (
|
|
1767
|
-
|
|
1768
|
-
|
|
1720
|
+
const discordSearch = document.getElementById("discordChannelSearch");
|
|
1721
|
+
if (discordSearch && !discordSearch.dataset.bound) {
|
|
1722
|
+
discordSearch.dataset.bound = "true";
|
|
1723
|
+
discordSearch.oninput = () => renderDiscordChannels();
|
|
1724
|
+
}
|
|
1725
|
+
const slackSearch = document.getElementById("slackChannelSearch");
|
|
1726
|
+
if (slackSearch && !slackSearch.dataset.bound) {
|
|
1727
|
+
slackSearch.dataset.bound = "true";
|
|
1728
|
+
slackSearch.oninput = () => renderSlackChannels();
|
|
1769
1729
|
}
|
|
1770
1730
|
}
|
|
1771
1731
|
function switchAccessTab(tab) {
|
|
@@ -1803,16 +1763,46 @@
|
|
|
1803
1763
|
bindAccessCopyButtons();
|
|
1804
1764
|
applyPermissions();
|
|
1805
1765
|
}
|
|
1766
|
+
function slackChannelLabel(channel) {
|
|
1767
|
+
return channel.title ? channel.title + " (" + channel.channelId + ")" : channel.channelId;
|
|
1768
|
+
}
|
|
1769
|
+
function slackScopeLabel(ids = []) {
|
|
1770
|
+
const channels = state.userManagement?.slackChannels || [];
|
|
1771
|
+
return (ids || []).map((id) => slackChannelLabel(channels.find((c) => c.channelId === id) || { channelId: id })).join(", ");
|
|
1772
|
+
}
|
|
1773
|
+
function slackChannelOptions(selected = []) {
|
|
1774
|
+
const selectedSet = new Set(selected || []);
|
|
1775
|
+
const channels = state.userManagement?.slackChannels || [];
|
|
1776
|
+
return '<div class="permission-grid full-span">' + (channels.map((c) => '<label class="checkbox"><input type="checkbox" data-slack-channel-choice="' + attr(c.channelId) + '" ' + (selectedSet.has(c.channelId) ? "checked" : "") + "> " + esc(slackChannelLabel(c) + " / " + (c.teamId || "team default")) + "</label>").join("") || "<small>No Slack channels registered.</small>") + "</div>";
|
|
1777
|
+
}
|
|
1778
|
+
function slackIdentityHtml(user, identity) {
|
|
1779
|
+
const id = String(identity.slackUserId || "");
|
|
1780
|
+
const team = identity.teamId ? " <span>" + esc(identity.teamId) + "</span>" : "";
|
|
1781
|
+
return '<span class="access-id-row">' + accessCopyButton(id, "Slack user ID copied") + team + (identity.username ? " <span>@" + esc(identity.username) + "</span>" : "") + ' <button class="secondary mini-button" data-slack-user="' + attr(user.id) + '" data-slack-unlink="' + attr(identity.id) + '"' + disabledAttr("users.write") + ">Unlink</button></span>";
|
|
1782
|
+
}
|
|
1783
|
+
function renderSlackChannels(channels = state.userManagement?.slackChannels || []) {
|
|
1784
|
+
const target = document.getElementById("slackChannelsList");
|
|
1785
|
+
if (!target) return;
|
|
1786
|
+
const query = (document.getElementById("slackChannelSearch")?.value || "").toLowerCase();
|
|
1787
|
+
const filtered = (channels || []).filter((c) => !query || [c.title, c.channelId, c.teamId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
|
|
1788
|
+
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>';
|
|
1789
|
+
bindSlackChannelButtons();
|
|
1790
|
+
bindAccessCopyButtons();
|
|
1791
|
+
applyPermissions();
|
|
1792
|
+
}
|
|
1806
1793
|
function renderUserManagement(d) {
|
|
1807
1794
|
document.getElementById("accessPanel").innerHTML = (d.users || []).map((u) => {
|
|
1808
1795
|
const telegram = (u.telegramIdentities || []).map((t) => t.telegramUserId + (t.username ? " @" + t.username : "") + ' <button class="secondary mini-button" data-telegram-user="' + attr(u.id) + '" data-telegram-unlink="' + attr(t.id) + '"' + disabledAttr("users.write") + ">Unlink</button>").join(" ");
|
|
1809
1796
|
const discord = (u.discordIdentities || []).map((identity) => discordIdentityHtml(u, identity)).join(" ");
|
|
1810
|
-
|
|
1797
|
+
const slack = (u.slackIdentities || []).map((identity) => slackIdentityHtml(u, identity)).join(" ");
|
|
1798
|
+
return '<div class="item"><strong>' + esc(u.displayName) + ' <span class="adapter-status ' + (u.active ? "enabled" : "disabled") + '">' + (u.active ? "active" : "disabled") + "</span></strong><small>" + esc(u.email + " / " + u.id) + "</small><small>Groups: " + esc((u.groups || []).map((g) => g.name).join(", ") || "-") + "</small><small>Telegram: " + (telegram || "-") + "</small><small>Discord: " + (discord || "-") + "</small><small>Slack: " + (slack || "-") + "</small><small>Web sessions: " + esc(String((u.webSessions || []).length)) + '</small><div class="row"><button 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><button class="secondary" data-user-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Telegram code</button><button class="secondary" data-user-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Telegram ID</button><button class="secondary" data-user-discord-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Discord code</button><button class="secondary" data-user-discord-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Discord ID</button><button class="secondary" data-user-slack-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Slack code</button><button class="secondary" data-user-slack-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Slack ID</button><button class="secondary" data-user-password="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Set password</button><button class="danger" data-user-revoke="' + attr(u.id) + '"' + disabledAttr("users.write") + ">Revoke sessions</button></div></div>";
|
|
1811
1799
|
}).join("") || '<div class="item">No users.</div>';
|
|
1812
|
-
document.getElementById("groupsList").innerHTML = (d.groups || []).map((g) => '<div class="item"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>Permissions: " + esc((g.permissions || []).join(", ") || "-") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Telegram chat scope: " + esc(csv(g.telegramChatIds) || "all") + "</small><small>Discord channel scope: " + esc(discordScopeLabel(g.discordChannelIds) || "all") + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>").join("") || '<div class="item">No groups.</div>';
|
|
1800
|
+
document.getElementById("groupsList").innerHTML = (d.groups || []).map((g) => '<div class="item"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>Permissions: " + esc((g.permissions || []).join(", ") || "-") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Telegram chat scope: " + esc(csv(g.telegramChatIds) || "all") + "</small><small>Discord channel scope: " + esc(discordScopeLabel(g.discordChannelIds) || "all") + "</small><small>Slack channel scope: " + esc(slackScopeLabel(g.slackChannelIds) || "all") + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>").join("") || '<div class="item">No groups.</div>';
|
|
1813
1801
|
document.getElementById("telegramChatsList").innerHTML = (d.telegramChats || []).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>" + esc("Chat ID: " + c.chatId + " / " + (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>';
|
|
1814
1802
|
renderDiscordChannels(d.discordChannels || []);
|
|
1803
|
+
renderSlackChannels(d.slackChannels || []);
|
|
1815
1804
|
bindUserButtons();
|
|
1805
|
+
bindSlackUserButtons();
|
|
1816
1806
|
bindAccessCopyButtons();
|
|
1817
1807
|
applyPermissions();
|
|
1818
1808
|
}
|
|
@@ -1900,6 +1890,33 @@
|
|
|
1900
1890
|
loadAccess();
|
|
1901
1891
|
}));
|
|
1902
1892
|
}
|
|
1893
|
+
function bindSlackUserButtons() {
|
|
1894
|
+
document.querySelectorAll("[data-user-slack-code]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1895
|
+
const r = await api("/api/users/" + encodeURIComponent(b.dataset.userSlackCode) + "/slack", { method: "POST", body: JSON.stringify({ createCode: true }) });
|
|
1896
|
+
toast("Slack link code: " + r.linkCode.code + " (expires " + fmtDate(r.linkCode.expiresAt) + ")", { duration: 15e3 });
|
|
1897
|
+
}));
|
|
1898
|
+
document.querySelectorAll("[data-user-slack-link]").forEach((b) => b.onclick = () => openSlackLinkDialog(b.dataset.userSlackLink));
|
|
1899
|
+
document.querySelectorAll("[data-slack-unlink]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1900
|
+
if (confirm("Unlink this Slack identity?")) {
|
|
1901
|
+
await api("/api/users/" + encodeURIComponent(b.dataset.slackUser) + "/slack/" + encodeURIComponent(b.dataset.slackUnlink), { method: "DELETE" });
|
|
1902
|
+
toast("Slack unlinked");
|
|
1903
|
+
loadAccess();
|
|
1904
|
+
}
|
|
1905
|
+
}));
|
|
1906
|
+
}
|
|
1907
|
+
function bindSlackChannelButtons() {
|
|
1908
|
+
document.querySelectorAll("[data-slack-channel-edit]").forEach((b) => b.onclick = () => {
|
|
1909
|
+
const c = (state.userManagement?.slackChannels || []).find((x) => x.id === b.dataset.slackChannelEdit);
|
|
1910
|
+
if (c) openSlackChannelDialog(c);
|
|
1911
|
+
});
|
|
1912
|
+
document.querySelectorAll("[data-slack-channel-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1913
|
+
const c = (state.userManagement?.slackChannels || []).find((x) => x.id === b.dataset.slackChannelToggle);
|
|
1914
|
+
if (!c) return;
|
|
1915
|
+
await api("/api/slack-channels/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
|
|
1916
|
+
toast("Slack channel updated");
|
|
1917
|
+
loadAccess();
|
|
1918
|
+
}));
|
|
1919
|
+
}
|
|
1903
1920
|
function openUserDialog(u) {
|
|
1904
1921
|
adminDialog(u ? "Edit user" : "Create user", '<label>Email<input id="dlgEmail" value="' + attr(u?.email || "") + '"></label><label>Display name<input id="dlgName" value="' + attr(u?.displayName || "") + '"></label>' + (!u ? '<label>Password<input id="dlgPassword" type="password" autocomplete="new-password"></label>' : "") + '<label class="checkbox"><input id="dlgActive" type="checkbox" ' + (!u || u.active ? "checked" : "") + '> Active</label><div class="full-span"><strong>Groups</strong><div class="row">' + groupOptions(u ? userGroups(u) : ["user"]) + "</div></div>", async () => {
|
|
1905
1922
|
const payload = { email: val("dlgEmail"), displayName: val("dlgName"), active: document.getElementById("dlgActive").checked, groupIds: selectedValues("[data-group-choice]") };
|
|
@@ -1927,6 +1944,19 @@
|
|
|
1927
1944
|
toast("Discord linked");
|
|
1928
1945
|
});
|
|
1929
1946
|
}
|
|
1947
|
+
function assertSlackId(value, label, required = true) {
|
|
1948
|
+
const text = String(value || "").trim();
|
|
1949
|
+
if (!text && !required) return "";
|
|
1950
|
+
if (!text) throw new Error(label + " is required");
|
|
1951
|
+
if (!/^[A-Z0-9]{2,64}$/.test(text)) throw new Error(label + " must look like a Slack ID");
|
|
1952
|
+
return text;
|
|
1953
|
+
}
|
|
1954
|
+
function openSlackLinkDialog(id) {
|
|
1955
|
+
adminDialog("Link Slack ID", '<label>Slack user ID<input id="dlgSlackId" placeholder="U..."></label><label>Team ID<input id="dlgSlackTeamId" placeholder="T..."></label><label>Username<input id="dlgSlackUsername"></label>', async () => {
|
|
1956
|
+
await api("/api/users/" + encodeURIComponent(id) + "/slack", { method: "POST", body: JSON.stringify({ slackUserId: assertSlackId(val("dlgSlackId"), "Slack user ID"), teamId: assertSlackId(val("dlgSlackTeamId"), "Slack team ID", false) || void 0, username: val("dlgSlackUsername") || void 0 }) });
|
|
1957
|
+
toast("Slack linked");
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1930
1960
|
function openTelegramLinkDialog(id) {
|
|
1931
1961
|
adminDialog("Link Telegram ID", '<label>Telegram user ID<input id="dlgTelegramId" type="number"></label><label>Username<input id="dlgUsername"></label>', async () => {
|
|
1932
1962
|
await api("/api/users/" + encodeURIComponent(id) + "/telegram", { method: "POST", body: JSON.stringify({ telegramUserId: Number(val("dlgTelegramId")), username: val("dlgUsername") || void 0 }) });
|
|
@@ -1934,8 +1964,8 @@
|
|
|
1934
1964
|
});
|
|
1935
1965
|
}
|
|
1936
1966
|
function openGroupDialog(g) {
|
|
1937
|
-
adminDialog(g ? "Edit group" : "Create group", '<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">Agent scope, comma-separated<input id="dlgAgentIds" value="' + attr(csv(g?.agentIds)) + '" placeholder="empty means all"></label><label class="full-span">Workspace scope, comma-separated<input id="dlgWorkspaceRoots" value="' + attr(csv(g?.workspaceRoots)) + '" placeholder="empty means all"></label><label class="full-span">Telegram chat scope, comma-separated<input id="dlgTelegramChatIds" value="' + attr(csv(g?.telegramChatIds)) + '" placeholder="empty means all"></label><div class="full-span"><strong>Discord channel scope</strong>' + discordChannelOptions(g?.discordChannelIds || []) + '<small>Leave empty to allow every registered Discord channel.</small></div><strong class="full-span">Permissions</strong>' + permissionOptions(g?.permissions || ["inspect", "sessions.read"]), async () => {
|
|
1938
|
-
const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: selectedValues("[data-permission-choice]"), agentIds: csvToList(val("dlgAgentIds")), workspaceRoots: csvToList(val("dlgWorkspaceRoots")), telegramChatIds: csvToList(val("dlgTelegramChatIds")).map(Number).filter(Number.isInteger), discordChannelIds: selectedValues("[data-discord-channel-choice]") };
|
|
1967
|
+
adminDialog(g ? "Edit group" : "Create group", '<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">Agent scope, comma-separated<input id="dlgAgentIds" value="' + attr(csv(g?.agentIds)) + '" placeholder="empty means all"></label><label class="full-span">Workspace scope, comma-separated<input id="dlgWorkspaceRoots" value="' + attr(csv(g?.workspaceRoots)) + '" placeholder="empty means all"></label><label class="full-span">Telegram chat scope, comma-separated<input id="dlgTelegramChatIds" value="' + attr(csv(g?.telegramChatIds)) + '" placeholder="empty means all"></label><div class="full-span"><strong>Discord channel scope</strong>' + discordChannelOptions(g?.discordChannelIds || []) + '<small>Leave empty to allow every registered Discord channel.</small></div><div class="full-span"><strong>Slack channel scope</strong>' + slackChannelOptions(g?.slackChannelIds || []) + '<small>Leave empty to allow every registered Slack channel.</small></div><strong class="full-span">Permissions</strong>' + permissionOptions(g?.permissions || ["inspect", "sessions.read"]), async () => {
|
|
1968
|
+
const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: selectedValues("[data-permission-choice]"), agentIds: csvToList(val("dlgAgentIds")), workspaceRoots: csvToList(val("dlgWorkspaceRoots")), telegramChatIds: csvToList(val("dlgTelegramChatIds")).map(Number).filter(Number.isInteger), discordChannelIds: selectedValues("[data-discord-channel-choice]"), slackChannelIds: selectedValues("[data-slack-channel-choice]") };
|
|
1939
1969
|
await api(g ? "/api/groups/" + encodeURIComponent(g.id) : "/api/groups", { method: g ? "PATCH" : "POST", body: JSON.stringify(payload) });
|
|
1940
1970
|
toast(g ? "Group updated" : "Group created");
|
|
1941
1971
|
});
|
|
@@ -1948,6 +1978,13 @@
|
|
|
1948
1978
|
toast(c ? "Discord channel updated" : "Discord channel added");
|
|
1949
1979
|
});
|
|
1950
1980
|
}
|
|
1981
|
+
function openSlackChannelDialog(c) {
|
|
1982
|
+
adminDialog(c ? "Edit Slack channel" : "Add Slack channel", '<label>Team ID<input id="dlgSlackTeamId" value="' + attr(c?.teamId || "") + '" ' + (c ? "disabled" : "") + ' placeholder="T..."></label><label>Channel ID<input id="dlgSlackChannelId" value="' + attr(c?.channelId || "") + '" ' + (c ? "disabled" : "") + ' placeholder="C..."></label><label>Title<input id="dlgSlackChannelTitle" value="' + attr(c?.title || "") + '"></label><label>Type<select id="dlgSlackChannelType"><option value="channel" ' + ((c?.type || "channel") === "channel" ? "selected" : "") + '>channel</option><option value="thread" ' + (c?.type === "thread" ? "selected" : "") + '>thread</option><option value="dm" ' + (c?.type === "dm" ? "selected" : "") + '>dm</option></select></label><label class="checkbox"><input id="dlgSlackChannelEnabled" type="checkbox" ' + (!c || c.enabled ? "checked" : "") + '> Enabled</label><div class="full-span"><strong>Allowed groups</strong><div class="row">' + groupOptions(c?.allowedGroupIds || []) + "</div><small>Leave empty to allow every group.</small></div>", async () => {
|
|
1983
|
+
const payload = { teamId: assertSlackId(val("dlgSlackTeamId"), "Team ID", false) || void 0, channelId: assertSlackId(val("dlgSlackChannelId"), "Channel ID"), title: val("dlgSlackChannelTitle") || void 0, type: val("dlgSlackChannelType") || "channel", enabled: document.getElementById("dlgSlackChannelEnabled").checked, allowedGroupIds: selectedValues("[data-group-choice]") };
|
|
1984
|
+
await api(c ? "/api/slack-channels/" + encodeURIComponent(c.id) : "/api/slack-channels", { method: c ? "PATCH" : "POST", body: JSON.stringify(payload) });
|
|
1985
|
+
toast(c ? "Slack channel updated" : "Slack channel added");
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1951
1988
|
function openChatDialog(c) {
|
|
1952
1989
|
adminDialog(c ? "Edit Telegram chat" : "Add Telegram chat", '<label>Chat ID<input id="dlgChatId" type="number" value="' + attr(c?.chatId || "") + '" ' + (c ? "disabled" : "") + '></label><label>Title<input id="dlgChatTitle" value="' + attr(c?.title || "") + '"></label><label>Type<input id="dlgChatType" value="' + attr(c?.type || "supergroup") + '"></label><label class="checkbox"><input id="dlgChatEnabled" type="checkbox" ' + (!c || c.enabled ? "checked" : "") + '> Enabled</label><div class="full-span"><strong>Allowed groups</strong><div class="row">' + groupOptions(c?.allowedGroupIds || []) + "</div><small>Leave empty to allow every group.</small></div>", async () => {
|
|
1953
1990
|
const payload = { chatId: Number(val("dlgChatId")), title: val("dlgChatTitle") || void 0, type: val("dlgChatType") || void 0, enabled: document.getElementById("dlgChatEnabled").checked, allowedGroupIds: selectedValues("[data-group-choice]") };
|
|
@@ -1986,6 +2023,13 @@
|
|
|
1986
2023
|
}
|
|
1987
2024
|
openDiscordChannelDialog(null);
|
|
1988
2025
|
};
|
|
2026
|
+
document.getElementById("createSlackChannelBtn").onclick = () => {
|
|
2027
|
+
if (!can("users.write")) {
|
|
2028
|
+
toast("Permission required: users.write");
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
openSlackChannelDialog(null);
|
|
2032
|
+
};
|
|
1989
2033
|
async function loadLocks() {
|
|
1990
2034
|
if (!can("sessions.read")) {
|
|
1991
2035
|
document.getElementById("locksList").innerHTML = '<div class="item">Permission required: sessions.read</div>';
|
|
@@ -2109,67 +2153,18 @@
|
|
|
2109
2153
|
setLoading("peersList", "Loading peers...");
|
|
2110
2154
|
const d = await api("/api/peers", { local: true });
|
|
2111
2155
|
state.peers = d;
|
|
2112
|
-
|
|
2156
|
+
const inviteIds = new Set((d.invitations || []).map((i) => i.id));
|
|
2157
|
+
Object.keys(state.peerInviteSecrets || {}).forEach((id) => {
|
|
2158
|
+
if (!inviteIds.has(id)) delete state.peerInviteSecrets[id];
|
|
2159
|
+
});
|
|
2160
|
+
document.getElementById("peerStatus").innerHTML = peerStatusHtml(d);
|
|
2113
2161
|
document.getElementById("peersList").innerHTML = (d.peers || []).map(peerCard).join("") || '<div class="item">No peers configured.</div>';
|
|
2114
|
-
document.getElementById("peerInvites").innerHTML = (d.invitations || []).map(
|
|
2162
|
+
document.getElementById("peerInvites").innerHTML = (d.invitations || []).map(peerInviteCard).join("") || '<div class="item">No open invitations.</div>';
|
|
2163
|
+
ensureGlobalPeerSessionsPanel();
|
|
2115
2164
|
bindPeerButtons();
|
|
2116
2165
|
await loadPeerSelector();
|
|
2117
2166
|
applyPermissions();
|
|
2118
2167
|
}
|
|
2119
|
-
function peerCard(p) {
|
|
2120
|
-
const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
|
|
2121
|
-
return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong><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>" + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + '<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-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>";
|
|
2122
|
-
}
|
|
2123
|
-
function bindPeerButtons() {
|
|
2124
|
-
document.querySelectorAll("[data-peer-select]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2125
|
-
state.selectedPeer = b.dataset.peerSelect || "local";
|
|
2126
|
-
localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
|
|
2127
|
-
connectEvents();
|
|
2128
|
-
await loadBootstrap();
|
|
2129
|
-
toast("Peer target selected");
|
|
2130
|
-
}));
|
|
2131
|
-
document.querySelectorAll("[data-peer-test]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2132
|
-
const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerTest) + "/proxy", { method: "POST", body: JSON.stringify({ method: "GET", path: "/api/health", query: {}, body: {} }), local: true });
|
|
2133
|
-
toast("Peer reachable: " + (r.health?.state?.status || r.state?.status || "ok"), { duration: 6e3 });
|
|
2134
|
-
loadPeers();
|
|
2135
|
-
}));
|
|
2136
|
-
document.querySelectorAll("[data-peer-edit]").forEach((b) => b.onclick = () => {
|
|
2137
|
-
const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerEdit);
|
|
2138
|
-
if (p) openPeerDialog(p);
|
|
2139
|
-
});
|
|
2140
|
-
document.querySelectorAll("[data-peer-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2141
|
-
const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerToggle);
|
|
2142
|
-
if (!p) return;
|
|
2143
|
-
await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ enabled: !p.enabled }), local: true });
|
|
2144
|
-
toast("Peer updated");
|
|
2145
|
-
loadPeers();
|
|
2146
|
-
}));
|
|
2147
|
-
document.querySelectorAll("[data-peer-revoke]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2148
|
-
if (confirm("Revoke this peer?")) {
|
|
2149
|
-
await api("/api/peers/" + encodeURIComponent(b.dataset.peerRevoke), { method: "DELETE", local: true });
|
|
2150
|
-
if (state.selectedPeer === b.dataset.peerRevoke) {
|
|
2151
|
-
state.selectedPeer = "local";
|
|
2152
|
-
localStorage.setItem("nordrelayPeerTarget", "local");
|
|
2153
|
-
}
|
|
2154
|
-
toast("Peer revoked");
|
|
2155
|
-
loadPeers();
|
|
2156
|
-
}
|
|
2157
|
-
}));
|
|
2158
|
-
}
|
|
2159
|
-
function openPeerDialog(p) {
|
|
2160
|
-
adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label>', async () => {
|
|
2161
|
-
await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")) }), local: true });
|
|
2162
|
-
toast("Peer updated");
|
|
2163
|
-
await loadPeers();
|
|
2164
|
-
});
|
|
2165
|
-
}
|
|
2166
|
-
function openPeerInviteDialog() {
|
|
2167
|
-
adminDialog("Create peer invite", '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label>', async () => {
|
|
2168
|
-
const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")) }), local: true });
|
|
2169
|
-
toast("Pairing code: " + r.code + "\\n" + r.command, { duration: 2e4 });
|
|
2170
|
-
await loadPeers();
|
|
2171
|
-
});
|
|
2172
|
-
}
|
|
2173
2168
|
function openPeerAddDialog() {
|
|
2174
2169
|
adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional"></label>', async () => {
|
|
2175
2170
|
const r = await api("/api/peers/pair", { method: "POST", body: JSON.stringify({ url: val("dlgPeerAddUrl"), code: val("dlgPeerAddCode"), name: val("dlgPeerAddName") || void 0, publicUrl: val("dlgPeerAddPublicUrl") || void 0 }), local: true });
|
|
@@ -2194,8 +2189,11 @@
|
|
|
2194
2189
|
};
|
|
2195
2190
|
async function loadAdapterHealth() {
|
|
2196
2191
|
setLoading("adapterHealth", "Loading adapters...");
|
|
2197
|
-
|
|
2192
|
+
setLoading("adapterConformance", "Loading conformance...");
|
|
2193
|
+
const [d, conformance] = await Promise.all([api("/api/adapters/health"), api("/api/adapters/conformance")]);
|
|
2194
|
+
state.adapterConformance = conformance;
|
|
2198
2195
|
document.getElementById("adapterHealth").innerHTML = (d.adapters || []).map((a) => '<div class="item"><strong>' + esc(a.label) + ' <span class="adapter-status ' + esc(a.status) + '">' + esc(a.status) + "</span></strong><small>" + esc("CLI: " + (a.cli.label || "-") + " / path " + (a.cli.path || "-") + " / version " + (a.cli.version || "-")) + "</small><small>" + esc("Auth: " + (a.auth.supported ? a.auth.authenticated ? "authenticated" : "not authenticated" : "not managed") + " " + (a.auth.detail || "")) + "</small><small>" + esc("Version: " + a.version.installed + " / latest " + (a.version.latest || "-") + " / " + a.version.status) + "</small>" + featureMatrix(a.capabilities) + '<div class="row"><button data-auth-status="' + attr(a.id) + '">Auth status</button><button data-auth-login="' + attr(a.id) + '" class="secondary" ' + (!a.capabilities.login ? "disabled" : "") + disabledAttr("auth.manage") + '>Login</button><button data-auth-logout="' + attr(a.id) + '" class="secondary" ' + (!a.capabilities.logout ? "disabled" : "") + disabledAttr("auth.manage") + ">Logout</button></div></div>").join("") || '<div class="item">No adapters.</div>';
|
|
2196
|
+
renderAdapterConformance(conformance);
|
|
2199
2197
|
document.querySelectorAll("[data-auth-status]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2200
2198
|
const r = await api("/api/auth/status", { query: { agent: b.dataset.authStatus } });
|
|
2201
2199
|
toast(r.agentLabel + ": " + r.detail, { duration: 6e3 });
|
|
@@ -2220,6 +2218,22 @@
|
|
|
2220
2218
|
}));
|
|
2221
2219
|
applyPermissions();
|
|
2222
2220
|
}
|
|
2221
|
+
function renderAdapterConformance(matrix) {
|
|
2222
|
+
const target = document.getElementById("adapterConformance");
|
|
2223
|
+
if (!target) return;
|
|
2224
|
+
const agents = (matrix?.agents || []).map((a) => conformanceCard(a, "agent")).join("");
|
|
2225
|
+
const channels = (matrix?.channels || []).map((c) => conformanceCard(c, "channel")).join("");
|
|
2226
|
+
target.innerHTML = '<div class="conformance-grid"><div><h3>Agent capability contract</h3>' + (agents || '<div class="item">No agent conformance rows.</div>') + "</div><div><h3>Channel command contract</h3>" + (channels || '<div class="item">No channel conformance rows.</div>') + "</div></div>";
|
|
2227
|
+
}
|
|
2228
|
+
function conformanceCard(item, kind) {
|
|
2229
|
+
const missing = (item.unsupported || []).length;
|
|
2230
|
+
const commands = kind === "channel" && item.commands ? "<small>" + esc("Commands: " + item.commands.length + (item.commands.length ? " / " + short(item.commands.join(", "), 180) : "")) + "</small>" : "";
|
|
2231
|
+
const badge = missing === 0 ? "enabled" : item.status === "planned" ? "disabled" : "planned";
|
|
2232
|
+
return '<div class="item"><strong>' + esc(item.label) + ' <span class="adapter-status ' + badge + '">' + esc(missing === 0 ? "complete" : missing + " missing") + "</span></strong><small>" + esc("Status: " + item.status + (item.enabled === void 0 ? "" : " / " + (item.enabled ? "enabled" : "disabled"))) + "</small>" + commands + conformanceFeatureMatrix(item.features || []) + "</div>";
|
|
2233
|
+
}
|
|
2234
|
+
function conformanceFeatureMatrix(features) {
|
|
2235
|
+
return '<div class="feature-matrix">' + (features || []).map((f) => '<span class="feature-chip ' + (f.supported ? "supported" : "unsupported") + '" title="' + attr(f.description || f.key) + '"><span>' + esc(f.label || f.key) + "</span><b>" + (f.supported ? "\u2713" : "-") + "</b></span>").join("") + "</div>";
|
|
2236
|
+
}
|
|
2223
2237
|
document.getElementById("reloadAdaptersBtn").onclick = () => loadAdapterHealth();
|
|
2224
2238
|
const versionAgentIds = { codex: "codex", pi: "pi", hermes: "hermes", openclaw: "openclaw", claudeCode: "claude-code" };
|
|
2225
2239
|
async function loadVersion() {
|
|
@@ -2382,7 +2396,9 @@
|
|
|
2382
2396
|
const vc = d.versionChecks || {};
|
|
2383
2397
|
const caps = s.capabilities || {};
|
|
2384
2398
|
const agentDiag = d.runtime?.agentDiagnostics;
|
|
2385
|
-
|
|
2399
|
+
const slack = d.runtime?.slackDiagnostics;
|
|
2400
|
+
const slackRows = slack ? [["Enabled", slack.enabled ? "yes" : "no"], ["Mode", slack.mode], ["Registered channels", slack.registeredChannels], ...(slack.checks || []).map((x) => [x.status.toUpperCase() + " " + x.label, x.detail]), ...(slack.channelChecks || []).map((x) => [x.status.toUpperCase() + " channel " + x.channelId, x.detail])] : [["Status", "not collected"]];
|
|
2401
|
+
return '<div class="list">' + card("Runtime", [["Status", h.state?.status], ["PID", h.state?.pid], ["App PID", h.state?.appPid], ["State", h.stateFile], ["Log", h.logFile], ["State backend", d.runtime?.stateBackend], ["Uptime", h.uptimeSeconds + "s"]]) + card("Agent", [["Agent", s.agentLabel], ["Thread", s.threadId], ["Workspace", s.workspace], ["Model", s.model], ["Reasoning", s.reasoningEffort], ["Fast", caps.fastMode ? s.fastMode ? "on" : "off" : "n/a"]]) + card("Agent State", (agentDiag?.lines || []).map((x) => [x.label, x.value])) + card("CLI Versions", Object.values(vc).map((v) => [v.label, (v.status === "current" ? "OK " : "WARN ") + (v.installedLabel || "-") + " latest " + (v.latestVersion || "-")])) + card("External Mirror", d.runtime?.externalMirror ? Object.entries(d.runtime.externalMirror) : [["Status", "idle"]]) + card("Slack Readiness", slackRows) + "</div>";
|
|
2386
2402
|
}
|
|
2387
2403
|
function card(title, rows) {
|
|
2388
2404
|
return '<div class="item"><strong>' + esc(title) + "</strong>" + rows.map((r) => "<small>" + esc(r[0]) + ": " + esc(r[1] ?? "-") + "</small>").join("") + "</div>";
|
|
@@ -2392,11 +2408,6 @@
|
|
|
2392
2408
|
Promise.resolve().then(fn).catch((err) => toast(err.message || String(err)));
|
|
2393
2409
|
}
|
|
2394
2410
|
loadBootstrap().then(() => connectEvents()).catch((err) => toast(err.message));
|
|
2395
|
-
const loadPeersBase = loadPeers;
|
|
2396
|
-
loadPeers = async function() {
|
|
2397
|
-
await loadPeersBase();
|
|
2398
|
-
ensureGlobalPeerSessionsPanel();
|
|
2399
|
-
};
|
|
2400
2411
|
function ensureGlobalPeerSessionsPanel() {
|
|
2401
2412
|
if (document.getElementById("globalPeerSessionsPanel")) return;
|
|
2402
2413
|
const anchor = document.getElementById("peersList");
|
|
@@ -2411,11 +2422,36 @@
|
|
|
2411
2422
|
const d = await api("/api/peers/global-sessions", { local: true, query: { query: q, agent: state.snapshot?.session?.agentId || void 0, limit: 50 } });
|
|
2412
2423
|
target.innerHTML = (d.targets || []).map((t) => '<div class="item"><strong>' + esc(t.peerName + " (" + t.peerId + ")") + ' <span class="adapter-status ' + (t.ok ? "enabled" : "disabled") + '">' + (t.ok ? "ok" : "error") + "</span></strong>" + (t.ok ? (t.data?.sessions || []).slice(0, 8).map((s) => "<small>" + esc(short(s.id, 48) + " | " + short(s.cwd || "", 80) + " | " + fmtDate(s.updatedAt)) + "</small>").join("") || "<small>No sessions.</small>" : '<small class="error">' + esc(t.error || "failed") + "</small>") + "</div>").join("") || '<div class="item">No peer sessions found.</div>';
|
|
2413
2424
|
}
|
|
2425
|
+
function peerStatusHtml(d) {
|
|
2426
|
+
const r = d.readiness || {};
|
|
2427
|
+
const rows = [["Peer server", d.enabled ? "enabled" : "disabled"], ["Listen URL", d.listenUrl], ["Bind", [r.bindHost || "-", r.port || ""].filter(Boolean).join(":")], ["Local port", r.localListening ? "listening" : "not listening"], ["TLS", r.tlsEnabled ? "enabled" : "disabled"], ["Require TLS", d.requireTls ? "yes" : "no"], ["Node ID", d.identity?.nodeId], ["Fingerprint", d.identity?.fingerprint]];
|
|
2428
|
+
const command = r.manualCheckCommand || "nordrelay peer check " + (d.listenUrl || "");
|
|
2429
|
+
return '<div class="item"><strong>Local peer identity <span class="adapter-status ' + (r.enabled && r.localListening ? "enabled" : r.enabled ? "planned" : "disabled") + '">' + esc(r.enabled ? r.localListening ? "ready" : "not listening" : "disabled") + "</span></strong>" + rows.map((row) => "<small>" + esc(row[0]) + ": " + esc(row[1] ?? "-") + "</small>").join("") + peerWarningsHtml(r.warnings || [], "Peer readiness") + '<div class="peer-invite-details"><small>Manual reachability check</small><button type="button" class="copy-id peer-invite-command" data-peer-invite-copy="' + attr(command) + '" data-peer-invite-copy-label="Peer check command copied">' + esc(command) + '</button><small>Run this on another machine to verify LAN, port-forward, or firewall reachability.</small></div><div class="row"><button id="checkPeerReachabilityBtn" class="secondary"' + disabledAttr("peers.connect") + '>Check local endpoint</button></div><div id="peerProbeResult">' + peerProbeResultHtml(state.peerProbeResult) + "</div></div>";
|
|
2430
|
+
}
|
|
2431
|
+
function peerWarningsHtml(warnings, title) {
|
|
2432
|
+
return (warnings || []).length ? '<div class="peer-warning full-span"><strong>' + esc(title || "Warning") + "</strong>" + warnings.map((w) => "<small>" + esc(w) + "</small>").join("") + "</div>" : "";
|
|
2433
|
+
}
|
|
2434
|
+
function peerProbeResultHtml(result) {
|
|
2435
|
+
if (!result) return "";
|
|
2436
|
+
const probe = result.probe || {};
|
|
2437
|
+
const label = result.type === "remote" ? "Remote probe from " + (result.peerName || result.peerId || "peer") : "Local endpoint check";
|
|
2438
|
+
return '<div class="peer-probe-result ' + (probe.ok ? "ok" : "warn") + '"><strong>' + esc(label) + ' <span class="adapter-status ' + (probe.ok ? "enabled" : "planned") + '">' + esc(probe.status || "unknown") + "</span></strong><small>" + esc("URL: " + (probe.url || result.readiness?.listenUrl || "-")) + "</small>" + (probe.latencyMs !== void 0 ? "<small>" + esc("Latency: " + probe.latencyMs + "ms") + "</small>" : "") + (probe.statusCode !== void 0 ? "<small>" + esc("HTTP: " + probe.statusCode) + "</small>" : "") + (probe.tlsFingerprint ? "<small>" + esc("TLS fingerprint: " + probe.tlsFingerprint) + "</small>" : "") + "<small>" + esc(probe.detail || "") + "</small></div>";
|
|
2439
|
+
}
|
|
2440
|
+
function peerInviteDetails(i) {
|
|
2441
|
+
const details = state.peerInviteSecrets?.[i.id];
|
|
2442
|
+
if (!details) return "";
|
|
2443
|
+
return '<div class="peer-invite-details"><small>Pairing code</small><button type="button" class="copy-id peer-invite-copy" data-peer-invite-copy="' + attr(details.code || "") + '" data-peer-invite-copy-label="Pairing code copied">' + esc(details.code || "") + '</button><small>Peer add command</small><button type="button" class="copy-id peer-invite-command" data-peer-invite-copy="' + attr(details.command || "") + '" data-peer-invite-copy-label="Peer add command copied">' + esc(details.command || "") + "</button></div>";
|
|
2444
|
+
}
|
|
2445
|
+
function peerInviteCard(i) {
|
|
2446
|
+
const open = new Date(i.expiresAt) > /* @__PURE__ */ new Date() && !i.usedAt;
|
|
2447
|
+
const readiness = state.peers?.readiness;
|
|
2448
|
+
return '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(open ? "open" : "closed") + "</span></strong><small>" + esc("Expires: " + fmtDate(i.expiresAt)) + "</small><small>" + esc("Scopes: " + (i.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((i.allowedAgents || []).join(", ") || "all")) + "</small>" + (i.usedAt ? "<small>" + esc("Used: " + fmtDate(i.usedAt) + " by " + (i.usedByNodeId || "-")) + "</small>" : "") + (open ? peerWarningsHtml(readiness?.warnings || [], "Pairing warning") + peerInviteDetails(i) + '<div class="row"><button class="danger" data-peer-invite-delete="' + attr(i.id) + '"' + disabledAttr("peers.write") + ">Delete invite</button></div>" : "") + "</div>";
|
|
2449
|
+
}
|
|
2414
2450
|
function peerCard(p) {
|
|
2415
2451
|
const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
|
|
2416
2452
|
const health = p.remoteStatus || p.lastSeenAt ? "Health: " + (p.remoteStatus || "seen") + (p.lastLatencyMs !== void 0 ? " / " + p.lastLatencyMs + "ms" : "") + (p.remoteVersion ? " / v" + p.remoteVersion : "") : "Health: unchecked";
|
|
2417
2453
|
const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
|
|
2418
|
-
return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong><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(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>" : "") + '<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-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>";
|
|
2454
|
+
return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong><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(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>" : "") + '<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-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>";
|
|
2419
2455
|
}
|
|
2420
2456
|
function openPeerDialog(p) {
|
|
2421
2457
|
const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
|
|
@@ -2426,11 +2462,15 @@
|
|
|
2426
2462
|
});
|
|
2427
2463
|
}
|
|
2428
2464
|
function openPeerInviteDialog() {
|
|
2429
|
-
|
|
2465
|
+
const warnings = state.peers?.readiness?.warnings || [];
|
|
2466
|
+
const warningHtml = peerWarningsHtml(warnings, "Pairing warning") + (warnings.length ? '<small class="full-span">The invite can still be created, but pairing may fail until the peer endpoint is reachable.</small>' : "");
|
|
2467
|
+
adminDialog("Create peer invite", warningHtml + '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1" max="1440"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label><label class="full-span">Workspace aliases<input id="dlgPeerInviteAliases" placeholder="project=/srv/project, demo=/home/me/demo"></label>', async () => {
|
|
2430
2468
|
const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerInviteAliases")) }), local: true });
|
|
2431
|
-
|
|
2469
|
+
if (r.invitation?.id) state.peerInviteSecrets[r.invitation.id] = { code: r.code || "", command: r.command || "" };
|
|
2470
|
+
toast("Peer invite created. Pairing details are shown under Open invitations.", { duration: 8e3 });
|
|
2432
2471
|
await loadPeers();
|
|
2433
2472
|
});
|
|
2473
|
+
document.getElementById("adminDialogSubmit").textContent = warnings.length ? "Create invite anyway" : "Create invite";
|
|
2434
2474
|
}
|
|
2435
2475
|
function aliasMap(text) {
|
|
2436
2476
|
return Object.fromEntries((text || "").split(",").map((item) => item.split("=", 2).map((part) => part.trim())).filter(([a, w]) => a && w));
|
|
@@ -2448,6 +2488,27 @@
|
|
|
2448
2488
|
toast("Peer reachable: " + (r.data?.version ? "v" + r.data.version : "ok"), { duration: 6e3 });
|
|
2449
2489
|
loadPeers();
|
|
2450
2490
|
}));
|
|
2491
|
+
const localProbe = document.getElementById("checkPeerReachabilityBtn");
|
|
2492
|
+
if (localProbe) localProbe.onclick = () => safe(async () => {
|
|
2493
|
+
if (!can("peers.connect")) {
|
|
2494
|
+
toast("Permission required: peers.connect");
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
state.peerProbeResult = await api("/api/peers/probe", { method: "POST", body: JSON.stringify({}), local: true });
|
|
2498
|
+
document.getElementById("peerProbeResult").innerHTML = peerProbeResultHtml(state.peerProbeResult);
|
|
2499
|
+
toast(state.peerProbeResult.probe?.ok ? "Peer endpoint reachable" : "Peer endpoint not reachable", { duration: 7e3 });
|
|
2500
|
+
});
|
|
2501
|
+
document.querySelectorAll("[data-peer-probe]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2502
|
+
if (!can("peers.connect")) {
|
|
2503
|
+
toast("Permission required: peers.connect");
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerProbe);
|
|
2507
|
+
state.peerProbeResult = await api("/api/peers/probe", { method: "POST", body: JSON.stringify({ peerId: b.dataset.peerProbe }), local: true });
|
|
2508
|
+
state.peerProbeResult.peerName = p?.name;
|
|
2509
|
+
document.getElementById("peerProbeResult").innerHTML = peerProbeResultHtml(state.peerProbeResult);
|
|
2510
|
+
toast(state.peerProbeResult.probe?.ok ? "Remote peer can reach this node" : "Remote peer cannot reach this node", { duration: 8e3 });
|
|
2511
|
+
}));
|
|
2451
2512
|
document.querySelectorAll("[data-peer-edit]").forEach((b) => b.onclick = () => {
|
|
2452
2513
|
const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerEdit);
|
|
2453
2514
|
if (p) openPeerDialog(p);
|
|
@@ -2470,5 +2531,344 @@
|
|
|
2470
2531
|
loadPeers();
|
|
2471
2532
|
}
|
|
2472
2533
|
}));
|
|
2534
|
+
document.querySelectorAll("[data-peer-invite-copy]").forEach((b) => b.onclick = () => copyText(b.dataset.peerInviteCopy || "", b.dataset.peerInviteCopyLabel || "Copied"));
|
|
2535
|
+
document.querySelectorAll("[data-peer-invite-delete]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2536
|
+
if (confirm("Delete this peer invitation?")) {
|
|
2537
|
+
await api("/api/peers/invitations/" + encodeURIComponent(b.dataset.peerInviteDelete), { method: "DELETE", local: true });
|
|
2538
|
+
delete state.peerInviteSecrets[b.dataset.peerInviteDelete];
|
|
2539
|
+
toast("Peer invitation deleted");
|
|
2540
|
+
loadPeers();
|
|
2541
|
+
}
|
|
2542
|
+
}));
|
|
2543
|
+
}
|
|
2544
|
+
const SETUP_WIZARDS = {
|
|
2545
|
+
telegram: {
|
|
2546
|
+
id: "telegram",
|
|
2547
|
+
label: "Telegram",
|
|
2548
|
+
description: "Create a Telegram bot, choose polling or webhook delivery, and enable the Telegram adapter.",
|
|
2549
|
+
docs: [
|
|
2550
|
+
["BotFather", "https://t.me/BotFather"],
|
|
2551
|
+
["Telegram Bot API", "https://core.telegram.org/bots/api"],
|
|
2552
|
+
["Webhook setup", "https://core.telegram.org/bots/api#setwebhook"]
|
|
2553
|
+
],
|
|
2554
|
+
defaults: { TELEGRAM_ENABLED: "true", TELEGRAM_TRANSPORT: "polling", TELEGRAM_WEBHOOK_HOST: "127.0.0.1", TELEGRAM_WEBHOOK_PORT: "8080", TELEGRAM_WEBHOOK_PATH: "/telegram/webhook" },
|
|
2555
|
+
required: ["TELEGRAM_BOT_TOKEN"],
|
|
2556
|
+
steps: [
|
|
2557
|
+
{ title: "Create the bot", body: "Open BotFather, create a bot with /newbot, then paste only the token value into NordRelay. Do not paste the full BotFather message.", settings: ["TELEGRAM_BOT_TOKEN"], links: [["Open BotFather", "https://t.me/BotFather"]] },
|
|
2558
|
+
{ title: "Choose delivery mode", body: "Polling is the simplest setup and works without a public URL. Use webhook only when this NordRelay instance is reachable through HTTPS from Telegram.", settings: ["TELEGRAM_TRANSPORT", "TELEGRAM_WEBHOOK_URL", "TELEGRAM_WEBHOOK_HOST", "TELEGRAM_WEBHOOK_PORT", "TELEGRAM_WEBHOOK_PATH", "TELEGRAM_WEBHOOK_SECRET"], links: [["Webhook documentation", "https://core.telegram.org/bots/api#setwebhook"]] },
|
|
2559
|
+
{ title: "Enable and secure access", body: "Enable Telegram only after the token is configured. Access is still controlled by NordRelay users, groups, and linked Telegram identities or registered chats.", settings: ["TELEGRAM_ENABLED"], links: [["Bot API overview", "https://core.telegram.org/bots/api"]] }
|
|
2560
|
+
]
|
|
2561
|
+
},
|
|
2562
|
+
discord: {
|
|
2563
|
+
id: "discord",
|
|
2564
|
+
label: "Discord",
|
|
2565
|
+
description: "Create a Discord application, configure slash commands or message commands, invite the bot, and enable Discord.",
|
|
2566
|
+
docs: [
|
|
2567
|
+
["Discord Developer Portal", "https://discord.com/developers/applications"],
|
|
2568
|
+
["Privileged intents", "https://discord.com/developers/docs/topics/gateway#privileged-intents"],
|
|
2569
|
+
["OAuth2 bot invite", "https://discord.com/developers/docs/topics/oauth2"]
|
|
2570
|
+
],
|
|
2571
|
+
defaults: { DISCORD_ENABLED: "true", DISCORD_MESSAGE_CONTENT_ENABLED: "true", DISCORD_COMMAND_MODE: "both", DISCORD_AUTO_REGISTER_COMMANDS: "true" },
|
|
2572
|
+
required: ["DISCORD_BOT_TOKEN", "DISCORD_CLIENT_ID"],
|
|
2573
|
+
steps: [
|
|
2574
|
+
{ title: "Create application and bot", body: "Create an application in the Discord Developer Portal, add a bot, copy the bot token, and copy the Application ID from General Information.", settings: ["DISCORD_BOT_TOKEN", "DISCORD_CLIENT_ID"], links: [["Developer Portal", "https://discord.com/developers/applications"]] },
|
|
2575
|
+
{ title: "Commands, intents, and invite", body: "For regular text commands, enable Message Content Intent under Bot > Privileged Gateway Intents. Invite the bot with bot and applications.commands scopes.", settings: ["DISCORD_MESSAGE_CONTENT_ENABLED", "DISCORD_COMMAND_MODE", "DISCORD_AUTO_REGISTER_COMMANDS"], links: [["Privileged intents", "https://discord.com/developers/docs/topics/gateway#privileged-intents"], ["OAuth2 scopes", "https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes"]] },
|
|
2576
|
+
{ title: "Limit server and channel scope", body: "Enable Discord Developer Mode, then right-click servers and channels to copy IDs. These allow-lists run before the NordRelay user/group access checks.", settings: ["DISCORD_GUILD_IDS", "DISCORD_ALLOWED_GUILD_IDS", "DISCORD_ALLOWED_CHANNEL_IDS"], links: [["Where to find IDs", "https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID"]] },
|
|
2577
|
+
{ title: "Enable Discord", body: "Enable the adapter after the bot token, client ID, and invite are ready. Users still need NordRelay permissions and linked Discord identities or registered channels.", settings: ["DISCORD_ENABLED"], links: [["Developer Portal", "https://discord.com/developers/applications"]] }
|
|
2578
|
+
]
|
|
2579
|
+
},
|
|
2580
|
+
slack: {
|
|
2581
|
+
id: "slack",
|
|
2582
|
+
label: "Slack",
|
|
2583
|
+
description: "Create a Slack app, configure Socket Mode or HTTP Events, add slash commands, and enable Slack.",
|
|
2584
|
+
docs: [
|
|
2585
|
+
["Slack API Apps", "https://api.slack.com/apps"],
|
|
2586
|
+
["Socket Mode", "https://api.slack.com/apis/connections/socket"],
|
|
2587
|
+
["Slash commands", "https://api.slack.com/interactivity/slash-commands"]
|
|
2588
|
+
],
|
|
2589
|
+
defaults: { SLACK_ENABLED: "true", SLACK_SOCKET_MODE: "true", SLACK_PORT: "3000", SLACK_COMMAND: "/nordrelay" },
|
|
2590
|
+
required: ["SLACK_BOT_TOKEN"],
|
|
2591
|
+
steps: [
|
|
2592
|
+
{ title: "Create and install the Slack app", body: "Create a Slack app, add bot scopes, install it into the workspace, and copy the OAuth bot token that starts with xoxb-.", settings: ["SLACK_BOT_TOKEN"], links: [["Slack API Apps", "https://api.slack.com/apps"], ["OAuth scopes", "https://api.slack.com/authentication/oauth-v2"]] },
|
|
2593
|
+
{ title: "Choose transport mode", body: "Socket Mode is recommended because it avoids exposing a public HTTP endpoint. For Socket Mode, create an app-level token with connections:write. For HTTP Events, use the Signing Secret and configure an externally reachable URL.", settings: ["SLACK_SOCKET_MODE", "SLACK_APP_TOKEN", "SLACK_SIGNING_SECRET", "SLACK_PORT"], links: [["Socket Mode", "https://api.slack.com/apis/connections/socket"], ["Events API", "https://api.slack.com/apis/events-api"]] },
|
|
2594
|
+
{ title: "Commands and scope", body: "Configure the slash command in Slack and optionally restrict workspaces or channels before NordRelay user/group permissions are applied.", settings: ["SLACK_COMMAND", "SLACK_ALLOWED_TEAM_IDS", "SLACK_ALLOWED_CHANNEL_IDS", "SLACK_MESSAGE_CONTENT_ENABLED"], links: [["Slash commands", "https://api.slack.com/interactivity/slash-commands"], ["Finding Slack IDs", "https://slack.com/help/articles/221769328-Locate-your-Slack-URL-or-ID"]] },
|
|
2595
|
+
{ title: "Enable Slack", body: "Enable the adapter after tokens and command settings are ready. Users still need NordRelay permissions and linked Slack identities or registered channels.", settings: ["SLACK_ENABLED"], links: [["Slack API Apps", "https://api.slack.com/apps"]] }
|
|
2596
|
+
]
|
|
2597
|
+
}
|
|
2598
|
+
};
|
|
2599
|
+
function settingByKey(key) {
|
|
2600
|
+
return (state.settings || []).find((s) => s.key === key) || null;
|
|
2601
|
+
}
|
|
2602
|
+
function wizardCurrentValue(key) {
|
|
2603
|
+
const values = state.settingsWizard?.values || {};
|
|
2604
|
+
if (Object.prototype.hasOwnProperty.call(values, key)) return values[key];
|
|
2605
|
+
const s = settingByKey(key);
|
|
2606
|
+
return s ? s.configured ? s.value || "" : s.effectiveValue || "" : "";
|
|
2607
|
+
}
|
|
2608
|
+
function wizardOriginalValue(key) {
|
|
2609
|
+
const s = settingByKey(key);
|
|
2610
|
+
return s && s.configured ? s.value : "";
|
|
2611
|
+
}
|
|
2612
|
+
function isMaskedSettingValue(value) {
|
|
2613
|
+
return /^\*+$/.test(String(value || "")) || String(value || "").includes("...");
|
|
2614
|
+
}
|
|
2615
|
+
function wizardKeys(wizard) {
|
|
2616
|
+
return Array.from(new Set(wizard.steps.flatMap((step) => step.settings)));
|
|
2617
|
+
}
|
|
2618
|
+
function safeDocLink(label, url) {
|
|
2619
|
+
return '<a href="' + attr(url) + '" target="_blank" rel="noopener noreferrer">' + esc(label) + "</a>";
|
|
2620
|
+
}
|
|
2621
|
+
function wizardLinkList(links = []) {
|
|
2622
|
+
return links.length ? '<div class="wizard-links">' + links.map(([label, url]) => safeDocLink(label, url)).join("") + "</div>" : "";
|
|
2623
|
+
}
|
|
2624
|
+
function startSettingsWizard(channel) {
|
|
2625
|
+
const wizard = SETUP_WIZARDS[channel];
|
|
2626
|
+
if (!wizard) return;
|
|
2627
|
+
const values = {};
|
|
2628
|
+
for (const [key, value] of Object.entries(wizard.defaults || {})) {
|
|
2629
|
+
const setting = settingByKey(key);
|
|
2630
|
+
if (!setting?.configured || key.endsWith("_ENABLED")) values[key] = value;
|
|
2631
|
+
}
|
|
2632
|
+
state.settingsWizard = { channel, step: 0, values, errors: [], testResult: null };
|
|
2633
|
+
renderSettingsWizardStep();
|
|
2634
|
+
}
|
|
2635
|
+
function openSettingsWizardHome() {
|
|
2636
|
+
if (!can("settings.write")) {
|
|
2637
|
+
toast("Permission required: settings.write");
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
state.settingsWizard = { home: true };
|
|
2641
|
+
renderSettingsWizardHome();
|
|
2642
|
+
}
|
|
2643
|
+
function closeSettingsWizard() {
|
|
2644
|
+
state.settingsWizard = null;
|
|
2645
|
+
document.getElementById("settingsTabs").style.display = "";
|
|
2646
|
+
renderSettings();
|
|
2647
|
+
}
|
|
2648
|
+
function wizardRequiredValuePresent(key) {
|
|
2649
|
+
const value = wizardCurrentValue(key);
|
|
2650
|
+
if (!value) return false;
|
|
2651
|
+
const setting = settingByKey(key);
|
|
2652
|
+
if (isMaskedSettingValue(value)) return setting?.kind === "secret" && (setting.configured || setting.masked);
|
|
2653
|
+
return true;
|
|
2654
|
+
}
|
|
2655
|
+
function wizardMissingRequired(wizard) {
|
|
2656
|
+
return (wizard.required || []).filter((key) => !wizardRequiredValuePresent(key));
|
|
2657
|
+
}
|
|
2658
|
+
function renderSettingsWizardHome() {
|
|
2659
|
+
document.getElementById("settingsTabs").style.display = "none";
|
|
2660
|
+
const cards = Object.values(SETUP_WIZARDS).map((wizard) => {
|
|
2661
|
+
const missing = wizardMissingRequired(wizard);
|
|
2662
|
+
const docs = wizardLinkList(wizard.docs);
|
|
2663
|
+
return '<div class="wizard-card"><strong>' + esc(wizard.label) + ' <span class="adapter-status ' + (missing.length ? "planned" : "enabled") + '">' + esc(missing.length ? missing.length + " missing" : "ready") + "</span></strong><p>" + esc(wizard.description) + "</p><small>" + esc(missing.length ? "Missing: " + missing.join(", ") : "Required values are present or configured.") + "</small>" + docs + '<button type="button" data-start-wizard="' + attr(wizard.id) + '">Start wizard</button></div>';
|
|
2664
|
+
}).join("");
|
|
2665
|
+
document.getElementById("settingsForm").innerHTML = '<div class="settings-wizard"><div class="wizard-header"><div><h2>Setup wizard</h2><p>Choose a chat adapter and follow the guided setup. Bot/app credentials only enable transport; NordRelay user and group permissions still control access.</p></div><button type="button" class="secondary" id="backToSettingsBtn">Back to settings</button></div><div class="wizard-choice-grid">' + cards + "</div></div>";
|
|
2666
|
+
document.getElementById("backToSettingsBtn").onclick = closeSettingsWizard;
|
|
2667
|
+
document.querySelectorAll("[data-start-wizard]").forEach((b) => b.onclick = () => startSettingsWizard(b.dataset.startWizard));
|
|
2668
|
+
applyPermissions();
|
|
2669
|
+
}
|
|
2670
|
+
function wizardSettingInput(s) {
|
|
2671
|
+
const value = wizardCurrentValue(s.key);
|
|
2672
|
+
const attrs = ' data-wizard-setting="' + attr(s.key) + '" data-original-value="' + attr(wizardOriginalValue(s.key)) + '"';
|
|
2673
|
+
if (s.options) {
|
|
2674
|
+
return "<select" + attrs + '><option value="">Unset / inherit active default</option>' + s.options.map((o) => '<option value="' + attr(o) + '" ' + (value === o ? "selected" : "") + ">" + esc(o) + "</option>").join("") + "</select>";
|
|
2675
|
+
}
|
|
2676
|
+
if (s.kind === "boolean") {
|
|
2677
|
+
return "<select" + attrs + '><option value="">Unset / inherit active default</option><option value="true" ' + (value === "true" ? "selected" : "") + '>true</option><option value="false" ' + (value === "false" ? "selected" : "") + ">false</option></select>";
|
|
2678
|
+
}
|
|
2679
|
+
if (s.kind === "json") return '<textarea rows="4"' + attrs + ">" + esc(value) + "</textarea>";
|
|
2680
|
+
return "<input" + attrs + ' value="' + attr(value) + '" ' + (s.kind === "secret" ? 'type="password" autocomplete="new-password"' : "") + ">";
|
|
2681
|
+
}
|
|
2682
|
+
function renderWizardSetting(key) {
|
|
2683
|
+
const s = settingByKey(key);
|
|
2684
|
+
if (!s) return '<div class="setting"><strong>' + esc(key) + "</strong><small>Unknown setting.</small></div>";
|
|
2685
|
+
return '<div class="setting" data-wizard-setting-box="' + attr(key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '">' + settingLabel(s) + wizardSettingInput(s) + "<small>" + esc(s.key + " - " + s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + '</small><div class="setting-error"></div></div>';
|
|
2686
|
+
}
|
|
2687
|
+
function renderWizardProgress(wizard) {
|
|
2688
|
+
return '<div class="wizard-progress">' + wizard.steps.map((step, index) => '<button type="button" data-wizard-step="' + index + '" class="' + (index === state.settingsWizard.step ? "active" : "") + '">' + esc(String(index + 1) + ". " + step.title) + "</button>").join("") + "</div>";
|
|
2689
|
+
}
|
|
2690
|
+
function renderSettingsWizardStep() {
|
|
2691
|
+
const wizard = SETUP_WIZARDS[state.settingsWizard?.channel];
|
|
2692
|
+
if (!wizard) {
|
|
2693
|
+
renderSettingsWizardHome();
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
const step = wizard.steps[state.settingsWizard.step] || wizard.steps[0];
|
|
2697
|
+
document.getElementById("settingsTabs").style.display = "none";
|
|
2698
|
+
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>';
|
|
2699
|
+
bindWizardUx();
|
|
2700
|
+
renderWizardValidation();
|
|
2701
|
+
}
|
|
2702
|
+
function bindWizardUx() {
|
|
2703
|
+
document.getElementById("wizardHomeBtn").onclick = renderSettingsWizardHome;
|
|
2704
|
+
document.querySelectorAll("[data-wizard-step]").forEach((b) => b.onclick = () => {
|
|
2705
|
+
collectWizardValues();
|
|
2706
|
+
state.settingsWizard.step = Number(b.dataset.wizardStep) || 0;
|
|
2707
|
+
renderSettingsWizardStep();
|
|
2708
|
+
});
|
|
2709
|
+
document.querySelectorAll("[data-wizard-setting]").forEach((el) => {
|
|
2710
|
+
el.oninput = () => {
|
|
2711
|
+
state.settingsWizard.values[el.dataset.wizardSetting] = el.value;
|
|
2712
|
+
renderWizardValidation();
|
|
2713
|
+
};
|
|
2714
|
+
el.onchange = el.oninput;
|
|
2715
|
+
});
|
|
2716
|
+
document.getElementById("wizardPrevBtn").onclick = () => {
|
|
2717
|
+
collectWizardValues();
|
|
2718
|
+
if (state.settingsWizard.step > 0) {
|
|
2719
|
+
state.settingsWizard.step--;
|
|
2720
|
+
renderSettingsWizardStep();
|
|
2721
|
+
} else renderSettingsWizardHome();
|
|
2722
|
+
};
|
|
2723
|
+
document.getElementById("wizardNextBtn").onclick = () => {
|
|
2724
|
+
collectWizardValues();
|
|
2725
|
+
if (state.settingsWizard.step < SETUP_WIZARDS[state.settingsWizard.channel].steps.length - 1) {
|
|
2726
|
+
state.settingsWizard.step++;
|
|
2727
|
+
renderSettingsWizardStep();
|
|
2728
|
+
} else renderWizardReview();
|
|
2729
|
+
};
|
|
2730
|
+
document.getElementById("wizardSaveBtn").onclick = () => safe(() => saveWizard(false));
|
|
2731
|
+
document.getElementById("wizardSaveRestartBtn").onclick = () => safe(() => saveWizard(true));
|
|
2732
|
+
document.getElementById("wizardTestBtn").onclick = () => safe(testWizardSetup);
|
|
2733
|
+
applyPermissions();
|
|
2734
|
+
}
|
|
2735
|
+
function collectWizardValues() {
|
|
2736
|
+
document.querySelectorAll("[data-wizard-setting]").forEach((el) => {
|
|
2737
|
+
state.settingsWizard.values[el.dataset.wizardSetting] = el.value;
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
function collectWizardPatch(wizard) {
|
|
2741
|
+
collectWizardValues();
|
|
2742
|
+
const patch = {};
|
|
2743
|
+
for (const key of wizardKeys(wizard)) {
|
|
2744
|
+
if (!Object.prototype.hasOwnProperty.call(state.settingsWizard.values, key)) continue;
|
|
2745
|
+
const value = state.settingsWizard.values[key];
|
|
2746
|
+
if (value !== wizardOriginalValue(key)) patch[key] = value;
|
|
2747
|
+
}
|
|
2748
|
+
return patch;
|
|
2749
|
+
}
|
|
2750
|
+
function collectWizardSettings(wizard) {
|
|
2751
|
+
collectWizardValues();
|
|
2752
|
+
const settings = {};
|
|
2753
|
+
for (const key of wizardKeys(wizard)) settings[key] = wizardCurrentValue(key);
|
|
2754
|
+
return settings;
|
|
2755
|
+
}
|
|
2756
|
+
function parseList(text) {
|
|
2757
|
+
return String(text || "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
2758
|
+
}
|
|
2759
|
+
function wizardValueBool(value) {
|
|
2760
|
+
return ["true", "1", "yes", "on"].includes(String(value || "").toLowerCase());
|
|
2761
|
+
}
|
|
2762
|
+
function validateSnowflakeList(value, label, required = false) {
|
|
2763
|
+
const items = parseList(value);
|
|
2764
|
+
if (required && items.length === 0) return [label + " is required."];
|
|
2765
|
+
return items.filter((item) => !/^[0-9]{5,32}$/.test(item)).map((item) => label + " contains invalid Discord ID: " + item);
|
|
2766
|
+
}
|
|
2767
|
+
function validateSlackIdList(value, label) {
|
|
2768
|
+
return parseList(value).filter((item) => !/^[A-Z0-9]{2,64}$/.test(item)).map((item) => label + " contains invalid Slack ID: " + item);
|
|
2769
|
+
}
|
|
2770
|
+
function validateWizard(wizard) {
|
|
2771
|
+
const v = Object.fromEntries(wizardKeys(wizard).map((key) => [key, wizardCurrentValue(key)]));
|
|
2772
|
+
const errors = [];
|
|
2773
|
+
const warnings = [];
|
|
2774
|
+
if (wizard.id === "telegram") {
|
|
2775
|
+
if (!v.TELEGRAM_BOT_TOKEN) errors.push("Telegram bot token is required.");
|
|
2776
|
+
else if (!isMaskedSettingValue(v.TELEGRAM_BOT_TOKEN) && !/^[0-9]{5,}:[A-Za-z0-9_-]{20,}$/.test(v.TELEGRAM_BOT_TOKEN)) errors.push("Telegram bot token does not look like a BotFather token.");
|
|
2777
|
+
if (!["polling", "webhook"].includes(v.TELEGRAM_TRANSPORT || "polling")) errors.push("Telegram transport must be polling or webhook.");
|
|
2778
|
+
if ((v.TELEGRAM_TRANSPORT || "polling") === "webhook") {
|
|
2779
|
+
if (!String(v.TELEGRAM_WEBHOOK_URL || "").startsWith("https://")) errors.push("Webhook public URL must be HTTPS.");
|
|
2780
|
+
if (!v.TELEGRAM_WEBHOOK_HOST) errors.push("Webhook bind host is required.");
|
|
2781
|
+
if (!Number.isFinite(Number(v.TELEGRAM_WEBHOOK_PORT))) errors.push("Webhook port must be numeric.");
|
|
2782
|
+
if (!String(v.TELEGRAM_WEBHOOK_PATH || "").startsWith("/")) errors.push("Webhook path must start with /.");
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
if (wizard.id === "discord") {
|
|
2786
|
+
if (!v.DISCORD_BOT_TOKEN) errors.push("Discord bot token is required.");
|
|
2787
|
+
else if (!isMaskedSettingValue(v.DISCORD_BOT_TOKEN) && String(v.DISCORD_BOT_TOKEN).length < 20) errors.push("Discord bot token is too short.");
|
|
2788
|
+
if (!v.DISCORD_CLIENT_ID) errors.push("Discord client ID is required.");
|
|
2789
|
+
else errors.push(...validateSnowflakeList(v.DISCORD_CLIENT_ID, "Discord client ID", true));
|
|
2790
|
+
errors.push(...validateSnowflakeList(v.DISCORD_GUILD_IDS, "Discord guild IDs"));
|
|
2791
|
+
errors.push(...validateSnowflakeList(v.DISCORD_ALLOWED_GUILD_IDS, "Allowed Discord guilds"));
|
|
2792
|
+
errors.push(...validateSnowflakeList(v.DISCORD_ALLOWED_CHANNEL_IDS, "Allowed Discord channels"));
|
|
2793
|
+
if (["message", "both"].includes(v.DISCORD_COMMAND_MODE || "both") && !wizardValueBool(v.DISCORD_MESSAGE_CONTENT_ENABLED)) warnings.push("Message command mode needs Message Content Intent enabled in the Discord Developer Portal.");
|
|
2794
|
+
}
|
|
2795
|
+
if (wizard.id === "slack") {
|
|
2796
|
+
if (!v.SLACK_BOT_TOKEN) errors.push("Slack bot token is required.");
|
|
2797
|
+
else if (!isMaskedSettingValue(v.SLACK_BOT_TOKEN) && !String(v.SLACK_BOT_TOKEN).startsWith("xoxb-")) errors.push("Slack bot token should start with xoxb-.");
|
|
2798
|
+
if (wizardValueBool(v.SLACK_SOCKET_MODE)) {
|
|
2799
|
+
if (!v.SLACK_APP_TOKEN) errors.push("Slack app token is required for Socket Mode.");
|
|
2800
|
+
else if (!isMaskedSettingValue(v.SLACK_APP_TOKEN) && !String(v.SLACK_APP_TOKEN).startsWith("xapp-")) errors.push("Slack app token should start with xapp-.");
|
|
2801
|
+
} else {
|
|
2802
|
+
if (!v.SLACK_SIGNING_SECRET) errors.push("Slack signing secret is required when Socket Mode is disabled.");
|
|
2803
|
+
if (!Number.isFinite(Number(v.SLACK_PORT))) errors.push("Slack HTTP port must be numeric.");
|
|
2804
|
+
}
|
|
2805
|
+
if (v.SLACK_COMMAND && !String(v.SLACK_COMMAND).startsWith("/")) errors.push("Slack slash command must start with /.");
|
|
2806
|
+
errors.push(...validateSlackIdList(v.SLACK_ALLOWED_TEAM_IDS, "Allowed Slack teams"));
|
|
2807
|
+
errors.push(...validateSlackIdList(v.SLACK_ALLOWED_CHANNEL_IDS, "Allowed Slack channels"));
|
|
2808
|
+
}
|
|
2809
|
+
return { errors, warnings };
|
|
2810
|
+
}
|
|
2811
|
+
function renderWizardValidation() {
|
|
2812
|
+
const wizard = SETUP_WIZARDS[state.settingsWizard?.channel];
|
|
2813
|
+
if (!wizard) return;
|
|
2814
|
+
const result = validateWizard(wizard);
|
|
2815
|
+
const target = document.getElementById("wizardErrors");
|
|
2816
|
+
if (target) target.innerHTML = [...result.errors.map((e) => '<div class="wizard-error">' + esc(e) + "</div>"), ...result.warnings.map((w) => '<div class="wizard-warning">' + esc(w) + "</div>")].join("");
|
|
2817
|
+
const changed = Object.keys(collectWizardPatch(wizard)).length;
|
|
2818
|
+
const banner = document.getElementById("wizardRestartBanner");
|
|
2819
|
+
if (banner) banner.innerHTML = changed ? '<div class="restart-banner">' + changed + " wizard setting(s) will be saved. A NordRelay restart may be required.</div>" : "";
|
|
2820
|
+
document.getElementById("wizardSaveBtn").disabled = !can("settings.write") || result.errors.length > 0;
|
|
2821
|
+
document.getElementById("wizardSaveRestartBtn").disabled = !can("settings.write") || !can("system.restart") || result.errors.length > 0;
|
|
2822
|
+
}
|
|
2823
|
+
function renderWizardReview() {
|
|
2824
|
+
const wizard = SETUP_WIZARDS[state.settingsWizard.channel];
|
|
2825
|
+
collectWizardValues();
|
|
2826
|
+
const validation = validateWizard(wizard);
|
|
2827
|
+
const patch = collectWizardPatch(wizard);
|
|
2828
|
+
document.getElementById("settingsForm").innerHTML = '<div class="settings-wizard"><div class="wizard-header"><div><h2>' + esc(wizard.label) + ' review</h2><p>Review changed settings before saving. Secret values stay masked when they are already configured.</p></div><button type="button" class="secondary" id="wizardEditBtn">Back to wizard</button></div><div class="list">' + (Object.keys(patch).map((key) => '<div class="item"><strong>' + esc(key) + "</strong><small>" + esc(isMaskedSettingValue(patch[key]) ? "configured secret / unchanged" : patch[key] || "(unset)") + "</small></div>").join("") || '<div class="item">No wizard changes.</div>') + '</div><div id="wizardErrors">' + [...validation.errors.map((e) => '<div class="wizard-error">' + esc(e) + "</div>"), ...validation.warnings.map((w) => '<div class="wizard-warning">' + esc(w) + "</div>")].join("") + '</div><div class="wizard-actions"><button type="button" id="wizardSaveBtn">Save wizard settings</button><button type="button" id="wizardSaveRestartBtn" class="secondary"' + disabledAttr("system.restart") + '>Save and restart</button><button type="button" class="secondary" id="wizardHomeBtn">Wizard home</button></div></div>';
|
|
2829
|
+
document.getElementById("wizardEditBtn").onclick = renderSettingsWizardStep;
|
|
2830
|
+
document.getElementById("wizardHomeBtn").onclick = renderSettingsWizardHome;
|
|
2831
|
+
document.getElementById("wizardSaveBtn").onclick = () => safe(() => saveWizard(false));
|
|
2832
|
+
document.getElementById("wizardSaveRestartBtn").onclick = () => safe(() => saveWizard(true));
|
|
2833
|
+
document.getElementById("wizardSaveBtn").disabled = validation.errors.length > 0;
|
|
2834
|
+
document.getElementById("wizardSaveRestartBtn").disabled = !can("system.restart") || validation.errors.length > 0;
|
|
2835
|
+
applyPermissions();
|
|
2836
|
+
}
|
|
2837
|
+
async function saveWizard(restart) {
|
|
2838
|
+
const wizard = SETUP_WIZARDS[state.settingsWizard.channel];
|
|
2839
|
+
const validation = validateWizard(wizard);
|
|
2840
|
+
if (validation.errors.length) {
|
|
2841
|
+
renderWizardValidation();
|
|
2842
|
+
toast("Wizard settings need attention");
|
|
2843
|
+
return;
|
|
2844
|
+
}
|
|
2845
|
+
const patch = collectWizardPatch(wizard);
|
|
2846
|
+
const r = await api("/api/settings", { method: "PATCH", body: JSON.stringify({ settings: patch }) });
|
|
2847
|
+
if (r.errors && r.errors.length) {
|
|
2848
|
+
document.getElementById("settingsStatus").textContent = "Fix " + r.errors.length + " setting error(s)";
|
|
2849
|
+
toast("Settings need attention");
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2852
|
+
toast("Wizard settings saved" + (r.restartRequired ? " - restart required" : ""));
|
|
2853
|
+
document.getElementById("settingsStatus").textContent = (r.changedKeys?.length ? "Saved " + r.changedKeys.length + " wizard setting(s)" : "No changes") + (r.restartRequired ? " - restart required" : "");
|
|
2854
|
+
if (restart) {
|
|
2855
|
+
await api("/api/runtime/restart", { method: "POST" });
|
|
2856
|
+
toast("Settings saved and restart requested", { duration: 6e3 });
|
|
2857
|
+
}
|
|
2858
|
+
await loadSettings();
|
|
2859
|
+
}
|
|
2860
|
+
async function testWizardSetup() {
|
|
2861
|
+
const wizard = SETUP_WIZARDS[state.settingsWizard.channel];
|
|
2862
|
+
const validation = validateWizard(wizard);
|
|
2863
|
+
if (validation.errors.length) {
|
|
2864
|
+
renderWizardValidation();
|
|
2865
|
+
toast("Fix validation errors before testing");
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
const target = document.getElementById("wizardTestResult");
|
|
2869
|
+
target.innerHTML = loadingHtml("Testing setup...");
|
|
2870
|
+
const result = await api("/api/settings/wizard/test", { method: "POST", body: JSON.stringify({ channel: wizard.id, settings: collectWizardSettings(wizard) }) });
|
|
2871
|
+
target.innerHTML = (result.checks || []).map((check) => '<div class="item"><strong>' + esc(check.label) + ' <span class="adapter-status ' + (check.status === "ok" ? "enabled" : check.status === "warn" ? "planned" : "disabled") + '">' + esc(check.status) + "</span></strong><small>" + esc(check.detail || "") + "</small></div>").join("") || '<div class="item">No checks returned.</div>';
|
|
2473
2872
|
}
|
|
2873
|
+
document.getElementById("settingsWizardBtn").onclick = openSettingsWizardHome;
|
|
2474
2874
|
})();
|