react
This commit is contained in:
384
frontend/src/App.jsx
Normal file
384
frontend/src/App.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user