const params = new URLSearchParams(window.location.search); const targetUserId = Number(params.get("userId") || 0); const subtitleEl = document.getElementById("target-user-subtitle"); const messageEl = document.getElementById("access-message"); const searchInput = document.getElementById("clients-search"); const refreshBtn = document.getElementById("clients-refresh-btn"); const tableBody = document.getElementById("clients-table-body"); const pageLabel = document.getElementById("clients-page-label"); const prevBtn = document.getElementById("clients-prev-btn"); const nextBtn = document.getElementById("clients-next-btn"); const scopeForm = document.getElementById("scope-form"); const scopeSelect = document.getElementById("scope-select"); const rulesList = document.getElementById("rules-list"); const state = { me: null, targetUser: null, scope: "none", rules: [], rulesMap: new Map(), page: 1, pageSize: 30, total: 0, items: [], search: "", }; function showMessage(text, type = "ok") { messageEl.textContent = text; messageEl.classList.remove( "hidden", "text-emerald-200", "border-emerald-700", "bg-emerald-900/30", "text-rose-200", "border-rose-700", "bg-rose-900/30", ); if (type === "error") { messageEl.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30"); } else { messageEl.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30"); } } function escapeHtml(value) { const div = document.createElement("div"); div.textContent = value == null ? "" : String(value); return div.innerHTML.replace(/"/g, """).replace(/'/g, "'"); } function getRule(clientId) { return state.rulesMap.get(clientId) || ""; } function getEffective(clientId) { if (state.targetUser?.role === "admin") return true; const rule = getRule(clientId); if (state.scope === "none") return false; if (state.scope === "all") return true; if (state.scope === "allowlist") return rule === "allow"; if (state.scope === "denylist") return rule !== "deny"; return false; } function renderRules() { if (!state.rules.length) { rulesList.innerHTML = '
No explicit rules.
'; return; } rulesList.innerHTML = state.rules .map( (rule) => `
${escapeHtml(rule.clientId)} ${rule.access}
`, ) .join(""); } function renderClients() { if (!state.items.length) { tableBody.innerHTML = ` No matching clients `; } else { tableBody.innerHTML = state.items .map((client) => { const rule = getRule(client.id); const effective = getEffective(client.id); const status = client.online ? "online" : "offline"; return `
${escapeHtml(client.id)}
${escapeHtml(client.os || "unknown")} / ${escapeHtml(client.arch || "-")}
${escapeHtml(client.host || "-")}
${escapeHtml(client.user || "-")}
${status} ${rule || "none"} ${effective ? "visible" : "hidden"}
`; }) .join(""); } const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize)); pageLabel.textContent = `Page ${state.page} / ${totalPages} (${state.total} clients)`; prevBtn.disabled = state.page <= 1; nextBtn.disabled = state.page >= totalPages; } async function loadCurrentUser() { const res = await fetch("/api/auth/me", { credentials: "include" }); if (!res.ok) { window.location.href = "/"; return; } state.me = await res.json(); const permissions = Array.isArray(state.me.permissions) ? state.me.permissions : []; if (!permissions.includes("users:manage")) { window.location.href = "/"; } } async function loadTargetUser() { const res = await fetch("/api/users", { credentials: "include" }); if (!res.ok) { throw new Error("Failed to load users"); } const data = await res.json().catch(() => ({ users: [] })); state.targetUser = (data.users || []).find((user) => user.id === targetUserId) || null; if (!state.targetUser) { throw new Error("Target user not found"); } subtitleEl.textContent = `Managing visibility for ${state.targetUser.username} (${state.targetUser.role})`; } async function loadPolicy() { const res = await fetch(`/api/users/${targetUserId}/client-access`, { credentials: "include" }); if (!res.ok) { throw new Error("Failed to load access policy"); } const data = await res.json().catch(() => ({ scope: "none", rules: [] })); state.scope = data.scope || "none"; state.rules = Array.isArray(data.rules) ? data.rules : []; state.rulesMap = new Map(state.rules.map((rule) => [rule.clientId, rule.access])); scopeSelect.value = state.scope; renderRules(); } async function loadClients() { tableBody.innerHTML = ` Loading clients... `; const query = new URLSearchParams({ page: String(state.page), pageSize: String(state.pageSize), q: state.search, sort: "last_seen_desc", status: "all", os: "all", }); const res = await fetch(`/api/clients?${query.toString()}`, { credentials: "include" }); if (!res.ok) { throw new Error("Failed to load clients"); } const data = await res.json(); state.items = Array.isArray(data.items) ? data.items : []; state.total = Number(data.total) || 0; renderClients(); } async function saveScope(event) { event.preventDefault(); const nextScope = scopeSelect.value; const res = await fetch(`/api/users/${targetUserId}/client-access`, { method: "PUT", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ scope: nextScope }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Failed to save scope", "error"); return; } state.scope = nextScope; showMessage("Scope updated."); renderClients(); } async function setRule(clientId, access) { const res = await fetch(`/api/users/${targetUserId}/client-access/rules`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ clientId, access }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Failed to save rule", "error"); return; } await loadPolicy(); renderClients(); showMessage(`Rule saved: ${access} ${clientId}`); } async function clearRule(clientId) { const res = await fetch( `/api/users/${targetUserId}/client-access/rules?clientId=${encodeURIComponent(clientId)}`, { method: "DELETE", credentials: "include", }, ); const data = await res.json().catch(() => ({})); if (!res.ok) { showMessage(data.error || "Failed to remove rule", "error"); return; } await loadPolicy(); renderClients(); showMessage(`Rule removed for ${clientId}`); } tableBody.addEventListener("click", async (event) => { const ruleBtn = event.target.closest(".rule-btn"); if (ruleBtn) { const clientId = ruleBtn.dataset.clientId; const access = ruleBtn.dataset.access; if (clientId && (access === "allow" || access === "deny")) { await setRule(clientId, access); } return; } const clearBtn = event.target.closest(".rule-clear-btn"); if (clearBtn) { const clientId = clearBtn.dataset.clientId; if (clientId) { await clearRule(clientId); } } }); rulesList.addEventListener("click", async (event) => { const removeBtn = event.target.closest(".rule-remove-btn"); if (!removeBtn) return; const clientId = removeBtn.dataset.clientId; if (clientId) { await clearRule(clientId); } }); scopeForm.addEventListener("submit", saveScope); searchInput.addEventListener("input", () => { state.search = searchInput.value.trim(); state.page = 1; loadClients().catch((error) => { console.error(error); showMessage("Failed to load clients", "error"); }); }); refreshBtn.addEventListener("click", () => { loadClients().catch((error) => { console.error(error); showMessage("Failed to refresh clients", "error"); }); }); prevBtn.addEventListener("click", () => { if (state.page <= 1) return; state.page -= 1; loadClients().catch((error) => { console.error(error); showMessage("Failed to load previous page", "error"); }); }); nextBtn.addEventListener("click", () => { const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize)); if (state.page >= totalPages) return; state.page += 1; loadClients().catch((error) => { console.error(error); showMessage("Failed to load next page", "error"); }); }); async function init() { if (!targetUserId || Number.isNaN(targetUserId)) { window.location.href = "/users"; return; } try { await loadCurrentUser(); await loadTargetUser(); await loadPolicy(); await loadClients(); } catch (error) { console.error(error); showMessage("Failed to initialize access manager", "error"); } } init();