portal/src/pages/AdminUsers.tsx
2026-07-21 09:15:12 +00:00

311 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 "<app>:<cap>" grants (not role-derived)
}
export function AdminUsers({ user }: { user: User }) {
const [users, setUsers] = useState<ManagedUser[]>([])
const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([])
const [allRoles, setAllRoles] = useState<Role[]>([])
const [allCaps, setAllCaps] = useState<Capability[]>([])
const [showCreate, setShowCreate] = useState(false)
const [expanded, setExpanded] = useState<Set<number>>(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<string> {
const set = new Set<string>()
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 (
<PageShell user={user}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
<h1 style={{ fontSize: '1.2rem' }}>Users</h1>
<button onClick={() => setShowCreate(v => !v)} style={goldBtn}>
{showCreate ? 'Cancel' : '+ New user'}
</button>
</div>
{showCreate && <CreateUserForm onCreated={() => { setShowCreate(false); load() }} />}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{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 (
<div key={u.id} style={{
background: 'var(--card-bg)', borderRadius: '10px',
padding: '1rem 1.25rem', border: '1px solid var(--card-border)',
boxShadow: 'var(--shadow-sm)', opacity: u.active ? 1 : 0.5,
}}>
{/* Always-visible row */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: '140px' }}>
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{u.name}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>{u.email}</div>
</div>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Toggle label="Active" on={u.active} onClick={() => toggle(u.id, 'active', u.active)} />
<Toggle label="Admin" on={u.is_admin} onClick={() => toggle(u.id, 'is_admin', u.is_admin)} />
<Toggle label="Offsite" on={u.offsite_allowed} onClick={() => toggle(u.id, 'offsite_allowed', u.offsite_allowed)} />
</div>
</div>
{/* Summary row: role badges + expand toggle */}
<div style={{ marginTop: '0.6rem', display: 'flex', alignItems: 'center', gap: '0.4rem', flexWrap: 'wrap' }}>
{u.roles.length > 0
? u.roles.map(r => (
<span key={r.id} style={{
background: 'var(--navy)', color: '#fff',
borderRadius: '4px', padding: '0.15rem 0.5rem', fontSize: '0.72rem',
}}>{r.name}</span>
))
: <span style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>No roles</span>
}
<button onClick={toggleExpanded} style={{
marginLeft: 'auto', background: 'none', border: 'none',
color: 'var(--text-mid)', fontSize: '0.72rem', cursor: 'pointer',
padding: '0.15rem 0.25rem', display: 'flex', alignItems: 'center', gap: '0.2rem',
}}>
{isExpanded ? 'Hide permissions ▲' : 'Show permissions ▼'}
</button>
</div>
{/* Expanded: app grants, capabilities, role editor */}
{isExpanded && (
<>
<div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{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 (
<div key={app.slug} style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<button onClick={() => grantRevoke(u.id, app.slug, has)}
title={hasViaRole ? 'Also granted via a role' : ''} style={{
background: has ? 'var(--navy)' : 'var(--body-bg)',
border: `1px solid ${has ? 'var(--navy)' : hasViaRole ? 'var(--navy)' : 'var(--card-border)'}`,
color: has ? '#fff' : hasViaRole ? 'var(--navy)' : 'var(--text-mid)',
borderRadius: '4px', padding: '0.2rem 0.6rem', fontSize: '0.75rem', cursor: 'pointer',
minWidth: '120px', textAlign: 'left', flexShrink: 0,
}}>
{app.name}{hasViaRole && !has ? ' (role)' : ''}
</button>
{(has || hasViaRole) && appCaps.length > 0 && (
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap', alignItems: 'center' }}>
{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 (
<button key={capKey}
title={viaRole && !direct ? `Granted via role — ${cap.description ?? ''}` : (cap.description ?? '')}
onClick={() => { if (!viaRole) toggleUserCap(u.id, cap.app_slug, cap.slug, direct) }}
style={{
background: on ? (viaRole && !direct ? '#5a7d6a' : '#2d6a4f') : 'var(--body-bg)',
border: `1px solid ${on ? (viaRole && !direct ? '#5a7d6a' : '#2d6a4f') : 'var(--card-border)'}`,
color: on ? '#fff' : 'var(--text-mid)',
borderRadius: '4px', padding: '0.15rem 0.5rem', fontSize: '0.7rem',
cursor: viaRole && !direct ? 'default' : 'pointer',
}}>
{on ? '✓ ' : ''}{cap.name}{viaRole && !direct ? ' (role)' : ''}
</button>
)
})}
</div>
)}
</div>
)
})}
</div>
{/* Role editor */}
<div style={{ marginTop: '0.5rem', display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: '0.72rem', color: 'var(--text-mid)', marginRight: '0.15rem' }}>Roles:</span>
{u.roles.map(r => (
<button key={r.id} onClick={() => removeRole(u.id, r.id)} title="Click to remove" style={{
background: 'var(--navy)', border: '1px solid var(--navy)',
color: '#fff', borderRadius: '4px',
padding: '0.15rem 0.5rem', fontSize: '0.72rem', cursor: 'pointer',
}}>
{r.name} ×
</button>
))}
<select
value=""
onChange={e => { if (e.target.value) addRole(u.id, Number(e.target.value)) }}
style={{
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
borderRadius: '4px', color: 'var(--text-mid)',
padding: '0.15rem 0.4rem', fontSize: '0.72rem',
}}
>
<option value="">+ Add role</option>
{allRoles.filter(r => !u.roles.find(ur => ur.id === r.id)).map(r => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
{u.workforce_user_id && (
<button onClick={() => syncWfRoles(u.id)} style={{
background: 'var(--body-bg)', border: '1px solid var(--card-border)',
color: 'var(--text-mid)', borderRadius: '4px',
padding: '0.15rem 0.5rem', fontSize: '0.72rem', cursor: 'pointer',
}}>
Sync WF roles
</button>
)}
</div>
</>
)}
</div>
)
})}
</div>
</PageShell>
)
}
function Toggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
return (
<button onClick={onClick} style={{
background: on ? 'var(--navy)' : 'var(--body-bg)',
border: `1px solid ${on ? 'var(--navy)' : 'var(--card-border)'}`,
color: on ? '#fff' : 'var(--text-mid)',
borderRadius: '4px', padding: '0.2rem 0.6rem', fontSize: '0.75rem', cursor: 'pointer',
}}>
{label}
</button>
)
}
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<HTMLInputElement>) =>
setForm(v => ({ ...v, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
return (
<form onSubmit={submit} style={{
background: 'var(--card-bg)', borderRadius: '10px', padding: '1.25rem',
border: '1px solid var(--card-border)', marginBottom: '1rem',
display: 'flex', flexDirection: 'column', gap: '0.75rem',
boxShadow: 'var(--shadow-sm)',
}}>
<h2 style={{ fontSize: '0.9rem', color: 'var(--navy)', fontWeight: 700 }}>New User</h2>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<input placeholder="Full name" value={form.name} onChange={f('name')} required style={{ ...inp, flex: 1 }} />
<input type="email" placeholder="Email" value={form.email} onChange={f('email')} required style={{ ...inp, flex: 1 }} />
<input type="password" placeholder="Password" value={form.password} onChange={f('password')} required style={{ ...inp, flex: 1 }} />
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<label style={chk}><input type="checkbox" checked={form.is_admin} onChange={f('is_admin')} /> Admin</label>
<label style={chk}><input type="checkbox" checked={form.offsite_allowed} onChange={f('offsite_allowed')} /> Offsite access</label>
</div>
{error && <p style={{ color: 'var(--danger)', fontSize: '0.85rem' }}>{error}</p>}
<button type="submit" style={goldBtn}>Create user</button>
</form>
)
}
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',
}