initial commit
This commit is contained in:
162
frontend/app.js
Normal file
162
frontend/app.js
Normal file
@@ -0,0 +1,162 @@
|
||||
const app = document.getElementById('app');
|
||||
const state = { user: null, domains: [], selectedDomain: null, mailboxes: [], audit: [], message: '', error: '' };
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function bytes(n) {
|
||||
n = Number(n || 0);
|
||||
if (n <= 0) return '0 B';
|
||||
const u = ['B','KB','MB','GB','TB']; let i = 0;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
|
||||
}
|
||||
|
||||
function esc(s) { return String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); }
|
||||
|
||||
async function init() {
|
||||
try { state.user = await api('/api/auth/me'); await loadDomains(true); }
|
||||
catch { state.user = null; }
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadDomains(resync = false) {
|
||||
state.domains = await api(`/api/domains${resync ? '?resync=true' : ''}`);
|
||||
if (!state.selectedDomain && state.domains.length) state.selectedDomain = state.domains[0].domain;
|
||||
if (state.selectedDomain) await loadMailboxes();
|
||||
}
|
||||
|
||||
async function loadMailboxes() {
|
||||
state.mailboxes = await api(`/api/mailboxes?domain=${encodeURIComponent(state.selectedDomain)}`);
|
||||
}
|
||||
|
||||
async function loadAudit() {
|
||||
state.audit = await api('/api/audit');
|
||||
renderAuditModal();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.user) return renderLogin();
|
||||
app.innerHTML = `
|
||||
<div class="header">
|
||||
<div><div class="brand">MailAdmin</div><div class="muted">${esc(state.user.email)} · ${esc(state.user.role)}</div></div>
|
||||
<div class="actions">
|
||||
<button class="secondary" id="auditBtn">Audit Log</button>
|
||||
<button class="secondary" id="resyncBtn">DMS Resync</button>
|
||||
<button class="ghost" id="logoutBtn">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
${state.error ? `<div class="error">${esc(state.error)}</div>` : ''}
|
||||
${state.message ? `<div class="success">${esc(state.message)}</div>` : ''}
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h2>Domains on this node</h2>
|
||||
<div class="muted" style="margin-bottom:12px">Domains are discovered dynamically from DMS accounts.</div>
|
||||
<div class="list">
|
||||
${state.domains.map(d => `
|
||||
<div class="list-item ${d.domain === state.selectedDomain ? 'active' : ''}" data-domain="${esc(d.domain)}">
|
||||
<strong>${esc(d.domain)}</strong><br>
|
||||
<span class="muted">${d.active_mailboxes || 0} inboxes · ${bytes(d.used_bytes)}</span><br>
|
||||
<span class="pill">${esc(d.current_node)}</span> <span class="pill">${esc(d.status)}</span>
|
||||
</div>`).join('') || '<div class="muted">No domains found yet.</div>'}
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<div style="display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:14px">
|
||||
<div><h2 style="margin:0">${esc(state.selectedDomain || 'Mailboxes')}</h2><div class="muted">Create/delete mailboxes, reset passwords, edit rules.</div></div>
|
||||
<div class="actions"><button id="newMailboxBtn">New mailbox</button><button class="secondary" id="usageBtn">Scan usage</button></div>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>Email</th><th>Status</th><th>Usage</th><th>Updated</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
${state.mailboxes.map(m => `
|
||||
<tr>
|
||||
<td><strong>${esc(m.email_address)}</strong><div class="muted">${esc(m.node_name)}</div></td>
|
||||
<td><span class="pill">${esc(m.status)}</span></td>
|
||||
<td>${bytes(m.used_bytes)}<div class="muted">${m.usage_scanned_at ? new Date(m.usage_scanned_at).toLocaleString() : 'not scanned'}</div></td>
|
||||
<td class="muted">${new Date(m.updated_at).toLocaleString()}</td>
|
||||
<td><div class="actions">
|
||||
<button class="secondary" data-rules="${esc(m.email_address)}">Rules</button>
|
||||
<button class="secondary" data-blocks="${esc(m.email_address)}">Blocklist</button>
|
||||
<button class="secondary" data-password="${esc(m.email_address)}">Password</button>
|
||||
<button class="danger" data-delete="${esc(m.email_address)}">Delete</button>
|
||||
</div></td>
|
||||
</tr>`).join('') || '<tr><td colspan="5" class="muted">No mailboxes for this domain.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('logoutBtn').onclick = async () => { await api('/api/auth/logout', { method:'POST' }); state.user = null; render(); };
|
||||
document.getElementById('resyncBtn').onclick = guard(async () => { await api('/api/domains/resync', { method:'POST' }); await loadDomains(false); state.message = 'DMS sync completed.'; render(); });
|
||||
document.getElementById('auditBtn').onclick = guard(loadAudit);
|
||||
document.getElementById('newMailboxBtn').onclick = renderCreateMailboxModal;
|
||||
document.getElementById('usageBtn').onclick = guard(async () => { await api('/api/mailboxes/usage/rescan', { method:'POST', body: JSON.stringify({ domain: state.selectedDomain }) }); await loadMailboxes(); render(); });
|
||||
document.querySelectorAll('[data-domain]').forEach(el => el.onclick = guard(async () => { state.selectedDomain = el.dataset.domain; await loadMailboxes(); render(); }));
|
||||
document.querySelectorAll('[data-delete]').forEach(el => el.onclick = () => renderDeleteModal(el.dataset.delete));
|
||||
document.querySelectorAll('[data-password]').forEach(el => el.onclick = () => renderPasswordModal(el.dataset.password));
|
||||
document.querySelectorAll('[data-rules]').forEach(el => el.onclick = () => renderRulesModal(el.dataset.rules));
|
||||
document.querySelectorAll('[data-blocks]').forEach(el => el.onclick = () => renderBlocklistModal(el.dataset.blocks));
|
||||
}
|
||||
|
||||
function renderLogin() {
|
||||
app.innerHTML = `<div class="login"><div class="card"><h2>MailAdmin Login</h2>${state.error ? `<div class="error">${esc(state.error)}</div>` : ''}<form id="loginForm"><label>Email<br><input name="email" type="email" required></label><br><br><label>Password<br><input name="password" type="password" required></label><br><br><button>Login</button></form></div></div>`;
|
||||
document.getElementById('loginForm').onsubmit = guard(async e => {
|
||||
e.preventDefault(); const f = new FormData(e.target);
|
||||
state.user = await api('/api/auth/login', { method:'POST', body: JSON.stringify({ email: f.get('email'), password: f.get('password') }) });
|
||||
await loadDomains(true); render();
|
||||
});
|
||||
}
|
||||
|
||||
function modal(html) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'modal-backdrop';
|
||||
div.innerHTML = `<div class="card modal">${html}<div style="margin-top:14px"><button class="ghost" data-close>Close</button></div></div>`;
|
||||
document.body.appendChild(div);
|
||||
div.querySelector('[data-close]').onclick = () => div.remove();
|
||||
div.onclick = e => { if (e.target === div) div.remove(); };
|
||||
return div;
|
||||
}
|
||||
|
||||
function renderCreateMailboxModal() {
|
||||
const d = modal(`<h2>New mailbox</h2><form id="createForm" class="form-grid"><label>Email<input name="email" type="email" value="@${esc(state.selectedDomain || '')}" required></label><label>Password<input name="password" type="password" minlength="8" required></label><div><button>Create</button></div></form>`);
|
||||
d.querySelector('#createForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api('/api/mailboxes', { method:'POST', body: JSON.stringify({ email:f.get('email'), password:f.get('password') }) }); d.remove(); await loadDomains(false); render(); });
|
||||
}
|
||||
|
||||
function renderDeleteModal(email) {
|
||||
const d = modal(`<h2>Delete mailbox</h2><p>Delete <strong>${esc(email)}</strong> from DMS?</p><button class="danger" id="confirmDelete">Delete</button>`);
|
||||
d.querySelector('#confirmDelete').onclick = guard(async () => { await api(`/api/mailboxes/${encodeURIComponent(email)}`, { method:'DELETE' }); d.remove(); await loadMailboxes(); render(); });
|
||||
}
|
||||
|
||||
function renderPasswordModal(email) {
|
||||
const d = modal(`<h2>Reset password</h2><form id="pwForm"><label>New password<input name="password" type="password" minlength="8" required></label><br><br><button>Update password</button></form>`);
|
||||
d.querySelector('#pwForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/password`, { method:'POST', body: JSON.stringify({ password:f.get('password') }) }); d.remove(); state.message = `Password updated for ${email}.`; render(); });
|
||||
}
|
||||
|
||||
async function renderRulesModal(email) {
|
||||
const rules = await api(`/api/mailboxes/${encodeURIComponent(email)}/rules`);
|
||||
const d = modal(`<h2>Rules for ${esc(email)}</h2><form id="rulesForm"><label><input type="checkbox" name="ooo_active" ${rules.ooo_active ? 'checked' : ''} style="width:auto"> Auto reply active</label><br><br><label>Auto reply message<textarea name="ooo_message">${esc(rules.ooo_message || '')}</textarea></label><br><br><label>Forwards, one email per line<textarea name="forwards">${esc((rules.forwards || []).join('\n'))}</textarea></label><br><br><button>Save rules</button></form>`);
|
||||
d.querySelector('#rulesForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/rules`, { method:'PUT', body: JSON.stringify({ ooo_active: !!f.get('ooo_active'), ooo_message:f.get('ooo_message'), forwards:String(f.get('forwards')||'').split(/\n|,/).map(x=>x.trim()).filter(Boolean) }) }); d.remove(); });
|
||||
}
|
||||
|
||||
async function renderBlocklistModal(email) {
|
||||
const block = await api(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`);
|
||||
const d = modal(`<h2>Blocklist for ${esc(email)}</h2><form id="blockForm"><label>Patterns, one per line<br><span class="muted">Examples: spam@example.com, *@bad-domain.com</span><textarea name="blocked_patterns">${esc((block.blocked_patterns || []).join('\n'))}</textarea></label><br><br><button>Save blocklist</button></form>`);
|
||||
d.querySelector('#blockForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`, { method:'PUT', body: JSON.stringify({ blocked_patterns:String(f.get('blocked_patterns')||'').split('\n').map(x=>x.trim()).filter(Boolean) }) }); d.remove(); });
|
||||
}
|
||||
|
||||
function renderAuditModal() {
|
||||
modal(`<h2>Audit Log</h2><table class="table"><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th></tr></thead><tbody>${state.audit.map(a => `<tr><td class="muted">${new Date(a.created_at).toLocaleString()}</td><td>${esc(a.actor_email)}</td><td>${esc(a.action)}</td><td>${esc(a.target_id)}</td></tr>`).join('')}</tbody></table>`);
|
||||
}
|
||||
|
||||
function guard(fn) { return async function(...args) { try { state.error=''; state.message=''; await fn.apply(this,args); } catch(e) { state.error = e.message; render(); } }; }
|
||||
|
||||
init();
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MailAdmin</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
frontend/styles.css
Normal file
35
frontend/styles.css
Normal file
@@ -0,0 +1,35 @@
|
||||
:root { --bg:#f6f7fb; --card:#fff; --line:#e5e7eb; --text:#111827; --muted:#6b7280; --accent:#2563eb; --danger:#dc2626; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, sans-serif; background: var(--bg); color: var(--text); }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { border: 0; border-radius: 10px; padding: 10px 14px; background: var(--accent); color: white; cursor: pointer; }
|
||||
button.secondary { background: #eef2ff; color: #1e3a8a; }
|
||||
button.danger { background: var(--danger); }
|
||||
button.ghost { background: transparent; color: var(--accent); }
|
||||
input, textarea, select { width: 100%; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: white; }
|
||||
textarea { min-height: 110px; }
|
||||
.header { display:flex; justify-content:space-between; align-items:center; padding: 18px 28px; background: white; border-bottom:1px solid var(--line); position: sticky; top: 0; z-index: 5; }
|
||||
.brand { font-weight: 800; font-size: 20px; }
|
||||
.container { max-width: 1280px; margin: 0 auto; padding: 24px; }
|
||||
.grid { display:grid; grid-template-columns: 320px 1fr; gap: 20px; align-items:start; }
|
||||
.card { background: var(--card); border:1px solid var(--line); border-radius: 18px; padding: 18px; box-shadow: 0 10px 25px rgba(15,23,42,.04); }
|
||||
.card h2 { margin:0 0 14px; font-size: 18px; }
|
||||
.list { display:flex; flex-direction:column; gap: 8px; }
|
||||
.list-item { border:1px solid var(--line); border-radius: 14px; padding: 12px; background:#fff; cursor:pointer; }
|
||||
.list-item.active { border-color: var(--accent); background:#eff6ff; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
.row { display:flex; gap: 10px; align-items:center; }
|
||||
.row > * { flex: 1; }
|
||||
.table { width:100%; border-collapse: collapse; }
|
||||
.table th, .table td { text-align:left; padding: 10px; border-bottom:1px solid var(--line); vertical-align: top; }
|
||||
.table th { font-size:12px; color:var(--muted); text-transform: uppercase; letter-spacing:.04em; }
|
||||
.pill { display:inline-flex; align-items:center; padding: 3px 8px; border-radius: 999px; background:#f3f4f6; font-size:12px; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.login { min-height: 100vh; display:grid; place-items:center; padding:24px; }
|
||||
.login .card { width:min(420px, 100%); }
|
||||
.error { color: var(--danger); margin: 10px 0; }
|
||||
.success { color: #047857; margin: 10px 0; }
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(15,23,42,.35); display:grid; place-items:center; padding: 20px; z-index: 10; }
|
||||
.modal { width:min(720px, 100%); max-height: 90vh; overflow:auto; }
|
||||
.form-grid { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
|
||||
@media (max-width: 900px) { .grid { grid-template-columns:1fr; } .form-grid { grid-template-columns:1fr; } .header { padding:14px; } .container { padding:14px; } }
|
||||
Reference in New Issue
Block a user