Admin UI: manage per-app granular capabilities

- AdminRoles: capability sub-toggles under each granted app
- AdminUsers: per-app direct capability grants; role-derived
  capabilities shown read-only as "(role)"
- types: Role.capabilities + Capability interface

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 14:19:16 +00:00
parent bababbfc1a
commit f5969b0001
3 changed files with 124 additions and 28 deletions

View file

@ -1,19 +1,22 @@
import { useEffect, useState } from 'react'
import { Sidebar } from '../components/Sidebar'
import type { User, Role } from '../types'
import type { User, Role, Capability } from '../types'
export function AdminRoles({ user }: { user: User }) {
const [roles, setRoles] = useState<Role[]>([])
const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([])
const [allCaps, setAllCaps] = useState<Capability[]>([])
const [showCreate, setShowCreate] = useState(false)
async function load() {
const [r, a] = await Promise.all([
fetch('/api/auth/admin/roles', { credentials: 'include' }).then(r => r.json()),
fetch('/api/auth/admin/apps', { credentials: 'include' }).then(r => r.json()),
const [r, a, c] = await Promise.all([
fetch('/api/auth/admin/roles', { credentials: 'include' }).then(r => r.json()),
fetch('/api/auth/admin/apps', { credentials: 'include' }).then(r => r.json()),
fetch('/api/auth/admin/capabilities', { credentials: 'include' }).then(r => r.json()),
])
setRoles(r)
setAllApps(a)
setAllCaps(c)
}
useEffect(() => { load() }, [])
@ -40,6 +43,13 @@ export function AdminRoles({ user }: { user: User }) {
load()
}
async function toggleCapability(roleId: number, appSlug: string, capSlug: string, has: boolean) {
await fetch(`/api/auth/admin/roles/${roleId}/capabilities/${appSlug}/${capSlug}`, {
method: has ? 'DELETE' : 'POST', credentials: 'include',
})
load()
}
return (
<div style={{ display: 'flex', height: '100dvh' }}>
<Sidebar user={user} />
@ -86,18 +96,42 @@ export function AdminRoles({ user }: { user: User }) {
</button>
</div>
</div>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{allApps.map(app => {
const has = role.app_slugs.includes(app.slug)
const appCaps = allCaps.filter(c => c.app_slug === app.slug)
return (
<button key={app.slug} onClick={() => toggleApp(role.id, app.slug, has)} style={{
background: has ? 'var(--navy)' : 'var(--body-bg)',
border: `1px solid ${has ? 'var(--navy)' : 'var(--card-border)'}`,
color: has ? '#fff' : 'var(--text-mid)',
borderRadius: '4px', padding: '0.2rem 0.6rem', fontSize: '0.75rem', cursor: 'pointer',
}}>
{app.name}
</button>
<div key={app.slug} style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<button onClick={() => toggleApp(role.id, app.slug, has)} style={{
background: has ? 'var(--navy)' : 'var(--body-bg)',
border: `1px solid ${has ? 'var(--navy)' : 'var(--card-border)'}`,
color: has ? '#fff' : 'var(--text-mid)',
borderRadius: '4px', padding: '0.2rem 0.6rem', fontSize: '0.75rem', cursor: 'pointer',
minWidth: '120px', textAlign: 'left', flexShrink: 0,
}}>
{app.name}
</button>
{/* Capability sub-toggles — only meaningful once the app is granted */}
{has && 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 capHas = role.capabilities.includes(capKey)
return (
<button key={capKey} title={cap.description ?? ''}
onClick={() => toggleCapability(role.id, cap.app_slug, cap.slug, capHas)} style={{
background: capHas ? '#2d6a4f' : 'var(--body-bg)',
border: `1px solid ${capHas ? '#2d6a4f' : 'var(--card-border)'}`,
color: capHas ? '#fff' : 'var(--text-mid)',
borderRadius: '4px', padding: '0.15rem 0.5rem', fontSize: '0.7rem', cursor: 'pointer',
}}>
{capHas ? '✓ ' : ''}{cap.name}
</button>
)
})}
</div>
)}
</div>
)
})}
</div>