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</h1>
2026-03-08 11:48:27 +00:00
<p>Manage GoWebMail accounts and permissions.</p>
2026-03-07 06:20:39 +00:00
</div>
<div class="admin-card">
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
<button class="btn-primary" onclick="openCreateUser()">+ New User</button>
</div>
<div id="users-table"><div class="spinner"></div></div>
</div>
<div class="modal-overlay" id="user-modal">
<div class="modal">
<h2 id="user-modal-title">New User</h2>
<input type="hidden" id="user-id">
<div class="modal-field"><label>Username</label><input type="text" id="user-username"></div>
<div class="modal-field"><label>Email</label><input type="email" id="user-email"></div>
<div class="modal-field"><label id="user-pw-label">Password</label><input type="password" id="user-password" placeholder="Min. 8 characters"></div>
<div class="modal-field">
<label>Role</label>
<select id="user-role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="modal-field" id="user-active-field">
<label>Active</label>
<select id="user-active"><option value="1">Active</option><option value="0">Disabled</option></select>
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('user-modal')">Cancel</button>
<button class="modal-submit" onclick="saveUser()">Save</button>
</div>
</div>
</div> ` ;
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</th><th>Email</th><th>Role</th><th>Status</th><th>MFA</th><th>Last Login</th><th></th></tr></thead>
2026-03-07 06:20:39 +00:00
<tbody> ${ r . map ( u => `
<tr>
<td style="font-weight:500"> ${ esc ( u . username ) } </td>
<td style="color:var(--muted)"> ${ esc ( u . email ) } </td>
<td><span class="badge ${ u . role === 'admin' ? 'blue' : 'amber' } "> ${ u . role } </span></td>
<td><span class="badge ${ u . is _active ? 'green' : 'red' } "> ${ u . is _active ? 'Active' : 'Disabled' } </span></td>
2026-03-08 11:48:27 +00:00
<td><span class="badge ${ u . mfa _enabled ? 'blue' : 'amber' } "> ${ u . mfa _enabled ? 'On' : 'Off' } </span></td>
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' } </td>
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</button>
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</button>
${ 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</button>
</td>
</tr> ` ) . join ( '' ) }
</tbody></table> ` ;
}
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 } </div>
${ 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 } </div> ${ f . desc ? ` <div class="setting-desc"> ${ f . desc } </div> ` : '' } </div>
<div class="setting-control"> ${ control } </div>
</div> ` ;
} ).join('')}
</div> ` ) . join ( '' ) ;
el . innerHTML = `
<div class="admin-page-header">
<h1>Application Settings</h1>
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</code> and take effect immediately for most settings. A restart is required for LISTEN_ADDR changes.</p>
2026-03-07 06:20:39 +00:00
</div>
<div id="settings-alert" style="display:none"></div>
<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</button>
<button class="btn-primary" onclick="saveSettings()">Save Settings</button>
</div>
</div> ` ;
}
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 ( ) } </td>
<td style="font-weight:500"> ${ esc ( l . user _email || 'system' ) } </td>
<td><span class="badge ${ eventBadge ( l . event ) } "> ${ esc ( l . event ) } </span></td>
<td style="color:var(--muted);font-size:12px"> ${ esc ( l . detail ) } </td>
<td style="font-family:monospace;font-size:11px;color:var(--muted)"> ${ esc ( l . ip _address ) } </td>
</tr> ` ) . join ( '' ) ;
el . innerHTML = `
<div class="admin-page-header">
<h1>Audit Log</h1>
<p>Security and administrative activity log.</p>
</div>
<div class="admin-card" style="padding:0;overflow:hidden">
<table class="data-table">
<thead><tr><th>Time</th><th>User</th><th>Event</th><th>Detail</th><th>IP</th></tr></thead>
<tbody> ${ rows || '<tr><td colspan="5" style="text-align:center;color:var(--muted);padding:30px">No events</td></tr>' } </tbody>
</table>
${ r . has _more ? ` <div style="padding:12px;text-align:center"><button class="load-more-btn" onclick="renderAudit( ${ page + 1 } )">Load more</button></div> ` : '' }
</div> ` ;
}
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</h1>
<p>Monitor login attempts, manage IP blocks, and control access by country.</p>
</div>
<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</h2>
<button class="btn-primary" onclick="openAddBlock()">+ Block IP</button>
</div>
<div id="blocks-table"><div class="spinner"></div></div>
</div>
<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 72h)</h2>
<button class="btn-secondary" onclick="loadLoginAttempts()">↻ Refresh</button>
</div>
<div id="attempts-table"><div class="spinner"></div></div>
</div>
<div class="modal-overlay" id="add-block-modal">
<div class="modal" style="max-width:420px">
<h2>Block IP Address</h2>
<div class="modal-field"><label>IP Address</label><input type="text" id="block-ip" placeholder="e.g. 192.168.1.100"></div>
<div class="modal-field"><label>Reason</label><input type="text" id="block-reason" placeholder="Manual admin block"></div>
<div class="modal-field"><label>Ban Hours (0 = permanent)</label><input type="number" id="block-hours" value="24" min="0"></div>
<div class="modal-actions">
<button class="btn-secondary" onclick="closeModal('add-block-modal')">Cancel</button>
<button class="btn-primary" onclick="submitAddBlock()">Block IP</button>
</div>
</div>
</div> ` ;
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</th><th>Country</th><th>Reason</th><th>Attempts</th><th>Blocked At</th><th>Expires</th><th></th>
</tr></thead>
<tbody>
${ blocks . map ( b => ` <tr>
<td><code> ${ esc ( b . ip ) } </code></td>
<td> ${ b . country _code ? ` <span title=" ${ esc ( b . country ) } "> ${ esc ( b . country _code ) } </span> ` : '—' } </td>
<td> ${ esc ( b . reason ) } </td>
<td> ${ b . attempts || 0 } </td>
<td style="font-size:11px"> ${ fmtDate ( b . blocked _at ) } </td>
<td style="font-size:11px;color:var(--muted)"> ${ b . is _permanent ? '♾ Permanent' : b . expires _at ? fmtDate ( b . expires _at ) : '—' } </td>
<td><button class="action-btn danger" onclick="unblockIP(' ${ esc ( b . ip ) } ')">Unblock</button></td>
</tr> ` ) . join ( '' ) }
</tbody>
</table> ` ;
}
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</th><th>Country</th><th>Total</th><th>Failures</th><th>Last Seen</th><th></th>
</tr></thead>
<tbody>
${ attempts . map ( a => ` <tr ${ a . failures > 3 ? 'style="background:rgba(255,80,80,.07)"' : '' } >
<td><code> ${ esc ( a . ip ) } </code></td>
<td> ${ a . country _code ? ` <span title=" ${ esc ( a . country ) } "> ${ esc ( a . country _code ) } ${ esc ( a . country ) } </span> ` : '—' } </td>
<td> ${ a . total } </td>
<td style=" ${ a . failures > 3 ? 'color:#f87;font-weight:600' : '' } "> ${ a . failures } </td>
<td style="font-size:11px"> ${ a . last _seen || '—' } </td>
<td><button class="action-btn danger" onclick="blockFromAttempt(' ${ esc ( a . ip ) } ')">Block</button></td>
</tr> ` ) . join ( '' ) }
</tbody>
</table> ` ;
}
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 , '"' ) ;
}