new features
This commit is contained in:
@@ -1,49 +1,14 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { pool } from '../db.js';
|
import { pool } from '../db.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth, requireSuperAdmin } from '../middleware/auth.js';
|
||||||
|
|
||||||
export const auditRouter = Router();
|
export const auditRouter = Router();
|
||||||
auditRouter.use(requireAuth);
|
auditRouter.use(requireAuth);
|
||||||
|
auditRouter.use(requireSuperAdmin);
|
||||||
|
|
||||||
auditRouter.get('/', async (req, res) => {
|
auditRouter.get('/', async (_req, res) => {
|
||||||
const user = req.user!;
|
|
||||||
|
|
||||||
if (user.role === 'super_admin') {
|
|
||||||
// Super admin sees everything.
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200`,
|
|
||||||
);
|
|
||||||
res.json(result.rows);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Domain admin: filter to entries that touch one of their domains.
|
|
||||||
// We fetch a slightly larger window because filtering happens after the LIMIT
|
|
||||||
// would skip relevant rows. 1000 should be plenty for a single domain.
|
|
||||||
const allowed = (user.allowed_domains ?? []).map((d) => d.toLowerCase());
|
|
||||||
if (allowed.length === 0) {
|
|
||||||
res.json([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 1000`,
|
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200`,
|
||||||
);
|
);
|
||||||
|
res.json(result.rows);
|
||||||
const visible = result.rows
|
|
||||||
.filter((row) => {
|
|
||||||
const t = String(row.target_type ?? '').toLowerCase();
|
|
||||||
const id = String(row.target_id ?? '').toLowerCase();
|
|
||||||
if (t === 'domain') return allowed.includes(id);
|
|
||||||
if (t === 'mailbox') {
|
|
||||||
const at = id.lastIndexOf('@');
|
|
||||||
if (at < 0) return false;
|
|
||||||
return allowed.includes(id.slice(at + 1));
|
|
||||||
}
|
|
||||||
// 'node' and unknown types: globally scoped, hidden from domain admins.
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
.slice(0, 200);
|
|
||||||
|
|
||||||
res.json(visible);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ async function refreshQuotaForDomain(req: any, domain: string): Promise<number>
|
|||||||
mailboxesRouter.get('/', async (req, res) => {
|
mailboxesRouter.get('/', async (req, res) => {
|
||||||
const domain = String(req.query.domain ?? '').toLowerCase();
|
const domain = String(req.query.domain ?? '').toLowerCase();
|
||||||
const refreshQuota = String(req.query.refreshQuota ?? '').toLowerCase() === 'true';
|
const refreshQuota = String(req.query.refreshQuota ?? '').toLowerCase() === 'true';
|
||||||
// Hide soft-deleted mailboxes by default. Set ?includeDeleted=true if you ever want them.
|
|
||||||
const includeDeleted = String(req.query.includeDeleted ?? '').toLowerCase() === 'true';
|
const includeDeleted = String(req.query.includeDeleted ?? '').toLowerCase() === 'true';
|
||||||
|
|
||||||
if (domain) {
|
if (domain) {
|
||||||
@@ -126,6 +125,18 @@ mailboxesRouter.post('/:email/password', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set storage quota for a single mailbox.
|
||||||
|
// quota_gb is an integer between 1 and 1024 (GB).
|
||||||
|
mailboxesRouter.post('/:email/quota', async (req, res) => {
|
||||||
|
const body = z.object({ quota_gb: z.number().int().min(1).max(1024) }).parse(req.body);
|
||||||
|
const email = normalizeEmail(req.params.email);
|
||||||
|
ensureDomain(req, domainFromEmail(email));
|
||||||
|
await dms.setQuota(email, body.quota_gb);
|
||||||
|
await refreshQuotaForMailbox(email).catch((err) => console.warn(`Could not refresh quota for ${email}:`, err));
|
||||||
|
await audit(req.user!.email, 'mailbox.quota_set', 'mailbox', email, { quota_gb: body.quota_gb }, req.ip);
|
||||||
|
res.json({ ok: true, email, quota_gb: body.quota_gb });
|
||||||
|
});
|
||||||
|
|
||||||
mailboxesRouter.get('/:email/rules', async (req, res) => {
|
mailboxesRouter.get('/:email/rules', async (req, res) => {
|
||||||
const email = normalizeEmail(req.params.email);
|
const email = normalizeEmail(req.params.email);
|
||||||
ensureDomain(req, domainFromEmail(email));
|
ensureDomain(req, domainFromEmail(email));
|
||||||
@@ -138,8 +149,6 @@ mailboxesRouter.put('/:email/rules', async (req, res) => {
|
|||||||
const body = z.object({
|
const body = z.object({
|
||||||
ooo_active: z.boolean().optional(),
|
ooo_active: z.boolean().optional(),
|
||||||
ooo_message: z.string().optional(),
|
ooo_message: z.string().optional(),
|
||||||
// 'text' or 'html'. Stored as-is in DynamoDB so the email worker can
|
|
||||||
// pick the correct Content-Type when sending the auto-reply.
|
|
||||||
ooo_content_type: z.enum(['text', 'html']).optional(),
|
ooo_content_type: z.enum(['text', 'html']).optional(),
|
||||||
forwards: z.array(z.string().email()).optional(),
|
forwards: z.array(z.string().email()).optional(),
|
||||||
}).parse(req.body);
|
}).parse(req.body);
|
||||||
@@ -167,4 +176,4 @@ mailboxesRouter.put('/:email/blocklist', async (req, res) => {
|
|||||||
const saved = await dynamo.putBlocklist(email, body.blocked_patterns);
|
const saved = await dynamo.putBlocklist(email, body.blocked_patterns);
|
||||||
await audit(req.user!.email, 'mailbox.blocklist_update', 'mailbox', email, saved, req.ip);
|
await audit(req.user!.email, 'mailbox.blocklist_update', 'mailbox', email, saved, req.ip);
|
||||||
res.json(saved);
|
res.json(saved);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -179,6 +179,32 @@ export class DmsService {
|
|||||||
console.log(`[dms] password updated for mailbox: ${normalized}`);
|
console.log(`[dms] password updated for mailbox: ${normalized}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the storage quota for a mailbox via the docker-mailserver
|
||||||
|
* `setup quota set` subcommand. quotaGb must be a positive integer.
|
||||||
|
* Equivalent to: docker exec mailserver setup quota set user@example.com 35G
|
||||||
|
*/
|
||||||
|
async setQuota(email: string, quotaGb: number): Promise<void> {
|
||||||
|
const normalized = normalizeEmail(email);
|
||||||
|
const gb = Math.floor(Number(quotaGb));
|
||||||
|
|
||||||
|
if (!Number.isFinite(gb) || gb <= 0 || gb > 1024) {
|
||||||
|
throw new Error(`Invalid quota size: ${quotaGb}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[dms] setting quota for ${normalized} to ${gb}G`);
|
||||||
|
|
||||||
|
await this.assertDockerAccess();
|
||||||
|
|
||||||
|
await run(
|
||||||
|
'docker',
|
||||||
|
['exec', config.dmsContainer, 'setup', 'quota', 'set', normalized, `${gb}G`],
|
||||||
|
120000,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[dms] quota set for ${normalized}: ${gb}G`);
|
||||||
|
}
|
||||||
|
|
||||||
async syncSesDomain(domain: string): Promise<void> {
|
async syncSesDomain(domain: string): Promise<void> {
|
||||||
const normalizedDomain = domain.toLowerCase();
|
const normalizedDomain = domain.toLowerCase();
|
||||||
|
|
||||||
@@ -204,4 +230,4 @@ export class DmsService {
|
|||||||
|
|
||||||
return parseDoveadmQuota(stdout);
|
return parseDoveadmQuota(stdout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
||||||
FiUsers, FiUser,
|
FiUsers, FiUser, FiHardDrive,
|
||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
|
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
@@ -15,6 +15,7 @@ import ConfirmDialog from './components/ConfirmDialog';
|
|||||||
import AuditLogModal from './components/AuditLogModal';
|
import AuditLogModal from './components/AuditLogModal';
|
||||||
import AdminUsersModal from './components/AdminUsersModal';
|
import AdminUsersModal from './components/AdminUsersModal';
|
||||||
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
|
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
|
||||||
|
import DomainQuotaModal from './components/DomainQuotaModal';
|
||||||
|
|
||||||
import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
|
import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
|
||||||
|
|
||||||
@@ -36,16 +37,15 @@ function App() {
|
|||||||
const [showAudit, setShowAudit] = useState(false);
|
const [showAudit, setShowAudit] = useState(false);
|
||||||
const [showAdmins, setShowAdmins] = useState(false);
|
const [showAdmins, setShowAdmins] = useState(false);
|
||||||
const [showChangePw, setShowChangePw] = useState(false);
|
const [showChangePw, setShowChangePw] = useState(false);
|
||||||
|
const [showDomainQuota, setShowDomainQuota] = useState(false);
|
||||||
|
|
||||||
const showToast = useCallback((message, type = 'success') => {
|
const showToast = useCallback((message, type = 'success') => {
|
||||||
setToast({ message, type });
|
setToast({ message, type });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isSuperAdmin = user?.role === 'super_admin';
|
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;
|
const hideDomainList = !isSuperAdmin && domains.length <= 1;
|
||||||
|
|
||||||
// ---- data loading helpers ----
|
|
||||||
const loadDomains = useCallback(async (resync = false) => {
|
const loadDomains = useCallback(async (resync = false) => {
|
||||||
const list = await domainsAPI.list(resync);
|
const list = await domainsAPI.list(resync);
|
||||||
setDomains(list);
|
setDomains(list);
|
||||||
@@ -58,7 +58,6 @@ function App() {
|
|||||||
setMailboxes(list);
|
setMailboxes(list);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---- initial boot ----
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -77,7 +76,6 @@ function App() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
setBusyMessage('Loading domains...');
|
setBusyMessage('Loading domains...');
|
||||||
try {
|
try {
|
||||||
// Only super admin should trigger an upstream DMS resync on login.
|
|
||||||
const list = await loadDomains(isSuperAdmin);
|
const list = await loadDomains(isSuperAdmin);
|
||||||
const first = list[0]?.domain || null;
|
const first = list[0]?.domain || null;
|
||||||
setSelectedDomain(first);
|
setSelectedDomain(first);
|
||||||
@@ -158,7 +156,15 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- render ----
|
const handleQuotaApplied = async () => {
|
||||||
|
setBusyMessage('Refreshing quotas...');
|
||||||
|
try {
|
||||||
|
await loadMailboxes(selectedDomain, true);
|
||||||
|
} finally {
|
||||||
|
setBusyMessage('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!bootChecked) {
|
if (!bootChecked) {
|
||||||
return <div className="min-h-screen flex items-center justify-center text-gray-400">Loading...</div>;
|
return <div className="min-h-screen flex items-center justify-center text-gray-400">Loading...</div>;
|
||||||
}
|
}
|
||||||
@@ -174,7 +180,6 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
<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="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -188,12 +193,13 @@ function App() {
|
|||||||
<FiUser className="w-4 h-4 mr-2" />
|
<FiUser className="w-4 h-4 mr-2" />
|
||||||
My Password
|
My Password
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setShowAudit(true)} className="btn-secondary">
|
{/* Audit log: super_admin only. */}
|
||||||
<FiList className="w-4 h-4 mr-2" />
|
|
||||||
Audit Log
|
|
||||||
</button>
|
|
||||||
{isSuperAdmin && (
|
{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">
|
<button onClick={() => setShowAdmins(true)} className="btn-secondary">
|
||||||
<FiUsers className="w-4 h-4 mr-2" />
|
<FiUsers className="w-4 h-4 mr-2" />
|
||||||
Admins
|
Admins
|
||||||
@@ -212,10 +218,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main */}
|
|
||||||
<main className="max-w-7xl mx-auto px-6 py-6">
|
<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`}>
|
<div className={`grid grid-cols-1 ${hideDomainList ? '' : 'lg:grid-cols-[320px_1fr]'} gap-6 items-start`}>
|
||||||
{/* Domains - hidden when single-domain user */}
|
|
||||||
{!hideDomainList && (
|
{!hideDomainList && (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2 className="text-base font-semibold text-gray-900">Domains on this node</h2>
|
<h2 className="text-base font-semibold text-gray-900">Domains on this node</h2>
|
||||||
@@ -259,9 +263,8 @@ function App() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mailboxes */}
|
|
||||||
<section className="card relative">
|
<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>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-gray-900">
|
<h2 className="text-base font-semibold text-gray-900">
|
||||||
{selectedDomain || 'Mailboxes'}
|
{selectedDomain || 'Mailboxes'}
|
||||||
@@ -270,14 +273,28 @@ function App() {
|
|||||||
Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
|
Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex flex-row flex-nowrap gap-2">
|
||||||
onClick={() => setShowNew(true)}
|
{/* Set domain-wide quota: super_admin only. */}
|
||||||
disabled={!selectedDomain}
|
{isSuperAdmin && (
|
||||||
className="btn-primary"
|
<button
|
||||||
>
|
onClick={() => setShowDomainQuota(true)}
|
||||||
<FiPlus className="w-4 h-4 mr-2" />
|
disabled={!selectedDomain || mailboxes.length === 0}
|
||||||
New mailbox
|
className="btn-secondary"
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{mailboxes.length === 0 ? (
|
{mailboxes.length === 0 ? (
|
||||||
@@ -354,7 +371,6 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<MailboxSettingsModal
|
<MailboxSettingsModal
|
||||||
open={!!settingsTarget}
|
open={!!settingsTarget}
|
||||||
email={settingsTarget?.email}
|
email={settingsTarget?.email}
|
||||||
@@ -388,11 +404,13 @@ function App() {
|
|||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
onClose={() => setDeleteTarget(null)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
/>
|
/>
|
||||||
<AuditLogModal
|
{isSuperAdmin && (
|
||||||
open={showAudit}
|
<AuditLogModal
|
||||||
onClose={() => setShowAudit(false)}
|
open={showAudit}
|
||||||
onToast={showToast}
|
onClose={() => setShowAudit(false)}
|
||||||
/>
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<AdminUsersModal
|
<AdminUsersModal
|
||||||
open={showAdmins}
|
open={showAdmins}
|
||||||
@@ -401,6 +419,16 @@ function App() {
|
|||||||
onToast={showToast}
|
onToast={showToast}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<DomainQuotaModal
|
||||||
|
open={showDomainQuota}
|
||||||
|
domain={selectedDomain}
|
||||||
|
mailboxes={mailboxes}
|
||||||
|
onClose={() => setShowDomainQuota(false)}
|
||||||
|
onApplied={handleQuotaApplied}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ChangeMyPasswordModal
|
<ChangeMyPasswordModal
|
||||||
open={showChangePw}
|
open={showChangePw}
|
||||||
onClose={() => setShowChangePw(false)}
|
onClose={() => setShowChangePw(false)}
|
||||||
|
|||||||
@@ -5,13 +5,6 @@ import LoadingOverlay from './LoadingOverlay';
|
|||||||
import ConfirmDialog from './ConfirmDialog';
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
import { adminsAPI, domainsAPI } from '../services/api';
|
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 AdminUsersModal = ({ open, currentUser, onClose, onToast }) => {
|
||||||
const [view, setView] = useState('list');
|
const [view, setView] = useState('list');
|
||||||
const [admins, setAdmins] = useState([]);
|
const [admins, setAdmins] = useState([]);
|
||||||
@@ -219,8 +212,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
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 [emailTouched, setEmailTouched] = useState(isEdit);
|
||||||
const emailRef = useRef(null);
|
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
|
// Auto-fill email with "@<domain>" when creating a NEW domain_admin
|
||||||
// and exactly one domain is selected, as long as the user hasn't
|
// and exactly one domain is selected.
|
||||||
// manually edited the email field yet.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEdit) return;
|
if (isEdit) return;
|
||||||
if (role !== 'domain_admin') return;
|
if (role !== 'domain_admin') return;
|
||||||
@@ -239,7 +229,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
|||||||
if (allowedDomains.size === 1) {
|
if (allowedDomains.size === 1) {
|
||||||
const [onlyDomain] = [...allowedDomains];
|
const [onlyDomain] = [...allowedDomains];
|
||||||
setEmail(`@${onlyDomain}`);
|
setEmail(`@${onlyDomain}`);
|
||||||
// Place caret before the @ so the user can type the local part directly.
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = emailRef.current;
|
const el = emailRef.current;
|
||||||
if (el && document.activeElement === el) {
|
if (el && document.activeElement === el) {
|
||||||
@@ -251,8 +240,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
|||||||
}
|
}
|
||||||
}, [allowedDomains, role, isEdit, emailTouched]);
|
}, [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 handleEmailFocus = () => {
|
||||||
const el = emailRef.current;
|
const el = emailRef.current;
|
||||||
if (el && !emailTouched && email.startsWith('@')) {
|
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 passwordMismatch = password.length > 0 && confirmPw.length > 0 && password !== confirmPw;
|
||||||
const passwordTooShort = password.length > 0 && password.length < 8;
|
const passwordTooShort = password.length > 0 && password.length < 8;
|
||||||
|
|
||||||
// For new admins password is required.
|
|
||||||
const passwordRequired = !isEdit;
|
const passwordRequired = !isEdit;
|
||||||
const passwordProvided = password.length > 0;
|
const passwordProvided = password.length > 0;
|
||||||
|
|
||||||
@@ -299,7 +285,6 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
|||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
const payload = { role, allowed_domains: allowedList, active };
|
const payload = { role, allowed_domains: allowedList, active };
|
||||||
if (passwordProvided) payload.password = password;
|
if (passwordProvided) payload.password = password;
|
||||||
// Don't send forbidden fields when editing self.
|
|
||||||
if (isSelf) {
|
if (isSelf) {
|
||||||
delete payload.role;
|
delete payload.role;
|
||||||
delete payload.active;
|
delete payload.active;
|
||||||
@@ -324,23 +309,42 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="space-y-5">
|
||||||
<button onClick={onCancel} className="btn-ghost -ml-2">
|
<button onClick={onCancel} className="btn-ghost -ml-2">
|
||||||
<FiArrowLeft className="w-4 h-4 mr-1.5" />
|
<FiArrowLeft className="w-4 h-4 mr-1.5" />
|
||||||
Back to list
|
Back to list
|
||||||
</button>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
|
||||||
<input
|
<input
|
||||||
ref={emailRef}
|
ref={emailRef}
|
||||||
type="email"
|
type="text"
|
||||||
|
inputMode="email"
|
||||||
|
name="admin_create_email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => { setEmail(e.target.value); setEmailTouched(true); }}
|
onChange={(e) => { setEmail(e.target.value); setEmailTouched(true); }}
|
||||||
onFocus={handleEmailFocus}
|
onFocus={handleEmailFocus}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
disabled={isEdit}
|
disabled={isEdit}
|
||||||
placeholder="admin@example.com"
|
placeholder="admin@example.com"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
spellCheck="false"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
/>
|
/>
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<p className="mt-1 text-xs text-gray-500">Email cannot be changed after creation.</p>
|
<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">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
name="admin_create_pw_new"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className={`input-field ${passwordTooShort ? 'border-red-500' : ''}`}
|
className={`input-field ${passwordTooShort ? 'border-red-500' : ''}`}
|
||||||
placeholder="At least 8 characters"
|
placeholder="At least 8 characters"
|
||||||
|
autoComplete="new-password"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
name="admin_create_pw_confirm"
|
||||||
value={confirmPw}
|
value={confirmPw}
|
||||||
onChange={(e) => setConfirmPw(e.target.value)}
|
onChange={(e) => setConfirmPw(e.target.value)}
|
||||||
className={`input-field ${passwordMismatch ? 'border-red-500' : ''}`}
|
className={`input-field ${passwordMismatch ? 'border-red-500' : ''}`}
|
||||||
placeholder="Confirm"
|
placeholder="Confirm"
|
||||||
|
autoComplete="new-password"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{passwordTooShort && <p className="mt-1 text-xs text-red-600">Minimum 8 characters.</p>}
|
{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';
|
import axios from 'axios';
|
||||||
|
|
||||||
// Same-origin: backend serves the built frontend, vite dev proxies /api.
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/',
|
baseURL: '/',
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Centralized error normalization so callers always see err.message + err.statusCode.
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(r) => r,
|
(r) => r,
|
||||||
(err) => {
|
(err) => {
|
||||||
@@ -52,6 +50,12 @@ export const mailboxesAPI = {
|
|||||||
`/api/mailboxes/${encodeURIComponent(email)}/password`,
|
`/api/mailboxes/${encodeURIComponent(email)}/password`,
|
||||||
{ password }
|
{ password }
|
||||||
)).data,
|
)).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) =>
|
getRules: async (email) =>
|
||||||
(await api.get(`/api/mailboxes/${encodeURIComponent(email)}/rules`)).data,
|
(await api.get(`/api/mailboxes/${encodeURIComponent(email)}/rules`)).data,
|
||||||
putRules: async (email, payload) =>
|
putRules: async (email, payload) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user