311 lines
15 KiB
TypeScript
311 lines
15 KiB
TypeScript
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',
|
||
}
|