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:
parent
bcd91f1c50
commit
4cff59f8ca
1 changed files with 131 additions and 96 deletions
|
|
@ -21,6 +21,7 @@ export function AdminUsers({ user }: { user: User }) {
|
||||||
const [allRoles, setAllRoles] = useState<Role[]>([])
|
const [allRoles, setAllRoles] = useState<Role[]>([])
|
||||||
const [allCaps, setAllCaps] = useState<Capability[]>([])
|
const [allCaps, setAllCaps] = useState<Capability[]>([])
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const [u, a, r, c] = await Promise.all([
|
const [u, a, r, c] = await Promise.all([
|
||||||
|
|
@ -105,35 +106,66 @@ export function AdminUsers({ user }: { user: User }) {
|
||||||
{showCreate && <CreateUserForm onCreated={() => { setShowCreate(false); load() }} />}
|
{showCreate && <CreateUserForm onCreated={() => { setShowCreate(false); load() }} />}
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
{users.map(u => (
|
{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={{
|
<div key={u.id} style={{
|
||||||
background: 'var(--card-bg)', borderRadius: '10px',
|
background: 'var(--card-bg)', borderRadius: '10px',
|
||||||
padding: '1rem 1.25rem', border: '1px solid var(--card-border)',
|
padding: '1rem 1.25rem', border: '1px solid var(--card-border)',
|
||||||
boxShadow: 'var(--shadow-sm)', opacity: u.active ? 1 : 0.5,
|
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={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
<div style={{ flex: 1, minWidth: '140px' }}>
|
<div style={{ flex: 1, minWidth: '140px' }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{u.name}</div>
|
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{u.name}</div>
|
||||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>{u.email}</div>
|
<div style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>{u.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
<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="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="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)} />
|
<Toggle label="Offsite" on={u.offsite_allowed} onClick={() => toggle(u.id, 'offsite_allowed', u.offsite_allowed)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* App grants + per-app capabilities */}
|
|
||||||
|
{/* 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' }}>
|
<div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
{allApps.map(app => {
|
{allApps.map(app => {
|
||||||
const has = u.app_slugs.includes(app.slug)
|
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 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 appCaps = allCaps.filter(c => c.app_slug === app.slug)
|
||||||
const inherited = roleDerivedCaps(u)
|
|
||||||
return (
|
return (
|
||||||
<div key={app.slug} style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
<div key={app.slug} style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||||
<button onClick={() => grantRevoke(u.id, app.slug, has)}
|
<button onClick={() => grantRevoke(u.id, app.slug, has)}
|
||||||
title={hasViaRole ? 'Also granted via a role' : ''} style={{
|
title={hasViaRole ? 'Also granted via a role' : ''} style={{
|
||||||
background: has ? 'var(--navy)' : hasViaRole ? 'var(--body-bg)' : 'var(--body-bg)',
|
background: has ? 'var(--navy)' : 'var(--body-bg)',
|
||||||
border: `1px solid ${has ? 'var(--navy)' : hasViaRole ? 'var(--navy)' : 'var(--card-border)'}`,
|
border: `1px solid ${has ? 'var(--navy)' : hasViaRole ? 'var(--navy)' : 'var(--card-border)'}`,
|
||||||
color: has ? '#fff' : hasViaRole ? 'var(--navy)' : 'var(--text-mid)',
|
color: has ? '#fff' : hasViaRole ? 'var(--navy)' : 'var(--text-mid)',
|
||||||
borderRadius: '4px', padding: '0.2rem 0.6rem', fontSize: '0.75rem', cursor: 'pointer',
|
borderRadius: '4px', padding: '0.2rem 0.6rem', fontSize: '0.75rem', cursor: 'pointer',
|
||||||
|
|
@ -170,7 +202,7 @@ export function AdminUsers({ user }: { user: User }) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Roles */}
|
{/* Role editor */}
|
||||||
<div style={{ marginTop: '0.5rem', display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
<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>
|
<span style={{ fontSize: '0.72rem', color: 'var(--text-mid)', marginRight: '0.15rem' }}>Roles:</span>
|
||||||
{u.roles.map(r => (
|
{u.roles.map(r => (
|
||||||
|
|
@ -206,8 +238,11 @@ export function AdminUsers({ user }: { user: User }) {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue