// GoWebMail Admin SPA const adminRoutes = { '/admin': renderUsers, '/admin/settings': renderSettings, '/admin/audit': renderAudit, '/admin/security': renderSecurity, }; 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 = `

Users

Manage GoWebMail accounts and permissions.

`; loadUsersTable(); } async function loadUsersTable() { const r = await api('GET', '/admin/users'); const el = document.getElementById('users-table'); if (!r) { el.innerHTML = '

Failed to load users

'; return; } if (!r.length) { el.innerHTML = '

No users yet.

'; return; } el.innerHTML = `${r.map(u => ` `).join('')}
UsernameEmailRoleStatusMFALast Login
${esc(u.username)} ${esc(u.email)} ${u.role} ${u.is_active?'Active':'Disabled'} ${u.mfa_enabled?'On':'Off'} ${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'} ${u.mfa_enabled?``:''}
`; } 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'); } 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\nEnter 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'); }); } // ============================================================ // 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' }, ] }, { 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' }, ] }, ]; async function renderSettings() { const el = document.getElementById('admin-content'); el.innerHTML = '
'; const r = await api('GET', '/admin/settings'); if (!r) { el.innerHTML = '

Failed to load settings

'; return; } const groups = SETTINGS_META.map(g => `
${g.group}
${g.fields.map(f => { const val = esc(r[f.key] || ''); const control = f.type === 'select' ? `` : ``; return `
${f.label}
${f.desc?`
${f.desc}
`:''}
${control}
`; }).join('')}
`).join(''); el.innerHTML = `

Application Settings

Changes are saved to data/gowebmail.conf and take effect immediately for most settings. A restart is required for LISTEN_ADDR changes.

${groups}
`; } 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 = '
'; const r = await api('GET', '/admin/audit?page=' + page + '&page_size=50'); if (!r) { el.innerHTML = '

Failed to load audit log

'; return; } const rows = (r.logs || []).map(l => ` ${new Date(l.created_at).toLocaleString()} ${esc(l.user_email || 'system')} ${esc(l.event)} ${esc(l.detail)} ${esc(l.ip_address)} `).join(''); el.innerHTML = `

Audit Log

Security and administrative activity log.

${rows || ''}
TimeUserEventDetailIP
No events
${r.has_more ? `
` : ''}
`; } 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')); }); }); })(); // ============================================================ // Security — IP Blocks & Login Attempts // ============================================================ async function renderSecurity() { const el = document.getElementById('admin-content'); el.innerHTML = `

Security

Monitor login attempts, manage IP blocks, and control access by country.

Blocked IPs

Login Attempts (last 72h)

`; 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 = '

No blocked IPs.

'; return; } el.innerHTML = ` ${blocks.map(b => ``).join('')}
IPCountryReasonAttemptsBlocked AtExpires
${esc(b.ip)} ${b.country_code ? `${esc(b.country_code)}` : '—'} ${esc(b.reason)} ${b.attempts||0} ${fmtDate(b.blocked_at)} ${b.is_permanent ? '♾ Permanent' : b.expires_at ? fmtDate(b.expires_at) : '—'}
`; } 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 = '

No login attempts recorded in the last 72 hours.

'; return; } el.innerHTML = ` ${attempts.map(a => `3?'style="background:rgba(255,80,80,.07)"':''}> `).join('')}
IPCountryTotalFailuresLast Seen
${esc(a.ip)} ${a.country_code ? `${esc(a.country_code)} ${esc(a.country)}` : '—'} ${a.total} ${a.failures} ${a.last_seen||'—'}
`; } 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,'"'); }