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.

) : (
{admins.map((a) => { const isMe = a.email === currentUserEmail; return ( ); })}
Email Role Domains Status Actions
{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;