508 lines
19 KiB
JavaScript
508 lines
19 KiB
JavaScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
|
FiUsers, FiUser, FiHardDrive, FiDollarSign, FiActivity,
|
|
} 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 AdminUsersModal from './components/AdminUsersModal';
|
|
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
|
|
import DomainQuotaModal from './components/DomainQuotaModal';
|
|
import BillingModal from './components/BillingModal';
|
|
import HealthModal from './components/HealthModal';
|
|
import HealthBanner from './components/HealthBanner';
|
|
|
|
import { authAPI, domainsAPI, mailboxesAPI, healthAPI } 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('');
|
|
const [toast, setToast] = useState(null);
|
|
|
|
const [settingsTarget, setSettingsTarget] = useState(null);
|
|
const [pwTarget, setPwTarget] = useState(null);
|
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
const [showNew, setShowNew] = useState(false);
|
|
const [showAudit, setShowAudit] = useState(false);
|
|
const [showAdmins, setShowAdmins] = useState(false);
|
|
const [showChangePw, setShowChangePw] = useState(false);
|
|
const [showDomainQuota, setShowDomainQuota] = useState(false);
|
|
const [showBilling, setShowBilling] = useState(false);
|
|
const [showHealth, setShowHealth] = useState(false);
|
|
|
|
// Persisted health status for the currently selected domain (drives the banner).
|
|
const [healthStatus, setHealthStatus] = useState(null);
|
|
|
|
const showToast = useCallback((message, type = 'success') => {
|
|
setToast({ message, type });
|
|
}, []);
|
|
|
|
const isSuperAdmin = user?.role === 'super_admin';
|
|
const hideDomainList = !isSuperAdmin && domains.length <= 1;
|
|
|
|
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);
|
|
}, []);
|
|
|
|
// Load (or re-load) the persisted health status for a domain.
|
|
// Cheap call — just reads from PostgreSQL, no checks are run.
|
|
const loadHealthStatus = useCallback(async (domain) => {
|
|
if (!domain) { setHealthStatus(null); return; }
|
|
try {
|
|
const status = await healthAPI.getStatus(domain);
|
|
setHealthStatus(status); // null if never checked, that's fine
|
|
} catch (err) {
|
|
// Silent: don't block the UI just because health status load failed.
|
|
console.warn('Failed to load health status:', err);
|
|
setHealthStatus(null);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const me = await authAPI.me();
|
|
setUser(me);
|
|
} catch {
|
|
setUser(null);
|
|
} finally {
|
|
setBootChecked(true);
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
(async () => {
|
|
setBusyMessage('Loading domains...');
|
|
try {
|
|
const list = await loadDomains(isSuperAdmin);
|
|
const first = list[0]?.domain || null;
|
|
setSelectedDomain(first);
|
|
if (first) {
|
|
setBusyMessage('Refreshing quotas...');
|
|
await loadMailboxes(first, true);
|
|
await loadHealthStatus(first);
|
|
}
|
|
} catch (err) {
|
|
showToast(`Failed to load: ${err.message}`, 'error');
|
|
} finally {
|
|
setBusyMessage('');
|
|
}
|
|
})();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [user]);
|
|
|
|
const selectDomain = async (domain) => {
|
|
if (domain === selectedDomain) return;
|
|
setSelectedDomain(domain);
|
|
setBusyMessage('Loading mailboxes...');
|
|
try {
|
|
await loadMailboxes(domain, true);
|
|
await loadHealthStatus(domain);
|
|
} 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 (selectedDomain && !list.find((d) => d.domain === selectedDomain)) {
|
|
const first = list[0]?.domain || null;
|
|
setSelectedDomain(first);
|
|
if (first) {
|
|
await loadMailboxes(first, false);
|
|
await loadHealthStatus(first);
|
|
}
|
|
} 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);
|
|
setHealthStatus(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('');
|
|
}
|
|
};
|
|
|
|
const handleQuotaApplied = async () => {
|
|
setBusyMessage('Refreshing quotas...');
|
|
try {
|
|
await loadMailboxes(selectedDomain, true);
|
|
} finally {
|
|
setBusyMessage('');
|
|
}
|
|
};
|
|
|
|
// Called by HealthModal after a fresh check completes — re-load the
|
|
// persisted summary so the banner reflects the new state.
|
|
const handleHealthChecked = async () => {
|
|
if (selectedDomain) await loadHealthStatus(selectedDomain);
|
|
};
|
|
|
|
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 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 gap-4">
|
|
<div className="min-w-0">
|
|
<h1 className="text-xl font-bold text-gray-900">MailAdmin</h1>
|
|
<p className="text-xs text-gray-500 truncate">
|
|
{user.email} · <span className="font-medium">{user.role}</span>
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
<button onClick={() => setShowChangePw(true)} className="btn-secondary" title="Change my password">
|
|
<FiUser className="w-4 h-4 mr-2" />
|
|
My Password
|
|
</button>
|
|
{isSuperAdmin && (
|
|
<>
|
|
<button onClick={() => setShowBilling(true)} className="btn-secondary">
|
|
<FiDollarSign className="w-4 h-4 mr-2" />
|
|
Billing
|
|
</button>
|
|
<button onClick={() => setShowAudit(true)} className="btn-secondary">
|
|
<FiList className="w-4 h-4 mr-2" />
|
|
Audit Log
|
|
</button>
|
|
<button onClick={() => setShowAdmins(true)} className="btn-secondary">
|
|
<FiUsers className="w-4 h-4 mr-2" />
|
|
Admins
|
|
</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 className="max-w-7xl mx-auto px-6 py-6">
|
|
<div className={`grid grid-cols-1 ${hideDomainList ? '' : 'lg:grid-cols-[320px_1fr]'} gap-6 items-start`}>
|
|
{!hideDomainList && (
|
|
<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">
|
|
{isSuperAdmin
|
|
? 'Domains are discovered dynamically from DMS accounts.'
|
|
: 'Your assigned domains.'}
|
|
</p>
|
|
|
|
{domains.length === 0 ? (
|
|
<p className="text-sm text-gray-400">No domains available.</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>
|
|
)}
|
|
|
|
<section className="card relative">
|
|
{/* Health banner (shows only when last check found problems) */}
|
|
<HealthBanner status={healthStatus} onOpen={() => setShowHealth(true)} />
|
|
|
|
<div className="flex items-start justify-between gap-4 mb-5 flex-wrap">
|
|
<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>
|
|
<div className="flex flex-row flex-wrap gap-2">
|
|
<button
|
|
onClick={() => setShowHealth(true)}
|
|
disabled={!selectedDomain}
|
|
className="btn-secondary"
|
|
title="Run DNS, DMS and TLS cert checks for this domain"
|
|
>
|
|
<FiActivity className="w-4 h-4 mr-2" />
|
|
Check health
|
|
</button>
|
|
{isSuperAdmin && (
|
|
<button
|
|
onClick={() => setShowDomainQuota(true)}
|
|
disabled={!selectedDomain || mailboxes.length === 0}
|
|
className="btn-secondary"
|
|
title="Set quota for all mailboxes in this domain"
|
|
>
|
|
<FiHardDrive className="w-4 h-4 mr-2" />
|
|
Set quota
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setShowNew(true)}
|
|
disabled={!selectedDomain}
|
|
className="btn-primary"
|
|
>
|
|
<FiPlus className="w-4 h-4 mr-2" />
|
|
New mailbox
|
|
</button>
|
|
</div>
|
|
</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>
|
|
<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>
|
|
|
|
<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)}
|
|
/>
|
|
{isSuperAdmin && (
|
|
<AuditLogModal
|
|
open={showAudit}
|
|
onClose={() => setShowAudit(false)}
|
|
onToast={showToast}
|
|
/>
|
|
)}
|
|
{isSuperAdmin && (
|
|
<AdminUsersModal
|
|
open={showAdmins}
|
|
currentUser={user}
|
|
onClose={() => setShowAdmins(false)}
|
|
onToast={showToast}
|
|
/>
|
|
)}
|
|
{isSuperAdmin && (
|
|
<DomainQuotaModal
|
|
open={showDomainQuota}
|
|
domain={selectedDomain}
|
|
mailboxes={mailboxes}
|
|
onClose={() => setShowDomainQuota(false)}
|
|
onApplied={handleQuotaApplied}
|
|
onToast={showToast}
|
|
/>
|
|
)}
|
|
{isSuperAdmin && (
|
|
<BillingModal
|
|
open={showBilling}
|
|
domains={domains}
|
|
onClose={() => setShowBilling(false)}
|
|
onToast={showToast}
|
|
/>
|
|
)}
|
|
<HealthModal
|
|
open={showHealth}
|
|
domain={selectedDomain}
|
|
onClose={() => setShowHealth(false)}
|
|
onCheckedReport={handleHealthChecked}
|
|
onToast={showToast}
|
|
/>
|
|
<ChangeMyPasswordModal
|
|
open={showChangePw}
|
|
onClose={() => setShowChangePw(false)}
|
|
onToast={showToast}
|
|
/>
|
|
|
|
{busyMessage && <LoadingOverlay message={busyMessage} fullscreen />}
|
|
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|