2026-03-20 22:38:17 -05:00
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" );
let currentFilter = "pending" ;
let searchQuery = "" ;
let clients = [];
2026-04-01 17:27:31 -05:00
const expandedCells = new Set (); // tracks "clientId:field" keys
2026-03-20 22:38:17 -05:00
// ── 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 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 ) => {
2026-04-01 17:27:31 -05:00
const fields = [ c . host , c . user , c . ip , c . id , c . os , c . country , c . keyFingerprint , c . cpu , c . gpu , c . ram ];
2026-03-20 22:38:17 -05:00
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>
2026-04-01 17:27:31 -05:00
<td class="px-4 py-3 text-sm text-slate-400"> ${ expandableCell ( c . id , "cpu" , c . cpu ) } </td>
<td class="px-4 py-3 text-sm text-slate-400"> ${ expandableCell ( c . id , "gpu" , c . gpu ) } </td>
<td class="px-4 py-3 text-sm text-slate-400"> ${ esc ( c . ram || "-" ) } </td>
2026-03-20 22:38:17 -05:00
<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>
2026-04-01 17:27:31 -05:00
<td class="px-4 py-3"><div class="flex items-center justify-end gap-2 flex-nowrap"> ${ actionButtons ( c ) } </div></td>
2026-03-20 22:38:17 -05:00
` ;
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" ) {
2026-04-01 17:27:31 -05:00
html += `<button class="act-approve whitespace-nowrap 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>` ;
2026-03-20 22:38:17 -05:00
}
if ( status !== "denied" ) {
2026-04-01 17:27:31 -05:00
html += `<button class="act-deny whitespace-nowrap 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>` ;
2026-03-20 22:38:17 -05:00
}
if ( status !== "pending" ) {
2026-04-01 17:27:31 -05:00
html += `<button class="act-reset whitespace-nowrap 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>` ;
2026-03-20 22:38:17 -05:00
}
if ( c . ip ) {
2026-04-01 17:27:31 -05:00
html += `<button class="act-ban-ip whitespace-nowrap 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>` ;
2026-03-20 22:38:17 -05:00
}
return html ;
}
function esc ( s ) {
const d = document . createElement ( "div" );
d . textContent = String ( s ?? "" );
return d . innerHTML ;
}
2026-04-01 17:27:31 -05:00
function expandableCell ( clientId , field , value ) {
const text = value || "-" ;
if ( text === "-" || text . length <= 24 ) {
return `<span> ${ esc ( text ) } </span>` ;
}
const key = ` ${ clientId } : ${ field } ` ;
const isOpen = expandedCells . has ( key );
const short = text . substring ( 0 , 22 ) + "…" ;
return `<span class="hw-expand cursor-pointer select-none" data-expand-key=" ${ esc ( key ) } " data-expanded=" ${ isOpen ? "1" : "0" } ">` +
`<span class="hw-short ${ isOpen ? " hidden" : "" } "> ${ esc ( short ) } </span>` +
`<span class="hw-full ${ isOpen ? "" : " hidden" } "> ${ esc ( text ) } </span>` +
`<i class="fa-solid fa-chevron-down text-[10px] ml-1 text-slate-500 transition-transform hw-chevron" style=" ${ isOpen ? "transform:rotate(180deg)" : "" } "></i>` +
`</span>` ;
}
2026-03-20 22:38:17 -05:00
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 ();
}
});
});
// ── Table delegation ───────────────────────────────────────────────
body . addEventListener ( "click" , ( e ) => {
2026-04-01 17:27:31 -05:00
// 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 ;
}
2026-03-20 22:38:17 -05:00
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 ───────────────────────────────────────────────────────────
loadStats ();
loadClients ();
// Auto-refresh every 15 seconds
setInterval (() => {
loadStats ();
loadClients ();
}, 15000 );