// 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 = `
`;
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 = `
| Username | Email | Role | Status | MFA | Last Login | |
${r.map(u => `
| ${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?``:''}
|
`).join('')}
`;
}
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 = `
${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 = `
| Time | User | Event | Detail | IP |
${rows || '| 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 = `
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 = `
| IP | Country | Reason | Attempts | Blocked At | Expires | |
${blocks.map(b => `
${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) : '—'} |
|
`).join('')}
`;
}
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 = `
| IP | Country | Total | Failures | Last Seen | |
${attempts.map(a => `3?'style="background:rgba(255,80,80,.07)"':''}>
${esc(a.ip)} |
${a.country_code ? `${esc(a.country_code)} ${esc(a.country)}` : '—'} |
${a.total} |
${a.failures} |
${a.last_seen||'—'} |
|
`).join('')}
`;
}
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,'"');
}