Domain Admin

This commit is contained in:
2026-04-27 16:57:08 -05:00
parent 31b3fd8c9f
commit 85b608e3d4
10 changed files with 903 additions and 61 deletions

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
FiUsers, FiUser,
} from 'react-icons/fi';
import Login from './components/Login';
@@ -12,6 +13,8 @@ import NewMailboxModal from './components/NewMailboxModal';
import PasswordResetModal from './components/PasswordResetModal';
import ConfirmDialog from './components/ConfirmDialog';
import AuditLogModal from './components/AuditLogModal';
import AdminUsersModal from './components/AdminUsersModal';
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
@@ -23,19 +26,25 @@ function App() {
const [selectedDomain, setSelectedDomain] = useState(null);
const [mailboxes, setMailboxes] = useState([]);
const [busyMessage, setBusyMessage] = useState(''); // global blocking spinner
const [busyMessage, setBusyMessage] = useState('');
const [toast, setToast] = useState(null);
const [settingsTarget, setSettingsTarget] = useState(null); // { email, tab }
const [settingsTarget, setSettingsTarget] = useState(null);
const [pwTarget, setPwTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [showNew, setShowNew] = useState(false);
const [showAudit, setShowAudit] = useState(false);
const [showAdmins, setShowAdmins] = useState(false);
const [showChangePw, setShowChangePw] = 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);
@@ -63,13 +72,13 @@ function App() {
})();
}, []);
// After login (or on first authenticated render): load domains.
useEffect(() => {
if (!user) return;
(async () => {
setBusyMessage('Loading domains...');
try {
const list = await loadDomains(true);
// Only super admin should trigger an upstream DMS resync on login.
const list = await loadDomains(isSuperAdmin);
const first = list[0]?.domain || null;
setSelectedDomain(first);
if (first) {
@@ -85,7 +94,6 @@ function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
// Switch domain -> reload mailboxes (with quota refresh, like the original).
const selectDomain = async (domain) => {
if (domain === selectedDomain) return;
setSelectedDomain(domain);
@@ -104,7 +112,6 @@ function App() {
try {
await domainsAPI.resync();
const list = await loadDomains(false);
// If selected domain disappeared, fall back to first.
if (selectedDomain && !list.find((d) => d.domain === selectedDomain)) {
const first = list[0]?.domain || null;
setSelectedDomain(first);
@@ -169,22 +176,34 @@ function App() {
<div className="min-h-screen">
{/* Header */}
<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">
<div>
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between gap-4">
<div className="min-w-0">
<h1 className="text-xl font-bold text-gray-900">MailAdmin</h1>
<p className="text-xs text-gray-500">
<p className="text-xs text-gray-500 truncate">
{user.email} · <span className="font-medium">{user.role}</span>
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap justify-end">
<button onClick={() => setShowChangePw(true)} className="btn-secondary" title="Change my password">
<FiUser className="w-4 h-4 mr-2" />
My Password
</button>
<button onClick={() => setShowAudit(true)} className="btn-secondary">
<FiList className="w-4 h-4 mr-2" />
Audit Log
</button>
<button onClick={handleResync} className="btn-secondary">
<FiRefreshCw className="w-4 h-4 mr-2" />
DMS Resync
</button>
{isSuperAdmin && (
<>
<button onClick={() => setShowAdmins(true)} className="btn-secondary">
<FiUsers className="w-4 h-4 mr-2" />
Admins
</button>
<button onClick={handleResync} className="btn-secondary">
<FiRefreshCw className="w-4 h-4 mr-2" />
DMS Resync
</button>
</>
)}
<button onClick={handleLogout} className="btn-ghost">
<FiLogOut className="w-4 h-4 mr-2" />
Logout
@@ -195,46 +214,50 @@ function App() {
{/* Main */}
<main className="max-w-7xl mx-auto px-6 py-6">
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6 items-start">
{/* Domains */}
<section className="card">
<h2 className="text-base font-semibold text-gray-900">Domains on this node</h2>
<p className="text-xs text-gray-500 mt-1 mb-4">
Domains are discovered dynamically from DMS accounts.
</p>
<div className={`grid grid-cols-1 ${hideDomainList ? '' : 'lg:grid-cols-[320px_1fr]'} gap-6 items-start`}>
{/* Domains - hidden when single-domain user */}
{!hideDomainList && (
<section className="card">
<h2 className="text-base font-semibold text-gray-900">Domains on this node</h2>
<p className="text-xs text-gray-500 mt-1 mb-4">
{isSuperAdmin
? 'Domains are discovered dynamically from DMS accounts.'
: 'Your assigned domains.'}
</p>
{domains.length === 0 ? (
<p className="text-sm text-gray-400">No domains found yet.</p>
) : (
<div className="flex flex-col gap-2">
{domains.map((d) => {
const active = d.domain === selectedDomain;
return (
<button
key={d.domain}
onClick={() => selectDomain(d.domain)}
className={`text-left p-3 rounded-lg border transition-colors ${
active
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="font-semibold text-gray-900">{d.domain}</div>
<div className="text-xs text-gray-500 mt-0.5">
{d.active_mailboxes || 0} inboxes · {formatBytes(d.used_bytes)}
</div>
<div className="flex flex-wrap gap-1 mt-2">
<span className="pill">{d.current_node || d.node_name}</span>
<span className={d.status === 'active' ? 'pill-success' : 'pill'}>
{d.status}
</span>
</div>
</button>
);
})}
</div>
)}
</section>
{domains.length === 0 ? (
<p className="text-sm text-gray-400">No domains available.</p>
) : (
<div className="flex flex-col gap-2">
{domains.map((d) => {
const active = d.domain === selectedDomain;
return (
<button
key={d.domain}
onClick={() => selectDomain(d.domain)}
className={`text-left p-3 rounded-lg border transition-colors ${
active
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="font-semibold text-gray-900">{d.domain}</div>
<div className="text-xs text-gray-500 mt-0.5">
{d.active_mailboxes || 0} inboxes · {formatBytes(d.used_bytes)}
</div>
<div className="flex flex-wrap gap-1 mt-2">
<span className="pill">{d.current_node || d.node_name}</span>
<span className={d.status === 'active' ? 'pill-success' : 'pill'}>
{d.status}
</span>
</div>
</button>
);
})}
</div>
)}
</section>
)}
{/* Mailboxes */}
<section className="card relative">
@@ -293,7 +316,6 @@ function App() {
<td className="py-3 pr-4 text-xs text-gray-500 whitespace-nowrap">
{new Date(m.updated_at).toLocaleString()}
</td>
{/* Actions: single row, no wrapping. Punkt 3 deiner Liste. */}
<td className="py-3 pr-2">
<div className="flex flex-row flex-nowrap items-center gap-2">
<button
@@ -371,11 +393,21 @@ function App() {
onClose={() => setShowAudit(false)}
onToast={showToast}
/>
{isSuperAdmin && (
<AdminUsersModal
open={showAdmins}
currentUser={user}
onClose={() => setShowAdmins(false)}
onToast={showToast}
/>
)}
<ChangeMyPasswordModal
open={showChangePw}
onClose={() => setShowChangePw(false)}
onToast={showToast}
/>
{/* Global blocking overlay (Punkt 2 deiner Liste) */}
{busyMessage && <LoadingOverlay message={busyMessage} fullscreen />}
{/* Toast */}
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
</div>
);

View File

@@ -0,0 +1,439 @@
import React, { useEffect, useMemo, useState } from 'react';
import { FiPlus, FiTrash2, FiEdit2, FiUser, FiShield, FiArrowLeft } from 'react-icons/fi';
import Modal from './Modal';
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([]);
const [domains, setDomains] = useState([]);
const [loading, setLoading] = useState(false);
const [editTarget, setEditTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const reload = async () => {
setLoading(true);
try {
const [a, d] = await Promise.all([
adminsAPI.list(),
domainsAPI.list(false),
]);
setAdmins(a);
setDomains(d);
} catch (err) {
onToast?.(`Failed to load admins: ${err.message}`, 'error');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) {
setView('list');
setEditTarget(null);
reload();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await adminsAPI.remove(deleteTarget);
onToast?.(`Deleted ${deleteTarget}`, 'success');
setDeleteTarget(null);
await reload();
} catch (err) {
onToast?.(`Delete failed: ${err.message}`, 'error');
}
};
return (
<Modal
open={open}
onClose={onClose}
title={view === 'list' ? 'Manage admins' : (editTarget ? 'Edit admin' : 'New admin')}
subtitle={view === 'list' ? 'Super admins and per-domain admins' : null}
size="lg"
>
<div className="relative min-h-[300px]">
{loading && <LoadingOverlay message="Loading..." />}
{view === 'list' && !loading && (
<AdminList
admins={admins}
currentUserEmail={currentUser?.email}
onCreate={() => { setEditTarget(null); setView('form'); }}
onEdit={(a) => { setEditTarget(a); setView('form'); }}
onDelete={(email) => setDeleteTarget(email)}
/>
)}
{view === 'form' && !loading && (
<AdminForm
target={editTarget}
domains={domains}
currentUserEmail={currentUser?.email}
onCancel={() => { setView('list'); setEditTarget(null); }}
onSaved={async () => {
await reload();
setView('list');
setEditTarget(null);
}}
onToast={onToast}
/>
)}
</div>
<ConfirmDialog
open={!!deleteTarget}
title="Delete admin"
message={deleteTarget ? `Permanently delete admin ${deleteTarget}?` : ''}
confirmLabel="Delete"
danger
onConfirm={handleDelete}
onClose={() => setDeleteTarget(null)}
/>
</Modal>
);
};
// ============================================================
// Sub: AdminList
// ============================================================
const AdminList = ({ admins, currentUserEmail, onCreate, onEdit, onDelete }) => {
return (
<div className="space-y-4">
<div className="flex justify-end">
<button onClick={onCreate} className="btn-primary">
<FiPlus className="w-4 h-4 mr-2" />
New admin
</button>
</div>
{admins.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-8">No admins yet.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
<th className="py-2 pr-4 font-semibold">Email</th>
<th className="py-2 pr-4 font-semibold">Role</th>
<th className="py-2 pr-4 font-semibold">Domains</th>
<th className="py-2 pr-4 font-semibold">Status</th>
<th className="py-2 pr-2 font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{admins.map((a) => {
const isMe = a.email === currentUserEmail;
return (
<tr key={a.email} className="align-top hover:bg-gray-50">
<td className="py-3 pr-4">
<div className="flex items-center gap-2">
{a.role === 'super_admin'
? <FiShield className="w-4 h-4 text-primary-600" />
: <FiUser className="w-4 h-4 text-gray-400" />}
<span className="font-semibold text-gray-900">{a.email}</span>
{isMe && <span className="pill">you</span>}
</div>
</td>
<td className="py-3 pr-4">
{a.role === 'super_admin'
? <span className="pill-success">super_admin</span>
: <span className="pill">domain_admin</span>}
</td>
<td className="py-3 pr-4">
{a.role === 'super_admin' ? (
<span className="text-xs text-gray-400 italic">all domains</span>
) : (
<div className="flex flex-wrap gap-1">
{(a.allowed_domains || []).map((d) => (
<span key={d} className="pill">{d}</span>
))}
</div>
)}
</td>
<td className="py-3 pr-4">
<span className={a.active ? 'pill-success' : 'pill-warn'}>
{a.active ? 'active' : 'disabled'}
</span>
</td>
<td className="py-3 pr-2">
<div className="flex flex-row flex-nowrap items-center gap-2">
<button
onClick={() => onEdit(a)}
className="btn-secondary"
title="Edit"
>
<FiEdit2 className="w-3.5 h-3.5 mr-1.5" />
Edit
</button>
<button
onClick={() => onDelete(a.email)}
className="btn-danger"
disabled={isMe}
title={isMe ? "You can't delete yourself" : 'Delete'}
>
<FiTrash2 className="w-3.5 h-3.5 mr-1.5" />
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
};
// ============================================================
// Sub: AdminForm
// ============================================================
const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToast }) => {
const isEdit = !!target;
const isSelf = isEdit && target.email === currentUserEmail;
const [email, setEmail] = useState(target?.email || '');
const [password, setPassword] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [role, setRole] = useState(target?.role || 'domain_admin');
const [allowedDomains, setAllowedDomains] = useState(
new Set((target?.allowed_domains || []).map((d) => d.toLowerCase()))
);
const [active, setActive] = useState(target?.active ?? true);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const sortedDomainNames = useMemo(
() => [...domains].map((d) => d.domain).sort(),
[domains]
);
const toggleDomain = (d) => {
const next = new Set(allowedDomains);
if (next.has(d)) next.delete(d); else next.add(d);
setAllowedDomains(next);
};
const passwordOk = !password || (password.length >= 8 && password === confirmPw);
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;
const submit = async () => {
setError('');
if (!email.trim() || !email.includes('@')) {
setError('Please enter a valid email.'); return;
}
if (passwordRequired && !passwordProvided) {
setError('Password is required for new admins.'); return;
}
if (passwordProvided && !passwordOk) {
if (passwordTooShort) setError('Password must have at least 8 characters.');
else if (passwordMismatch) setError('Passwords do not match.');
return;
}
if (role === 'domain_admin' && allowedDomains.size === 0) {
setError('A domain admin needs at least one allowed domain.'); return;
}
setBusy(true);
try {
const allowedList = role === 'super_admin' ? [] : [...allowedDomains];
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;
}
await adminsAPI.update(target.email, payload);
onToast?.(`Updated ${target.email}`, 'success');
} else {
await adminsAPI.create({
email: email.trim().toLowerCase(),
password,
role,
allowed_domains: allowedList,
});
onToast?.(`Created ${email.trim().toLowerCase()}`, 'success');
}
onSaved();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<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>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input-field"
disabled={isEdit}
placeholder="admin@example.com"
/>
{isEdit && (
<p className="mt-1 text-xs text-gray-500">Email cannot be changed after creation.</p>
)}
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
{isEdit ? 'Set new password (leave empty to keep current)' : 'Password'}
</label>
<div className="grid grid-cols-2 gap-2">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`input-field ${passwordTooShort ? 'border-red-500' : ''}`}
placeholder="At least 8 characters"
/>
<input
type="password"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
className={`input-field ${passwordMismatch ? 'border-red-500' : ''}`}
placeholder="Confirm"
/>
</div>
{passwordTooShort && <p className="mt-1 text-xs text-red-600">Minimum 8 characters.</p>}
{!passwordTooShort && passwordMismatch && (
<p className="mt-1 text-xs text-red-600">Passwords do not match.</p>
)}
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Role</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setRole('domain_admin')}
disabled={isSelf}
className={`flex-1 px-4 py-2 rounded-lg border-2 transition-all ${
role === 'domain_admin'
? 'border-primary-600 bg-primary-50 text-primary-700 font-semibold'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<FiUser className="inline w-4 h-4 mr-2" />
Domain admin
</button>
<button
type="button"
onClick={() => setRole('super_admin')}
disabled={isSelf}
className={`flex-1 px-4 py-2 rounded-lg border-2 transition-all ${
role === 'super_admin'
? 'border-primary-600 bg-primary-50 text-primary-700 font-semibold'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<FiShield className="inline w-4 h-4 mr-2" />
Super admin
</button>
</div>
{isSelf && (
<p className="mt-1 text-xs text-gray-500">You can't change your own role.</p>
)}
</div>
{role === 'domain_admin' && (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Allowed domains ({allowedDomains.size}/{sortedDomainNames.length})
</label>
{sortedDomainNames.length === 0 ? (
<p className="text-sm text-gray-500 italic">No domains in the system yet.</p>
) : (
<div className="border border-gray-200 rounded-lg max-h-48 overflow-y-auto custom-scrollbar p-2 grid grid-cols-2 gap-1">
{sortedDomainNames.map((d) => (
<label
key={d}
className="flex items-center gap-2 p-2 rounded hover:bg-gray-50 cursor-pointer"
>
<input
type="checkbox"
checked={allowedDomains.has(d)}
onChange={() => toggleDomain(d)}
className="w-4 h-4 text-primary-600 rounded border-gray-300 focus:ring-primary-500"
/>
<span className="text-sm text-gray-800">{d}</span>
</label>
))}
</div>
)}
</div>
)}
{isEdit && !isSelf && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
<div>
<h3 className="font-semibold text-gray-900 text-sm">Account active</h3>
<p className="text-xs text-gray-600">
Disabled accounts cannot log in.
</p>
</div>
<button
type="button"
onClick={() => setActive(!active)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
active ? 'bg-primary-600' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
active ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200">
<button onClick={onCancel} className="btn-secondary px-4 py-2" disabled={busy}>
Cancel
</button>
<button onClick={submit} className="btn-primary" disabled={busy}>
{busy ? 'Saving...' : (isEdit ? 'Save changes' : 'Create admin')}
</button>
</div>
</div>
);
};
export default AdminUsersModal;

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useState } from 'react';
import Modal from './Modal';
import { authAPI } from '../services/api';
const ChangeMyPasswordModal = ({ open, onClose, onToast }) => {
const [currentPw, setCurrentPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
useEffect(() => {
if (open) {
setCurrentPw(''); setNewPw(''); setConfirmPw(''); setError('');
}
}, [open]);
const tooShort = newPw.length > 0 && newPw.length < 8;
const mismatch = confirmPw.length > 0 && newPw !== confirmPw;
const canSubmit =
currentPw.length > 0 && newPw.length >= 8 && newPw === confirmPw;
const submit = async () => {
if (!canSubmit) {
if (!currentPw) setError('Please enter your current password.');
else if (newPw.length < 8) setError('New password must have at least 8 characters.');
else if (newPw !== confirmPw) setError('Passwords do not match.');
return;
}
setBusy(true); setError('');
try {
await authAPI.changePassword(currentPw, newPw);
onToast?.('Your password has been updated.', 'success');
onClose();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<Modal open={open} onClose={onClose} title="Change my password" size="sm">
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Current password</label>
<input
type="password"
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
className="input-field"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">New password</label>
<input
type="password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
className={`input-field ${tooShort ? 'border-red-500 focus:ring-red-500' : ''}`}
minLength={8}
/>
{tooShort && <p className="mt-1 text-xs text-red-600">Minimum 8 characters.</p>}
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Confirm new password</label>
<input
type="password"
value={confirmPw}
onChange={(e) => setConfirmPw(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 && confirmPw.length > 0 && newPw === confirmPw && newPw.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>}
<div className="flex justify-end gap-2 pt-2 border-t border-gray-200">
<button onClick={onClose} className="btn-secondary px-4 py-2" disabled={busy}>Cancel</button>
<button onClick={submit} disabled={busy || !canSubmit} className="btn-primary">
{busy ? 'Updating...' : 'Update password'}
</button>
</div>
</div>
</Modal>
);
};
export default ChangeMyPasswordModal;

View File

@@ -26,6 +26,8 @@ export const authAPI = {
login: async (email, password) =>
(await api.post('/api/auth/login', { email, password })).data,
logout: async () => (await api.post('/api/auth/logout')).data,
changePassword: async (current_password, new_password) =>
(await api.post('/api/auth/change-password', { current_password, new_password })).data,
};
export const domainsAPI = {
@@ -69,3 +71,13 @@ export const mailboxesAPI = {
export const auditAPI = {
list: async () => (await api.get('/api/audit')).data,
};
export const adminsAPI = {
list: async () => (await api.get('/api/admins')).data,
create: async ({ email, password, role, allowed_domains }) =>
(await api.post('/api/admins', { email, password, role, allowed_domains })).data,
update: async (email, payload) =>
(await api.put(`/api/admins/${encodeURIComponent(email)}`, payload)).data,
remove: async (email) =>
(await api.delete(`/api/admins/${encodeURIComponent(email)}`)).data,
};