// 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' : ''}
setUsername(e.target.value)} />
{ALL_VIEWS.map(v => ( ))}
setNewPw(e.target.value)} placeholder="at least 6 chars" />
{err &&
{err}
}
); } 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 (
setUsername(e.target.value)} />
setPassword(e.target.value)} />
{ALL_VIEWS.map(v => ( ))}
{err &&
{err}
}
); } 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}
}
{(users || []).map(u => { const self = currentUser && u.id === currentUser.id; return ( ); })} {(!users || users.length === 0) && ( )}
usernameroleviewsstatuscreated actions
{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 === 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}
}
{(entries || []).map(e => ( ))} {(entries || []).length === 0 && ( )}
whenusermethodpath targetstatusdurip
{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 === null ? 'Loading…' : 'No audit entries yet — take an admin action to see one here.'}
); } Object.assign(window, { UsersAdmin, AuditLog });