This commit is contained in:
2026-04-27 15:24:23 -05:00
parent 32a00b3706
commit c4acdb2a66
28 changed files with 3575 additions and 358 deletions

384
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,384 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
} from 'react-icons/fi';
import Login from './components/Login';
import Toast from './components/Toast';
import LoadingOverlay from './components/LoadingOverlay';
import UsageBar, { formatBytes } from './components/UsageBar';
import MailboxSettingsModal from './components/MailboxSettingsModal';
import NewMailboxModal from './components/NewMailboxModal';
import PasswordResetModal from './components/PasswordResetModal';
import ConfirmDialog from './components/ConfirmDialog';
import AuditLogModal from './components/AuditLogModal';
import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
function App() {
const [user, setUser] = useState(null);
const [bootChecked, setBootChecked] = useState(false);
const [domains, setDomains] = useState([]);
const [selectedDomain, setSelectedDomain] = useState(null);
const [mailboxes, setMailboxes] = useState([]);
const [busyMessage, setBusyMessage] = useState(''); // global blocking spinner
const [toast, setToast] = useState(null);
const [settingsTarget, setSettingsTarget] = useState(null); // { email, tab }
const [pwTarget, setPwTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [showNew, setShowNew] = useState(false);
const [showAudit, setShowAudit] = useState(false);
const showToast = useCallback((message, type = 'success') => {
setToast({ message, type });
}, []);
// ---- data loading helpers ----
const loadDomains = useCallback(async (resync = false) => {
const list = await domainsAPI.list(resync);
setDomains(list);
return list;
}, []);
const loadMailboxes = useCallback(async (domain, refreshQuota = false) => {
if (!domain) { setMailboxes([]); return; }
const list = await mailboxesAPI.list(domain, refreshQuota);
setMailboxes(list);
}, []);
// ---- initial boot ----
useEffect(() => {
(async () => {
try {
const me = await authAPI.me();
setUser(me);
} catch {
setUser(null);
} finally {
setBootChecked(true);
}
})();
}, []);
// After login (or on first authenticated render): load domains.
useEffect(() => {
if (!user) return;
(async () => {
setBusyMessage('Loading domains...');
try {
const list = await loadDomains(true);
const first = list[0]?.domain || null;
setSelectedDomain(first);
if (first) {
setBusyMessage('Refreshing quotas...');
await loadMailboxes(first, true);
}
} catch (err) {
showToast(`Failed to load: ${err.message}`, 'error');
} finally {
setBusyMessage('');
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
// Switch domain -> reload mailboxes (with quota refresh, like the original).
const selectDomain = async (domain) => {
if (domain === selectedDomain) return;
setSelectedDomain(domain);
setBusyMessage('Loading mailboxes...');
try {
await loadMailboxes(domain, true);
} catch (err) {
showToast(`Failed to load mailboxes: ${err.message}`, 'error');
} finally {
setBusyMessage('');
}
};
const handleResync = async () => {
setBusyMessage('Re-syncing from DMS...');
try {
await domainsAPI.resync();
const list = await loadDomains(false);
// If selected domain disappeared, fall back to first.
if (selectedDomain && !list.find((d) => d.domain === selectedDomain)) {
const first = list[0]?.domain || null;
setSelectedDomain(first);
if (first) await loadMailboxes(first, false);
} else if (selectedDomain) {
await loadMailboxes(selectedDomain, false);
}
showToast('DMS sync complete', 'success');
} catch (err) {
showToast(`Sync failed: ${err.message}`, 'error');
} finally {
setBusyMessage('');
}
};
const handleLogout = async () => {
try { await authAPI.logout(); } catch { /* ignore */ }
setUser(null);
setDomains([]);
setMailboxes([]);
setSelectedDomain(null);
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await mailboxesAPI.remove(deleteTarget);
showToast(`Deleted ${deleteTarget}`, 'success');
setDeleteTarget(null);
await loadMailboxes(selectedDomain, false);
await loadDomains(false);
} catch (err) {
showToast(`Delete failed: ${err.message}`, 'error');
}
};
const handleMailboxCreated = async () => {
setBusyMessage('Refreshing...');
try {
await loadDomains(false);
await loadMailboxes(selectedDomain, true);
} finally {
setBusyMessage('');
}
};
// ---- render ----
if (!bootChecked) {
return <div className="min-h-screen flex items-center justify-center text-gray-400">Loading...</div>;
}
if (!user) {
return (
<>
<Login onLogin={setUser} />
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
</>
);
}
return (
<div className="min-h-screen">
{/* Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900">MailAdmin</h1>
<p className="text-xs text-gray-500">
{user.email} · <span className="font-medium">{user.role}</span>
</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => setShowAudit(true)} className="btn-secondary">
<FiList className="w-4 h-4 mr-2" />
Audit Log
</button>
<button onClick={handleResync} className="btn-secondary">
<FiRefreshCw className="w-4 h-4 mr-2" />
DMS Resync
</button>
<button onClick={handleLogout} className="btn-ghost">
<FiLogOut className="w-4 h-4 mr-2" />
Logout
</button>
</div>
</div>
</header>
{/* Main */}
<main className="max-w-7xl mx-auto px-6 py-6">
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6 items-start">
{/* Domains */}
<section className="card">
<h2 className="text-base font-semibold text-gray-900">Domains on this node</h2>
<p className="text-xs text-gray-500 mt-1 mb-4">
Domains are discovered dynamically from DMS accounts.
</p>
{domains.length === 0 ? (
<p className="text-sm text-gray-400">No domains found yet.</p>
) : (
<div className="flex flex-col gap-2">
{domains.map((d) => {
const active = d.domain === selectedDomain;
return (
<button
key={d.domain}
onClick={() => selectDomain(d.domain)}
className={`text-left p-3 rounded-lg border transition-colors ${
active
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="font-semibold text-gray-900">{d.domain}</div>
<div className="text-xs text-gray-500 mt-0.5">
{d.active_mailboxes || 0} inboxes · {formatBytes(d.used_bytes)}
</div>
<div className="flex flex-wrap gap-1 mt-2">
<span className="pill">{d.current_node || d.node_name}</span>
<span className={d.status === 'active' ? 'pill-success' : 'pill'}>
{d.status}
</span>
</div>
</button>
);
})}
</div>
)}
</section>
{/* Mailboxes */}
<section className="card relative">
<div className="flex items-start justify-between gap-4 mb-5">
<div>
<h2 className="text-base font-semibold text-gray-900">
{selectedDomain || 'Mailboxes'}
</h2>
<p className="text-xs text-gray-500 mt-1">
Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
</p>
</div>
<button
onClick={() => setShowNew(true)}
disabled={!selectedDomain}
className="btn-primary"
>
<FiPlus className="w-4 h-4 mr-2" />
New mailbox
</button>
</div>
{mailboxes.length === 0 ? (
<div className="text-center py-12 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
<FiInbox className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 font-medium">No mailboxes for this domain</p>
<p className="text-sm text-gray-500 mt-1">
{selectedDomain ? 'Click "New mailbox" to create the first one.' : 'Select a domain first.'}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
<th className="py-3 pr-4 font-semibold">Email</th>
<th className="py-3 pr-4 font-semibold">Status</th>
<th className="py-3 pr-4 font-semibold">Usage</th>
<th className="py-3 pr-4 font-semibold whitespace-nowrap">Updated</th>
<th className="py-3 pr-2 font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{mailboxes.map((m) => (
<tr key={m.email_address} className="align-top hover:bg-gray-50">
<td className="py-3 pr-4">
<div className="font-semibold text-gray-900">{m.email_address}</div>
<div className="text-xs text-gray-500">{m.node_name}</div>
</td>
<td className="py-3 pr-4">
<span className={m.status === 'active' ? 'pill-success' : 'pill'}>
{m.status}
</span>
</td>
<td className="py-3 pr-4"><UsageBar mailbox={m} /></td>
<td className="py-3 pr-4 text-xs text-gray-500 whitespace-nowrap">
{new Date(m.updated_at).toLocaleString()}
</td>
{/* Actions: single row, no wrapping. Punkt 3 deiner Liste. */}
<td className="py-3 pr-2">
<div className="flex flex-row flex-nowrap items-center gap-2">
<button
onClick={() => setSettingsTarget({ email: m.email_address, tab: 'fwd' })}
className="btn-secondary"
title="Forwarding & Auto-Reply"
>
<FiSettings className="w-3.5 h-3.5 mr-1.5" />
Settings
</button>
<button
onClick={() => setPwTarget(m.email_address)}
className="btn-secondary"
title="Reset password"
>
<FiKey className="w-3.5 h-3.5 mr-1.5" />
Password
</button>
<button
onClick={() => setDeleteTarget(m.email_address)}
className="btn-danger"
title="Delete mailbox"
>
<FiTrash2 className="w-3.5 h-3.5 mr-1.5" />
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</main>
{/* Modals */}
<MailboxSettingsModal
open={!!settingsTarget}
email={settingsTarget?.email}
initialTab={settingsTarget?.tab}
onClose={() => setSettingsTarget(null)}
onToast={showToast}
/>
<NewMailboxModal
open={showNew}
domain={selectedDomain}
onClose={() => setShowNew(false)}
onCreated={handleMailboxCreated}
onToast={showToast}
/>
<PasswordResetModal
open={!!pwTarget}
email={pwTarget}
onClose={() => setPwTarget(null)}
onToast={showToast}
/>
<ConfirmDialog
open={!!deleteTarget}
title="Delete mailbox"
message={
deleteTarget
? `Delete ${deleteTarget} from DMS? This cannot be undone.`
: ''
}
confirmLabel="Delete"
danger
onConfirm={handleDelete}
onClose={() => setDeleteTarget(null)}
/>
<AuditLogModal
open={showAudit}
onClose={() => setShowAudit(false)}
onToast={showToast}
/>
{/* Global blocking overlay (Punkt 2 deiner Liste) */}
{busyMessage && <LoadingOverlay message={busyMessage} fullscreen />}
{/* Toast */}
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
</div>
);
}
export default App;