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 { useEffect, useState } from 'react'
import { Sidebar } from '../components/Sidebar' import { Sidebar } from '../components/Sidebar'
import type { User, Role } from '../types' import type { User, Role, Capability } from '../types'
export function AdminRoles({ user }: { user: User }) { export function AdminRoles({ user }: { user: User }) {
const [roles, setRoles] = useState<Role[]>([]) const [roles, setRoles] = useState<Role[]>([])
const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([]) const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([])
const [allCaps, setAllCaps] = useState<Capability[]>([])
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
async function load() { async function load() {
const [r, a] = await Promise.all([ const [r, a, c] = await Promise.all([
fetch('/api/auth/admin/roles', { credentials: 'include' }).then(r => r.json()), 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/apps', { credentials: 'include' }).then(r => r.json()),
fetch('/api/auth/admin/capabilities', { credentials: 'include' }).then(r => r.json()),
]) ])
setRoles(r) setRoles(r)
setAllApps(a) setAllApps(a)
setAllCaps(c)
} }
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
@ -40,6 +43,13 @@ export function AdminRoles({ user }: { user: User }) {
load() 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 ( return (
<div style={{ display: 'flex', height: '100dvh' }}> <div style={{ display: 'flex', height: '100dvh' }}>
<Sidebar user={user} /> <Sidebar user={user} />
@ -86,18 +96,42 @@ export function AdminRoles({ user }: { user: User }) {
</button> </button>
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{allApps.map(app => { {allApps.map(app => {
const has = role.app_slugs.includes(app.slug) const has = role.app_slugs.includes(app.slug)
const appCaps = allCaps.filter(c => c.app_slug === app.slug)
return ( return (
<button key={app.slug} onClick={() => toggleApp(role.id, app.slug, has)} style={{ <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)', background: has ? 'var(--navy)' : 'var(--body-bg)',
border: `1px solid ${has ? 'var(--navy)' : 'var(--card-border)'}`, border: `1px solid ${has ? 'var(--navy)' : 'var(--card-border)'}`,
color: has ? '#fff' : 'var(--text-mid)', color: has ? '#fff' : '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',
minWidth: '120px', textAlign: 'left', flexShrink: 0,
}}> }}>
{app.name} {app.name}
</button> </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> </div>

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Sidebar } from '../components/Sidebar' import { Sidebar } from '../components/Sidebar'
import type { User, Role } from '../types' import type { User, Role, Capability } from '../types'
interface ManagedUser { interface ManagedUser {
id: number id: number
@ -12,27 +12,41 @@ interface ManagedUser {
workforce_user_id: string | null workforce_user_id: string | null
app_slugs: string[] app_slugs: string[]
roles: { id: number; name: string; slug: string }[] roles: { id: number; name: string; slug: string }[]
capabilities: string[] // direct "<app>:<cap>" grants (not role-derived)
} }
export function AdminUsers({ user }: { user: User }) { export function AdminUsers({ user }: { user: User }) {
const [users, setUsers] = useState<ManagedUser[]>([]) const [users, setUsers] = useState<ManagedUser[]>([])
const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([]) const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([])
const [allRoles, setAllRoles] = useState<Role[]>([]) const [allRoles, setAllRoles] = useState<Role[]>([])
const [allCaps, setAllCaps] = useState<Capability[]>([])
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
async function load() { async function load() {
const [u, a, r] = await Promise.all([ const [u, a, r, c] = await Promise.all([
fetch('/api/auth/admin/users', { credentials: 'include' }).then(r => r.json()), 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/apps', { credentials: 'include' }).then(r => r.json()),
fetch('/api/auth/admin/roles', { 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) setUsers(u)
setAllApps(a) setAllApps(a)
setAllRoles(r) setAllRoles(r)
setAllCaps(c)
} }
useEffect(() => { load() }, []) 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) { async function toggle(userId: number, field: string, current: boolean) {
await fetch(`/api/auth/admin/users/${userId}`, { await fetch(`/api/auth/admin/users/${userId}`, {
method: 'PATCH', credentials: 'include', method: 'PATCH', credentials: 'include',
@ -49,6 +63,13 @@ export function AdminUsers({ user }: { user: User }) {
load() 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) { async function addRole(userId: number, roleId: number) {
await fetch(`/api/auth/admin/users/${userId}/roles/${roleId}`, { await fetch(`/api/auth/admin/users/${userId}/roles/${roleId}`, {
method: 'POST', credentials: 'include', method: 'POST', credentials: 'include',
@ -101,19 +122,50 @@ export function AdminUsers({ user }: { user: User }) {
<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 */} {/* App grants + per-app capabilities */}
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}> <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 appCaps = allCaps.filter(c => c.app_slug === app.slug)
const inherited = roleDerivedCaps(u)
return ( return (
<button key={app.slug} onClick={() => grantRevoke(u.id, app.slug, has)} style={{ <div key={app.slug} style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
background: has ? 'var(--navy)' : 'var(--body-bg)', <button onClick={() => grantRevoke(u.id, app.slug, has)}
border: `1px solid ${has ? 'var(--navy)' : 'var(--card-border)'}`, title={hasViaRole ? 'Also granted via a role' : ''} style={{
color: has ? '#fff' : 'var(--text-mid)', 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', borderRadius: '4px', padding: '0.2rem 0.6rem', fontSize: '0.75rem', cursor: 'pointer',
minWidth: '120px', textAlign: 'left', flexShrink: 0,
}}> }}>
{app.name} {app.name}{hasViaRole && !has ? ' (role)' : ''}
</button> </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> </div>

View file

@ -5,6 +5,16 @@ export interface Role {
description: string | null description: string | null
is_default: boolean is_default: boolean
app_slugs: string[] app_slugs: string[]
capabilities: string[] // "<app>:<cap>" strings granted to this role
}
// One capability an app exposes, as returned by /admin/capabilities.
export interface Capability {
app_slug: string
slug: string
name: string
description: string | null
sort_order: number
} }
export interface App { export interface App {