This commit is contained in:
2026-04-27 16:01:02 -05:00
parent c4acdb2a66
commit b03c257de1
3 changed files with 65 additions and 16 deletions

View File

@@ -71,6 +71,8 @@ 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';
if (domain) { if (domain) {
ensureDomain(req, domain); ensureDomain(req, domain);
@@ -82,6 +84,7 @@ mailboxesRouter.get('/', async (req, res) => {
const params: unknown[] = [config.nodeName]; const params: unknown[] = [config.nodeName];
let where = 'WHERE node_name=$1'; let where = 'WHERE node_name=$1';
if (!includeDeleted) where += ` AND status <> 'deleted'`;
if (domain) { params.push(domain); where += ` AND domain=$${params.length}`; } if (domain) { params.push(domain); where += ` AND domain=$${params.length}`; }
const result = await pool.query( const result = await pool.query(

View File

@@ -4,15 +4,25 @@ import { mailboxesAPI } from '../services/api';
const PasswordResetModal = ({ open, email, onClose, onToast }) => { const PasswordResetModal = ({ open, email, onClose, onToast }) => {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
useEffect(() => { useEffect(() => {
if (open) { setPassword(''); setError(''); } if (open) { setPassword(''); setConfirm(''); setError(''); }
}, [open, email]); }, [open, email]);
// Live mismatch hint, only after the user has typed something in the second field.
const mismatch = confirm.length > 0 && password !== confirm;
const tooShort = password.length > 0 && password.length < 8;
const canSubmit = password.length >= 8 && password === confirm;
const submit = async () => { const submit = async () => {
if (password.length < 8) { setError('Password must have at least 8 characters.'); return; } if (!canSubmit) {
if (password.length < 8) setError('Password must have at least 8 characters.');
else if (password !== confirm) setError('Passwords do not match.');
return;
}
setBusy(true); setError(''); setBusy(true); setError('');
try { try {
await mailboxesAPI.setPassword(email, password); await mailboxesAPI.setPassword(email, password);
@@ -34,16 +44,51 @@ const PasswordResetModal = ({ open, email, onClose, onToast }) => {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }} onKeyDown={(e) => {
className="input-field" if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('pw-confirm')?.focus();
}
}}
className={`input-field ${tooShort ? 'border-red-500 focus:ring-red-500' : ''}`}
minLength={8} minLength={8}
autoFocus autoFocus
/> />
{tooShort && (
<p className="mt-1 text-xs text-red-600">Minimum 8 characters.</p>
)}
</div> </div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Confirm password</label>
<input
id="pw-confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
className={`input-field ${mismatch ? 'border-red-500 focus:ring-red-500' : ''}`}
minLength={8}
/>
{mismatch && (
<p className="mt-1 text-xs text-red-600">Passwords do not match.</p>
)}
{!mismatch && confirm.length > 0 && password === confirm && password.length >= 8 && (
<p className="mt-1 text-xs text-green-600">Passwords match.</p>
)}
</div>
{error && <p className="text-sm text-red-600">{error}</p>} {error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-2 border-t border-gray-200"> <div className="flex justify-end gap-2 pt-2 border-t border-gray-200">
<button onClick={onClose} className="btn-secondary px-4 py-2">Cancel</button> <button onClick={onClose} className="btn-secondary px-4 py-2" disabled={busy}>
<button onClick={submit} disabled={busy} className="btn-primary"> Cancel
</button>
<button
onClick={submit}
disabled={busy || !canSubmit}
className="btn-primary"
>
{busy ? 'Updating...' : 'Update password'} {busy ? 'Updating...' : 'Update password'}
</button> </button>
</div> </div>
@@ -52,4 +97,4 @@ const PasswordResetModal = ({ open, email, onClose, onToast }) => {
); );
}; };
export default PasswordResetModal; export default PasswordResetModal;

View File

@@ -30,13 +30,16 @@ const UsageBar = ({ mailbox }) => {
? `${bytes(used)} / ${bytes(limit)} (${fmtPercent(pct)}%)` ? `${bytes(used)} / ${bytes(limit)} (${fmtPercent(pct)}%)`
: `${bytes(used)} / unlimited`; : `${bytes(used)} / unlimited`;
// Color the bar by usage. No quota -> neutral primary tone.
const fillColor = pct === null const fillColor = pct === null
? 'bg-primary-500' ? 'bg-primary-500'
: pct >= 90 ? 'bg-red-500' : pct >= 90 ? 'bg-red-500'
: pct >= 75 ? 'bg-amber-500' : pct >= 75 ? 'bg-amber-500'
: 'bg-emerald-500'; : 'bg-emerald-500';
const hasMessageCount =
mailbox.message_count !== null && mailbox.message_count !== undefined;
const messageCount = hasMessageCount ? Number(mailbox.message_count) : null;
return ( return (
<div className="min-w-[200px]"> <div className="min-w-[200px]">
<div className="text-xs font-semibold text-gray-700 mb-1.5">Disk Usage: {label}</div> <div className="text-xs font-semibold text-gray-700 mb-1.5">Disk Usage: {label}</div>
@@ -46,15 +49,13 @@ const UsageBar = ({ mailbox }) => {
style={{ width: `${pct ?? 0}%` }} style={{ width: `${pct ?? 0}%` }}
/> />
</div> </div>
<div className="text-xs text-gray-500 mt-1"> {hasMessageCount && (
{mailbox.usage_scanned_at <div className="text-xs text-gray-500 mt-1">
? `quota checked ${new Date(mailbox.usage_scanned_at).toLocaleString()}` {messageCount} {messageCount === 1 ? 'message' : 'messages'}
: 'quota not checked yet'} </div>
{mailbox.message_count !== null && mailbox.message_count !== undefined )}
? ` · ${Number(mailbox.message_count)} messages` : ''}
</div>
</div> </div>
); );
}; };
export default UsageBar; export default UsageBar;