NetDesk 1.5.0 — source release

This commit is contained in:
NetDesk committed 2026-10-18 02:02:46 +00:00
commit 40318aafdd
227 files changed
+80798

No files matched your search

+447
View File
@@ -0,0 +1,447 @@
/* 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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 = '<h1>Admin access required</h1><p>Your account is signed in but is not an administrator. Ask an admin to grant you the admin role, then reload.</p><a class="btn btn-ghost" href="/app" style="margin-top:8px">Back to app</a>';
return;
}
if (!r.ok) { $("#gate").innerHTML = '<p class="muted">Could not reach the server.</p>'; 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 '<div class="ov-card"><b>' + (d[i[0]] != null ? d[i[0]] : "—") + '</b><span>' + i[1] + '</span></div>'; }).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 = '<select class="in" data-role="' + esc(u.email) + '">' +
'<option value="member"' + (u.role === "member" ? " selected" : "") + '>member</option>' +
'<option value="admin"' + (u.role === "admin" ? " selected" : "") + '>admin</option></select>';
var seat = '<input class="in in-sm" type="number" min="0" value="' + (u.seats || 0) + '" data-seat="' + esc(u.email) + '" title="0 = unlimited">';
return '<tr><td>' + esc(u.email) + (u.email === (r.d.me || "") ? ' <span class="faint">(you)</span>' : "") + '</td>' +
'<td>' + rsel + '</td><td>' + seat + '</td><td>' + (u.devices || 0) + '</td>' +
'<td><span class="pill ' + (u.twofa ? "on" : "off") + '">' + (u.twofa ? "on" : "off") + '</span></td>' +
'<td><span class="pill ' + (u.verified ? "on" : "off") + '">' + (u.verified ? "yes" : "no") + '</span></td></tr>';
}).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 ? '<span class="dot on"></span>' + (dv.busy ? "in session" : "online") : '<span class="dot off"></span>offline';
var tags = (dv.tags || []).map(function (t) { return '<span class="tag">' + esc(t) + '</span>'; }).join("") || '<span class="faint">—</span>';
return '<tr data-id="' + esc(dv.id) + '"><td><b>' + esc(dv.name) + '</b><div class="faint" style="font-size:12px">' + esc(dv.id) + '</div></td>' +
'<td class="muted">' + esc(dv.owner || "—") + '</td><td>' + st + '</td>' +
'<td><input class="in in-sm" style="width:110px" value="' + esc(dv.group || "") + '" data-grp placeholder="group"></td>' +
'<td><input class="in" style="width:150px" value="' + esc((dv.tags || []).join(", ")) + '" data-tags placeholder="tag1, tag2"></td>' +
'<td style="white-space:nowrap"><button class="b" data-save>Save</button> <button class="b b-danger" data-del>Remove</button></td></tr>';
}).join("") || '<tr><td colspan="6" class="muted" style="padding:24px">No devices yet.</td></tr>';
$("#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 '<option value="' + esc(u.email) + '">' + esc(u.email) + '</option>'; }).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 '<div class="pol-field"><label>' + k[1] + '</label><input class="in" data-pk="' + k[0] + '" value="' + esc(pol[k[0]] || "") + '"></div>'; }).join("");
function chk(key, lbl, on) { return '<label class="pol-check"><input type="checkbox" data-pb="' + key + '"' + (on ? " checked" : "") + '> ' + lbl + '</label>'; }
h += '<div class="pol-field"><label>Behaviour</label>' + 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) + '</div>';
h += '<div class="pol-field"><label>Allowed channels (perms)</label>' +
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) + '</div>';
$("#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 '<tr><td><b>' + esc(k.name) + '</b></td><td class="mono faint">' + esc(k.key_preview) + '</td><td>' + fmtTs(k.created) + '</td><td>' + fmtAgo(k.last_used) + '</td>' +
'<td><button class="b b-danger" data-rev="' + esc(k.key_preview) + '">Revoke</button></td></tr>'; }).join("") ||
'<tr><td colspan="5" class="muted" style="padding:24px">No API keys yet.</td></tr>';
$("#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): <code class="mono" style="color:var(--accent2)">' + esc(x.d.key) + '</code>';
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): <code class="mono" style="color:var(--accent2)">' + esc(x.d.token) + '</code>';
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 <code class="mono" style="color:var(--accent2)">' + esc(r.d.base) + '</code> with the token as an <b>HTTP Bearer</b> header. Provisions accounts (Users) &amp; teams (Groups).';
$("#scBody").innerHTML = ts.map(function (t) {
return '<tr><td><b>' + esc(t.label) + '</b></td><td class="mono faint">' + esc(t.token_preview) + '</td><td>' + fmtTs(t.created) + '</td><td>' + fmtAgo(t.last_used) + '</td>' +
'<td><button class="b b-danger" data-screv="' + esc(t.token_preview) + '">Revoke</button></td></tr>'; }).join("") ||
'<tr><td colspan="5" class="muted" style="padding:20px">No provisioning tokens yet.</td></tr>';
$("#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 '<tr><td><b>' + esc(x.id) + '</b></td><td>' + esc(x.region || "—") + '</td><td class="mono faint">' + esc(x.url) + '</td><td>' + esc(x.tier || "—") + '</td><td class="muted">' + x.lat + ", " + x.lon + '</td></tr>'; }).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 '<div class="t-item" data-tid="' + t.id + '"><div><b>' + esc(t.name) + '</b>' +
'<div class="t-sub">' + t.members + ' member' + (t.members === 1 ? "" : "s") + ' · ' + t.devices + ' device' + (t.devices === 1 ? "" : "s") + '</div></div>' +
'<span class="faint" style="font-size:12px">#' + t.id + '</span></div>';
}).join("") || '<div class="muted" style="padding:16px 20px">No teams yet — create one above.</div>';
$("#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 '<div class="t-row"><span>' + esc(m.email) + ' <span class="pill ' + (m.role === "owner" ? "admin" : "member") + '">' + esc(m.role) + '</span></span>' +
'<button class="b b-danger" data-rm="' + esc(m.email) + '">Remove</button></div>';
}).join("") || '<div class="muted">No members.</div>';
$("#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 '<div class="t-row"><span>' + (d.online ? '<span class="dot on"></span>' : '<span class="dot off"></span>') + esc(d.name) +
' <span class="t-meta">' + esc(d.owner || "") + '</span></span><button class="b b-danger" data-unshare="' + esc(d.id) + '">Unshare</button></div>';
}).join("") || '<div class="muted">No shared devices.</div>';
$("#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 '<option value="' + esc(d.id) + '">' + esc(d.name) + ' (' + esc(d.id) + (d.owner ? " · " + esc(d.owner) : "") + ')</option>';
}).join("") || '<option value="">— no more devices —</option>';
};
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 '<tr><td>' + x.id + '</td><td><b>' + esc(x.host_id) + '</b></td><td class="muted">' + esc(x.owner || "—") + '</td>' +
'<td>' + esc(x.viewer || "—") + '</td><td>' + fmtSize(x.size) + '</td><td>' + fmtDur(x.duration) + '</td>' +
'<td class="muted">' + fmtTs(x.created) + '</td>' +
'<td style="white-space:nowrap"><a class="b" href="/api/recordings/' + x.id + '/download">Download</a> <button class="b b-danger" data-recdel="' + x.id + '">Delete</button></td></tr>';
}).join("") || '<tr><td colspan="8" class="muted" style="padding:24px">No recordings yet. Set a <b>record_required</b> MDM policy on an account, then its hosts upload session recordings here.</td></tr>';
$("#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 '<label><input type="checkbox" data-day="' + i + '"> ' + d + '</label>'; }).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 '<option value="' + esc(u.email) + '"' + (u.email === me ? " selected" : "") + '>' + esc(u.email) + '</option>'; }).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(" · ") || '<span class="faint">any</span>';
}
function subjText(r) {
if (r.subject_type === "any") return '<span class="faint">any connection</span>';
return esc(r.subject_type) + ": <b>" + esc(r.subject || "—") + "</b>";
}
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 '<tr><td>' + x.priority + '</td>' +
'<td><span class="ac-pill ' + (x.action === "allow" ? "ac-allow" : "ac-deny") + '">' + x.action + '</span></td>' +
'<td>' + subjText(x) + '</td><td class="ac-cond">' + condText(x) + '</td>' +
'<td class="muted">' + esc(x.reason || "") + '</td>' +
'<td><input type="checkbox" data-toggle="' + x.id + '"' + (x.enabled ? " checked" : "") + '></td>' +
'<td style="white-space:nowrap"><button class="b" data-edit="' + x.id + '">Edit</button> <button class="b b-danger" data-del="' + x.id + '">Delete</button></td></tr>';
}).join("") || '<tr><td colspan="7" class="muted" style="padding:22px">No rules — every connection is allowed. Add a rule below.</td></tr>';
$("#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 = '<span class="ac-res-no">' + esc(x.d.error || "error") + '</span>'; return; }
var d = x.d;
$("#tResult").innerHTML = d.allowed
? '<span class="ac-res-ok">✓ ALLOWED</span>' + (d.rule_id ? ' <span class="faint">by rule #' + d.rule_id + '</span>' : ' <span class="faint">(no rule matched — default allow)</span>')
: '<span class="ac-res-no">✕ DENIED</span> <span class="faint">rule #' + esc(d.rule_id) + ' — ' + esc(d.reason) + '</span>';
});
};
})();
})();