new features

This commit is contained in:
2026-04-28 17:18:09 -05:00
parent 830e52a4fa
commit ffe2204597
7 changed files with 291 additions and 94 deletions

View File

@@ -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;

View 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;