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:
parent
bababbfc1a
commit
f5969b0001
3 changed files with 124 additions and 28 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue