Files

448 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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>';
});
};
})();
})();