1.5.0 update

This commit is contained in:
KingKDot
2026-03-20 22:38:17 -05:00
parent 265321dce2
commit e8ad1e3649
30 changed files with 2259 additions and 79 deletions
@@ -8,6 +8,7 @@ export function applyUserRoleUI(user, refs) {
scriptsLink,
logsLink,
notificationsLink,
enrollmentLink,
} = refs;
if (!user || !usernameDisplay || !roleBadge) return;
@@ -70,6 +71,7 @@ export function applyUserRoleUI(user, refs) {
if (user.role === "admin" || user.role === "operator") {
buildLink?.classList.remove("hidden");
notificationsLink?.classList.remove("hidden");
enrollmentLink?.classList.remove("hidden");
}
if (user.role !== "viewer") {
scriptsLink?.classList.remove("hidden");
@@ -77,6 +77,12 @@ export function mountNav(host) {
class="hidden inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 hover:bg-slate-800 text-slate-300 transition-colors"
><i class="fa-solid fa-users text-indigo-400"></i> Users</a
>
<a
href="/purgatory"
id="enrollment-link"
class="hidden inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 hover:bg-slate-800 text-slate-300 transition-colors"
><i class="fa-solid fa-user-clock text-amber-400"></i> Purgatory<span id="enrollment-badge" class="hidden min-w-[20px] h-5 px-1 rounded-full bg-amber-500 text-white text-xs flex items-center justify-center"></span></a
>
</nav>
<div id="nav-utility" class="flex flex-wrap items-center gap-2 md:w-auto md:justify-end md:shrink-0">
<button
@@ -135,5 +141,7 @@ export function mountNav(host) {
scriptsLink: document.getElementById("scripts-link"),
logsLink: document.getElementById("logs-link"),
notificationsLink: document.getElementById("notifications-link"),
enrollmentLink: document.getElementById("enrollment-link"),
enrollmentBadge: document.getElementById("enrollment-badge"),
};
}
@@ -0,0 +1,445 @@
const body = document.getElementById("enrollment-body");
const loadingRow = document.getElementById("enrollment-loading");
const emptyEl = document.getElementById("enrollment-empty");
const toggleEl = document.getElementById("require-approval-toggle");
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");
let currentFilter = "pending";
let searchQuery = "";
let clients = [];
// ── 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 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 loadSettings() {
try {
const s = await api("/api/enrollment/settings");
toggleEl.checked = s.requireApproval;
} catch {}
}
async function loadClients() {
loadingRow.classList.remove("hidden");
emptyEl.classList.add("hidden");
body.querySelectorAll("tr:not(#enrollment-loading)").forEach((r) => r.remove());
try {
const data = await api(`/api/clients?page=1&pageSize=500&enrollmentFilter=${currentFilter}`);
clients = data.items || [];
} catch {
clients = [];
}
// Sort by newest first (highest lastSeen = most recent)
clients.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0));
// Apply search filter
const q = searchQuery.toLowerCase().trim();
const filtered = q
? clients.filter((c) => {
const fields = [c.host, c.user, c.ip, c.id, c.os, c.country, c.keyFingerprint];
return fields.some((f) => f && String(f).toLowerCase().includes(q));
})
: clients;
loadingRow.classList.add("hidden");
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 = statusBadge(c.enrollmentStatus || "pending");
const fp = c.keyFingerprint ? c.keyFingerprint.substring(0, 16) + "..." : "-";
const lastSeen = c.lastSeen ? timeAgo(c.lastSeen) : "-";
tr.innerHTML = `
<td class="px-4 py-3"><input type="checkbox" class="row-check h-4 w-4 rounded border-slate-600" data-id="${esc(c.id)}" /></td>
<td class="px-4 py-3 text-sm font-medium text-slate-200">${esc(c.host || c.id)}</td>
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.user || "-")}</td>
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.os || "-")}</td>
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.ip || "-")}</td>
<td class="px-4 py-3 text-sm text-slate-400">${esc(c.country || "-")}</td>
<td class="px-4 py-3 text-sm text-slate-500 font-mono">${esc(fp)}</td>
<td class="px-4 py-3 text-sm text-slate-500">${lastSeen}</td>
<td class="px-4 py-3">${statusPill}</td>
<td class="px-4 py-3 text-right space-x-2">${actionButtons(c)}</td>
`;
body.appendChild(tr);
}
updateBulkButtons();
}
function statusBadge(status) {
const map = {
pending:
'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/20 text-amber-300 border border-amber-500/40"><i class="fa-solid fa-clock"></i>Pending</span>',
approved:
'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-500/20 text-emerald-300 border border-emerald-500/40"><i class="fa-solid fa-check"></i>Approved</span>',
denied:
'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/20 text-red-300 border border-red-500/40"><i class="fa-solid fa-ban"></i>Denied</span>',
};
return map[status] || map.pending;
}
function actionButtons(c) {
const status = c.enrollmentStatus || "pending";
let html = "";
if (status !== "approved") {
html += `<button class="act-approve px-2 py-1 rounded text-xs font-medium bg-emerald-600 hover:bg-emerald-700 text-white" data-id="${esc(c.id)}"><i class="fa-solid fa-check mr-1"></i>Approve</button>`;
}
if (status !== "denied") {
html += `<button class="act-deny px-2 py-1 rounded text-xs font-medium bg-red-600 hover:bg-red-700 text-white" data-id="${esc(c.id)}"><i class="fa-solid fa-ban mr-1"></i>Deny</button>`;
}
if (status !== "pending") {
html += `<button class="act-reset px-2 py-1 rounded text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white" data-id="${esc(c.id)}"><i class="fa-solid fa-rotate-left mr-1"></i>Reset</button>`;
}
if (c.ip) {
html += `<button class="act-ban-ip px-2 py-1 rounded text-xs font-medium bg-rose-700 hover:bg-rose-800 text-white" data-id="${esc(c.id)}" title="Ban IP ${esc(c.ip)}"><i class="fa-solid fa-shield-halved mr-1"></i>Ban IP</button>`;
}
return html;
}
function esc(s) {
const d = document.createElement("div");
d.textContent = String(s ?? "");
return d.innerHTML;
}
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 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",
};
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");
loadBannedIps();
} else {
bannedIpsSection.classList.add("hidden");
if (clientsTable) clientsTable.classList.remove("hidden");
loadClients();
}
});
});
// ── Toggle require approval ────────────────────────────────────────
toggleEl.addEventListener("change", async () => {
try {
await api("/api/enrollment/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requireApproval: toggleEl.checked }),
});
if (window.showToast)
window.showToast(
toggleEl.checked ? "Approval required" : "Auto-approve enabled",
"success",
);
} catch (e) {
toggleEl.checked = !toggleEl.checked;
if (window.showToast) window.showToast(`Failed: ${e.message}`, "error");
}
});
// ── Table delegation ───────────────────────────────────────────────
body.addEventListener("click", (e) => {
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")) setStatus(id, "deny");
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 <strong>ban ${ips.length} IP address${ips.length !== 1 ? "es" : ""}</strong> affecting <strong>${ids.length} client${ids.length !== 1 ? "s" : ""}</strong>. 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 = `<p class="mb-3">${message}</p>`;
if (ips && ips.length > 0 && ips.length <= 10) {
html += `<div class="bg-slate-800/60 border border-slate-700 rounded-lg p-3 font-mono text-xs text-rose-300 space-y-1">`;
for (const ip of ips) html += `<div>${esc(ip)}</div>`;
html += `</div>`;
}
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 <strong>ban the IP address</strong> of client <strong>${esc(client?.host || clientId)}</strong>${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 = `
<td class="px-4 py-3 text-sm font-mono text-slate-200">${esc(entry.ip)}</td>
<td class="px-4 py-3 text-sm text-slate-400">${esc(entry.reason || "-")}</td>
<td class="px-4 py-3 text-sm text-slate-500">${entry.createdAt ? timeAgo(entry.createdAt) : "-"}</td>
<td class="px-4 py-3 text-right">
<button class="act-unban px-2 py-1 rounded text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white" data-ip="${esc(entry.ip)}">
<i class="fa-solid fa-unlock mr-1"></i>Unban
</button>
</td>
`;
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()]);
});
// ── Init ───────────────────────────────────────────────────────────
loadSettings();
loadStats();
loadClients();
// Auto-refresh every 15 seconds
setInterval(() => {
loadStats();
loadClients();
}, 15000);
+218
View File
@@ -0,0 +1,218 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Purgatory - Overlord</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="stylesheet" href="/assets/tailwind.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="stylesheet" href="/assets/main.css" />
<script src="/assets/toast.js"></script>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<header id="top-nav"></header>
<div class="min-h-full flex flex-col">
<main class="flex-1 max-w-7xl w-full mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 class="text-2xl font-bold text-slate-100 mb-1">
<i class="fa-solid fa-user-clock text-amber-400 mr-2"></i>Purgatory
</h2>
<p class="text-slate-400 text-sm">
Approve or deny clients before they can connect to the server.
</p>
</div>
<div class="flex items-center gap-4">
<div id="stats-bar" class="flex items-center gap-3 text-sm">
<span class="text-amber-400"><i class="fa-solid fa-clock mr-1"></i><span id="stat-pending">0</span> pending</span>
<span class="text-emerald-400"><i class="fa-solid fa-check mr-1"></i><span id="stat-approved">0</span> approved</span>
<span class="text-red-400"><i class="fa-solid fa-ban mr-1"></i><span id="stat-denied">0</span> denied</span>
<span class="text-rose-400"><i class="fa-solid fa-shield-halved mr-1"></i><span id="stat-banned-ips">0</span> banned IPs</span>
</div>
</div>
</div>
<!-- Settings Card -->
<div class="mb-6 bg-slate-900/60 border border-slate-800 rounded-xl p-5">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-slate-100">Approval Mode</h3>
<p class="text-sm text-slate-400">
When enabled, new clients must be approved before connecting.
When disabled, clients auto-approve on first connection.
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="require-approval-toggle" class="sr-only peer" />
<div class="w-11 h-6 bg-slate-700 peer-focus:ring-2 peer-focus:ring-sky-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-500"></div>
</label>
</div>
</div>
<!-- Filter Tabs -->
<div class="mb-4 flex items-center gap-2 flex-wrap">
<button data-filter="pending"
class="enrollment-tab px-4 py-2 rounded-lg text-sm font-medium bg-amber-500/20 text-amber-300 border border-amber-500/40">
Pending
</button>
<button data-filter="denied"
class="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">
Denied
</button>
<button data-filter="approved"
class="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">
Approved
</button>
<button data-filter="banned-ips"
class="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">
<i class="fa-solid fa-shield-halved mr-1"></i>Banned IPs
</button>
<div class="flex-1"></div>
<div class="relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none"></i>
<input type="text" id="search-input" placeholder="Search host, user, IP..."
class="w-56 pl-9 pr-3 py-2 rounded-lg text-sm bg-slate-800 border border-slate-700 text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent" />
</div>
<button id="bulk-approve-btn"
class="hidden px-3 py-2 rounded-lg text-sm font-medium bg-emerald-600 hover:bg-emerald-700 text-white transition-colors">
<i class="fa-solid fa-check mr-1"></i>Approve Selected
</button>
<button id="bulk-deny-btn"
class="hidden px-3 py-2 rounded-lg text-sm font-medium bg-red-600 hover:bg-red-700 text-white transition-colors">
<i class="fa-solid fa-ban mr-1"></i>Deny Selected
</button>
<button id="bulk-ban-btn"
class="hidden px-3 py-2 rounded-lg text-sm font-medium bg-rose-700 hover:bg-rose-800 text-white transition-colors">
<i class="fa-solid fa-shield-halved mr-1"></i>Ban Selected IPs
</button>
</div>
<!-- Clients Table -->
<div class="bg-slate-900/50 border border-slate-800 rounded-xl overflow-hidden">
<table class="w-full" id="enrollment-table">
<thead class="bg-slate-800/50 border-b border-slate-700">
<tr>
<th class="w-10 px-4 py-3">
<input type="checkbox" id="select-all" class="h-4 w-4 rounded border-slate-600" />
</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Host</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">User</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">OS</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">IP</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Country</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Key Fingerprint</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Last Seen</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Status</th>
<th class="text-right px-4 py-3 text-sm font-semibold text-slate-300">Actions</th>
</tr>
</thead>
<tbody id="enrollment-body" class="divide-y divide-slate-800">
<tr id="enrollment-loading">
<td colspan="10" class="px-6 py-12 text-center text-slate-500">
<i class="fa-solid fa-spinner fa-spin mr-2"></i>Loading...
</td>
</tr>
</tbody>
</table>
<div id="enrollment-empty" class="hidden px-6 py-12 text-center text-slate-500">
<i class="fa-solid fa-inbox text-3xl mb-3 block"></i>
<p>No clients in this category.</p>
</div>
</div>
<!-- Banned IPs Table (hidden by default, shown on Banned IPs tab) -->
<div id="banned-ips-section" class="hidden">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-100">
<i class="fa-solid fa-shield-halved text-rose-400 mr-2"></i>Banned IPs
</h3>
<button id="add-ban-btn"
class="px-3 py-2 rounded-lg text-sm font-medium bg-red-600 hover:bg-red-700 text-white transition-colors">
<i class="fa-solid fa-plus mr-1"></i>Ban IP
</button>
</div>
<!-- Manual ban form (hidden by default) -->
<div id="manual-ban-form" class="hidden mb-4 bg-slate-900/60 border border-slate-800 rounded-xl p-4">
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-sm font-medium text-slate-300 mb-1">IP Address</label>
<input type="text" id="ban-ip-input" placeholder="e.g. 192.168.1.100"
class="w-full px-3 py-2 rounded-lg text-sm bg-slate-800 border border-slate-700 text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent" />
</div>
<div class="flex-1">
<label class="block text-sm font-medium text-slate-300 mb-1">Reason (optional)</label>
<input type="text" id="ban-reason-input" placeholder="Reason for ban"
class="w-full px-3 py-2 rounded-lg text-sm bg-slate-800 border border-slate-700 text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent" />
</div>
<button id="confirm-ban-btn"
class="px-4 py-2 rounded-lg text-sm font-medium bg-red-600 hover:bg-red-700 text-white transition-colors">
Ban
</button>
<button id="cancel-ban-btn"
class="px-4 py-2 rounded-lg text-sm font-medium bg-slate-700 hover:bg-slate-600 text-white transition-colors">
Cancel
</button>
</div>
</div>
<div class="bg-slate-900/50 border border-slate-800 rounded-xl overflow-hidden">
<table class="w-full">
<thead class="bg-slate-800/50 border-b border-slate-700">
<tr>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">IP Address</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Reason</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-slate-300">Banned At</th>
<th class="text-right px-4 py-3 text-sm font-semibold text-slate-300">Actions</th>
</tr>
</thead>
<tbody id="banned-ips-body" class="divide-y divide-slate-800"></tbody>
</table>
<div id="banned-ips-empty" class="hidden px-6 py-12 text-center text-slate-500">
<i class="fa-solid fa-shield-halved text-3xl mb-3 block"></i>
<p>No banned IPs.</p>
</div>
</div>
</div>
</main>
</div>
<!-- Ban Confirmation Modal -->
<div id="ban-confirm-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" id="ban-modal-backdrop"></div>
<div class="relative bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl max-w-md w-full mx-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-rose-500/20 flex items-center justify-center">
<i class="fa-solid fa-shield-halved text-rose-400 text-xl"></i>
</div>
<div>
<h3 class="text-lg font-bold text-slate-100">Confirm IP Ban</h3>
<p class="text-sm text-slate-400">This action cannot be easily undone.</p>
</div>
</div>
<div id="ban-modal-body" class="mb-5 text-sm text-slate-300">
<!-- Filled dynamically -->
</div>
<div class="flex items-center justify-end gap-3">
<button id="ban-modal-cancel"
class="px-4 py-2 rounded-lg text-sm font-medium bg-slate-700 hover:bg-slate-600 text-white transition-colors">
Cancel
</button>
<button id="ban-modal-confirm"
class="px-4 py-2 rounded-lg text-sm font-medium bg-rose-600 hover:bg-rose-700 text-white transition-colors">
<i class="fa-solid fa-shield-halved mr-1"></i>Ban
</button>
</div>
</div>
</div>
<script type="module" src="/assets/nav.js"></script>
<script type="module" src="/assets/purgatory-ui.js"></script>
</body>
</html>