Collapse user permissions into expandable section on admin users page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-14 15:55:08 +00:00
parent bcd91f1c50
commit 4cff59f8ca

View file

@ -21,6 +21,7 @@ export function AdminUsers({ user }: { user: User }) {
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([
@ -105,109 +106,143 @@ export function AdminUsers({ user }: { user: User }) {
{showCreate && <CreateUserForm onCreated={() => { setShowCreate(false); load() }} />}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{users.map(u => (
<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,
}}>
<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>
{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>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<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)} />
{/* 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>
</div>
{/* App grants + per-app capabilities */}
<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)
const inherited = roleDerivedCaps(u)
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)' : hasViaRole ? 'var(--body-bg)' : '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,
{/* 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',
}}>
{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>
{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>
{/* Roles */}
<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>
</main>
</div>