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([
|
||||
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={{
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Sidebar } from '../components/Sidebar'
|
||||
import type { User, Role } from '../types'
|
||||
import type { User, Role, Capability } from '../types'
|
||||
|
||||
interface ManagedUser {
|
||||
id: number
|
||||
|
|
@ -12,27 +12,41 @@ interface ManagedUser {
|
|||
workforce_user_id: string | null
|
||||
app_slugs: string[]
|
||||
roles: { id: number; name: string; slug: string }[]
|
||||
capabilities: string[] // direct "<app>:<cap>" grants (not role-derived)
|
||||
}
|
||||
|
||||
export function AdminUsers({ user }: { user: User }) {
|
||||
const [users, setUsers] = useState<ManagedUser[]>([])
|
||||
const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([])
|
||||
const [allRoles, setAllRoles] = useState<Role[]>([])
|
||||
const [allCaps, setAllCaps] = useState<Capability[]>([])
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
|
||||
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/apps', { 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)
|
||||
setAllApps(a)
|
||||
setAllRoles(r)
|
||||
setAllCaps(c)
|
||||
}
|
||||
|
||||
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) {
|
||||
await fetch(`/api/auth/admin/users/${userId}`, {
|
||||
method: 'PATCH', credentials: 'include',
|
||||
|
|
@ -49,6 +63,13 @@ export function AdminUsers({ user }: { user: User }) {
|
|||
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) {
|
||||
await fetch(`/api/auth/admin/users/${userId}/roles/${roleId}`, {
|
||||
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)} />
|
||||
</div>
|
||||
</div>
|
||||
{/* App grants */}
|
||||
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||
{/* 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 (
|
||||
<button key={app.slug} onClick={() => grantRevoke(u.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)',
|
||||
<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,
|
||||
}}>
|
||||
{app.name}
|
||||
{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>
|
||||
|
|
|
|||
10
src/types.ts
10
src/types.ts
|
|
@ -5,6 +5,16 @@ export interface Role {
|
|||
description: string | null
|
||||
is_default: boolean
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue