482 lines
17 KiB
JavaScript
482 lines
17 KiB
JavaScript
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 (
|
|
<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);
|
|
|
|
// 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 "@<domain>" 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 (
|
|
<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
|
|
ref={emailRef}
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => { setEmail(e.target.value); setEmailTouched(true); }}
|
|
onFocus={handleEmailFocus}
|
|
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>
|
|
)}
|
|
{!isEdit && allowedDomains.size === 1 && !emailTouched && (
|
|
<p className="mt-2 text-xs text-gray-500">
|
|
Email pre-filled with the selected domain. You can still edit it.
|
|
</p>
|
|
)}
|
|
</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; |