// Users management — appended to the Admin view.
//
// Rules-of-Hooks: every useState / useEffect / useMemo is declared
// BEFORE any early return. See components/admin.jsx:180-210 for the
// canonical pattern.
const USER_INPUT = {
width: '100%', padding: '5px 9px', background: 'var(--surface)',
border: '1px solid var(--border-2)', borderRadius: 2,
color: 'var(--text)', fontFamily: 'var(--mono)', fontSize: 12, outline: 'none',
};
const ALL_VIEWS = ['overview','threed','topology','nodes','alerts','logs','admin','notifications'];
function UserEditor({ user, currentUser, onClose, onSaved, onResetPassword }) {
const open = !!user;
const [username, setUsername] = React.useState('');
const [isAdmin, setIsAdmin] = React.useState(false);
const [enabled, setEnabled] = React.useState(true);
const [views, setViews] = React.useState([]);
const [newPw, setNewPw] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
React.useEffect(() => {
if (!user) return;
setUsername(user.username || '');
setIsAdmin(!!user.isAdmin);
setEnabled(user.enabled !== false);
setViews(user.isAdmin ? [...ALL_VIEWS] : (user.views || []));
setNewPw('');
setErr(null);
}, [user?.id]);
if (!open) return
;
function toggleView(v) {
setViews(cur => cur.includes(v) ? cur.filter(x => x !== v) : [...cur, v]);
}
async function save() {
setBusy(true); setErr(null);
try {
const patch = {
username: username.trim(),
isAdmin,
enabled,
views: isAdmin ? ALL_VIEWS : views,
};
const updated = await window.apiFetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
if (newPw.trim()) {
await window.apiFetch(`/api/admin/users/${user.id}/password`, {
method: 'POST',
body: JSON.stringify({ password: newPw }),
});
}
onSaved(updated);
} catch (e) { setErr(e.message || 'save failed'); }
finally { setBusy(false); }
}
const self = user && currentUser && user.id === currentUser.id;
return (
Edit user
{user.id}{self ? ' · you' : ''}
);
}
function AddUserForm({ onAdded }) {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [isAdmin, setIsAdmin] = React.useState(false);
const [views, setViews] = React.useState(['overview']);
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
function toggleView(v) {
setViews(cur => cur.includes(v) ? cur.filter(x => x !== v) : [...cur, v]);
}
async function submit(e) {
e.preventDefault();
setBusy(true); setErr(null);
try {
const created = await window.apiFetch('/api/admin/users', {
method: 'POST',
body: JSON.stringify({
username: username.trim(), password,
isAdmin, enabled: true,
views: isAdmin ? ALL_VIEWS : views,
}),
});
onAdded(created);
setUsername(''); setPassword(''); setIsAdmin(false); setViews(['overview']);
} catch (e2) { setErr(e2.message || 'add failed'); }
finally { setBusy(false); }
}
return (
);
}
function UsersAdmin({ currentUser }) {
const [users, setUsers] = React.useState(null);
const [editing, setEditing] = React.useState(null);
const [confirmDelete, setConfirmDelete] = React.useState(null);
const [err, setErr] = React.useState(null);
async function load() {
setErr(null);
try {
const list = await window.apiFetch('/api/admin/users');
setUsers(list);
} catch (e) {
if (e.message !== 'unauthorized') setErr(e.message);
}
}
React.useEffect(() => { load(); }, []);
React.useEffect(() => {
if (!confirmDelete) return;
const t = setTimeout(() => setConfirmDelete(null), 4000);
return () => clearTimeout(t);
}, [confirmDelete]);
async function onAdded(_u) { await load(); }
async function onSaved(_u) { setEditing(null); await load(); }
async function onToggleEnabled(u) {
try {
await window.apiFetch(`/api/admin/users/${u.id}`, {
method: 'PATCH', body: JSON.stringify({ enabled: !u.enabled }),
});
await load();
} catch (e) { setErr(e.message); }
}
async function onDelete(u) {
try {
await window.apiFetch(`/api/admin/users/${u.id}`, { method: 'DELETE' });
await load();
} catch (e) { setErr(e.message); }
}
async function onResetPassword(u) {
const pw = prompt(`New password for "${u.username}" (min 6 chars, admin reset will sign them out everywhere):`);
if (!pw || pw.length < 6) return;
try {
await window.apiFetch(`/api/admin/users/${u.id}/password`, {
method: 'POST', body: JSON.stringify({ password: pw }),
});
} catch (e) { setErr(e.message); }
}
return (
Users
{users ? `${users.length} account${users.length === 1 ? '' : 's'}` : 'loading…'}
{err &&
{err}
}
| username | role | views | status | created |
actions |
{(users || []).map(u => {
const self = currentUser && u.id === currentUser.id;
return (
| {u.username}{self && you} |
{u.isAdmin
? admin
: user}
|
{u.isAdmin
? all 8
: (u.views || []).map(v => (
{v}
))}
|
{u.enabled ? 'enabled' : 'disabled'}
|
{window.fmtRel(u.createdAt)} |
{confirmDelete === u.id ? (
) : (
)}
|
);
})}
{(!users || users.length === 0) && (
|
{users === null ? 'Loading…' : 'No users yet.'}
|
)}
setEditing(null)} onSaved={onSaved} />
);
}
function AuditLog() {
// All hooks above any early return.
const [entries, setEntries] = React.useState(null);
const [userFilter, setUserFilter] = React.useState('');
const [limit, setLimit] = React.useState(200);
const [err, setErr] = React.useState(null);
async function load() {
setErr(null);
try {
const q = new URLSearchParams({ limit: String(limit), days: '14' });
if (userFilter.trim()) q.set('username', userFilter.trim());
const data = await window.apiFetch(`/api/admin/audit?${q.toString()}`);
setEntries(data.entries || []);
} catch (e) {
if (e.message !== 'unauthorized') setErr(e.message);
}
}
React.useEffect(() => { load(); }, []);
React.useEffect(() => {
// Auto-refresh every 30s while panel is mounted.
const t = setInterval(load, 30_000);
return () => clearInterval(t);
}, [userFilter, limit]);
function statusChip(code) {
if (code == null) return —;
if (code >= 500) return {code};
if (code >= 400) return {code};
return {code};
}
return (
Audit log
{entries ? `${entries.length} entries · last 14d` : 'loading…'}
setUserFilter(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') load(); }} />
{err &&
{err}
}
| when | user | method | path |
target | status | dur | ip |
{(entries || []).map(e => (
| {window.fmtRel(e.ts)} |
{e.username || —}
{e.isAdmin && admin}
|
{e.method} |
{e.path}
|
{e.target_id || —}
|
{statusChip(e.status_code)}{e.error && ✗} |
{e.duration_ms != null ? `${e.duration_ms}ms` : '—'} |
{e.client_ip || '—'} |
))}
{(entries || []).length === 0 && (
|
{entries === null ? 'Loading…' : 'No audit entries yet — take an admin action to see one here.'}
|
)}
);
}
Object.assign(window, { UsersAdmin, AuditLog });