2026-05-30 22:44:55 -05:00
const body = document . getElementById ( "enrollment-body" );
const loadingRow = document . getElementById ( "enrollment-loading" );
2026-03-20 22:38:17 -05:00
const emptyEl = document . getElementById ( "enrollment-empty" );
2026-05-30 22:44:55 -05:00
const selectAllEl = document . getElementById ( "select-all" );
2026-03-20 22:38:17 -05:00
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" );
2026-05-30 22:44:55 -05:00
const bannedIpsBody = document . getElementById ( "banned-ips-body" );
2026-03-20 22:38:17 -05:00
const bannedIpsEmpty = document . getElementById ( "banned-ips-empty" );
2026-05-30 22:44:55 -05:00
const clientsTable = document . getElementById ( "enrollment-table" ) ? . closest ( ".bg-slate-900\\/50" );
2026-03-20 22:38:17 -05:00
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" );
2026-05-22 09:32:03 -05:00
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" );
2026-05-22 10:12:40 -05:00
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" );
2026-03-20 22:38:17 -05:00
let currentFilter = "pending" ;
let searchQuery = "" ;
let clients = [];
2026-04-01 17:27:31 -05:00
const expandedCells = new Set (); // tracks "clientId:field" keys
2026-05-30 22:44:55 -05:00
let lastEnrollmentDigest = "" ;
2026-03-20 22:38:17 -05:00
2026-05-22 10:12:40 -05:00
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)" ,
};
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 ();
}
2026-05-22 09:32:03 -05:00
async function loadSettings () {
try {
const s = await api ( "/api/enrollment/settings" );
autoAcceptToggle . checked = ! s . requireApproval ;
2026-05-22 10:12:40 -05:00
unlessSuspiciousToggle . checked = !! s . autoApproveUnlessSuspicious ;
unlessSuspiciousRow . classList . toggle ( "hidden" , ! autoAcceptToggle . checked );
2026-05-22 09:32:03 -05:00
} catch {}
}
2026-03-20 22:38:17 -05:00
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 () {
2026-05-30 22:44:55 -05:00
const hasRows = body . querySelector ( "tr[data-id]" );
loadingRow . classList . toggle ( "hidden" , !! hasRows );
2026-03-20 22:38:17 -05:00
emptyEl . classList . add ( "hidden" );
2026-05-22 10:12:40 -05:00
const fetchFilter = currentFilter === "suspicious" ? "all" : currentFilter ;
2026-03-20 22:38:17 -05:00
try {
2026-05-22 10:12:40 -05:00
const data = await api ( `/api/clients?page=1&pageSize=1000&enrollmentFilter= ${ fetchFilter } ` );
2026-03-20 22:38:17 -05:00
clients = data . items || [];
} catch {
clients = [];
}
// Sort by newest first (highest lastSeen = most recent)
clients . sort (( a , b ) => ( b . lastSeen || 0 ) - ( a . lastSeen || 0 ));
2026-05-22 10:12:40 -05:00
// 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 );
}
2026-03-20 22:38:17 -05:00
// Apply search filter
const q = searchQuery . toLowerCase (). trim ();
const filtered = q
2026-05-22 10:12:40 -05:00
? base . 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 ));
})
2026-05-22 10:12:40 -05:00
: base ;
2026-03-20 22:38:17 -05:00
2026-05-30 22:44:55 -05:00
loadingRow . classList . add ( "hidden" );
2026-05-22 10:12:40 -05:00
const showBulkActions = ( currentFilter === "pending" || currentFilter === "suspicious" ) &&
filtered . some (( c ) => ( c . enrollmentStatus || "pending" ) === "pending" );
approveAllBtn . classList . toggle ( "hidden" , ! showBulkActions );
denyAllBtn . classList . toggle ( "hidden" , ! showBulkActions );
2026-05-22 09:32:03 -05:00
2026-03-20 22:38:17 -05:00
if ( filtered . length === 0 ) {
2026-05-30 22:44:55 -05:00
lastEnrollmentDigest = `empty: ${ currentFilter } : ${ q } ` ;
body . querySelectorAll ( "tr:not(#enrollment-loading)" ). forEach (( r ) => r . remove ());
2026-03-20 22:38:17 -05:00
emptyEl . classList . remove ( "hidden" );
2026-05-30 20:38:32 -05:00
updateBulkButtons ();
2026-03-20 22:38:17 -05:00
return ;
}
2026-05-30 22:44:55 -05:00
const nextDigest = tableDigest ( filtered , [
"id" ,
"host" ,
"user" ,
"os" ,
"cpu" ,
"gpu" ,
"ram" ,
"ip" ,
"country" ,
"keyFingerprint" ,
"lastSeen" ,
"enrollmentStatus" ,
"denyReason" ,
"suspiciousFlags" ,
]);
if ( nextDigest === lastEnrollmentDigest ) {
updateBulkButtons ();
return ;
}
lastEnrollmentDigest = nextDigest ;
const selectedBeforeRender = new Set ( getSelectedIds ());
body . querySelectorAll ( "tr:not(#enrollment-loading)" ). forEach (( r ) => r . remove ());
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 = `
<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 ) } " ${ selectedBeforeRender . has ( c . id ) ? "checked" : "" } /></td>
<td class="px-4 py-3 text-sm font-medium text-slate-200"> ${ esc ( c . host || c . id ) }${ suspiciousBadges ( c . suspiciousFlags ) } </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"> ${ expandableCell ( c . id , "cpu" , c . cpu ) } </td>
<td class="px-4 py-3 text-sm text-slate-400"> ${ c . gpu ? dedupeGpu ( c . gpu ) : esc ( "-" ) } </td>
<td class="px-4 py-3 text-sm text-slate-400"> ${ esc ( c . ram || "-" ) } </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"><div class="flex items-center justify-end gap-2 flex-nowrap"> ${ actionButtons ( c ) } </div></td>
` ;
tr . classList . add ( "cursor-pointer" );
body . appendChild ( tr );
}
2026-03-20 22:38:17 -05:00
updateBulkButtons ();
}
2026-05-30 22:44:55 -05:00
function tableDigest ( items , fields ) {
return JSON . stringify ( items . map (( item ) => {
const row = {};
for ( const field of fields ) row [ field ] = item [ field ] ?? null ;
return row ;
}));
}
2026-03-20 22:38:17 -05:00
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 ;
}
2026-05-22 10:12:40 -05:00
function statusBadgeWithReason ( status , denyReason ) {
const pill = statusBadge ( status );
if ( status === "denied" && denyReason ) {
return `<div class="space-y-0.5"> ${ pill } <div class="text-xs text-slate-500 italic max-w-[140px] truncate" title=" ${ esc ( denyReason ) } "> ${ esc ( denyReason ) } </div></div>` ;
}
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 `<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs ${ color } border cursor-help" title=" ${ esc ( label ) } "><i class="fa-solid fa-triangle-exclamation text-[9px]"></i> ${ esc ( label . split ( " " )[ 0 ]) } </span>` ;
}). join ( " " );
}
2026-03-20 22:38:17 -05:00
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-03 20:24:34 -05:00
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 ) } <span class="hw-gpu-count">× ${ n } </span>` : esc ( name )). join ( ", " );
}
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 ()]);
}
2026-05-22 10:12:40 -05:00
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 ()]);
}
2026-03-20 22:38:17 -05:00
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" );
}
2026-05-30 22:44:55 -05:00
selectAllEl . checked = false ;
2026-03-20 22:38:17 -05:00
await Promise . all ([ loadClients (), loadStats ()]);
}
function getSelectedIds () {
2026-05-30 22:44:55 -05:00
return [... document . querySelectorAll ( ".row-check:checked" )]. map (
( el ) => el . dataset . id ,
);
2026-03-20 22:38:17 -05:00
}
function updateBulkButtons () {
2026-05-30 22:44:55 -05:00
const rowChecks = [... document . querySelectorAll ( ".row-check" )];
const selected = getSelectedIds ();
const count = selected . length ;
2026-03-20 22:38:17 -05:00
bulkApproveBtn . classList . toggle ( "hidden" , count === 0 );
bulkDenyBtn . classList . toggle ( "hidden" , count === 0 );
bulkBanBtn . classList . toggle ( "hidden" , count === 0 );
2026-05-30 22:44:55 -05:00
if ( selectAllEl ) {
selectAllEl . checked = rowChecks . length > 0 && rowChecks . every (( cb ) => cb . checked );
}
2026-03-20 22:38:17 -05:00
}
// ── 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" ,
2026-05-22 10:12:40 -05:00
suspicious : "bg-orange-500/20 text-orange-300 border-orange-500/40" ,
2026-03-20 22:38:17 -05:00
};
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" );
2026-05-22 09:32:03 -05:00
approveAllBtn . classList . add ( "hidden" );
2026-05-22 10:12:40 -05:00
denyAllBtn . classList . add ( "hidden" );
2026-03-20 22:38:17 -05:00
loadBannedIps ();
} else {
bannedIpsSection . classList . add ( "hidden" );
if ( clientsTable ) clientsTable . classList . remove ( "hidden" );
loadClients ();
}
});
});
// ── Table delegation ───────────────────────────────────────────────
2026-05-30 22:44:55 -05:00
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-05-30 22:44:55 -05:00
const row = e . target . closest ( "tr[data-id]" );
if (
row &&
! e . target . closest ( "button, input, a, select, textarea" )
) {
const checkbox = row . querySelector ( ".row-check" );
if ( checkbox ) {
checkbox . checked = ! checkbox . checked ;
updateBulkButtons ();
}
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" );
2026-05-22 10:12:40 -05:00
else if ( btn . classList . contains ( "act-deny" )) showDenyReasonModal ( id );
2026-03-20 22:38:17 -05:00
else if ( btn . classList . contains ( "act-reset" )) setStatus ( id , "reset" );
else if ( btn . classList . contains ( "act-ban-ip" )) banClientIp ( id );
});
2026-05-30 22:44:55 -05:00
body . addEventListener ( "change" , () => updateBulkButtons ());
selectAllEl . addEventListener ( "change" , () => {
document . querySelectorAll ( ".row-check" ). forEach (( cb ) => {
cb . checked = selectAllEl . checked ;
});
2026-03-20 22:38:17 -05:00
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 () {
2026-05-30 22:44:55 -05:00
bannedIpsBody . innerHTML = "" ;
2026-03-20 22:38:17 -05:00
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 ;
}
2026-05-30 22:44:55 -05:00
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 );
}
2026-03-20 22:38:17 -05:00
} catch {
bannedIpsEmpty . classList . remove ( "hidden" );
}
}
2026-05-30 22:44:55 -05:00
bannedIpsBody . addEventListener ( "click" , async ( e ) => {
2026-03-20 22:38:17 -05:00
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 ()]);
});
2026-05-22 09:32:03 -05:00
// ── 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 }),
});
2026-05-22 10:12:40 -05:00
unlessSuspiciousRow . classList . add ( "hidden" );
2026-05-22 09:32:03 -05:00
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 ;
2026-05-22 10:12:40 -05:00
unlessSuspiciousRow . classList . remove ( "hidden" );
2026-05-22 09:32:03 -05:00
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" );
}
});
2026-05-22 10:12:40 -05:00
// ── 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 );
}
2026-05-22 09:32:03 -05:00
approveAllBtn . addEventListener ( "click" , async () => {
2026-05-22 10:12:40 -05:00
const ids = getActionablePendingIds ();
if ( ids . length === 0 ) return ;
2026-05-22 09:32:03 -05:00
try {
await api ( "/api/enrollment/bulk" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
2026-05-22 10:12:40 -05:00
body : JSON . stringify ({ ids , action : "approve" }),
2026-05-22 09:32:03 -05:00
});
2026-05-22 10:12:40 -05:00
if ( window . showToast ) window . showToast ( `Approved ${ ids . length } client ${ ids . length !== 1 ? "s" : "" } ` , "success" );
2026-05-22 09:32:03 -05:00
} catch ( e ) {
if ( window . showToast ) window . showToast ( `Failed: ${ e . message } ` , "error" );
}
await Promise . all ([ loadClients (), loadStats ()]);
});
2026-05-22 10:12:40 -05:00
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 );
});
2026-03-20 22:38:17 -05:00
// ── Init ───────────────────────────────────────────────────────────
2026-05-22 09:32:03 -05:00
loadSettings ();
2026-03-20 22:38:17 -05:00
loadStats ();
loadClients ();
// Auto-refresh every 15 seconds
setInterval (() => {
loadStats ();
loadClients ();
}, 15000 );