From ffe2204597d344bb6a32dcf94cc42b14aedc64bf Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Tue, 28 Apr 2026 17:18:09 -0500 Subject: [PATCH] new features --- backend/src/routes/audit.ts | 45 +----- backend/src/routes/mailboxes.ts | 17 ++- backend/src/services/dms.ts | 28 +++- frontend/src/App.jsx | 86 +++++++---- frontend/src/components/AdminUsersModal.jsx | 48 +++--- frontend/src/components/DomainQuotaModal.jsx | 153 +++++++++++++++++++ frontend/src/services/api.js | 8 +- 7 files changed, 291 insertions(+), 94 deletions(-) create mode 100644 frontend/src/components/DomainQuotaModal.jsx diff --git a/backend/src/routes/audit.ts b/backend/src/routes/audit.ts index 4a6a253..3d3e6c2 100644 --- a/backend/src/routes/audit.ts +++ b/backend/src/routes/audit.ts @@ -1,49 +1,14 @@ import { Router } from 'express'; import { pool } from '../db.js'; -import { requireAuth } from '../middleware/auth.js'; +import { requireAuth, requireSuperAdmin } from '../middleware/auth.js'; export const auditRouter = Router(); auditRouter.use(requireAuth); +auditRouter.use(requireSuperAdmin); -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; - } - +auditRouter.get('/', async (_req, res) => { 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`, ); - - 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); + res.json(result.rows); }); diff --git a/backend/src/routes/mailboxes.ts b/backend/src/routes/mailboxes.ts index 612756a..72c8e9c 100644 --- a/backend/src/routes/mailboxes.ts +++ b/backend/src/routes/mailboxes.ts @@ -71,7 +71,6 @@ async function refreshQuotaForDomain(req: any, domain: string): Promise mailboxesRouter.get('/', async (req, res) => { const domain = String(req.query.domain ?? '').toLowerCase(); 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'; if (domain) { @@ -126,6 +125,18 @@ mailboxesRouter.post('/:email/password', async (req, res) => { 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) => { const email = normalizeEmail(req.params.email); ensureDomain(req, domainFromEmail(email)); @@ -138,8 +149,6 @@ mailboxesRouter.put('/:email/rules', async (req, res) => { const body = z.object({ ooo_active: z.boolean().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(), forwards: z.array(z.string().email()).optional(), }).parse(req.body); @@ -167,4 +176,4 @@ mailboxesRouter.put('/:email/blocklist', async (req, res) => { const saved = await dynamo.putBlocklist(email, body.blocked_patterns); await audit(req.user!.email, 'mailbox.blocklist_update', 'mailbox', email, saved, req.ip); res.json(saved); -}); \ No newline at end of file +}); diff --git a/backend/src/services/dms.ts b/backend/src/services/dms.ts index 0c6a799..3e37753 100644 --- a/backend/src/services/dms.ts +++ b/backend/src/services/dms.ts @@ -179,6 +179,32 @@ export class DmsService { 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 { + 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 { const normalizedDomain = domain.toLowerCase(); @@ -204,4 +230,4 @@ export class DmsService { return parseDoveadmQuota(stdout); } -} \ No newline at end of file +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 453e16d..28aba5e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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
Loading...
; } @@ -174,7 +180,6 @@ function App() { return (
- {/* Header */}
@@ -188,12 +193,13 @@ function App() { My Password - + {/* Audit log: super_admin only. */} {isSuperAdmin && ( <> +
- {/* Main */}
- {/* Domains - hidden when single-domain user */} {!hideDomainList && (

Domains on this node

@@ -259,9 +263,8 @@ function App() {
)} - {/* Mailboxes */}
-
+

{selectedDomain || 'Mailboxes'} @@ -270,14 +273,28 @@ function App() { Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.

- +
+ {/* Set domain-wide quota: super_admin only. */} + {isSuperAdmin && ( + + )} + +
{mailboxes.length === 0 ? ( @@ -354,7 +371,6 @@ function App() {
- {/* Modals */} setDeleteTarget(null)} /> - setShowAudit(false)} - onToast={showToast} - /> + {isSuperAdmin && ( + setShowAudit(false)} + onToast={showToast} + /> + )} {isSuperAdmin && ( )} + {isSuperAdmin && ( + setShowDomainQuota(false)} + onApplied={handleQuotaApplied} + onToast={showToast} + /> + )} setShowChangePw(false)} diff --git a/frontend/src/components/AdminUsersModal.jsx b/frontend/src/components/AdminUsersModal.jsx index 024945c..28b9716 100644 --- a/frontend/src/components/AdminUsersModal.jsx +++ b/frontend/src/components/AdminUsersModal.jsx @@ -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 "@" 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.
+ {/* Decoy fields to absorb Chrome's autofill attempts. */} + +
{ 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 && (

Email cannot be changed after creation.

@@ -354,17 +358,25 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa
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" /> setConfirmPw(e.target.value)} className={`input-field ${passwordMismatch ? 'border-red-500' : ''}`} placeholder="Confirm" + autoComplete="new-password" + data-form-type="other" + data-lpignore="true" />
{passwordTooShort &&

Minimum 8 characters.

} @@ -479,4 +491,4 @@ const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToa ); }; -export default AdminUsersModal; \ No newline at end of file +export default AdminUsersModal; diff --git a/frontend/src/components/DomainQuotaModal.jsx b/frontend/src/components/DomainQuotaModal.jsx new file mode 100644 index 0000000..fe872aa --- /dev/null +++ b/frontend/src/components/DomainQuotaModal.jsx @@ -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 ( + {} : onClose} + title="Set quota for all mailboxes" + subtitle={domain} + size="md" + > +
+
+ +
+ This applies the selected quota to all {activeMailboxes.length} active mailboxes in + this domain. Existing per-mailbox overrides will be replaced. +
+
+ +
+ +
+ {QUOTA_OPTIONS.map((gb) => { + const isSel = selectedGb === gb; + return ( + + ); + })} +
+
+ + {busy && ( +
+
+ + {progress.current ? `Processing: ${progress.current}` : 'Finishing...'} + + + {progress.done} / {progress.total} + +
+
+
+
+
+ )} + + {errors.length > 0 && ( +
+

+ {errors.length} mailbox{errors.length > 1 ? 'es' : ''} failed: +

+
    + {errors.map((e) => ( +
  • + {e.email}: {e.message} +
  • + ))} +
+
+ )} + +
+ + +
+
+ + ); +}; + +export default DomainQuotaModal; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 9b08d7c..b20acac 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -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) =>