new features
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
||||
FiUsers, FiUser,
|
||||
FiUsers, FiUser, FiHardDrive,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
import Login from './components/Login';
|
||||
@@ -15,6 +15,7 @@ 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 { authAPI, domainsAPI, mailboxesAPI } from './services/api';
|
||||
|
||||
@@ -36,16 +37,15 @@ function App() {
|
||||
const [showAudit, setShowAudit] = useState(false);
|
||||
const [showAdmins, setShowAdmins] = useState(false);
|
||||
const [showChangePw, setShowChangePw] = useState(false);
|
||||
const [showDomainQuota, setShowDomainQuota] = useState(false);
|
||||
|
||||
const showToast = useCallback((message, type = 'success') => {
|
||||
setToast({ message, type });
|
||||
}, []);
|
||||
|
||||
const isSuperAdmin = user?.role === 'super_admin';
|
||||
// Hide left column entirely if a domain admin only has a single domain.
|
||||
const hideDomainList = !isSuperAdmin && domains.length <= 1;
|
||||
|
||||
// ---- data loading helpers ----
|
||||
const loadDomains = useCallback(async (resync = false) => {
|
||||
const list = await domainsAPI.list(resync);
|
||||
setDomains(list);
|
||||
@@ -58,7 +58,6 @@ function App() {
|
||||
setMailboxes(list);
|
||||
}, []);
|
||||
|
||||
// ---- initial boot ----
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -77,7 +76,6 @@ function App() {
|
||||
(async () => {
|
||||
setBusyMessage('Loading domains...');
|
||||
try {
|
||||
// Only super admin should trigger an upstream DMS resync on login.
|
||||
const list = await loadDomains(isSuperAdmin);
|
||||
const first = list[0]?.domain || null;
|
||||
setSelectedDomain(first);
|
||||
@@ -158,7 +156,15 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- render ----
|
||||
const handleQuotaApplied = async () => {
|
||||
setBusyMessage('Refreshing quotas...');
|
||||
try {
|
||||
await loadMailboxes(selectedDomain, true);
|
||||
} finally {
|
||||
setBusyMessage('');
|
||||
}
|
||||
};
|
||||
|
||||
if (!bootChecked) {
|
||||
return <div className="min-h-screen flex items-center justify-center text-gray-400">Loading...</div>;
|
||||
}
|
||||
@@ -174,7 +180,6 @@ function App() {
|
||||
|
||||
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 gap-4">
|
||||
<div className="min-w-0">
|
||||
@@ -188,12 +193,13 @@ function App() {
|
||||
<FiUser className="w-4 h-4 mr-2" />
|
||||
My Password
|
||||
</button>
|
||||
<button onClick={() => setShowAudit(true)} className="btn-secondary">
|
||||
<FiList className="w-4 h-4 mr-2" />
|
||||
Audit Log
|
||||
</button>
|
||||
{/* Audit log: super_admin only. */}
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<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
|
||||
@@ -212,10 +218,8 @@ function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main */}
|
||||
<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`}>
|
||||
{/* Domains - hidden when single-domain user */}
|
||||
{!hideDomainList && (
|
||||
<section className="card">
|
||||
<h2 className="text-base font-semibold text-gray-900">Domains on this node</h2>
|
||||
@@ -259,9 +263,8 @@ function App() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Mailboxes */}
|
||||
<section className="card relative">
|
||||
<div className="flex items-start justify-between gap-4 mb-5">
|
||||
<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'}
|
||||
@@ -270,14 +273,28 @@ function App() {
|
||||
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 className="flex flex-row flex-nowrap gap-2">
|
||||
{/* Set domain-wide quota: super_admin only. */}
|
||||
{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 ? (
|
||||
@@ -354,7 +371,6 @@ function App() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
<MailboxSettingsModal
|
||||
open={!!settingsTarget}
|
||||
email={settingsTarget?.email}
|
||||
@@ -388,11 +404,13 @@ function App() {
|
||||
onConfirm={handleDelete}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
/>
|
||||
<AuditLogModal
|
||||
open={showAudit}
|
||||
onClose={() => setShowAudit(false)}
|
||||
onToast={showToast}
|
||||
/>
|
||||
{isSuperAdmin && (
|
||||
<AuditLogModal
|
||||
open={showAudit}
|
||||
onClose={() => setShowAudit(false)}
|
||||
onToast={showToast}
|
||||
/>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<AdminUsersModal
|
||||
open={showAdmins}
|
||||
@@ -401,6 +419,16 @@ function App() {
|
||||
onToast={showToast}
|
||||
/>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<DomainQuotaModal
|
||||
open={showDomainQuota}
|
||||
domain={selectedDomain}
|
||||
mailboxes={mailboxes}
|
||||
onClose={() => setShowDomainQuota(false)}
|
||||
onApplied={handleQuotaApplied}
|
||||
onToast={showToast}
|
||||
/>
|
||||
)}
|
||||
<ChangeMyPasswordModal
|
||||
open={showChangePw}
|
||||
onClose={() => setShowChangePw(false)}
|
||||
|
||||
@@ -5,13 +5,6 @@ import LoadingOverlay from './LoadingOverlay';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import { adminsAPI, domainsAPI } from '../services/api';
|
||||
|
||||
/**
|
||||
* Two-pane modal:
|
||||
* - 'list' shows all admins
|
||||
* - 'form' shows create/edit form
|
||||
*
|
||||
* editTarget is null for "create new" or an existing admin for "edit".
|
||||
*/
|
||||
const AdminUsersModal = ({ open, currentUser, onClose, onToast }) => {
|
||||
const [view, setView] = useState('list');
|
||||
const [admins, setAdmins] = useState([]);
|
||||
@@ -219,8 +212,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Tracks whether the user has manually typed in the email field.
|
||||
// Once they have, we stop auto-filling to avoid clobbering their input.
|
||||
const [emailTouched, setEmailTouched] = useState(isEdit);
|
||||
const emailRef = useRef(null);
|
||||
|
||||
@@ -230,8 +221,7 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
||||
);
|
||||
|
||||
// Auto-fill email with "@<domain>" when creating a NEW domain_admin
|
||||
// and exactly one domain is selected, as long as the user hasn't
|
||||
// manually edited the email field yet.
|
||||
// and exactly one domain is selected.
|
||||
useEffect(() => {
|
||||
if (isEdit) return;
|
||||
if (role !== 'domain_admin') return;
|
||||
@@ -239,7 +229,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
||||
if (allowedDomains.size === 1) {
|
||||
const [onlyDomain] = [...allowedDomains];
|
||||
setEmail(`@${onlyDomain}`);
|
||||
// Place caret before the @ so the user can type the local part directly.
|
||||
requestAnimationFrame(() => {
|
||||
const el = emailRef.current;
|
||||
if (el && document.activeElement === el) {
|
||||
@@ -251,8 +240,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
||||
}
|
||||
}, [allowedDomains, role, isEdit, emailTouched]);
|
||||
|
||||
// When the user focuses the email field for the first time after
|
||||
// an auto-fill, place the caret at position 0 so they can just type.
|
||||
const handleEmailFocus = () => {
|
||||
const el = emailRef.current;
|
||||
if (el && !emailTouched && email.startsWith('@')) {
|
||||
@@ -270,7 +257,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
||||
const passwordMismatch = password.length > 0 && confirmPw.length > 0 && password !== confirmPw;
|
||||
const passwordTooShort = password.length > 0 && password.length < 8;
|
||||
|
||||
// For new admins password is required.
|
||||
const passwordRequired = !isEdit;
|
||||
const passwordProvided = password.length > 0;
|
||||
|
||||
@@ -299,7 +285,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
||||
if (isEdit) {
|
||||
const payload = { role, allowed_domains: allowedList, active };
|
||||
if (passwordProvided) payload.password = password;
|
||||
// Don't send forbidden fields when editing self.
|
||||
if (isSelf) {
|
||||
delete payload.role;
|
||||
delete payload.active;
|
||||
@@ -324,23 +309,42 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
||||
};
|
||||
|
||||
return (
|
||||
// Wrap fields in a "fake form" with autoComplete="off" so that
|
||||
// browsers don't autofill the user's own login credentials into the
|
||||
// new-admin form. We also use uncommon name= values and a hidden
|
||||
// dummy field as a decoy — Chromium ignores autoComplete="off" on
|
||||
// login-shaped inputs unless tricks like these are used.
|
||||
<div className="space-y-5">
|
||||
<button onClick={onCancel} className="btn-ghost -ml-2">
|
||||
<FiArrowLeft className="w-4 h-4 mr-1.5" />
|
||||
Back to list
|
||||
</button>
|
||||
|
||||
{/* Decoy fields to absorb Chrome's autofill attempts. */}
|
||||
<div style={{ display: 'none' }} aria-hidden="true">
|
||||
<input type="text" name="username" autoComplete="username" tabIndex={-1} />
|
||||
<input type="password" name="password" autoComplete="current-password" tabIndex={-1} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
type="text"
|
||||
inputMode="email"
|
||||
name="admin_create_email"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setEmailTouched(true); }}
|
||||
onFocus={handleEmailFocus}
|
||||
className="input-field"
|
||||
disabled={isEdit}
|
||||
placeholder="admin@example.com"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
/>
|
||||
{isEdit && (
|
||||
<p className="mt-1 text-xs text-gray-500">Email cannot be changed after creation.</p>
|
||||
@@ -354,17 +358,25 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="password"
|
||||
name="admin_create_pw_new"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`input-field ${passwordTooShort ? 'border-red-500' : ''}`}
|
||||
placeholder="At least 8 characters"
|
||||
autoComplete="new-password"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="admin_create_pw_confirm"
|
||||
value={confirmPw}
|
||||
onChange={(e) => setConfirmPw(e.target.value)}
|
||||
className={`input-field ${passwordMismatch ? 'border-red-500' : ''}`}
|
||||
placeholder="Confirm"
|
||||
autoComplete="new-password"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
/>
|
||||
</div>
|
||||
{passwordTooShort && <p className="mt-1 text-xs text-red-600">Minimum 8 characters.</p>}
|
||||
@@ -479,4 +491,4 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsersModal;
|
||||
export default AdminUsersModal;
|
||||
|
||||
153
frontend/src/components/DomainQuotaModal.jsx
Normal file
153
frontend/src/components/DomainQuotaModal.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FiHardDrive, FiAlertTriangle } from 'react-icons/fi';
|
||||
import Modal from './Modal';
|
||||
import { mailboxesAPI } from '../services/api';
|
||||
|
||||
const QUOTA_OPTIONS = [5, 10, 15, 20, 25, 30, 35, 40];
|
||||
|
||||
const DomainQuotaModal = ({ open, domain, mailboxes, onClose, onApplied, onToast }) => {
|
||||
const [selectedGb, setSelectedGb] = useState(10);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [progress, setProgress] = useState({ done: 0, total: 0, current: '' });
|
||||
const [errors, setErrors] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedGb(10);
|
||||
setBusy(false);
|
||||
setProgress({ done: 0, total: 0, current: '' });
|
||||
setErrors([]);
|
||||
}
|
||||
}, [open, domain]);
|
||||
|
||||
const activeMailboxes = (mailboxes || []).filter((m) => m.status === 'active');
|
||||
|
||||
const apply = async () => {
|
||||
if (activeMailboxes.length === 0) return;
|
||||
setBusy(true);
|
||||
setErrors([]);
|
||||
setProgress({ done: 0, total: activeMailboxes.length, current: '' });
|
||||
|
||||
const failed = [];
|
||||
|
||||
// Sequential to avoid hammering the DMS container with parallel
|
||||
// docker exec calls and to give the user honest progress.
|
||||
for (let i = 0; i < activeMailboxes.length; i++) {
|
||||
const m = activeMailboxes[i];
|
||||
setProgress({ done: i, total: activeMailboxes.length, current: m.email_address });
|
||||
try {
|
||||
await mailboxesAPI.setQuota(m.email_address, selectedGb);
|
||||
} catch (err) {
|
||||
failed.push({ email: m.email_address, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
setProgress({ done: activeMailboxes.length, total: activeMailboxes.length, current: '' });
|
||||
|
||||
if (failed.length === 0) {
|
||||
onToast?.(`Quota set to ${selectedGb} GB for ${activeMailboxes.length} mailboxes.`, 'success');
|
||||
await onApplied?.();
|
||||
onClose();
|
||||
} else {
|
||||
setErrors(failed);
|
||||
onToast?.(
|
||||
`${activeMailboxes.length - failed.length}/${activeMailboxes.length} succeeded. See dialog for failures.`,
|
||||
'warning',
|
||||
);
|
||||
// Refresh anyway so the user sees what worked.
|
||||
await onApplied?.();
|
||||
}
|
||||
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={busy ? () => {} : onClose}
|
||||
title="Set quota for all mailboxes"
|
||||
subtitle={domain}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start gap-3 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<FiAlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-amber-900">
|
||||
This applies the selected quota to <strong>all {activeMailboxes.length} active mailboxes</strong> in
|
||||
this domain. Existing per-mailbox overrides will be replaced.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Quota size</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{QUOTA_OPTIONS.map((gb) => {
|
||||
const isSel = selectedGb === gb;
|
||||
return (
|
||||
<button
|
||||
key={gb}
|
||||
type="button"
|
||||
onClick={() => setSelectedGb(gb)}
|
||||
disabled={busy}
|
||||
className={`px-4 py-3 rounded-lg border-2 font-semibold transition-all ${
|
||||
isSel
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{gb} GB
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{busy && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-gray-600">
|
||||
<span className="truncate">
|
||||
{progress.current ? `Processing: ${progress.current}` : 'Finishing...'}
|
||||
</span>
|
||||
<span className="font-mono whitespace-nowrap">
|
||||
{progress.done} / {progress.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-600 transition-all duration-200"
|
||||
style={{ width: `${progress.total ? (progress.done / progress.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="border border-red-200 rounded-lg bg-red-50 p-3">
|
||||
<p className="text-sm font-semibold text-red-800 mb-1">
|
||||
{errors.length} mailbox{errors.length > 1 ? 'es' : ''} failed:
|
||||
</p>
|
||||
<ul className="text-xs text-red-700 space-y-1 max-h-32 overflow-y-auto custom-scrollbar">
|
||||
{errors.map((e) => (
|
||||
<li key={e.email}>
|
||||
<span className="font-mono">{e.email}</span>: {e.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200">
|
||||
<button onClick={onClose} className="btn-secondary px-4 py-2" disabled={busy}>
|
||||
{errors.length > 0 ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
<button onClick={apply} className="btn-primary" disabled={busy || activeMailboxes.length === 0}>
|
||||
<FiHardDrive className="w-4 h-4 mr-2" />
|
||||
{busy ? 'Applying...' : `Apply ${selectedGb} GB to ${activeMailboxes.length} mailboxes`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DomainQuotaModal;
|
||||
@@ -1,13 +1,11 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Same-origin: backend serves the built frontend, vite dev proxies /api.
|
||||
const api = axios.create({
|
||||
baseURL: '/',
|
||||
withCredentials: true,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// Centralized error normalization so callers always see err.message + err.statusCode.
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err) => {
|
||||
@@ -52,6 +50,12 @@ export const mailboxesAPI = {
|
||||
`/api/mailboxes/${encodeURIComponent(email)}/password`,
|
||||
{ password }
|
||||
)).data,
|
||||
// quota_gb: integer, e.g. 5, 10, 15...
|
||||
setQuota: async (email, quota_gb) =>
|
||||
(await api.post(
|
||||
`/api/mailboxes/${encodeURIComponent(email)}/quota`,
|
||||
{ quota_gb }
|
||||
)).data,
|
||||
getRules: async (email) =>
|
||||
(await api.get(`/api/mailboxes/${encodeURIComponent(email)}/rules`)).data,
|
||||
putRules: async (email, payload) =>
|
||||
|
||||
Reference in New Issue
Block a user