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 { 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
10
src/types.ts
10
src/types.ts
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue