import React, { useEffect, useMemo, useRef, 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 (
{loading &&
}
{view === 'list' && !loading && (
{ setEditTarget(null); setView('form'); }}
onEdit={(a) => { setEditTarget(a); setView('form'); }}
onDelete={(email) => setDeleteTarget(email)}
/>
)}
{view === 'form' && !loading && (
{ setView('list'); setEditTarget(null); }}
onSaved={async () => {
await reload();
setView('list');
setEditTarget(null);
}}
onToast={onToast}
/>
)}
setDeleteTarget(null)}
/>
);
};
// ============================================================
// Sub: AdminList
// ============================================================
const AdminList = ({ admins, currentUserEmail, onCreate, onEdit, onDelete }) => {
return (
{admins.length === 0 ? (
No admins yet.
) : (
| Email |
Role |
Domains |
Status |
Actions |
{admins.map((a) => {
const isMe = a.email === currentUserEmail;
return (
|
{a.role === 'super_admin'
?
: }
{a.email}
{isMe && you}
|
{a.role === 'super_admin'
? super_admin
: domain_admin}
|
{a.role === 'super_admin' ? (
all domains
) : (
{(a.allowed_domains || []).map((d) => (
{d}
))}
)}
|
{a.active ? 'active' : 'disabled'}
|
|
);
})}
)}
);
};
// ============================================================
// 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);
// 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);
const sortedDomainNames = useMemo(
() => [...domains].map((d) => d.domain).sort(),
[domains]
);
// 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.
useEffect(() => {
if (isEdit) return;
if (role !== 'domain_admin') return;
if (emailTouched) return;
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) {
try { el.setSelectionRange(0, 0); } catch { /* ignore */ }
}
});
} else if (allowedDomains.size === 0) {
setEmail('');
}
}, [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('@')) {
try { el.setSelectionRange(0, 0); } catch { /* ignore */ }
}
};
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 (
{ setEmail(e.target.value); setEmailTouched(true); }}
onFocus={handleEmailFocus}
className="input-field"
disabled={isEdit}
placeholder="admin@example.com"
/>
{isEdit && (
Email cannot be changed after creation.
)}
setPassword(e.target.value)}
className={`input-field ${passwordTooShort ? 'border-red-500' : ''}`}
placeholder="At least 8 characters"
/>
setConfirmPw(e.target.value)}
className={`input-field ${passwordMismatch ? 'border-red-500' : ''}`}
placeholder="Confirm"
/>
{passwordTooShort &&
Minimum 8 characters.
}
{!passwordTooShort && passwordMismatch && (
Passwords do not match.
)}
{isSelf && (
You can't change your own role.
)}
{role === 'domain_admin' && (
{sortedDomainNames.length === 0 ? (
No domains in the system yet.
) : (
{sortedDomainNames.map((d) => (
))}
)}
{!isEdit && allowedDomains.size === 1 && !emailTouched && (
Email pre-filled with the selected domain. You can still edit it.
)}
)}
{isEdit && !isSelf && (
Account active
Disabled accounts cannot log in.
)}
{error &&
{error}
}
);
};
export default AdminUsersModal;