react
This commit is contained in:
65
frontend/src/components/AuditLogModal.jsx
Normal file
65
frontend/src/components/AuditLogModal.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Modal from './Modal';
|
||||
import LoadingOverlay from './LoadingOverlay';
|
||||
import { auditAPI } from '../services/api';
|
||||
|
||||
const AuditLogModal = ({ open, onClose, onToast }) => {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await auditAPI.list();
|
||||
if (!cancelled) setRows(data || []);
|
||||
} catch (err) {
|
||||
if (!cancelled) onToast?.(`Failed to load audit log: ${err.message}`, 'error');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, onToast]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Audit Log" subtitle="Most recent 200 actions" size="lg">
|
||||
<div className="relative min-h-[300px]">
|
||||
{loading && <LoadingOverlay message="Loading..." />}
|
||||
{!loading && rows.length === 0 && (
|
||||
<p className="text-sm text-gray-500 text-center py-12">No audit entries yet.</p>
|
||||
)}
|
||||
{!loading && rows.length > 0 && (
|
||||
<div className="overflow-x-auto max-h-[60vh] custom-scrollbar">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="py-2 pr-4 font-semibold">Time</th>
|
||||
<th className="py-2 pr-4 font-semibold">Actor</th>
|
||||
<th className="py-2 pr-4 font-semibold">Action</th>
|
||||
<th className="py-2 pr-4 font-semibold">Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rows.map((a) => (
|
||||
<tr key={a.id} className="hover:bg-gray-50">
|
||||
<td className="py-2 pr-4 text-gray-500 whitespace-nowrap">
|
||||
{new Date(a.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-900">{a.actor_email}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-gray-700">{a.action}</td>
|
||||
<td className="py-2 pr-4 text-gray-700">{a.target_id}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditLogModal;
|
||||
158
frontend/src/components/BlockedSenders.jsx
Normal file
158
frontend/src/components/BlockedSenders.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FiSlash, FiPlus, FiTrash2, FiShield } from 'react-icons/fi';
|
||||
|
||||
const BlockedSenders = ({ blocklist, onSave }) => {
|
||||
const [patterns, setPatterns] = useState(blocklist?.blocked_patterns || []);
|
||||
const [newPattern, setNewPattern] = useState('');
|
||||
const [inputMessage, setInputMessage] = useState({ text: '', kind: '' });
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const validatePattern = (p) => p.length >= 3;
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newPattern.trim()) {
|
||||
setInputMessage({ text: 'Please enter a pattern', kind: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = newPattern
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const accepted = [];
|
||||
let invalid = 0;
|
||||
let dupes = 0;
|
||||
|
||||
for (const p of candidates) {
|
||||
if (!validatePattern(p)) { invalid++; continue; }
|
||||
if (patterns.includes(p) || accepted.includes(p)) { dupes++; continue; }
|
||||
accepted.push(p);
|
||||
}
|
||||
|
||||
if (accepted.length === 0) {
|
||||
if (invalid > 0 && candidates.length === invalid) {
|
||||
setInputMessage({ text: 'All entered patterns were too short (min. 3 chars).', kind: 'error' });
|
||||
} else if (dupes > 0) {
|
||||
setInputMessage({ text: 'All entered patterns are already in the list.', kind: 'error' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setPatterns([...patterns, ...accepted]);
|
||||
setNewPattern('');
|
||||
|
||||
if (invalid > 0 || dupes > 0) {
|
||||
setInputMessage({
|
||||
text: `Added ${accepted.length} patterns. (${invalid} invalid, ${dupes} duplicates skipped)`,
|
||||
kind: 'success',
|
||||
});
|
||||
} else {
|
||||
setInputMessage({ text: '', kind: '' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (p) => setPatterns(patterns.filter((x) => x !== p));
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(patterns);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="block-pattern" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Block Sender Pattern(s)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
id="block-pattern"
|
||||
type="text"
|
||||
value={newPattern}
|
||||
onChange={(e) => {
|
||||
setNewPattern(e.target.value);
|
||||
if (inputMessage.kind === 'error') setInputMessage({ text: '', kind: '' });
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||
placeholder="spam@*.com, *@badsite.org (comma separated)"
|
||||
className={`input-field ${inputMessage.kind === 'error' ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||
/>
|
||||
{inputMessage.text && (
|
||||
<p className={`mt-1 text-sm ${inputMessage.kind === 'success' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{inputMessage.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={handleAdd} className="btn-danger whitespace-nowrap px-4 py-2">
|
||||
<FiPlus className="w-4 h-4 mr-2" />
|
||||
Block
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Paste a comma-separated list to add multiple entries at once. Supports wildcards (*).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="block text-sm font-semibold text-gray-700">
|
||||
Blocked Patterns ({patterns.length})
|
||||
</label>
|
||||
{patterns.length > 0 && (
|
||||
<button
|
||||
onClick={() => setPatterns([])}
|
||||
className="text-xs text-red-600 hover:text-red-800 hover:underline"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{patterns.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
|
||||
<FiSlash className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600 font-medium">No blocked senders configured</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Add a pattern above to block incoming emails</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 custom-scrollbar">
|
||||
{patterns.map((pattern, idx) => (
|
||||
<div
|
||||
key={`${pattern}-${idx}`}
|
||||
className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg hover:border-red-300 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-red-100 rounded-full">
|
||||
<FiShield className="w-4 h-4 text-red-600" />
|
||||
</div>
|
||||
<p className="font-medium text-gray-900 font-mono text-sm">{pattern}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(pattern)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Remove block"
|
||||
>
|
||||
<FiTrash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button onClick={handleSave} disabled={isSaving} className="btn-primary">
|
||||
{isSaving ? 'Saving...' : 'Save Block List'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockedSenders;
|
||||
37
frontend/src/components/ConfirmDialog.jsx
Normal file
37
frontend/src/components/ConfirmDialog.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FiAlertTriangle } from 'react-icons/fi';
|
||||
import Modal from './Modal';
|
||||
|
||||
const ConfirmDialog = ({ open, title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onClose }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const handleConfirm = async () => {
|
||||
setBusy(true);
|
||||
try { await onConfirm(); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={title} size="sm">
|
||||
<div className="flex items-start gap-3">
|
||||
{danger && (
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-red-100 flex-shrink-0">
|
||||
<FiAlertTriangle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-700 flex-1 pt-1">{message}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4 mt-4 border-t border-gray-200">
|
||||
<button onClick={onClose} className="btn-secondary px-4 py-2" disabled={busy}>Cancel</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={busy}
|
||||
className={danger ? 'btn-danger px-4 py-2' : 'btn-primary'}
|
||||
>
|
||||
{busy ? 'Working...' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialog;
|
||||
118
frontend/src/components/Forwarding.jsx
Normal file
118
frontend/src/components/Forwarding.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FiMail, FiPlus, FiTrash2, FiCheck } from 'react-icons/fi';
|
||||
|
||||
/**
|
||||
* Forwarding tab for one mailbox.
|
||||
* Receives the current rule object and onSave callback that updates the
|
||||
* mailadmin /rules endpoint (which expects { ooo_active, ooo_message, forwards }).
|
||||
* onSave is called with just the updated `forwards` field; the parent
|
||||
* merges it with the existing rule before persisting.
|
||||
*/
|
||||
const Forwarding = ({ rule, onSave }) => {
|
||||
const [forwards, setForwards] = useState(rule?.forwards || []);
|
||||
const [newEmail, setNewEmail] = useState('');
|
||||
const [emailError, setEmailError] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const validateEmail = (email) =>
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
|
||||
const handleAdd = () => {
|
||||
const trimmed = newEmail.trim().toLowerCase();
|
||||
if (!trimmed) { setEmailError('Email address is required'); return; }
|
||||
if (!validateEmail(trimmed)) { setEmailError('Please enter a valid email address'); return; }
|
||||
if (forwards.includes(trimmed)) { setEmailError('Already in the list'); return; }
|
||||
setForwards([...forwards, trimmed]);
|
||||
setNewEmail('');
|
||||
setEmailError('');
|
||||
};
|
||||
|
||||
const handleRemove = (email) => setForwards(forwards.filter((e) => e !== email));
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave({ forwards });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="fwd-email" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Add Forward Address
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
id="fwd-email"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => { setNewEmail(e.target.value); setEmailError(''); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||
placeholder="email@example.com"
|
||||
className={`input-field ${emailError ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||
/>
|
||||
{emailError && <p className="mt-1 text-sm text-red-600">{emailError}</p>}
|
||||
</div>
|
||||
<button onClick={handleAdd} className="btn-primary whitespace-nowrap">
|
||||
<FiPlus className="w-4 h-4 mr-2" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
All emails sent to this mailbox will also be delivered to the addresses below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="block text-sm font-semibold text-gray-700">
|
||||
Forward Addresses ({forwards.length})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{forwards.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
|
||||
<FiMail className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600 font-medium">No forward addresses configured</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Add an email address above to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 custom-scrollbar">
|
||||
{forwards.map((email, idx) => (
|
||||
<div
|
||||
key={`${email}-${idx}`}
|
||||
className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg hover:border-primary-300 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-primary-100 rounded-full">
|
||||
<FiCheck className="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
<p className="font-medium text-gray-900">{email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(email)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Remove forward"
|
||||
>
|
||||
<FiTrash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button onClick={handleSave} disabled={isSaving} className="btn-primary">
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Forwarding;
|
||||
23
frontend/src/components/LoadingOverlay.jsx
Normal file
23
frontend/src/components/LoadingOverlay.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { FiLoader } from 'react-icons/fi';
|
||||
|
||||
/**
|
||||
* Block UI overlay. Pass `fullscreen` for app-level blocking,
|
||||
* otherwise it covers the nearest positioned (relative) parent.
|
||||
*/
|
||||
const LoadingOverlay = ({ message = 'Loading...', fullscreen = false }) => {
|
||||
const positioning = fullscreen
|
||||
? 'fixed inset-0 z-40'
|
||||
: 'absolute inset-0 z-20 rounded-xl';
|
||||
|
||||
return (
|
||||
<div className={`${positioning} bg-white/70 backdrop-blur-sm flex items-center justify-center`}>
|
||||
<div className="flex flex-col items-center gap-3 bg-white rounded-xl shadow-lg border border-gray-200 px-6 py-5">
|
||||
<FiLoader className="w-7 h-7 text-primary-600 animate-spin" />
|
||||
<p className="text-sm font-medium text-gray-700">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingOverlay;
|
||||
73
frontend/src/components/Login.jsx
Normal file
73
frontend/src/components/Login.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FiMail, FiLock } from 'react-icons/fi';
|
||||
import { authAPI } from '../services/api';
|
||||
|
||||
const Login = ({ onLogin }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
const me = await authAPI.login(email.trim().toLowerCase(), password);
|
||||
onLogin(me);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-primary-50 p-6">
|
||||
<div className="card w-full max-w-md">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">MailAdmin</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Sign in to manage your mail server</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
|
||||
<div className="relative">
|
||||
<FiMail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input-field pl-10"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Password</label>
|
||||
<div className="relative">
|
||||
<FiLock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input-field pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn-primary w-full">
|
||||
{busy ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
130
frontend/src/components/MailboxSettingsModal.jsx
Normal file
130
frontend/src/components/MailboxSettingsModal.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FiCornerUpRight, FiCalendar, FiSlash } from 'react-icons/fi';
|
||||
import Modal from './Modal';
|
||||
import LoadingOverlay from './LoadingOverlay';
|
||||
import Forwarding from './Forwarding';
|
||||
import OutOfOffice from './OutOfOffice';
|
||||
import BlockedSenders from './BlockedSenders';
|
||||
import { mailboxesAPI } from '../services/api';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'fwd', label: 'Forwarding', icon: FiCornerUpRight },
|
||||
{ id: 'ooo', label: 'Out of Office', icon: FiCalendar },
|
||||
{ id: 'block', label: 'Blocklist', icon: FiSlash },
|
||||
];
|
||||
|
||||
const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToast }) => {
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const [rule, setRule] = useState(null);
|
||||
const [blocklist, setBlocklist] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { setActiveTab(initialTab); }, [initialTab, email]);
|
||||
|
||||
// Load both /rules and /blocklist in parallel when the modal opens.
|
||||
useEffect(() => {
|
||||
if (!open || !email) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [r, b] = await Promise.all([
|
||||
mailboxesAPI.getRules(email).catch(() => ({
|
||||
email_address: email, ooo_active: false, ooo_message: '', forwards: [],
|
||||
})),
|
||||
mailboxesAPI.getBlocklist(email).catch(() => ({
|
||||
email_address: email, blocked_patterns: [],
|
||||
})),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setRule(r || { email_address: email, ooo_active: false, ooo_message: '', forwards: [] });
|
||||
setBlocklist(b || { email_address: email, blocked_patterns: [] });
|
||||
} catch (err) {
|
||||
if (!cancelled) onToast?.(`Failed to load settings: ${err.message}`, 'error');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, email, onToast]);
|
||||
|
||||
// Merge updates with existing rule and persist.
|
||||
const saveRule = async (updates) => {
|
||||
const merged = {
|
||||
ooo_active: rule?.ooo_active ?? false,
|
||||
ooo_message: rule?.ooo_message ?? '',
|
||||
forwards: rule?.forwards ?? [],
|
||||
...updates,
|
||||
};
|
||||
try {
|
||||
const saved = await mailboxesAPI.putRules(email, merged);
|
||||
setRule({ email_address: email, ...merged, ...saved });
|
||||
onToast?.('Rule saved', 'success');
|
||||
} catch (err) {
|
||||
onToast?.(`Failed to save: ${err.message}`, 'error');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const saveBlocklist = async (patterns) => {
|
||||
try {
|
||||
const saved = await mailboxesAPI.putBlocklist(email, patterns);
|
||||
setBlocklist({ email_address: email, blocked_patterns: patterns, ...saved });
|
||||
onToast?.('Block list saved', 'success');
|
||||
} catch (err) {
|
||||
onToast?.(`Failed to save: ${err.message}`, 'error');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={email || 'Mailbox settings'}
|
||||
subtitle="Forwarding, auto-reply and blocklist"
|
||||
size="md"
|
||||
>
|
||||
<div className="relative min-h-[400px]">
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="flex gap-1">
|
||||
{TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const isActive = activeTab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'border-b-2 border-primary-600 text-primary-700 -mb-px'
|
||||
: 'border-b-2 border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading || !rule || !blocklist ? (
|
||||
<div className="py-16 flex items-center justify-center">
|
||||
<LoadingOverlay message="Loading settings..." />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'fwd' && <Forwarding rule={rule} onSave={saveRule} />}
|
||||
{activeTab === 'ooo' && <OutOfOffice rule={rule} onSave={saveRule} />}
|
||||
{activeTab === 'block' && <BlockedSenders blocklist={blocklist} onSave={saveBlocklist} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MailboxSettingsModal;
|
||||
49
frontend/src/components/Modal.jsx
Normal file
49
frontend/src/components/Modal.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { FiX } from 'react-icons/fi';
|
||||
|
||||
const Modal = ({ open, onClose, title, subtitle, children, size = 'md' }) => {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const widths = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-2xl',
|
||||
lg: 'max-w-4xl',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-gray-900/40 backdrop-blur-sm flex items-start justify-center p-4 sm:p-8 overflow-y-auto"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className={`bg-white rounded-xl shadow-xl border border-gray-200 w-full ${widths[size]} my-8`}>
|
||||
<div className="flex items-start justify-between px-6 py-4 border-b border-gray-100">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
{subtitle && <p className="text-sm text-gray-500 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-700 p-1 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<FiX className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
91
frontend/src/components/NewMailboxModal.jsx
Normal file
91
frontend/src/components/NewMailboxModal.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Modal from './Modal';
|
||||
import { mailboxesAPI } from '../services/api';
|
||||
|
||||
const NewMailboxModal = ({ open, domain, onClose, onCreated, onToast }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const localPartRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && domain) {
|
||||
setEmail(`@${domain}`);
|
||||
setPassword('');
|
||||
setError('');
|
||||
// Focus the input and put the cursor at position 0 so the user types
|
||||
// the local part before the existing @domain suffix.
|
||||
requestAnimationFrame(() => {
|
||||
const el = localPartRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
try { el.setSelectionRange(0, 0); } catch { /* some browsers don't allow this */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [open, domain]);
|
||||
|
||||
const submit = async () => {
|
||||
const e = email.trim().toLowerCase();
|
||||
if (!e || !e.includes('@')) { setError('Please enter a valid email address.'); return; }
|
||||
if (!e.endsWith(`@${domain}`)) { setError(`Mailbox must belong to ${domain}.`); return; }
|
||||
if (password.length < 8) { setError('Password must have at least 8 characters.'); return; }
|
||||
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
await mailboxesAPI.create(e, password);
|
||||
onToast?.(`Mailbox created: ${e}`, 'success');
|
||||
onCreated?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="New mailbox" subtitle={domain} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
|
||||
<input
|
||||
ref={localPartRef}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); document.getElementById('new-mb-pw')?.focus(); } }}
|
||||
className="input-field"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Password</label>
|
||||
<input
|
||||
id="new-mb-pw"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
|
||||
className="input-field"
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<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={submit} disabled={busy} className="btn-primary">
|
||||
{busy ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewMailboxModal;
|
||||
88
frontend/src/components/OutOfOffice.jsx
Normal file
88
frontend/src/components/OutOfOffice.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FiCalendar } from 'react-icons/fi';
|
||||
|
||||
const OutOfOffice = ({ rule, onSave }) => {
|
||||
const [isActive, setIsActive] = useState(rule?.ooo_active || false);
|
||||
const [message, setMessage] = useState(rule?.ooo_message || '');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave({ ooo_active: isActive, ooo_message: message });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<FiCalendar className="w-5 h-5 text-gray-600" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Out of Office Status</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{isActive ? 'Auto-reply is currently active' : 'Auto-reply is currently inactive'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
isActive ? 'bg-primary-600' : 'bg-gray-300'
|
||||
}`}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
isActive ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="ooo-message" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Auto-Reply Message
|
||||
</label>
|
||||
<textarea
|
||||
id="ooo-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={8}
|
||||
placeholder="I am currently out of office until [date]. Best regards, Your Name"
|
||||
className="input-field font-mono text-sm resize-none"
|
||||
disabled={!isActive}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Plain text message that gets returned automatically to senders.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isActive && message && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Message Preview
|
||||
</label>
|
||||
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap font-sans">{message}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || (isActive && !message.trim())}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutOfOffice;
|
||||
55
frontend/src/components/PasswordResetModal.jsx
Normal file
55
frontend/src/components/PasswordResetModal.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Modal from './Modal';
|
||||
import { mailboxesAPI } from '../services/api';
|
||||
|
||||
const PasswordResetModal = ({ open, email, onClose, onToast }) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setPassword(''); setError(''); }
|
||||
}, [open, email]);
|
||||
|
||||
const submit = async () => {
|
||||
if (password.length < 8) { setError('Password must have at least 8 characters.'); return; }
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
await mailboxesAPI.setPassword(email, password);
|
||||
onToast?.(`Password updated for ${email}`, 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Reset password" subtitle={email} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
|
||||
className="input-field"
|
||||
minLength={8}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<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={submit} disabled={busy} className="btn-primary">
|
||||
{busy ? 'Updating...' : 'Update password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordResetModal;
|
||||
30
frontend/src/components/Toast.jsx
Normal file
30
frontend/src/components/Toast.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { FiCheckCircle, FiXCircle, FiAlertCircle, FiX } from 'react-icons/fi';
|
||||
|
||||
const Toast = ({ message, type = 'success', onClose }) => {
|
||||
useEffect(() => {
|
||||
const t = setTimeout(onClose, 3500);
|
||||
return () => clearTimeout(t);
|
||||
}, [onClose]);
|
||||
|
||||
const styles = {
|
||||
success: { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-800', icon: <FiCheckCircle className="w-5 h-5 text-green-600" /> },
|
||||
error: { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-800', icon: <FiXCircle className="w-5 h-5 text-red-600" /> },
|
||||
warning: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-800', icon: <FiAlertCircle className="w-5 h-5 text-amber-600" /> },
|
||||
};
|
||||
const s = styles[type] || styles.success;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 animate-slide-in">
|
||||
<div className={`flex items-start gap-3 ${s.bg} ${s.border} border rounded-lg shadow-lg p-4 min-w-[300px] max-w-md`}>
|
||||
{s.icon}
|
||||
<p className={`flex-1 text-sm font-medium ${s.text}`}>{message}</p>
|
||||
<button onClick={onClose} className={`${s.text} opacity-60 hover:opacity-100`}>
|
||||
<FiX className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
60
frontend/src/components/UsageBar.jsx
Normal file
60
frontend/src/components/UsageBar.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
|
||||
const bytes = (n) => {
|
||||
n = Number(n || 0);
|
||||
if (n <= 0) return '0 B';
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
|
||||
};
|
||||
|
||||
const fmtPercent = (n) => {
|
||||
if (n === null || n === undefined || Number.isNaN(Number(n))) return null;
|
||||
const v = Number(n);
|
||||
if (v < 1 && v > 0) return v.toFixed(1);
|
||||
return v.toFixed(0);
|
||||
};
|
||||
|
||||
export const formatBytes = bytes;
|
||||
|
||||
const UsageBar = ({ mailbox }) => {
|
||||
const used = Number(mailbox.used_bytes || 0);
|
||||
const limit = mailbox.quota_bytes === null || mailbox.quota_bytes === undefined
|
||||
? null : Number(mailbox.quota_bytes);
|
||||
const calc = limit && limit > 0 ? (used / limit) * 100 : null;
|
||||
const raw = mailbox.quota_percent === null || mailbox.quota_percent === undefined
|
||||
? calc : Number(mailbox.quota_percent);
|
||||
const pct = raw === null || Number.isNaN(raw) ? null : Math.max(0, Math.min(100, raw));
|
||||
const label = limit
|
||||
? `${bytes(used)} / ${bytes(limit)} (${fmtPercent(pct)}%)`
|
||||
: `${bytes(used)} / unlimited`;
|
||||
|
||||
// Color the bar by usage. No quota -> neutral primary tone.
|
||||
const fillColor = pct === null
|
||||
? 'bg-primary-500'
|
||||
: pct >= 90 ? 'bg-red-500'
|
||||
: pct >= 75 ? 'bg-amber-500'
|
||||
: 'bg-emerald-500';
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px]">
|
||||
<div className="text-xs font-semibold text-gray-700 mb-1.5">Disk Usage: {label}</div>
|
||||
<div className="w-full h-2 rounded-full bg-gray-200 overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${fillColor} transition-all duration-300`}
|
||||
style={{ width: `${pct ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{mailbox.usage_scanned_at
|
||||
? `quota checked ${new Date(mailbox.usage_scanned_at).toLocaleString()}`
|
||||
: 'quota not checked yet'}
|
||||
{mailbox.message_count !== null && mailbox.message_count !== undefined
|
||||
? ` · ${Number(mailbox.message_count)} messages` : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageBar;
|
||||
Reference in New Issue
Block a user