/* NetDesk admin console — manages users/roles, devices, MDM policy, API keys, relays. Account admin-role gated (the /api/admin/* endpoints return 403 for non-admins; the server is the authority, this UI just reacts). */ (function () { "use strict"; var $ = function (s) { return document.querySelector(s); }; function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } function fmtTs(t) { return t ? new Date(t * 1000).toLocaleDateString() : "—"; } function fmtAgo(t) { if (!t) return "never"; var s = Date.now() / 1000 - t; if (s < 60) return "just now"; if (s < 3600) return Math.floor(s / 60) + "m ago"; if (s < 86400) return Math.floor(s / 3600) + "h ago"; return Math.floor(s / 86400) + "d ago"; } var toastT; function toast(msg, bad) { var t = $("#toast"); t.textContent = msg; t.style.borderColor = bad ? "#ef4444" : ""; t.classList.add("show"); clearTimeout(toastT); toastT = setTimeout(function () { t.classList.remove("show"); }, 2400); } function getJSON(p) { return fetch(p, { credentials: "same-origin", cache: "no-store" }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }).catch(function () { return { ok: r.ok, status: r.status, d: {} }; }); }); } function post(p, body) { return fetch(p, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }).catch(function () { return { ok: r.ok, status: r.status, d: {} }; }); }); } /* ---- access gate ---- */ getJSON("/api/admin/overview").then(function (r) { if (r.status === 401) { location.href = "/app"; return; } if (r.status === 403) { $("#gate").innerHTML = '

Admin access required

Your account is signed in but is not an administrator. Ask an admin to grant you the admin role, then reload.

Back to app'; return; } if (!r.ok) { $("#gate").innerHTML = '

Could not reach the server.

'; return; } $("#gate").hidden = true; $("#shell").hidden = false; renderOverview(r.d); wireNav(); loadUsersForPolicy(); }); /* ---- nav ---- */ var loaded = {}; function wireNav() { $("#nav").querySelectorAll("a").forEach(function (a) { a.onclick = function () { $("#nav").querySelectorAll("a").forEach(function (x) { x.classList.remove("on"); }); a.classList.add("on"); var p = a.dataset.p; ["overview", "users", "devices", "policy", "keys", "relays", "teams", "access", "recordings"].forEach(function (s) { var el = $("#p-" + s); if (el) el.hidden = s !== p; }); if (p === "users" && !loaded.users) loadUsers(); if (p === "devices" && !loaded.devices) loadDevices(); if (p === "keys" && !loaded.keys) loadKeys(); if (p === "relays" && !loaded.relays) loadRelays(); if (p === "teams" && !loaded.teams) loadTeams(); if (p === "access" && !loaded.access) loadAccess(); if (p === "recordings" && !loaded.recordings) loadRecordings(); }; }); } /* ---- overview ---- */ function renderOverview(d) { var items = [["users", "Accounts"], ["admins", "Administrators"], ["devices", "Registered devices"], ["hosts_online", "Hosts online"], ["in_session", "In session now"], ["api_keys", "API keys"], ["relays", "Relay nodes"]]; $("#ovGrid").innerHTML = items.map(function (i) { return '
' + (d[i[0]] != null ? d[i[0]] : "—") + '' + i[1] + '
'; }).join(""); } /* ---- users + roles + seats ---- */ var USERS = []; function loadUsers() { loaded.users = true; getJSON("/api/admin/users").then(function (r) { if (!r.ok) { toast(r.d.error || "failed", true); return; } USERS = r.d.users || []; $("#uCount").textContent = USERS.length + " account" + (USERS.length === 1 ? "" : "s"); $("#uBody").innerHTML = USERS.map(function (u) { var rsel = ''; var seat = ''; return '' + esc(u.email) + (u.email === (r.d.me || "") ? ' (you)' : "") + '' + '' + rsel + '' + seat + '' + (u.devices || 0) + '' + '' + (u.twofa ? "on" : "off") + '' + '' + (u.verified ? "yes" : "no") + ''; }).join(""); $("#uBody").querySelectorAll("[data-role]").forEach(function (sel) { sel.onchange = function () { post("/api/admin/role", { email: sel.dataset.role, role: sel.value }) .then(function (x) { toast(x.ok ? "Role updated" : (x.d.error || "failed"), !x.ok); }); }; }); $("#uBody").querySelectorAll("[data-seat]").forEach(function (inp) { inp.onchange = function () { post("/api/admin/license", { email: inp.dataset.seat, seats: parseInt(inp.value, 10) || 0 }) .then(function (x) { toast(x.ok ? "Seat limit saved" : (x.d.error || "failed"), !x.ok); }); }; }); }); } /* ---- devices ---- */ function loadDevices() { loaded.devices = true; getJSON("/api/devices?scope=all").then(function (r) { if (!r.ok) { toast(r.d.error || "failed", true); return; } var ds = r.d.devices || []; $("#dCount").textContent = ds.length + " device" + (ds.length === 1 ? "" : "s"); $("#dBody").innerHTML = ds.map(function (dv) { var st = dv.online ? '' + (dv.busy ? "in session" : "online") : 'offline'; var tags = (dv.tags || []).map(function (t) { return '' + esc(t) + ''; }).join("") || '—'; return '' + esc(dv.name) + '
' + esc(dv.id) + '
' + '' + esc(dv.owner || "—") + '' + st + '' + '' + '' + ' '; }).join("") || 'No devices yet.'; $("#dBody").querySelectorAll("tr[data-id]").forEach(function (tr) { var id = tr.dataset.id; var save = tr.querySelector("[data-save]"); if (save) save.onclick = function () { post("/api/devices/meta", { id: id, group: tr.querySelector("[data-grp]").value, tags: tr.querySelector("[data-tags]").value }) .then(function (x) { toast(x.ok ? "Saved" : (x.d.error || "failed"), !x.ok); }); }; var del = tr.querySelector("[data-del]"); if (del) del.onclick = function () { if (!confirm("Remove " + id + "? It must re-register to return.")) return; post("/api/devices/remove", { id: id }).then(function (x) { if (x.ok) { tr.remove(); toast("Removed"); } else toast(x.d.error || "failed", true); }); }; }); }); } /* ---- MDM policy editor ---- */ var STRKEYS = [["server", "Relay server (wss://…)"], ["reg_key", "Registration key"], ["allow_ips", "Viewer IP allowlist (CIDR, comma-sep)"], ["totp", "2FA secret (base32)"]]; function loadUsersForPolicy() { getJSON("/api/admin/users").then(function (r) { var sel = $("#polAcct"); if (!sel) return; sel.innerHTML = (r.d.users || []).map(function (u) { return ''; }).join(""); renderPolicyForm({}); }); $("#polLoad").onclick = function () { getJSON("/api/admin/policy?email=" + encodeURIComponent($("#polAcct").value)).then(function (r) { renderPolicyForm(r.d.policy || {}); toast("Loaded"); }); }; $("#polSave").onclick = function () { post("/api/admin/policy", { email: $("#polAcct").value, policy: collectPolicy() }) .then(function (x) { toast(x.ok ? "Policy saved & will push on host registration" : (x.d.error || "failed"), !x.ok); if (x.ok) renderPolicyForm(x.d.policy || {}); }); }; $("#polClear").onclick = function () { post("/api/admin/policy", { email: $("#polAcct").value, policy: {} }).then(function (x) { toast(x.ok ? "Policy cleared" : "failed", !x.ok); renderPolicyForm({}); }); }; } function renderPolicyForm(pol) { var perms = pol.perms || {}; var h = STRKEYS.map(function (k) { return '
'; }).join(""); function chk(key, lbl, on) { return ''; } h += '
' + chk("lock_on_end", "Lock the PC when the session ends", !!pol.lock_on_end) + chk("approve_click", "Require the host user to approve each connect", !!pol.approve_click) + chk("record_required", "Force central session recording (upload to the server)", !!pol.record_required) + '
'; h += '
' + chk("perm_input", "Keyboard / mouse input", perms.input !== false) + chk("perm_clipboard", "Clipboard sync", perms.clipboard !== false) + chk("perm_files", "File transfer", perms.files !== false) + chk("perm_audio", "Audio", perms.audio !== false) + '
'; $("#polForm").innerHTML = h; } function collectPolicy() { var pol = {}, perms = {}; $("#polForm").querySelectorAll("[data-pk]").forEach(function (i) { if (i.value.trim()) pol[i.dataset.pk] = i.value.trim(); }); $("#polForm").querySelectorAll("[data-pb]").forEach(function (c) { var k = c.dataset.pb; if (k.indexOf("perm_") === 0) perms[k.slice(5)] = c.checked; else if (c.checked) pol[k] = true; }); pol.perms = perms; return pol; } /* ---- API keys ---- */ function loadKeys() { loaded.keys = true; getJSON("/api/keys").then(function (r) { var ks = (r.d && r.d.keys) || []; $("#kBody").innerHTML = ks.map(function (k) { return '' + esc(k.name) + '' + esc(k.key_preview) + '' + fmtTs(k.created) + '' + fmtAgo(k.last_used) + '' + ''; }).join("") || 'No API keys yet.'; $("#kBody").querySelectorAll("[data-rev]").forEach(function (b) { b.onclick = function () { if (!confirm("Revoke this key?")) return; post("/api/keys/revoke", { key: b.dataset.rev }).then(function (x) { toast(x.ok ? "Revoked" : "failed", !x.ok); loaded.keys = false; loadKeys(); }); }; }); }); $("#kNew").onclick = function () { var name = $("#kName").value.trim() || "key"; post("/api/keys", { name: name }).then(function (x) { if (!x.ok) { toast(x.d.error || "failed", true); return; } $("#kName").value = ""; $("#kNewBox").hidden = false; $("#kNewBox").innerHTML = 'New key (copy it now — shown once): ' + esc(x.d.key) + ''; loaded.keys = false; loadKeys(); }); }; loadScim(); $("#scNew").onclick = function () { var label = $("#scName").value.trim() || "SCIM"; post("/api/admin/scim/tokens", { label: label }).then(function (x) { if (!x.ok) { toast(x.d.error || "failed", true); return; } $("#scName").value = ""; $("#scNewBox").hidden = false; $("#scNewBox").innerHTML = 'New SCIM token (copy now — shown once): ' + esc(x.d.token) + ''; loadScim(); }); }; } function loadScim() { getJSON("/api/admin/scim/tokens").then(function (r) { if (!r.ok) return; var ts = (r.d && r.d.tokens) || []; if (r.d.base) $("#scBase").innerHTML = 'Point your IdP at ' + esc(r.d.base) + ' with the token as an HTTP Bearer header. Provisions accounts (Users) & teams (Groups).'; $("#scBody").innerHTML = ts.map(function (t) { return '' + esc(t.label) + '' + esc(t.token_preview) + '' + fmtTs(t.created) + '' + fmtAgo(t.last_used) + '' + ''; }).join("") || 'No provisioning tokens yet.'; $("#scBody").querySelectorAll("[data-screv]").forEach(function (b) { b.onclick = function () { if (!confirm("Revoke this SCIM token? The IdP will stop provisioning.")) return; post("/api/admin/scim/tokens/revoke", { token: b.dataset.screv }).then(function (x) { toast(x.ok ? "Revoked" : "failed", !x.ok); loadScim(); }); }; }); }); } /* ---- relays ---- */ function loadRelays() { loaded.relays = true; getJSON("/api/relays").then(function (r) { var rs = (r.d && r.d.relays) || []; $("#rBody").innerHTML = rs.map(function (x) { return '' + esc(x.id) + '' + esc(x.region || "—") + '' + esc(x.url) + '' + esc(x.tier || "—") + '' + x.lat + ", " + x.lon + ''; }).join(""); }); } /* ---- teams (shared address books) ---- */ var TEAMS = [], curTeam = null, ALLDEV = []; function refreshCounts() { loaded.teams = false; loadTeams(); } function loadTeams() { loaded.teams = true; getJSON("/api/admin/teams").then(function (r) { if (!r.ok) { toast(r.d.error || "failed", true); return; } TEAMS = r.d.teams || []; $("#tCount").textContent = TEAMS.length + " team" + (TEAMS.length === 1 ? "" : "s"); $("#tList").innerHTML = TEAMS.map(function (t) { return '
' + esc(t.name) + '' + '
' + t.members + ' member' + (t.members === 1 ? "" : "s") + ' · ' + t.devices + ' device' + (t.devices === 1 ? "" : "s") + '
' + '#' + t.id + '
'; }).join("") || '
No teams yet — create one above.
'; $("#tList").querySelectorAll("[data-tid]").forEach(function (el) { el.onclick = function () { selectTeam(parseInt(el.dataset.tid, 10)); }; }); if (curTeam && TEAMS.some(function (t) { return t.id === curTeam; })) selectTeam(curTeam); else { curTeam = null; $("#tDetail").hidden = true; } }); } function selectTeam(tid) { curTeam = tid; var t = TEAMS.filter(function (x) { return x.id === tid; })[0]; $("#tList").querySelectorAll("[data-tid]").forEach(function (el) { el.classList.toggle("on", parseInt(el.dataset.tid, 10) === tid); }); $("#tDetail").hidden = false; $("#tdName").textContent = t ? t.name : ("Team #" + tid); loadMembers(tid); loadTeamDevices(tid); } function loadMembers(tid) { getJSON("/api/teams/members?team_id=" + tid).then(function (r) { var ms = (r.d && r.d.members) || []; $("#tmList").innerHTML = ms.map(function (m) { return '
' + esc(m.email) + ' ' + esc(m.role) + '' + '
'; }).join("") || '
No members.
'; $("#tmList").querySelectorAll("[data-rm]").forEach(function (b) { b.onclick = function () { post("/api/teams/members/remove", { team_id: tid, email: b.dataset.rm }).then(function (x) { toast(x.ok ? "Removed" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); }); }; }); }); } function loadTeamDevices(tid) { getJSON("/api/teams/devices?team_id=" + tid).then(function (r) { var ds = (r.d && r.d.devices) || [], shared = {}; $("#tdDevList").innerHTML = ds.map(function (d) { shared[d.id] = true; return '
' + (d.online ? '' : '') + esc(d.name) + ' ' + esc(d.owner || "") + '
'; }).join("") || '
No shared devices.
'; $("#tdDevList").querySelectorAll("[data-unshare]").forEach(function (b) { b.onclick = function () { post("/api/teams/devices/remove", { team_id: tid, device_id: b.dataset.unshare }).then(function (x) { toast(x.ok ? "Unshared" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); }); }; }); var fill = function () { $("#tdDev").innerHTML = ALLDEV.filter(function (d) { return !shared[d.id]; }).map(function (d) { return ''; }).join("") || ''; }; if (ALLDEV.length) fill(); else getJSON("/api/devices?scope=all").then(function (rr) { ALLDEV = (rr.d && rr.d.devices) || []; fill(); }); }); } (function wireTeams() { var nw = $("#tNew"); if (nw) nw.onclick = function () { var name = $("#tName").value.trim(); if (!name) { toast("Enter a team name", true); return; } post("/api/teams", { name: name }).then(function (x) { if (!x.ok) { toast(x.d.error || "failed", true); return; } $("#tName").value = ""; toast("Team created"); curTeam = x.d.id; refreshCounts(); }); }; var ad = $("#tmAdd"); if (ad) ad.onclick = function () { if (!curTeam) return; var em = $("#tmEmail").value.trim(); if (!em) { toast("Enter an account email", true); return; } post("/api/teams/members", { team_id: curTeam, email: em, role: $("#tmRole").value }).then(function (x) { toast(x.ok ? "Member added" : (x.d.error || "failed"), !x.ok); if (x.ok) { $("#tmEmail").value = ""; refreshCounts(); } }); }; var sh = $("#tdShare"); if (sh) sh.onclick = function () { if (!curTeam) return; var id = $("#tdDev").value; if (!id) { toast("No device selected", true); return; } post("/api/teams/devices", { team_id: curTeam, device_id: id }).then(function (x) { toast(x.ok ? "Device shared" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); }); }; var rn = $("#tdRename"); if (rn) rn.onclick = function () { if (!curTeam) return; var cur = (TEAMS.filter(function (x) { return x.id === curTeam; })[0] || {}).name || ""; var name = prompt("Rename team", cur); if (name == null || !name.trim()) return; post("/api/teams/rename", { team_id: curTeam, name: name.trim() }).then(function (x) { toast(x.ok ? "Renamed" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); }); }; var dl = $("#tdDelete"); if (dl) dl.onclick = function () { if (!curTeam) return; if (!confirm("Delete this team? Members lose the shared list (the devices themselves are not deleted).")) return; post("/api/teams/delete", { team_id: curTeam }).then(function (x) { toast(x.ok ? "Team deleted" : (x.d.error || "failed"), !x.ok); if (x.ok) { curTeam = null; $("#tDetail").hidden = true; refreshCounts(); } }); }; })(); /* ---- session recordings ---- */ function fmtSize(b) { b = +b || 0; if (b < 1024) return b + " B"; if (b < 1048576) return (b / 1024).toFixed(1) + " KB"; if (b < 1073741824) return (b / 1048576).toFixed(1) + " MB"; return (b / 1073741824).toFixed(2) + " GB"; } function fmtDur(s) { s = +s || 0; if (!s) return "—"; var m = Math.floor(s / 60), ss = s % 60; return m + ":" + ("" + ss).padStart(2, "0"); } function loadRecordings() { loaded.recordings = true; getJSON("/api/recordings").then(function (r) { if (!r.ok) { toast(r.d.error || "failed", true); return; } var rs = r.d.recordings || []; $("#recRetention").textContent = r.d.retention_days > 0 ? ("Auto-deleted after " + r.d.retention_days + " days.") : "Kept indefinitely (no retention limit set)."; $("#recCount").textContent = rs.length + " recording" + (rs.length === 1 ? "" : "s"); $("#recBody").innerHTML = rs.map(function (x) { return '' + x.id + '' + esc(x.host_id) + '' + esc(x.owner || "—") + '' + '' + esc(x.viewer || "—") + '' + fmtSize(x.size) + '' + fmtDur(x.duration) + '' + '' + fmtTs(x.created) + '' + 'Download '; }).join("") || 'No recordings yet. Set a record_required MDM policy on an account, then its hosts upload session recordings here.'; $("#recBody").querySelectorAll("[data-recdel]").forEach(function (b) { b.onclick = function () { if (!confirm("Delete this recording permanently?")) return; post("/api/recordings/delete", { id: +b.dataset.recdel }).then(function (x) { toast(x.ok ? "Deleted" : (x.d.error || "failed"), !x.ok); loadRecordings(); }); }; }); }); } /* ---- conditional access rules ---- */ var DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; var editRuleId = null; function minsToHHMM(m) { if (m == null || m === "") return ""; m = +m; return (("" + Math.floor(m / 60)).padStart(2, "0")) + ":" + (("" + (m % 60)).padStart(2, "0")); } function hhmmToMins(s) { s = (s || "").trim(); if (!s) return null; var p = s.split(":"); if (p.length !== 2) return null; var h = +p[0], m = +p[1]; if (isNaN(h) || isNaN(m)) return null; return h * 60 + m; } function acOwner() { var s = $("#acAcct"); return (s && s.value) || ""; } function loadAccess() { loaded.access = true; // day checkboxes $("#acDays").innerHTML = DAYS.map(function (d, i) { return ''; }).join(""); // account selector (reuse users list) getJSON("/api/admin/users").then(function (r) { var us = (r.d && r.d.users) || [], me = (r.d && r.d.me) || ""; $("#acAcct").innerHTML = us.map(function (u) { return ''; }).join(""); loadRules(); }); var lb = $("#acLoad"); if (lb) lb.onclick = loadRules; } function condText(r) { var bits = []; if (r.cidr) bits.push("from " + esc(r.cidr)); if (r.weekdays) bits.push(r.weekdays.split(",").map(function (i) { return DAYS[+i] || i; }).join(" ")); if (r.time_start != null && r.time_end != null) bits.push(minsToHHMM(r.time_start) + "–" + minsToHHMM(r.time_end) + (r.tz_offset ? " (UTC" + (r.tz_offset >= 0 ? "+" : "") + (r.tz_offset / 60) + ")" : " UTC")); return bits.join(" · ") || 'any'; } function subjText(r) { if (r.subject_type === "any") return 'any connection'; return esc(r.subject_type) + ": " + esc(r.subject || "—") + ""; } function loadRules() { var owner = acOwner(); getJSON("/api/access/rules?owner=" + encodeURIComponent(owner)).then(function (r) { if (!r.ok) { toast(r.d.error || "failed", true); return; } var rs = r.d.rules || []; $("#acBody").innerHTML = rs.map(function (x) { return '' + x.priority + '' + '' + x.action + '' + '' + subjText(x) + '' + condText(x) + '' + '' + esc(x.reason || "") + '' + '' + ' '; }).join("") || 'No rules — every connection is allowed. Add a rule below.'; $("#acBody").querySelectorAll("[data-del]").forEach(function (b) { b.onclick = function () { if (!confirm("Delete this rule?")) return; post("/api/access/rules/delete", { id: +b.dataset.del }).then(function (x) { toast(x.ok ? "Deleted" : "failed", !x.ok); loadRules(); }); }; }); $("#acBody").querySelectorAll("[data-toggle]").forEach(function (cb) { cb.onchange = function () { var rule = rs.filter(function (z) { return z.id === +cb.dataset.toggle; })[0]; if (!rule) return; post("/api/access/rules", ruleBody(rule, { enabled: cb.checked })).then(function (x) { toast(x.ok ? (cb.checked ? "Enabled" : "Disabled") : "failed", !x.ok); }); }; }); $("#acBody").querySelectorAll("[data-edit]").forEach(function (b) { b.onclick = function () { var rule = rs.filter(function (z) { return z.id === +b.dataset.edit; })[0]; if (rule) fillRuleForm(rule); }; }); }); } function ruleBody(base, over) { var b = { owner: acOwner(), id: base.id, priority: base.priority, action: base.action, subject_type: base.subject_type, subject: base.subject, cidr: base.cidr, weekdays: base.weekdays, time_start: base.time_start, time_end: base.time_end, tz_offset: base.tz_offset, reason: base.reason, enabled: base.enabled }; for (var k in (over || {})) b[k] = over[k]; return b; } function fillRuleForm(r) { editRuleId = r.id; $("#acFormTitle").textContent = "Edit rule #" + r.id; $("#acReset").hidden = false; $("#acPrio").value = r.priority; $("#acAction").value = r.action; $("#acSubType").value = r.subject_type; $("#acSubject").value = r.subject || ""; $("#acCidr").value = r.cidr || ""; $("#acReason").value = r.reason || ""; $("#acTimeStart").value = minsToHHMM(r.time_start); $("#acTimeEnd").value = minsToHHMM(r.time_end); $("#acTz").value = r.tz_offset || 0; var days = (r.weekdays || "").split(","); $("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { cb.checked = days.indexOf(cb.dataset.day) >= 0; }); try { $("#acFormTitle").scrollIntoView({ behavior: "smooth", block: "center" }); } catch (e) {} } function resetRuleForm() { editRuleId = null; $("#acFormTitle").textContent = "Add a rule"; $("#acReset").hidden = true; $("#acPrio").value = 100; $("#acAction").value = "deny"; $("#acSubType").value = "any"; $("#acSubject").value = ""; $("#acCidr").value = ""; $("#acReason").value = ""; $("#acTimeStart").value = ""; $("#acTimeEnd").value = ""; $("#acTz").value = 0; $("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { cb.checked = false; }); } (function wireAccess() { var sv = $("#acSave"); if (sv) sv.onclick = function () { var days = []; $("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { if (cb.checked) days.push(cb.dataset.day); }); var body = { owner: acOwner(), priority: +$("#acPrio").value || 100, action: $("#acAction").value, subject_type: $("#acSubType").value, subject: $("#acSubject").value.trim(), cidr: $("#acCidr").value.trim(), reason: $("#acReason").value.trim(), weekdays: days.join(","), tz_offset: +$("#acTz").value || 0, time_start: hhmmToMins($("#acTimeStart").value), time_end: hhmmToMins($("#acTimeEnd").value), enabled: true }; if (editRuleId) body.id = editRuleId; post("/api/access/rules", body).then(function (x) { toast(x.ok ? "Rule saved" : (x.d.error || "failed"), !x.ok); if (x.ok) { resetRuleForm(); loadRules(); } }); }; var rs = $("#acReset"); if (rs) rs.onclick = resetRuleForm; var tr = $("#tRun"); if (tr) tr.onclick = function () { var q = new URLSearchParams({ owner: acOwner(), viewer: $("#tViewer").value.trim(), host: $("#tHost").value.trim(), group: $("#tGroup").value.trim(), ip: $("#tIp").value.trim() }); getJSON("/api/access/test?" + q.toString()).then(function (x) { if (!x.ok) { $("#tResult").innerHTML = '' + esc(x.d.error || "error") + ''; return; } var d = x.d; $("#tResult").innerHTML = d.allowed ? '✓ ALLOWED' + (d.rule_id ? ' by rule #' + d.rule_id + '' : ' (no rule matched — default allow)') : '✕ DENIED rule #' + esc(d.rule_id) + ' — ' + esc(d.reason) + ''; }); }; })(); })();