const body = document.getElementById("enrollment-body"); const loadingRow = document.getElementById("enrollment-loading"); const emptyEl = document.getElementById("enrollment-empty"); const selectAllEl = document.getElementById("select-all"); const bulkApproveBtn = document.getElementById("bulk-approve-btn"); const bulkDenyBtn = document.getElementById("bulk-deny-btn"); const statPending = document.getElementById("stat-pending"); const statApproved = document.getElementById("stat-approved"); const statDenied = document.getElementById("stat-denied"); const statBannedIps = document.getElementById("stat-banned-ips"); const searchInput = document.getElementById("search-input"); const bannedIpsSection = document.getElementById("banned-ips-section"); const bannedIpsBody = document.getElementById("banned-ips-body"); const bannedIpsEmpty = document.getElementById("banned-ips-empty"); const clientsTable = document.getElementById("enrollment-table")?.closest(".bg-slate-900\\/50"); const addBanBtn = document.getElementById("add-ban-btn"); const manualBanForm = document.getElementById("manual-ban-form"); const banIpInput = document.getElementById("ban-ip-input"); const banReasonInput = document.getElementById("ban-reason-input"); const confirmBanBtn = document.getElementById("confirm-ban-btn"); const cancelBanBtn = document.getElementById("cancel-ban-btn"); const bulkBanBtn = document.getElementById("bulk-ban-btn"); const banModal = document.getElementById("ban-confirm-modal"); const banModalBody = document.getElementById("ban-modal-body"); const banModalConfirm = document.getElementById("ban-modal-confirm"); const banModalCancel = document.getElementById("ban-modal-cancel"); const banModalBackdrop = document.getElementById("ban-modal-backdrop"); const autoAcceptToggle = document.getElementById("auto-accept-toggle"); const autoAcceptModal = document.getElementById("auto-accept-modal"); const autoAcceptModalConfirm = document.getElementById("auto-accept-modal-confirm"); const autoAcceptModalCancel = document.getElementById("auto-accept-modal-cancel"); const autoAcceptModalBackdrop = document.getElementById("auto-accept-modal-backdrop"); const approveAllBtn = document.getElementById("approve-all-btn"); const denyAllBtn = document.getElementById("deny-all-btn"); const unlessSuspiciousToggle = document.getElementById("unless-suspicious-toggle"); const unlessSuspiciousRow = document.getElementById("unless-suspicious-row"); const statSuspicious = document.getElementById("stat-suspicious"); const denyReasonModal = document.getElementById("deny-reason-modal"); const denyReasonInput = document.getElementById("deny-reason-input"); const denyReasonModalConfirm = document.getElementById("deny-reason-modal-confirm"); const denyReasonModalCancel = document.getElementById("deny-reason-modal-cancel"); const denyReasonModalBackdrop = document.getElementById("deny-reason-modal-backdrop"); let currentFilter = "pending"; let searchQuery = ""; let clients = []; const expandedCells = new Set(); // tracks "clientId:field" keys const SUSPICIOUS_FLAG_LABELS = { hwid_flood: "HWID Flood (40+ same hardware ID)", hw_flood: "Hardware Flood (40+ identical specs)", no_hostname: "No Hostname", no_user: "No Username", ip_flood: "IP Flood (40+ from same IP recently)", vm_hardware: "VM Detected (CPU/GPU indicates virtual machine)", vm_ram: "VM Likely (≤4 GB round RAM)", no_monitors: "No Monitors (headless/VM)", }; // ── API helpers ──────────────────────────────────────────────────── async function api(path, opts = {}) { const res = await fetch(path, { credentials: "include", ...opts }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); } async function loadSettings() { try { const s = await api("/api/enrollment/settings"); autoAcceptToggle.checked = !s.requireApproval; unlessSuspiciousToggle.checked = !!s.autoApproveUnlessSuspicious; unlessSuspiciousRow.classList.toggle("hidden", !autoAcceptToggle.checked); } catch {} } async function loadStats() { try { const s = await api("/api/enrollment/stats"); statPending.textContent = s.pending ?? 0; statApproved.textContent = s.approved ?? 0; statDenied.textContent = s.denied ?? 0; // Load banned IPs count try { const b = await api("/api/enrollment/banned-ips"); statBannedIps.textContent = (b.items || []).length; } catch { statBannedIps.textContent = 0; } // Update nav badge const badge = document.getElementById("enrollment-badge"); if (badge) { if (s.pending > 0) { badge.textContent = s.pending; badge.classList.remove("hidden"); } else { badge.classList.add("hidden"); } } } catch {} } async function loadClients() { loadingRow.classList.remove("hidden"); emptyEl.classList.add("hidden"); body.querySelectorAll("tr:not(#enrollment-loading)").forEach((r) => r.remove()); const fetchFilter = currentFilter === "suspicious" ? "all" : currentFilter; try { const data = await api(`/api/clients?page=1&pageSize=1000&enrollmentFilter=${fetchFilter}`); clients = data.items || []; } catch { clients = []; } // Sort by newest first (highest lastSeen = most recent) clients.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0)); // Update suspicious stat from full loaded set const suspiciousCount = clients.filter((c) => (c.suspiciousFlags || []).length > 0).length; if (statSuspicious) statSuspicious.textContent = suspiciousCount; // Apply suspicious tab filter let base = clients; if (currentFilter === "suspicious") { base = clients.filter((c) => (c.suspiciousFlags || []).length > 0); } // Apply search filter const q = searchQuery.toLowerCase().trim(); const filtered = q ? base.filter((c) => { const fields = [c.host, c.user, c.ip, c.id, c.os, c.country, c.keyFingerprint, c.cpu, c.gpu, c.ram]; return fields.some((f) => f && String(f).toLowerCase().includes(q)); }) : base; loadingRow.classList.add("hidden"); const showBulkActions = (currentFilter === "pending" || currentFilter === "suspicious") && filtered.some((c) => (c.enrollmentStatus || "pending") === "pending"); approveAllBtn.classList.toggle("hidden", !showBulkActions); denyAllBtn.classList.toggle("hidden", !showBulkActions); if (filtered.length === 0) { emptyEl.classList.remove("hidden"); return; } for (const c of filtered) { const tr = document.createElement("tr"); tr.className = "hover:bg-slate-800/40 transition-colors"; tr.dataset.id = c.id; const statusPill = statusBadgeWithReason(c.enrollmentStatus || "pending", c.denyReason); const fp = c.keyFingerprint ? c.keyFingerprint.substring(0, 16) + "..." : "-"; const lastSeen = c.lastSeen ? timeAgo(c.lastSeen) : "-"; tr.innerHTML = ` ${esc(c.host || c.id)}${suspiciousBadges(c.suspiciousFlags)} ${esc(c.user || "-")} ${esc(c.os || "-")} ${expandableCell(c.id, "cpu", c.cpu)} ${c.gpu ? dedupeGpu(c.gpu) : esc("-")} ${esc(c.ram || "-")} ${esc(c.ip || "-")} ${esc(c.country || "-")} ${esc(fp)} ${lastSeen} ${statusPill}
${actionButtons(c)}
`; body.appendChild(tr); } updateBulkButtons(); } function statusBadge(status) { const map = { pending: 'Pending', approved: 'Approved', denied: 'Denied', }; return map[status] || map.pending; } function statusBadgeWithReason(status, denyReason) { const pill = statusBadge(status); if (status === "denied" && denyReason) { return `
${pill}
${esc(denyReason)}
`; } return pill; } function suspiciousBadges(flags) { if (!flags || flags.length === 0) return ""; return flags.map((f) => { const label = SUSPICIOUS_FLAG_LABELS[f] || f; const isFlood = f.endsWith("_flood"); const color = isFlood ? "bg-red-500/20 text-red-300 border-red-500/40" : "bg-amber-500/20 text-amber-300 border-amber-500/40"; return `${esc(label.split(" ")[0])}`; }).join(" "); } function actionButtons(c) { const status = c.enrollmentStatus || "pending"; let html = ""; if (status !== "approved") { html += ``; } if (status !== "denied") { html += ``; } if (status !== "pending") { html += ``; } if (c.ip) { html += ``; } return html; } function esc(s) { const d = document.createElement("div"); d.textContent = String(s ?? ""); return d.innerHTML; } function dedupeGpu(raw) { if (!raw) return null; const counts = new Map(); raw.split(",").map(s => s.trim()).filter(Boolean).forEach(g => counts.set(g, (counts.get(g) || 0) + 1)); return [...counts.entries()].map(([name, n]) => n > 1 ? `${esc(name)} ×${n}` : esc(name)).join(", "); } function expandableCell(clientId, field, value) { const text = value || "-"; if (text === "-" || text.length <= 24) { return `${esc(text)}`; } const key = `${clientId}:${field}`; const isOpen = expandedCells.has(key); const short = text.substring(0, 22) + "…"; return `` + `${esc(short)}` + `${esc(text)}` + `` + ``; } function timeAgo(ts) { const diff = Date.now() - ts; if (diff < 60000) return "just now"; if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; return `${Math.floor(diff / 86400000)}d ago`; } // ── Event handlers ───────────────────────────────────────────────── async function setStatus(clientId, action) { try { await api(`/api/enrollment/${encodeURIComponent(clientId)}/${action}`, { method: "POST" }); if (window.showToast) window.showToast(`Client ${action}`, "success"); } catch (e) { if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); } await Promise.all([loadClients(), loadStats()]); } async function denyClient(clientId, reason) { try { await api(`/api/enrollment/${encodeURIComponent(clientId)}/deny`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason: reason || undefined }), }); if (window.showToast) window.showToast("Client denied", "success"); } catch (e) { if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); } await Promise.all([loadClients(), loadStats()]); } async function bulkAction(action) { const ids = getSelectedIds(); if (ids.length === 0) return; try { await api("/api/enrollment/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids, action }), }); if (window.showToast) window.showToast(`${ids.length} clients ${action}`, "success"); } catch (e) { if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); } selectAllEl.checked = false; await Promise.all([loadClients(), loadStats()]); } function getSelectedIds() { return [...document.querySelectorAll(".row-check:checked")].map( (el) => el.dataset.id, ); } function updateBulkButtons() { const count = getSelectedIds().length; bulkApproveBtn.classList.toggle("hidden", count === 0); bulkDenyBtn.classList.toggle("hidden", count === 0); bulkBanBtn.classList.toggle("hidden", count === 0); } // ── Tab switching ────────────────────────────────────────────────── document.querySelectorAll(".enrollment-tab").forEach((tab) => { tab.addEventListener("click", () => { currentFilter = tab.dataset.filter; document.querySelectorAll(".enrollment-tab").forEach((t) => { t.className = "enrollment-tab px-4 py-2 rounded-lg text-sm font-medium bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700"; }); const colorMap = { pending: "bg-amber-500/20 text-amber-300 border-amber-500/40", denied: "bg-red-500/20 text-red-300 border-red-500/40", approved: "bg-emerald-500/20 text-emerald-300 border-emerald-500/40", "banned-ips": "bg-rose-500/20 text-rose-300 border-rose-500/40", suspicious: "bg-orange-500/20 text-orange-300 border-orange-500/40", }; tab.className = `enrollment-tab px-4 py-2 rounded-lg text-sm font-medium ${colorMap[currentFilter] || ""} border`; if (currentFilter === "banned-ips") { if (clientsTable) clientsTable.classList.add("hidden"); emptyEl.classList.add("hidden"); bannedIpsSection.classList.remove("hidden"); approveAllBtn.classList.add("hidden"); denyAllBtn.classList.add("hidden"); loadBannedIps(); } else { bannedIpsSection.classList.add("hidden"); if (clientsTable) clientsTable.classList.remove("hidden"); loadClients(); } }); }); // ── Table delegation ─────────────────────────────────────────────── body.addEventListener("click", (e) => { // Expandable CPU/GPU cells const expander = e.target.closest(".hw-expand"); if (expander) { const isOpen = expander.dataset.expanded === "1"; const key = expander.dataset.expandKey; expander.dataset.expanded = isOpen ? "0" : "1"; if (isOpen) expandedCells.delete(key); else expandedCells.add(key); expander.querySelector(".hw-short").classList.toggle("hidden", !isOpen); expander.querySelector(".hw-full").classList.toggle("hidden", isOpen); const chevron = expander.querySelector(".hw-chevron"); if (chevron) chevron.style.transform = isOpen ? "" : "rotate(180deg)"; return; } const btn = e.target.closest("button"); if (!btn) return; const id = btn.dataset.id; if (!id) return; if (btn.classList.contains("act-approve")) setStatus(id, "approve"); else if (btn.classList.contains("act-deny")) showDenyReasonModal(id); else if (btn.classList.contains("act-reset")) setStatus(id, "reset"); else if (btn.classList.contains("act-ban-ip")) banClientIp(id); }); body.addEventListener("change", () => updateBulkButtons()); selectAllEl.addEventListener("change", () => { document.querySelectorAll(".row-check").forEach((cb) => { cb.checked = selectAllEl.checked; }); updateBulkButtons(); }); bulkApproveBtn.addEventListener("click", () => bulkAction("approve")); bulkDenyBtn.addEventListener("click", () => bulkAction("deny")); bulkBanBtn.addEventListener("click", () => { const ids = getSelectedIds(); if (ids.length === 0) return; // Find IP info for selected clients const selected = clients.filter((c) => ids.includes(c.id) && c.ip); const ips = [...new Set(selected.map((c) => c.ip))]; showBanModal( `You are about to ban ${ips.length} IP address${ips.length !== 1 ? "es" : ""} affecting ${ids.length} client${ids.length !== 1 ? "s" : ""}. Banned IPs will be blocked from all future connections.`, ips, async () => { await bulkAction("ban-ip"); }, ); }); // ── Search ───────────────────────────────────────────────────────── let searchDebounce; searchInput.addEventListener("input", () => { clearTimeout(searchDebounce); searchDebounce = setTimeout(() => { searchQuery = searchInput.value; loadClients(); }, 250); }); // ── Ban confirmation modal ────────────────────────────────────────── let banModalResolve = null; function showBanModal(message, ips, onConfirm) { let html = `

${message}

`; if (ips && ips.length > 0 && ips.length <= 10) { html += `
`; for (const ip of ips) html += `
${esc(ip)}
`; html += `
`; } banModalBody.innerHTML = html; banModal.classList.remove("hidden"); banModalResolve = onConfirm; } function closeBanModal() { banModal.classList.add("hidden"); banModalResolve = null; } banModalConfirm.addEventListener("click", async () => { const fn = banModalResolve; closeBanModal(); if (fn) await fn(); }); banModalCancel.addEventListener("click", closeBanModal); banModalBackdrop.addEventListener("click", closeBanModal); // ── Banned IPs ───────────────────────────────────────────────────── async function banClientIp(clientId) { const client = clients.find((c) => c.id === clientId); const ipText = client?.ip ? ` (${client.ip})` : ""; showBanModal( `You are about to ban the IP address of client ${esc(client?.host || clientId)}${esc(ipText)}. This will block all connections from this IP.`, client?.ip ? [client.ip] : [], async () => { try { await api(`/api/enrollment/${encodeURIComponent(clientId)}/ban-ip`, { method: "POST" }); if (window.showToast) window.showToast("IP banned", "success"); } catch (e) { if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); } await Promise.all([loadClients(), loadStats()]); }, ); } async function loadBannedIps() { bannedIpsBody.innerHTML = ""; bannedIpsEmpty.classList.add("hidden"); try { const data = await api("/api/enrollment/banned-ips"); const items = data.items || []; if (items.length === 0) { bannedIpsEmpty.classList.remove("hidden"); return; } for (const entry of items) { const tr = document.createElement("tr"); tr.className = "hover:bg-slate-800/40 transition-colors"; tr.innerHTML = ` ${esc(entry.ip)} ${esc(entry.reason || "-")} ${entry.createdAt ? timeAgo(entry.createdAt) : "-"} `; bannedIpsBody.appendChild(tr); } } catch { bannedIpsEmpty.classList.remove("hidden"); } } bannedIpsBody.addEventListener("click", async (e) => { const btn = e.target.closest("button"); if (!btn) return; if (btn.classList.contains("act-unban")) { const ip = btn.dataset.ip; if (!ip) return; try { await api(`/api/enrollment/banned-ips?ip=${encodeURIComponent(ip)}`, { method: "DELETE" }); if (window.showToast) window.showToast(`Unbanned ${ip}`, "success"); } catch (e) { if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); } await Promise.all([loadBannedIps(), loadStats()]); } }); // ── Manual IP ban form ───────────────────────────────────────────── addBanBtn.addEventListener("click", () => { manualBanForm.classList.toggle("hidden"); banIpInput.value = ""; banReasonInput.value = ""; if (!manualBanForm.classList.contains("hidden")) banIpInput.focus(); }); cancelBanBtn.addEventListener("click", () => { manualBanForm.classList.add("hidden"); }); confirmBanBtn.addEventListener("click", async () => { const ip = banIpInput.value.trim(); if (!ip) { banIpInput.focus(); return; } try { await api("/api/enrollment/ban-ip", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ip, reason: banReasonInput.value.trim() || undefined }), }); if (window.showToast) window.showToast(`Banned ${ip}`, "success"); manualBanForm.classList.add("hidden"); } catch (e) { if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); } await Promise.all([loadBannedIps(), loadStats()]); }); // ── Always Allow toggle ──────────────────────────────────────────── autoAcceptToggle.addEventListener("change", async () => { const wantsAlwaysAllow = autoAcceptToggle.checked; if (wantsAlwaysAllow) { autoAcceptToggle.checked = false; // revert until confirmed autoAcceptModal.classList.remove("hidden"); } else { try { await api("/api/enrollment/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requireApproval: true }), }); unlessSuspiciousRow.classList.add("hidden"); if (window.showToast) window.showToast("Approval required — purgatory is active", "success"); } catch (e) { autoAcceptToggle.checked = true; // revert on error if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); } } }); function closeAutoAcceptModal() { autoAcceptModal.classList.add("hidden"); } autoAcceptModalCancel.addEventListener("click", closeAutoAcceptModal); autoAcceptModalBackdrop.addEventListener("click", closeAutoAcceptModal); autoAcceptModalConfirm.addEventListener("click", async () => { closeAutoAcceptModal(); try { await api("/api/enrollment/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requireApproval: false }), }); autoAcceptToggle.checked = true; unlessSuspiciousRow.classList.remove("hidden"); if (window.showToast) window.showToast("Always Allow enabled — agents auto-approved on connect", "success"); } catch (e) { if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); } }); // ── Unless Suspicious sub-toggle ────────────────────────────────── unlessSuspiciousToggle.addEventListener("change", async () => { try { await api("/api/enrollment/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ autoApproveUnlessSuspicious: unlessSuspiciousToggle.checked }), }); const msg = unlessSuspiciousToggle.checked ? "Suspicious agents will be held for review" : "All agents auto-approved regardless of flags"; if (window.showToast) window.showToast(msg, "success"); } catch (e) { unlessSuspiciousToggle.checked = !unlessSuspiciousToggle.checked; // revert if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); } }); // ── Approve All / Deny All ──────────────────────────────────────── function getActionablePendingIds() { const base = currentFilter === "suspicious" ? clients.filter((c) => (c.suspiciousFlags || []).length > 0) : clients; return base.filter((c) => (c.enrollmentStatus || "pending") === "pending").map((c) => c.id); } approveAllBtn.addEventListener("click", async () => { const ids = getActionablePendingIds(); if (ids.length === 0) return; try { await api("/api/enrollment/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids, action: "approve" }), }); if (window.showToast) window.showToast(`Approved ${ids.length} client${ids.length !== 1 ? "s" : ""}`, "success"); } catch (e) { if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); } await Promise.all([loadClients(), loadStats()]); }); denyAllBtn.addEventListener("click", async () => { const ids = getActionablePendingIds(); if (ids.length === 0) return; try { await api("/api/enrollment/bulk", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids, action: "deny" }), }); if (window.showToast) window.showToast(`Denied ${ids.length} client${ids.length !== 1 ? "s" : ""}`, "success"); } catch (e) { if (window.showToast) window.showToast(`Failed: ${e.message}`, "error"); } await Promise.all([loadClients(), loadStats()]); }); // ── Deny Reason Modal ────────────────────────────────────────────── let _denyTargetId = null; function showDenyReasonModal(clientId) { _denyTargetId = clientId; denyReasonInput.value = ""; denyReasonModal.classList.remove("hidden"); setTimeout(() => denyReasonInput.focus(), 50); } function closeDenyReasonModal() { denyReasonModal.classList.add("hidden"); _denyTargetId = null; } denyReasonModalCancel.addEventListener("click", closeDenyReasonModal); denyReasonModalBackdrop.addEventListener("click", closeDenyReasonModal); denyReasonInput.addEventListener("keydown", (e) => { if (e.key === "Enter") denyReasonModalConfirm.click(); if (e.key === "Escape") closeDenyReasonModal(); }); denyReasonModalConfirm.addEventListener("click", async () => { const id = _denyTargetId; const reason = denyReasonInput.value.trim() || undefined; closeDenyReasonModal(); if (!id) return; await denyClient(id, reason); }); // ── Init ─────────────────────────────────────────────────────────── loadSettings(); loadStats(); loadClients(); // Auto-refresh every 15 seconds setInterval(() => { loadStats(); loadClients(); }, 15000);