import { useEffect, useState } from 'react' import { PageShell } from '../components/PageShell' import type { User, Role, Capability } from '../types' interface ManagedUser { id: number email: string name: string active: boolean is_admin: boolean offsite_allowed: boolean workforce_user_id: string | null app_slugs: string[] roles: { id: number; name: string; slug: string }[] capabilities: string[] // direct ":" grants (not role-derived) } export function AdminUsers({ user }: { user: User }) { const [users, setUsers] = useState([]) const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([]) const [allRoles, setAllRoles] = useState([]) const [allCaps, setAllCaps] = useState([]) const [showCreate, setShowCreate] = useState(false) const [expanded, setExpanded] = useState>(new Set()) async function load() { const [u, a, r, c] = await Promise.all([ fetch('/api/auth/admin/users', { credentials: 'include' }).then(r => r.json()), fetch('/api/auth/admin/apps', { credentials: 'include' }).then(r => r.json()), fetch('/api/auth/admin/roles', { credentials: 'include' }).then(r => r.json()), fetch('/api/auth/admin/capabilities', { credentials: 'include' }).then(r => r.json()), ]) setUsers(u) setAllApps(a) setAllRoles(r) setAllCaps(c) } useEffect(() => { load() }, []) // Capabilities a user inherits from their assigned roles (shown read-only). function roleDerivedCaps(u: ManagedUser): Set { const set = new Set() for (const ur of u.roles) { const role = allRoles.find(r => r.id === ur.id) role?.capabilities?.forEach(c => set.add(c)) } return set } async function toggle(userId: number, field: string, current: boolean) { await fetch(`/api/auth/admin/users/${userId}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [field]: !current }), }) load() } async function grantRevoke(userId: number, slug: string, has: boolean) { await fetch(`/api/auth/admin/users/${userId}/apps/${slug}`, { method: has ? 'DELETE' : 'POST', credentials: 'include', }) load() } async function toggleUserCap(userId: number, appSlug: string, capSlug: string, has: boolean) { await fetch(`/api/auth/admin/users/${userId}/capabilities/${appSlug}/${capSlug}`, { method: has ? 'DELETE' : 'POST', credentials: 'include', }) load() } async function addRole(userId: number, roleId: number) { await fetch(`/api/auth/admin/users/${userId}/roles/${roleId}`, { method: 'POST', credentials: 'include', }) load() } async function removeRole(userId: number, roleId: number) { await fetch(`/api/auth/admin/users/${userId}/roles/${roleId}`, { method: 'DELETE', credentials: 'include', }) load() } async function syncWfRoles(userId: number) { await fetch(`/api/auth/admin/workforce/sync-user/${userId}`, { method: 'POST', credentials: 'include', }) load() } return (

Users

{showCreate && { setShowCreate(false); load() }} />}
{users.map(u => { const isExpanded = expanded.has(u.id) const toggleExpanded = () => setExpanded(prev => { const next = new Set(prev) next.has(u.id) ? next.delete(u.id) : next.add(u.id) return next }) const inherited = roleDerivedCaps(u) return (
{/* Always-visible row */}
{u.name}
{u.email}
toggle(u.id, 'active', u.active)} /> toggle(u.id, 'is_admin', u.is_admin)} /> toggle(u.id, 'offsite_allowed', u.offsite_allowed)} />
{/* Summary row: role badges + expand toggle */}
{u.roles.length > 0 ? u.roles.map(r => ( {r.name} )) : No roles }
{/* Expanded: app grants, capabilities, role editor */} {isExpanded && ( <>
{allApps.map(app => { const has = u.app_slugs.includes(app.slug) const hasViaRole = u.roles.some(ur => allRoles.find(r => r.id === ur.id)?.app_slugs.includes(app.slug)) const appCaps = allCaps.filter(c => c.app_slug === app.slug) return (
{(has || hasViaRole) && appCaps.length > 0 && (
{appCaps.map(cap => { const capKey = `${cap.app_slug}:${cap.slug}` const direct = u.capabilities.includes(capKey) const viaRole = inherited.has(capKey) const on = direct || viaRole return ( ) })}
)}
) })}
{/* Role editor */}
Roles: {u.roles.map(r => ( ))} {u.workforce_user_id && ( )}
)}
) })}
) } function Toggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) { return ( ) } function CreateUserForm({ onCreated }: { onCreated: () => void }) { const [form, setForm] = useState({ email: '', name: '', password: '', is_admin: false, offsite_allowed: false }) const [error, setError] = useState('') async function submit(e: React.FormEvent) { e.preventDefault() const res = await fetch('/api/auth/admin/users', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form), }) if (res.ok) onCreated() else setError('Failed to create user') } const f = (k: string) => (e: React.ChangeEvent) => setForm(v => ({ ...v, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value })) return (

New User

{error &&

{error}

}
) } const inp: React.CSSProperties = { background: 'var(--body-bg)', border: '1px solid var(--card-border)', borderRadius: '6px', color: 'var(--text-dark)', padding: '0.6rem 0.75rem', fontSize: '0.875rem', } const chk: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.875rem', cursor: 'pointer' } const goldBtn: React.CSSProperties = { background: 'var(--navy)', color: '#fff', border: 'none', borderRadius: '6px', padding: '0.5rem 1rem', fontSize: '0.875rem', fontWeight: 600, cursor: 'pointer', }