2026-03-08 11:48:27 +00:00
// GoWebMail Admin SPA
2026-03-07 06:20:39 +00:00
const adminRoutes = {
2026-03-08 17:35:58 +00:00
'/admin' : renderUsers ,
'/admin/settings' : renderSettings ,
'/admin/audit' : renderAudit ,
'/admin/security' : renderSecurity ,
2026-03-07 06:20:39 +00:00
} ;
function navigate ( path ) {
history . pushState ( { } , '' , path ) ;
document . querySelectorAll ( '.admin-nav a' ) . forEach ( a => a . classList . toggle ( 'active' , a . getAttribute ( 'href' ) === path ) ) ;
const fn = adminRoutes [ path ] ;
if ( fn ) fn ( ) ;
}
window . addEventListener ( 'popstate' , ( ) => {
const fn = adminRoutes [ location . pathname ] ;
if ( fn ) fn ( ) ;
} ) ;
// ============================================================
// Users
// ============================================================
async function renderUsers ( ) {
const el = document . getElementById ( 'admin-content' ) ;
el . innerHTML = `
< div class = "admin-page-header" >
< h1 > Users < / h 1 >
2026-03-08 11:48:27 +00:00
< p > Manage GoWebMail accounts and permissions . < / p >
2026-03-07 06:20:39 +00:00
< / d i v >
< div class = "admin-card" >
< div style = "display:flex;justify-content:flex-end;margin-bottom:16px" >
< button class = "btn-primary" onclick = "openCreateUser()" > + New User < / b u t t o n >
< / d i v >
< div id = "users-table" > < div class = "spinner" > < / d i v > < / d i v >
< / d i v >
< div class = "modal-overlay" id = "user-modal" >
< div class = "modal" >
< h2 id = "user-modal-title" > New User < / h 2 >
< input type = "hidden" id = "user-id" >
< div class = "modal-field" > < label > Username < / l a b e l > < i n p u t t y p e = " t e x t " i d = " u s e r - u s e r n a m e " > < / d i v >
< div class = "modal-field" > < label > Email < / l a b e l > < i n p u t t y p e = " e m a i l " i d = " u s e r - e m a i l " > < / d i v >
< div class = "modal-field" > < label id = "user-pw-label" > Password < / l a b e l > < i n p u t t y p e = " p a s s w o r d " i d = " u s e r - p a s s w o r d " p l a c e h o l d e r = " M i n . 8 c h a r a c t e r s " > < / d i v >
< div class = "modal-field" >
< label > Role < / l a b e l >
< select id = "user-role" >
< option value = "user" > User < / o p t i o n >
< option value = "admin" > Admin < / o p t i o n >
< / s e l e c t >
< / d i v >
< div class = "modal-field" id = "user-active-field" >
< label > Active < / l a b e l >
< select id = "user-active" > < option value = "1" > Active < / o p t i o n > < o p t i o n v a l u e = " 0 " > D i s a b l e d < / o p t i o n > < / s e l e c t >
< / d i v >
< div class = "modal-actions" >
< button class = "modal-cancel" onclick = "closeModal('user-modal')" > Cancel < / b u t t o n >
< button class = "modal-submit" onclick = "saveUser()" > Save < / b u t t o n >
< / d i v >
< / d i v >
< / d i v > ` ;
loadUsersTable ( ) ;
}
async function loadUsersTable ( ) {
const r = await api ( 'GET' , '/admin/users' ) ;
const el = document . getElementById ( 'users-table' ) ;
if ( ! r ) { el . innerHTML = '<p class="alert error">Failed to load users</p>' ; return ; }
if ( ! r . length ) { el . innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>' ; return ; }
el . innerHTML = ` <table class="data-table">
2026-03-08 11:48:27 +00:00
< thead > < tr > < th > Username < / t h > < t h > E m a i l < / t h > < t h > R o l e < / t h > < t h > S t a t u s < / t h > < t h > M F A < / t h > < t h > L a s t L o g i n < / t h > < t h > < / t h > < / t r > < / t h e a d >
2026-03-07 06:20:39 +00:00
< tbody > $ { r . map ( u => `
< tr >
< td style = "font-weight:500" > $ { esc ( u . username ) } < / t d >
< td style = "color:var(--muted)" > $ { esc ( u . email ) } < / t d >
< td > < span class = "badge ${u.role==='admin'?'blue':'amber'}" > $ { u . role } < / s p a n > < / t d >
< td > < span class = "badge ${u.is_active?'green':'red'}" > $ { u . is _active ? 'Active' : 'Disabled' } < / s p a n > < / t d >
2026-03-08 11:48:27 +00:00
< td > < span class = "badge ${u.mfa_enabled?'blue':'amber'}" > $ { u . mfa _enabled ? 'On' : 'Off' } < / s p a n > < / t d >
2026-03-07 06:20:39 +00:00
< td style = "color:var(--muted);font-size:12px" > $ { u . last _login _at ? new Date ( u . last _login _at ) . toLocaleDateString ( ) : 'Never' } < / t d >
2026-03-08 11:48:27 +00:00
< td style = "display:flex;gap:4px;justify-content:flex-end;flex-wrap:wrap" >
2026-03-07 06:20:39 +00:00
< button class = "btn-secondary" style = "padding:4px 10px;font-size:12px" onclick = "openEditUser(${u.id})" > Edit < / b u t t o n >
2026-03-08 11:48:27 +00:00
< button class = "btn-secondary" style = "padding:4px 10px;font-size:12px" onclick = "openResetPassword(${u.id},'${esc(u.username)}')" > 🔑 Reset PW < / b u t t o n >
$ { u . mfa _enabled ? ` <button class="btn-secondary" style="padding:4px 10px;font-size:12px;color:var(--warning,#f90)" onclick="disableMFA( ${ u . id } ,' ${ esc ( u . username ) } ')">🔒 Disable MFA</button> ` : '' }
2026-03-07 06:20:39 +00:00
< button class = "btn-danger" style = "padding:4px 10px;font-size:12px" onclick = "deleteUser(${u.id})" > Delete < / b u t t o n >
< / t d >
< / t r > ` ) . j o i n ( ' ' ) }
< / t b o d y > < / t a b l e > ` ;
}
function openCreateUser ( ) {
document . getElementById ( 'user-modal-title' ) . textContent = 'New User' ;
document . getElementById ( 'user-id' ) . value = '' ;
document . getElementById ( 'user-username' ) . value = '' ;
document . getElementById ( 'user-email' ) . value = '' ;
document . getElementById ( 'user-password' ) . value = '' ;
document . getElementById ( 'user-role' ) . value = 'user' ;
document . getElementById ( 'user-pw-label' ) . textContent = 'Password' ;
document . getElementById ( 'user-active-field' ) . style . display = 'none' ;
openModal ( 'user-modal' ) ;
}
async function openEditUser ( userId ) {
const r = await api ( 'GET' , '/admin/users' ) ;
if ( ! r ) return ;
const user = r . find ( u => u . id === userId ) ;
if ( ! user ) return ;
document . getElementById ( 'user-modal-title' ) . textContent = 'Edit User' ;
document . getElementById ( 'user-id' ) . value = userId ;
document . getElementById ( 'user-username' ) . value = user . username ;
document . getElementById ( 'user-email' ) . value = user . email ;
document . getElementById ( 'user-password' ) . value = '' ;
document . getElementById ( 'user-role' ) . value = user . role ;
document . getElementById ( 'user-active' ) . value = user . is _active ? '1' : '0' ;
document . getElementById ( 'user-pw-label' ) . textContent = 'New Password (leave blank to keep)' ;
document . getElementById ( 'user-active-field' ) . style . display = 'block' ;
openModal ( 'user-modal' ) ;
}
async function saveUser ( ) {
const userId = document . getElementById ( 'user-id' ) . value ;
const body = {
username : document . getElementById ( 'user-username' ) . value . trim ( ) ,
email : document . getElementById ( 'user-email' ) . value . trim ( ) ,
role : document . getElementById ( 'user-role' ) . value ,
is _active : document . getElementById ( 'user-active' ) . value === '1' ,
} ;
const pw = document . getElementById ( 'user-password' ) . value ;
if ( pw ) body . password = pw ;
else if ( ! userId ) { toast ( 'Password required for new users' , 'error' ) ; return ; }
const r = userId
? await api ( 'PUT' , '/admin/users/' + userId , body )
: await api ( 'POST' , '/admin/users' , { ... body , password : pw } ) ;
if ( r && r . ok ) { toast ( userId ? 'User updated' : 'User created' , 'success' ) ; closeModal ( 'user-modal' ) ; loadUsersTable ( ) ; }
else toast ( ( r && r . error ) || 'Save failed' , 'error' ) ;
}
async function deleteUser ( userId ) {
if ( ! confirm ( 'Delete this user? All their accounts and messages will be deleted.' ) ) return ;
const r = await api ( 'DELETE' , '/admin/users/' + userId ) ;
if ( r && r . ok ) { toast ( 'User deleted' , 'success' ) ; loadUsersTable ( ) ; }
else toast ( ( r && r . error ) || 'Delete failed' , 'error' ) ;
}
2026-03-08 11:48:27 +00:00
async function disableMFA ( userId , username ) {
if ( ! confirm ( ` Disable MFA for " ${ username } "? They will be able to log in without a TOTP code until they re-enable it. ` ) ) return ;
const r = await api ( 'PUT' , '/admin/users/' + userId , { disable _mfa : true } ) ;
if ( r && r . ok ) { toast ( 'MFA disabled for ' + username , 'success' ) ; loadUsersTable ( ) ; }
else toast ( ( r && r . error ) || 'Failed to disable MFA' , 'error' ) ;
}
function openResetPassword ( userId , username ) {
const pw = prompt ( ` Reset password for " ${ username } " \n \n Enter new password (min. 8 characters): ` ) ;
if ( ! pw ) return ;
if ( pw . length < 8 ) { toast ( 'Password must be at least 8 characters' , 'error' ) ; return ; }
api ( 'PUT' , '/admin/users/' + userId , { password : pw } ) . then ( r => {
if ( r && r . ok ) toast ( 'Password reset for ' + username , 'success' ) ;
else toast ( ( r && r . error ) || 'Failed to reset password' , 'error' ) ;
} ) ;
}
2026-03-07 06:20:39 +00:00
// ============================================================
// Settings
// ============================================================
const SETTINGS _META = [
{
group : 'Server' ,
fields : [
{ key : 'HOSTNAME' , label : 'Hostname' , desc : 'Public hostname (no protocol or port). e.g. mail.example.com' , type : 'text' } ,
{ key : 'LISTEN_ADDR' , label : 'Listen Address' , desc : 'Bind address e.g. :8080 or 0.0.0.0:8080' , type : 'text' } ,
{ key : 'BASE_URL' , label : 'Base URL' , desc : 'Leave blank to auto-build from hostname + port' , type : 'text' } ,
]
} ,
{
group : 'Security' ,
fields : [
{ key : 'SECURE_COOKIE' , label : 'Secure Cookies' , desc : 'Set true when serving over HTTPS' , type : 'select' , options : [ 'false' , 'true' ] } ,
{ key : 'TRUSTED_PROXIES' , label : 'Trusted Proxies' , desc : 'Comma-separated IPs/CIDRs allowed to set X-Forwarded-For' , type : 'text' } ,
{ key : 'SESSION_MAX_AGE' , label : 'Session Max Age' , desc : 'Session lifetime in seconds (default 604800 = 7 days)' , type : 'number' } ,
]
} ,
{
group : 'Gmail OAuth' ,
fields : [
{ key : 'GOOGLE_CLIENT_ID' , label : 'Google Client ID' , type : 'text' } ,
{ key : 'GOOGLE_CLIENT_SECRET' , label : 'Google Client Secret' , type : 'password' } ,
{ key : 'GOOGLE_REDIRECT_URL' , label : 'Google Redirect URL' , desc : 'Leave blank to auto-derive from Base URL' , type : 'text' } ,
]
} ,
{
group : 'Outlook OAuth' ,
fields : [
{ key : 'MICROSOFT_CLIENT_ID' , label : 'Microsoft Client ID' , type : 'text' } ,
{ key : 'MICROSOFT_CLIENT_SECRET' , label : 'Microsoft Client Secret' , type : 'password' } ,
{ key : 'MICROSOFT_TENANT_ID' , label : 'Microsoft Tenant ID' , desc : 'Use "common" for multi-tenant' , type : 'text' } ,
{ key : 'MICROSOFT_REDIRECT_URL' , label : 'Microsoft Redirect URL' , desc : 'Leave blank to auto-derive from Base URL' , type : 'text' } ,
]
} ,
{
group : 'Database' ,
fields : [
{ key : 'DB_PATH' , label : 'Database Path' , desc : 'Path to SQLite file, relative to working directory' , type : 'text' } ,
]
} ,
2026-03-08 17:35:58 +00:00
{
group : 'Security Notifications' ,
fields : [
{ key : 'NOTIFY_ENABLED' , label : 'Enabled' , desc : 'Send email to users when brute-force attack is detected on their account' , type : 'select' , options : [ 'true' , 'false' ] } ,
{ key : 'NOTIFY_SMTP_HOST' , label : 'SMTP Host' , desc : 'SMTP server for sending alerts. Example: smtp.example.com' , type : 'text' } ,
{ key : 'NOTIFY_SMTP_PORT' , label : 'SMTP Port' , desc : '587 = STARTTLS, 465 = TLS, 25 = plain relay' , type : 'number' } ,
{ key : 'NOTIFY_FROM' , label : 'From Address' , desc : 'Sender email. Example: security@example.com' , type : 'text' } ,
{ key : 'NOTIFY_USER' , label : 'SMTP Username' , desc : 'Leave blank for unauthenticated relay' , type : 'text' } ,
{ key : 'NOTIFY_PASS' , label : 'SMTP Password' , desc : 'Leave blank for unauthenticated relay' , type : 'password' } ,
]
} ,
{
group : 'Brute Force Protection' ,
fields : [
{ key : 'BRUTE_ENABLED' , label : 'Enabled' , desc : 'Auto-block IPs after repeated failed logins' , type : 'select' , options : [ 'true' , 'false' ] } ,
{ key : 'BRUTE_MAX_ATTEMPTS' , label : 'Max Attempts' , desc : 'Failed logins before ban' , type : 'number' } ,
{ key : 'BRUTE_WINDOW_MINUTES' , label : 'Window (minutes)' , desc : 'Time window for counting failures' , type : 'number' } ,
{ key : 'BRUTE_BAN_HOURS' , label : 'Ban Duration (hours)' , desc : '0 = permanent ban (admin must unban)' , type : 'number' } ,
{ key : 'BRUTE_WHITELIST_IPS' , label : 'Whitelist IPs' , desc : 'Comma-separated IPs that are never blocked' , type : 'text' } ,
]
} ,
{
group : 'Geo Blocking' ,
fields : [
{ key : 'GEO_BLOCK_COUNTRIES' , label : 'Block Countries' , desc : 'Comma-separated ISO codes to DENY (e.g. CN,RU,KP). Takes precedence over Allow list.' , type : 'text' } ,
{ key : 'GEO_ALLOW_COUNTRIES' , label : 'Allow Countries' , desc : 'Comma-separated ISO codes to ALLOW exclusively (e.g. SK,CZ,DE). Leave blank to allow all.' , type : 'text' } ,
]
} ,
2026-03-07 06:20:39 +00:00
] ;
async function renderSettings ( ) {
const el = document . getElementById ( 'admin-content' ) ;
el . innerHTML = '<div class="spinner" style="margin-top:80px"></div>' ;
const r = await api ( 'GET' , '/admin/settings' ) ;
if ( ! r ) { el . innerHTML = '<p class="alert error">Failed to load settings</p>' ; return ; }
const groups = SETTINGS _META . map ( g => `
< div class = "settings-group" >
< div class = "settings-group-title" > $ { g . group } < / d i v >
$ { g . fields . map ( f => {
const val = esc ( r [ f . key ] || '' ) ;
const control = f . type === 'select'
? ` <select id="cfg- ${ f . key } "> ${ f . options . map ( o => ` <option value=" ${ o } " ${ r [ f . key ] === o ? 'selected' : '' } > ${ o } </option> ` ) . join ( '' ) } </select> `
: ` <input type=" ${ f . type } " id="cfg- ${ f . key } " value=" ${ val } " placeholder=" ${ f . desc || '' } "> ` ;
return `
< div class = "setting-row" >
< div > < div class = "setting-label" > $ { f . label } < / d i v > $ { f . d e s c ? ` < d i v c l a s s = " s e t t i n g - d e s c " > $ { f . d e s c } < / d i v > ` : ' ' } < / d i v >
< div class = "setting-control" > $ { control } < / d i v >
< / d i v > ` ;
} ) . join ( '' ) }
< / d i v > ` ) . j o i n ( ' ' ) ;
el . innerHTML = `
< div class = "admin-page-header" >
< h1 > Application Settings < / h 1 >
2026-03-08 06:06:38 +00:00
< p > Changes are saved to < code style = "font-family:monospace;background:var(--surface3);padding:2px 6px;border-radius:4px" > data / gowebmail . conf < / c o d e > a n d t a k e e f f e c t i m m e d i a t e l y f o r m o s t s e t t i n g s . A r e s t a r t i s r e q u i r e d f o r L I S T E N _ A D D R c h a n g e s . < / p >
2026-03-07 06:20:39 +00:00
< / d i v >
< div id = "settings-alert" style = "display:none" > < / d i v >
< div class = "admin-card" >
$ { groups }
< div style = "display:flex;justify-content:flex-end;gap:10px;margin-top:20px" >
< button class = "btn-secondary" onclick = "loadSettingsValues()" > Reset < / b u t t o n >
< button class = "btn-primary" onclick = "saveSettings()" > Save Settings < / b u t t o n >
< / d i v >
< / d i v > ` ;
}
async function loadSettingsValues ( ) {
const r = await api ( 'GET' , '/admin/settings' ) ;
if ( ! r ) return ;
SETTINGS _META . forEach ( g => g . fields . forEach ( f => {
const el = document . getElementById ( 'cfg-' + f . key ) ;
if ( el ) el . value = r [ f . key ] || '' ;
} ) ) ;
}
async function saveSettings ( ) {
const body = { } ;
SETTINGS _META . forEach ( g => g . fields . forEach ( f => {
const el = document . getElementById ( 'cfg-' + f . key ) ;
if ( el ) body [ f . key ] = el . value . trim ( ) ;
} ) ) ;
const r = await api ( 'PUT' , '/admin/settings' , body ) ;
const alertEl = document . getElementById ( 'settings-alert' ) ;
if ( r && r . ok ) {
toast ( 'Settings saved' , 'success' ) ;
alertEl . className = 'alert success' ;
alertEl . textContent = 'Settings saved. LISTEN_ADDR changes require a restart.' ;
alertEl . style . display = 'block' ;
setTimeout ( ( ) => alertEl . style . display = 'none' , 5000 ) ;
} else {
alertEl . className = 'alert error' ;
alertEl . textContent = ( r && r . error ) || 'Save failed' ;
alertEl . style . display = 'block' ;
}
}
// ============================================================
// Audit Log
// ============================================================
async function renderAudit ( page ) {
page = page || 1 ;
const el = document . getElementById ( 'admin-content' ) ;
if ( page === 1 ) el . innerHTML = '<div class="spinner" style="margin-top:80px"></div>' ;
const r = await api ( 'GET' , '/admin/audit?page=' + page + '&page_size=50' ) ;
if ( ! r ) { el . innerHTML = '<p class="alert error">Failed to load audit log</p>' ; return ; }
const rows = ( r . logs || [ ] ) . map ( l => `
< tr >
< td style = "font-family:monospace;font-size:11px;color:var(--muted)" > $ { new Date ( l . created _at ) . toLocaleString ( ) } < / t d >
< td style = "font-weight:500" > $ { esc ( l . user _email || 'system' ) } < / t d >
< td > < span class = "badge ${eventBadge(l.event)}" > $ { esc ( l . event ) } < / s p a n > < / t d >
< td style = "color:var(--muted);font-size:12px" > $ { esc ( l . detail ) } < / t d >
< td style = "font-family:monospace;font-size:11px;color:var(--muted)" > $ { esc ( l . ip _address ) } < / t d >
< / t r > ` ) . j o i n ( ' ' ) ;
el . innerHTML = `
< div class = "admin-page-header" >
< h1 > Audit Log < / h 1 >
< p > Security and administrative activity log . < / p >
< / d i v >
< div class = "admin-card" style = "padding:0;overflow:hidden" >
< table class = "data-table" >
< thead > < tr > < th > Time < / t h > < t h > U s e r < / t h > < t h > E v e n t < / t h > < t h > D e t a i l < / t h > < t h > I P < / t h > < / t r > < / t h e a d >
< tbody > $ { rows || '<tr><td colspan="5" style="text-align:center;color:var(--muted);padding:30px">No events</td></tr>' } < / t b o d y >
< / t a b l e >
$ { r . has _more ? ` <div style="padding:12px;text-align:center"><button class="load-more-btn" onclick="renderAudit( ${ page + 1 } )">Load more</button></div> ` : '' }
< / d i v > ` ;
}
function eventBadge ( evt ) {
if ( ! evt ) return 'amber' ;
if ( evt . includes ( 'login' ) || evt . includes ( 'auth' ) ) return 'blue' ;
if ( evt . includes ( 'error' ) || evt . includes ( 'fail' ) ) return 'red' ;
if ( evt . includes ( 'delete' ) || evt . includes ( 'remove' ) ) return 'red' ;
if ( evt . includes ( 'create' ) || evt . includes ( 'add' ) ) return 'green' ;
return 'amber' ;
}
// Boot: detect current page from URL
( function ( ) {
const path = location . pathname ;
document . querySelectorAll ( '.admin-nav a' ) . forEach ( a => a . classList . toggle ( 'active' , a . getAttribute ( 'href' ) === path ) ) ;
const fn = adminRoutes [ path ] ;
if ( fn ) fn ( ) ;
else renderUsers ( ) ;
document . querySelectorAll ( '.admin-nav a' ) . forEach ( a => {
a . addEventListener ( 'click' , e => {
e . preventDefault ( ) ;
navigate ( a . getAttribute ( 'href' ) ) ;
} ) ;
} ) ;
2026-03-08 17:35:58 +00:00
} ) ( ) ;
// ============================================================
// Security — IP Blocks & Login Attempts
// ============================================================
async function renderSecurity ( ) {
const el = document . getElementById ( 'admin-content' ) ;
el . innerHTML = `
< div class = "admin-page-header" >
< h1 > Security < / h 1 >
< p > Monitor login attempts , manage IP blocks , and control access by country . < / p >
< / d i v >
< div class = "admin-card" style = "margin-bottom:24px" >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-bottom:16px" >
< h2 style = "margin:0;font-size:16px" > Blocked IPs < / h 2 >
< button class = "btn-primary" onclick = "openAddBlock()" > + Block IP < / b u t t o n >
< / d i v >
< div id = "blocks-table" > < div class = "spinner" > < / d i v > < / d i v >
< / d i v >
< div class = "admin-card" >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-bottom:16px" >
< h2 style = "margin:0;font-size:16px" > Login Attempts ( last 72 h ) < / h 2 >
< button class = "btn-secondary" onclick = "loadLoginAttempts()" > ↻ Refresh < / b u t t o n >
< / d i v >
< div id = "attempts-table" > < div class = "spinner" > < / d i v > < / d i v >
< / d i v >
< div class = "modal-overlay" id = "add-block-modal" >
< div class = "modal" style = "max-width:420px" >
< h2 > Block IP Address < / h 2 >
< div class = "modal-field" > < label > IP Address < / l a b e l > < i n p u t t y p e = " t e x t " i d = " b l o c k - i p " p l a c e h o l d e r = " e . g . 1 9 2 . 1 6 8 . 1 . 1 0 0 " > < / d i v >
< div class = "modal-field" > < label > Reason < / l a b e l > < i n p u t t y p e = " t e x t " i d = " b l o c k - r e a s o n " p l a c e h o l d e r = " M a n u a l a d m i n b l o c k " > < / d i v >
< div class = "modal-field" > < label > Ban Hours ( 0 = permanent ) < / l a b e l > < i n p u t t y p e = " n u m b e r " i d = " b l o c k - h o u r s " v a l u e = " 2 4 " m i n = " 0 " > < / d i v >
< div class = "modal-actions" >
< button class = "btn-secondary" onclick = "closeModal('add-block-modal')" > Cancel < / b u t t o n >
< button class = "btn-primary" onclick = "submitAddBlock()" > Block IP < / b u t t o n >
< / d i v >
< / d i v >
< / d i v > ` ;
loadIPBlocks ( ) ;
loadLoginAttempts ( ) ;
}
async function loadIPBlocks ( ) {
const el = document . getElementById ( 'blocks-table' ) ;
if ( ! el ) return ;
const r = await api ( 'GET' , '/admin/ip-blocks' ) ;
const blocks = r ? . blocks || [ ] ;
if ( ! blocks . length ) {
el . innerHTML = '<p style="color:var(--muted);padding:8px 0">No blocked IPs.</p>' ;
return ;
}
el . innerHTML = ` <table class="admin-table" style="width:100%">
< thead > < tr >
< th > IP < / t h > < t h > C o u n t r y < / t h > < t h > R e a s o n < / t h > < t h > A t t e m p t s < / t h > < t h > B l o c k e d A t < / t h > < t h > E x p i r e s < / t h > < t h > < / t h >
< / t r > < / t h e a d >
< tbody >
$ { blocks . map ( b => ` <tr>
< td > < code > $ { esc ( b . ip ) } < / c o d e > < / t d >
< td > $ { b . country _code ? ` <span title=" ${ esc ( b . country ) } "> ${ esc ( b . country _code ) } </span> ` : '—' } < / t d >
< td > $ { esc ( b . reason ) } < / t d >
< td > $ { b . attempts || 0 } < / t d >
< td style = "font-size:11px" > $ { fmtDate ( b . blocked _at ) } < / t d >
< td style = "font-size:11px;color:var(--muted)" > $ { b . is _permanent ? '♾ Permanent' : b . expires _at ? fmtDate ( b . expires _at ) : '—' } < / t d >
< td > < button class = "action-btn danger" onclick = "unblockIP('${esc(b.ip)}')" > Unblock < / b u t t o n > < / t d >
< / t r > ` ) . j o i n ( ' ' ) }
< / t b o d y >
< / t a b l e > ` ;
}
async function loadLoginAttempts ( ) {
const el = document . getElementById ( 'attempts-table' ) ;
if ( ! el ) return ;
const r = await api ( 'GET' , '/admin/login-attempts' ) ;
const attempts = r ? . attempts || [ ] ;
if ( ! attempts . length ) {
el . innerHTML = '<p style="color:var(--muted);padding:8px 0">No login attempts recorded in the last 72 hours.</p>' ;
return ;
}
el . innerHTML = ` <table class="admin-table" style="width:100%">
< thead > < tr >
< th > IP < / t h > < t h > C o u n t r y < / t h > < t h > T o t a l < / t h > < t h > F a i l u r e s < / t h > < t h > L a s t S e e n < / t h > < t h > < / t h >
< / t r > < / t h e a d >
< tbody >
$ { attempts . map ( a => ` <tr ${ a . failures > 3 ? 'style="background:rgba(255,80,80,.07)"' : '' } >
< td > < code > $ { esc ( a . ip ) } < / c o d e > < / t d >
< td > $ { a . country _code ? ` <span title=" ${ esc ( a . country ) } "> ${ esc ( a . country _code ) } ${ esc ( a . country ) } </span> ` : '—' } < / t d >
< td > $ { a . total } < / t d >
< td style = "${a.failures>3?'color:#f87;font-weight:600':''}" > $ { a . failures } < / t d >
< td style = "font-size:11px" > $ { a . last _seen || '—' } < / t d >
< td > < button class = "action-btn danger" onclick = "blockFromAttempt('${esc(a.ip)}')" > Block < / b u t t o n > < / t d >
< / t r > ` ) . j o i n ( ' ' ) }
< / t b o d y >
< / t a b l e > ` ;
}
function openAddBlock ( ) { openModal ( 'add-block-modal' ) ; }
async function submitAddBlock ( ) {
const ip = document . getElementById ( 'block-ip' ) . value . trim ( ) ;
const reason = document . getElementById ( 'block-reason' ) . value . trim ( ) || 'Manual admin block' ;
const hours = parseInt ( document . getElementById ( 'block-hours' ) . value ) || 0 ;
if ( ! ip ) { toast ( 'IP address required' , 'error' ) ; return ; }
const r = await api ( 'POST' , '/admin/ip-blocks' , { ip , reason , ban _hours : hours } ) ;
if ( r ? . ok ) { toast ( 'IP blocked' , 'success' ) ; closeModal ( 'add-block-modal' ) ; loadIPBlocks ( ) ; }
else toast ( r ? . error || 'Failed' , 'error' ) ;
}
async function unblockIP ( ip ) {
const r = await fetch ( '/api/admin/ip-blocks/' + encodeURIComponent ( ip ) , { method : 'DELETE' } ) ;
const data = await r . json ( ) ;
if ( data ? . ok ) { toast ( 'IP unblocked' , 'success' ) ; loadIPBlocks ( ) ; }
else toast ( data ? . error || 'Failed' , 'error' ) ;
}
function blockFromAttempt ( ip ) {
document . getElementById ( 'block-ip' ) . value = ip ;
document . getElementById ( 'block-reason' ) . value = 'Manual block from login attempts' ;
openModal ( 'add-block-modal' ) ;
}
function fmtDate ( s ) {
if ( ! s ) return '—' ;
try { return new Date ( s ) . toLocaleString ( ) ; } catch ( e ) { return s ; }
}
function esc ( s ) {
if ( ! s ) return '' ;
return String ( s ) . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) . replace ( /"/g , '"' ) ;
}