diff --git a/src/pages/AdminRoles.tsx b/src/pages/AdminRoles.tsx index dad4c9c..d143fa1 100644 --- a/src/pages/AdminRoles.tsx +++ b/src/pages/AdminRoles.tsx @@ -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([]) const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([]) + const [allCaps, setAllCaps] = useState([]) 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 (
@@ -86,18 +96,42 @@ export function AdminRoles({ user }: { user: User }) {
-
+
{allApps.map(app => { const has = role.app_slugs.includes(app.slug) + const appCaps = allCaps.filter(c => c.app_slug === app.slug) return ( - +
+ + {/* Capability sub-toggles — only meaningful once the app is granted */} + {has && appCaps.length > 0 && ( +
+ {appCaps.map(cap => { + const capKey = `${cap.app_slug}:${cap.slug}` + const capHas = role.capabilities.includes(capKey) + return ( + + ) + })} +
+ )} +
) })}
diff --git a/src/pages/AdminUsers.tsx b/src/pages/AdminUsers.tsx index ab75a1f..b6e69c6 100644 --- a/src/pages/AdminUsers.tsx +++ b/src/pages/AdminUsers.tsx @@ -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 ":" grants (not role-derived) } export function AdminUsers({ user }: { user: User }) { const [users, setUsers] = useState([]) const [allApps, setAllApps] = useState<{ slug: string; name: string }[]>([]) const [allRoles, setAllRoles] = useState([]) + const [allCaps, setAllCaps] = useState([]) const [showCreate, setShowCreate] = useState(false) async function load() { - const [u, a, r] = 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()), + 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 { + const set = new Set() + 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(u.id, 'offsite_allowed', u.offsite_allowed)} />
- {/* App grants */} -
+ {/* App grants + per-app capabilities */} +
{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 ( - +
+ + {(has || hasViaRole) && appCaps.length > 0 && ( +
+ {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 ( + + ) + })} +
+ )} +
) })}
diff --git a/src/types.ts b/src/types.ts index 23e31dc..dc3d6d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,16 @@ export interface Role { description: string | null is_default: boolean app_slugs: string[] + capabilities: string[] // ":" 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 {