// GoMail Admin SPA const adminRoutes = { '/admin': renderUsers, '/admin/settings': renderSettings, '/admin/audit': renderAudit, }; 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 GoMail 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('')}
UsernameEmailRoleStatusLast Login
${esc(u.username)} ${esc(u.email)} ${u.role} ${u.is_active?'Active':'Disabled'} ${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}
`; } 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'); } // ============================================================ // 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' }, ] }, ]; 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/gomail.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')); }); }); })();