diff --git a/ui/src/api/queries/admin/rbac.ts b/ui/src/api/queries/admin/rbac.ts new file mode 100644 index 00000000..707c2b6d --- /dev/null +++ b/ui/src/api/queries/admin/rbac.ts @@ -0,0 +1,276 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { adminFetch } from './admin-api'; + +// ─── Types ─── + +export interface RoleSummary { + id: string; + name: string; + system: boolean; + source: string; +} + +export interface GroupSummary { + id: string; + name: string; +} + +export interface UserSummary { + userId: string; + displayName: string; + provider: string; +} + +export interface UserDetail { + userId: string; + provider: string; + email: string; + displayName: string; + createdAt: string; + directRoles: RoleSummary[]; + directGroups: GroupSummary[]; + effectiveRoles: RoleSummary[]; + effectiveGroups: GroupSummary[]; +} + +export interface GroupDetail { + id: string; + name: string; + parentGroupId: string | null; + createdAt: string; + directRoles: RoleSummary[]; + effectiveRoles: RoleSummary[]; + members: UserSummary[]; + childGroups: GroupSummary[]; +} + +export interface RoleDetail { + id: string; + name: string; + description: string; + scope: string; + system: boolean; + createdAt: string; + assignedGroups: GroupSummary[]; + directUsers: UserSummary[]; + effectivePrincipals: UserSummary[]; +} + +export interface RbacStats { + userCount: number; + activeUserCount: number; + groupCount: number; + maxGroupDepth: number; + roleCount: number; +} + +// ─── Query hooks ─── + +export function useUsers() { + return useQuery({ + queryKey: ['admin', 'rbac', 'users'], + queryFn: () => adminFetch('/users'), + }); +} + +export function useUser(userId: string | null) { + return useQuery({ + queryKey: ['admin', 'rbac', 'users', userId], + queryFn: () => adminFetch(`/users/${encodeURIComponent(userId!)}`), + enabled: !!userId, + }); +} + +export function useGroups() { + return useQuery({ + queryKey: ['admin', 'rbac', 'groups'], + queryFn: () => adminFetch('/groups'), + }); +} + +export function useGroup(groupId: string | null) { + return useQuery({ + queryKey: ['admin', 'rbac', 'groups', groupId], + queryFn: () => adminFetch(`/groups/${groupId}`), + enabled: !!groupId, + }); +} + +export function useRoles() { + return useQuery({ + queryKey: ['admin', 'rbac', 'roles'], + queryFn: () => adminFetch('/roles'), + }); +} + +export function useRole(roleId: string | null) { + return useQuery({ + queryKey: ['admin', 'rbac', 'roles', roleId], + queryFn: () => adminFetch(`/roles/${roleId}`), + enabled: !!roleId, + }); +} + +export function useRbacStats() { + return useQuery({ + queryKey: ['admin', 'rbac', 'stats'], + queryFn: () => adminFetch('/rbac/stats'), + }); +} + +// ─── Mutation hooks ─── + +export function useAssignRoleToUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) => + adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'POST' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useRemoveRoleFromUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) => + adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'DELETE' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useAddUserToGroup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) => + adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'POST' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useRemoveUserFromGroup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) => + adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'DELETE' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useCreateGroup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string; parentGroupId?: string }) => + adminFetch<{ id: string }>('/groups', { + method: 'POST', + body: JSON.stringify(data), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useUpdateGroup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: string; name?: string; parentGroupId?: string | null }) => + adminFetch(`/groups/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useDeleteGroup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + adminFetch(`/groups/${id}`, { method: 'DELETE' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useAssignRoleToGroup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) => + adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'POST' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useRemoveRoleFromGroup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) => + adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'DELETE' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useCreateRole() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string; description?: string; scope?: string }) => + adminFetch<{ id: string }>('/roles', { + method: 'POST', + body: JSON.stringify(data), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useUpdateRole() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: string; name?: string; description?: string; scope?: string }) => + adminFetch(`/roles/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useDeleteRole() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + adminFetch(`/roles/${id}`, { method: 'DELETE' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} + +export function useDeleteUser() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (userId: string) => + adminFetch(`/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); + }, + }); +} diff --git a/ui/src/components/layout/AppSidebar.tsx b/ui/src/components/layout/AppSidebar.tsx index 6426004a..666f2fc0 100644 --- a/ui/src/components/layout/AppSidebar.tsx +++ b/ui/src/components/layout/AppSidebar.tsx @@ -124,6 +124,7 @@ const ADMIN_LINKS = [ { to: '/admin/opensearch', label: 'OpenSearch' }, { to: '/admin/audit', label: 'Audit Log' }, { to: '/admin/oidc', label: 'OIDC' }, + { to: '/admin/rbac', label: 'User Management' }, ]; function AdminSubMenu({ collapsed: sidebarCollapsed }: { collapsed: boolean }) { diff --git a/ui/src/pages/admin/rbac/DashboardTab.tsx b/ui/src/pages/admin/rbac/DashboardTab.tsx new file mode 100644 index 00000000..df6985d6 --- /dev/null +++ b/ui/src/pages/admin/rbac/DashboardTab.tsx @@ -0,0 +1,117 @@ +import { useRbacStats, useGroups, useRoles } from '../../../api/queries/admin/rbac'; +import type { GroupDetail, RoleDetail } from '../../../api/queries/admin/rbac'; +import styles from './RbacPage.module.css'; + +export function DashboardTab() { + const stats = useRbacStats(); + const groups = useGroups(); + const roles = useRoles(); + + if (stats.isLoading) { + return
Loading...
; + } + + const s = stats.data; + const groupList: GroupDetail[] = groups.data ?? []; + const roleList: RoleDetail[] = roles.data ?? []; + + // Build inheritance diagram data + const topLevelGroups = groupList.filter((g) => !g.parentGroupId); + const childMap = new Map(); + for (const g of groupList) { + if (g.parentGroupId) { + const children = childMap.get(g.parentGroupId) ?? []; + children.push(g); + childMap.set(g.parentGroupId, children); + } + } + + // Collect unique users from all groups + const allUsers = new Map(); + for (const g of groupList) { + for (const m of g.members) { + allUsers.set(m.userId, m.displayName); + } + } + + return ( +
+
+
+
RBAC Overview
+
Inheritance model and system summary
+
+
+ +
+
+
Users
+
{s?.userCount ?? 0}
+
{s?.activeUserCount ?? 0} active
+
+
+
Groups
+
{s?.groupCount ?? 0}
+
Nested up to {s?.maxGroupDepth ?? 0} levels
+
+
+
Roles
+
{s?.roleCount ?? 0}
+
Direct + inherited
+
+
+ +
+
Inheritance model
+
+
+
Groups
+ {topLevelGroups.map((g) => ( +
+
{g.name}
+ {(childMap.get(g.id) ?? []).map((child) => ( +
+ {child.name} +
+ ))} +
+ ))} +
+
+
+
Roles on groups
+ {roleList.map((r) => ( +
+ {r.name} +
+ ))} +
+
+
+
Users inherit
+ {Array.from(allUsers.entries()) + .slice(0, 5) + .map(([id, name]) => ( +
+ {name} +
+ ))} + {allUsers.size > 5 && ( +
+ + {allUsers.size - 5} more... +
+ )} +
+
+
+ Users inherit all roles from every group they belong to — and transitively from parent + groups. Roles can also be assigned directly to users, overriding or extending inherited + permissions. +
+
+
+ ); +} diff --git a/ui/src/pages/admin/rbac/GroupsTab.tsx b/ui/src/pages/admin/rbac/GroupsTab.tsx new file mode 100644 index 00000000..262026f5 --- /dev/null +++ b/ui/src/pages/admin/rbac/GroupsTab.tsx @@ -0,0 +1,246 @@ +import { useState, useMemo } from 'react'; +import { useGroups, useGroup } from '../../../api/queries/admin/rbac'; +import type { GroupDetail } from '../../../api/queries/admin/rbac'; +import styles from './RbacPage.module.css'; + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return name.slice(0, 2).toUpperCase(); +} + +function getGroupMeta(group: GroupDetail, groupMap: Map): string { + const parts: string[] = []; + if (group.parentGroupId) { + const parent = groupMap.get(group.parentGroupId); + parts.push(`Child of ${parent?.name ?? 'unknown'}`); + } else { + parts.push('Top-level'); + } + if (group.childGroups.length > 0) { + parts.push(`${group.childGroups.length} child group${group.childGroups.length !== 1 ? 's' : ''}`); + } + parts.push(`${group.members.length} member${group.members.length !== 1 ? 's' : ''}`); + return parts.join(' · '); +} + +export function GroupsTab() { + const groups = useGroups(); + const [selectedId, setSelectedId] = useState(null); + const [filter, setFilter] = useState(''); + + const groupDetail = useGroup(selectedId); + + const groupMap = useMemo(() => { + const map = new Map(); + for (const g of groups.data ?? []) { + map.set(g.id, g); + } + return map; + }, [groups.data]); + + const filtered = useMemo(() => { + const list = groups.data ?? []; + if (!filter) return list; + const lower = filter.toLowerCase(); + return list.filter((g) => g.name.toLowerCase().includes(lower)); + }, [groups.data, filter]); + + if (groups.isLoading) { + return
Loading...
; + } + + const detail = groupDetail.data; + + return ( + <> +
+
+
Groups
+
+ Organise users in nested hierarchies; roles propagate to all members +
+
+
+
+
+
+ setFilter(e.target.value)} + /> +
+
+ {filtered.map((group) => { + const isSelected = group.id === selectedId; + return ( +
setSelectedId(group.id)} + > +
+ {getInitials(group.name)} +
+
+
{group.name}
+
{getGroupMeta(group, groupMap)}
+
+ {group.directRoles.map((r) => ( + + {r.name} + + ))} + {group.effectiveRoles + .filter((er) => !group.directRoles.some((dr) => dr.id === er.id)) + .map((r) => ( + + {r.name} + + ))} +
+
+
+ ); + })} +
+
+
+ {!detail ? ( +
+ Select a group to view details +
+ ) : ( + + )} +
+
+ + ); +} + +function GroupDetailView({ + group, + groupMap, +}: { + group: GroupDetail; + groupMap: Map; +}) { + const hierarchyLabel = group.parentGroupId + ? `Child of ${groupMap.get(group.parentGroupId)?.name ?? 'unknown'}` + : 'Top-level group'; + + const inheritedRoles = group.effectiveRoles.filter( + (er) => !group.directRoles.some((dr) => dr.id === er.id) + ); + + // Build hierarchy tree + const tree = useMemo(() => { + const rows: { name: string; depth: number }[] = []; + // Walk up to find root + const ancestors: GroupDetail[] = []; + let current: GroupDetail | undefined = group; + while (current?.parentGroupId) { + const parent = groupMap.get(current.parentGroupId); + if (parent) ancestors.unshift(parent); + current = parent; + } + for (let i = 0; i < ancestors.length; i++) { + rows.push({ name: ancestors[i].name, depth: i }); + } + rows.push({ name: group.name, depth: ancestors.length }); + for (const child of group.childGroups) { + rows.push({ name: child.name, depth: ancestors.length + 1 }); + } + return rows; + }, [group, groupMap]); + + return ( + <> +
+ {getInitials(group.name)} +
+
{group.name}
+
{hierarchyLabel}
+ +
+ ID + {group.id} +
+ +
+ +
+
+ Members direct +
+ {group.members.length === 0 ? ( + No direct members + ) : ( + group.members.map((m) => ( + + {m.displayName} + + )) + )} + {group.childGroups.length > 0 && ( +
+ + all members of {group.childGroups.map((c) => c.name).join(', ')} +
+ )} +
+ + {group.childGroups.length > 0 && ( +
+
Child groups
+ {group.childGroups.map((c) => ( + + {c.name} + + ))} +
+ )} + +
+
+ Assigned roles on this group +
+ {group.directRoles.length === 0 ? ( + No roles assigned + ) : ( + group.directRoles.map((r) => ( + + {r.name} + + )) + )} + {inheritedRoles.length > 0 && ( +
+ {group.childGroups.length > 0 + ? `Child groups ${group.childGroups.map((c) => c.name).join(' and ')} inherit these roles, and may additionally carry their own.` + : 'Roles are inherited from parent groups in the hierarchy.'} +
+ )} +
+ +
+
Group hierarchy
+ {tree.map((node, i) => ( +
+ {node.depth > 0 && ( +
+
+
+ )} + {node.name} +
+ ))} +
+ + ); +} diff --git a/ui/src/pages/admin/rbac/RbacPage.module.css b/ui/src/pages/admin/rbac/RbacPage.module.css new file mode 100644 index 00000000..ea4519d1 --- /dev/null +++ b/ui/src/pages/admin/rbac/RbacPage.module.css @@ -0,0 +1,570 @@ +/* ─── Page Layout ─── */ +.page { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.accessDenied { + text-align: center; + padding: 64px 16px; + color: var(--text-muted); + font-size: 14px; +} + +/* ─── Tabs ─── */ +.tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.tab { + font-size: 13px; + padding: 10px 18px; + cursor: pointer; + color: var(--text-secondary); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + background: none; + border-top: none; + border-left: none; + border-right: none; + font-family: var(--font-body); + transition: color 0.15s; +} + +.tab:hover { + color: var(--text-primary); +} + +.tabActive { + color: var(--text-primary); + border-bottom-color: var(--green); + font-weight: 500; +} + +/* ─── Split Layout ─── */ +.split { + display: flex; + flex: 1; + overflow: hidden; +} + +.listPane { + width: 52%; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.detailPane { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +/* ─── Panel Header ─── */ +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px 12px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.panelTitle { + font-size: 15px; + font-weight: 500; + color: var(--text-primary); +} + +.panelSubtitle { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.btnAdd { + font-size: 12px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: transparent; + color: var(--text-primary); + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + font-family: var(--font-body); +} + +.btnAdd:hover { + background: var(--bg-hover); +} + +/* ─── Search Bar ─── */ +.searchBar { + padding: 10px 20px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.searchInput { + width: 100%; + padding: 7px 10px; + font-size: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-base); + color: var(--text-primary); + outline: none; + font-family: var(--font-body); + transition: border-color 0.15s; +} + +.searchInput:focus { + border-color: var(--amber-dim); +} + +.searchInput::placeholder { + color: var(--text-muted); +} + +/* ─── Entity List ─── */ +.entityList { + flex: 1; + overflow-y: auto; +} + +.entityItem { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; + transition: background 0.1s; +} + +.entityItem:hover { + background: var(--bg-hover); +} + +.entityItemSelected { + background: var(--bg-raised); +} + +/* ─── Avatars ─── */ +.avatar { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 500; + flex-shrink: 0; +} + +.avatarUser { + background: rgba(59, 130, 246, 0.15); + color: var(--blue); +} + +.avatarGroup { + background: rgba(16, 185, 129, 0.15); + color: var(--green); + border-radius: 8px; +} + +.avatarRole { + background: rgba(240, 180, 41, 0.15); + color: var(--amber); + border-radius: 6px; +} + +/* ─── Entity Info ─── */ +.entityInfo { + flex: 1; + min-width: 0; +} + +.entityName { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.entityMeta { + font-size: 11px; + color: var(--text-muted); + margin-top: 1px; +} + +/* ─── Tags ─── */ +.tagList { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-top: 4px; +} + +.tag { + font-size: 10px; + padding: 1px 6px; + border-radius: 4px; +} + +.tagRole { + background: var(--amber-glow); + color: var(--amber); +} + +.tagGroup { + background: var(--green-glow); + color: var(--green); +} + +.tagInherited { + opacity: 0.65; + font-style: italic; +} + +/* ─── Status Dot ─── */ +.statusDot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.statusActive { + background: var(--green); +} + +.statusInactive { + background: var(--text-muted); +} + +/* ─── OIDC Badge ─── */ +.oidcBadge { + font-size: 10px; + padding: 1px 6px; + border-radius: 4px; + background: var(--cyan-glow); + color: var(--cyan); + margin-left: 6px; +} + +/* ─── Lock Icon (system role) ─── */ +.lockIcon { + font-size: 11px; + color: var(--text-muted); + margin-left: 4px; +} + +/* ─── Detail Pane ─── */ +.detailEmpty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + font-size: 13px; + gap: 8px; +} + +.detailAvatar { + width: 44px; + height: 44px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + font-weight: 500; + margin-bottom: 12px; +} + +.detailName { + font-size: 16px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 4px; +} + +.detailEmail { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 12px; +} + +.divider { + border: none; + border-top: 1px solid var(--border-subtle); + margin: 12px 0; +} + +.detailSection { + margin-bottom: 20px; +} + +.detailSectionTitle { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-muted); + margin-bottom: 8px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.detailSectionTitle span { + font-size: 10px; + color: var(--text-muted); + text-transform: none; + letter-spacing: 0; +} + +/* ─── Chips ─── */ +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + padding: 3px 8px; + border-radius: 20px; + border: 1px solid var(--border); + color: var(--text-secondary); + background: var(--bg-raised); + margin: 2px; +} + +.chipRole { + border-color: var(--amber-dim); + color: var(--amber); + background: var(--amber-glow); +} + +.chipGroup { + border-color: var(--green); + color: var(--green); + background: var(--green-glow); +} + +.chipUser { + border-color: var(--blue); + color: var(--blue); + background: rgba(59, 130, 246, 0.1); +} + +.chipInherited { + border-style: dashed; + opacity: 0.75; +} + +.chipSource { + font-size: 9px; + opacity: 0.6; + margin-left: 2px; +} + +/* ─── Inherit Note ─── */ +.inheritNote { + font-size: 11px; + color: var(--text-secondary); + font-style: italic; + margin-top: 6px; + padding: 8px 10px; + background: var(--bg-surface); + border-radius: var(--radius-sm); + border-left: 2px solid var(--green); +} + +/* ─── Field Rows ─── */ +.fieldRow { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.fieldLabel { + font-size: 11px; + color: var(--text-muted); + width: 70px; + flex-shrink: 0; +} + +.fieldVal { + font-size: 12px; + color: var(--text-primary); +} + +.fieldMono { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); +} + +/* ─── Tree ─── */ +.treeRow { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 0; + font-size: 12px; + color: var(--text-secondary); +} + +.treeIndent { + width: 16px; + flex-shrink: 0; + display: flex; + justify-content: center; +} + +.treeCorner { + width: 10px; + height: 10px; + border-left: 1px solid var(--border); + border-bottom: 1px solid var(--border); + border-bottom-left-radius: 2px; +} + +/* ─── Overview / Dashboard ─── */ +.overviewGrid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px; + padding: 16px 20px; +} + +.statCard { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 14px; +} + +.statLabel { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.statValue { + font-size: 22px; + font-weight: 500; + color: var(--text-primary); + line-height: 1; +} + +.statSub { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; +} + +/* ─── Inheritance Diagram ─── */ +.inhDiagram { + margin: 16px 20px 0; +} + +.inhTitle { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-muted); + margin-bottom: 10px; +} + +.inhRow { + display: flex; + align-items: flex-start; + gap: 0; +} + +.inhCol { + flex: 1; +} + +.inhColTitle { + font-size: 11px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; + text-align: center; +} + +.inhArrow { + width: 40px; + display: flex; + align-items: center; + justify-content: center; + padding-top: 22px; + color: var(--text-muted); + font-size: 14px; +} + +.inhItem { + font-size: 11px; + padding: 4px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + margin-bottom: 4px; + color: var(--text-secondary); + background: var(--bg-raised); + text-align: center; +} + +.inhItemGroup { + border-color: var(--green); + color: var(--green); + background: var(--green-glow); +} + +.inhItemRole { + border-color: var(--amber-dim); + color: var(--amber); + background: var(--amber-glow); +} + +.inhItemUser { + border-color: var(--blue); + color: var(--blue); + background: rgba(59, 130, 246, 0.1); +} + +.inhItemChild { + margin-left: 10px; + font-size: 10px; +} + +/* ─── Loading / Error ─── */ +.loading { + text-align: center; + padding: 32px; + color: var(--text-muted); + font-size: 14px; +} + +.tabContent { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} diff --git a/ui/src/pages/admin/rbac/RbacPage.tsx b/ui/src/pages/admin/rbac/RbacPage.tsx new file mode 100644 index 00000000..0231077c --- /dev/null +++ b/ui/src/pages/admin/rbac/RbacPage.tsx @@ -0,0 +1,66 @@ +import { useSearchParams } from 'react-router'; +import { useAuthStore } from '../../../auth/auth-store'; +import { DashboardTab } from './DashboardTab'; +import { UsersTab } from './UsersTab'; +import { GroupsTab } from './GroupsTab'; +import { RolesTab } from './RolesTab'; +import styles from './RbacPage.module.css'; + +const TABS = ['dashboard', 'users', 'groups', 'roles'] as const; +type TabKey = (typeof TABS)[number]; + +const TAB_LABELS: Record = { + dashboard: 'Dashboard', + users: 'Users', + groups: 'Groups', + roles: 'Roles', +}; + +export function RbacPage() { + const roles = useAuthStore((s) => s.roles); + + if (!roles.includes('ADMIN')) { + return ( +
+
+ Access Denied — this page requires the ADMIN role. +
+
+ ); + } + + return ; +} + +function RbacContent() { + const [searchParams, setSearchParams] = useSearchParams(); + const rawTab = searchParams.get('tab'); + const activeTab: TabKey = TABS.includes(rawTab as TabKey) ? (rawTab as TabKey) : 'dashboard'; + + function setTab(tab: TabKey) { + setSearchParams({ tab }, { replace: true }); + } + + return ( +
+
+ {TABS.map((tab) => ( + + ))} +
+
+ {activeTab === 'dashboard' && } + {activeTab === 'users' && } + {activeTab === 'groups' && } + {activeTab === 'roles' && } +
+
+ ); +} diff --git a/ui/src/pages/admin/rbac/RolesTab.tsx b/ui/src/pages/admin/rbac/RolesTab.tsx new file mode 100644 index 00000000..8ba2517d --- /dev/null +++ b/ui/src/pages/admin/rbac/RolesTab.tsx @@ -0,0 +1,201 @@ +import { useState, useMemo } from 'react'; +import { useRoles, useRole } from '../../../api/queries/admin/rbac'; +import type { RoleDetail } from '../../../api/queries/admin/rbac'; +import styles from './RbacPage.module.css'; + +function getInitials(name: string): string { + return name.slice(0, 2).toUpperCase(); +} + +function getRoleMeta(role: RoleDetail): string { + const parts: string[] = []; + if (role.description) parts.push(role.description); + const total = role.assignedGroups.length + role.directUsers.length; + parts.push(`${total} assignment${total !== 1 ? 's' : ''}`); + return parts.join(' · '); +} + +export function RolesTab() { + const roles = useRoles(); + const [selectedId, setSelectedId] = useState(null); + const [filter, setFilter] = useState(''); + + const roleDetail = useRole(selectedId); + + const filtered = useMemo(() => { + const list = roles.data ?? []; + if (!filter) return list; + const lower = filter.toLowerCase(); + return list.filter( + (r) => + r.name.toLowerCase().includes(lower) || + r.description.toLowerCase().includes(lower) + ); + }, [roles.data, filter]); + + if (roles.isLoading) { + return
Loading...
; + } + + const detail = roleDetail.data; + + return ( + <> +
+
+
Roles
+
+ Define permission scopes; assign to users or groups +
+
+
+
+
+
+ setFilter(e.target.value)} + /> +
+
+ {filtered.map((role) => { + const isSelected = role.id === selectedId; + return ( +
setSelectedId(role.id)} + > +
+ {getInitials(role.name)} +
+
+
+ {role.name} + {role.system && 🔒} +
+
{getRoleMeta(role)}
+
+ {role.assignedGroups.map((g) => ( + + {g.name} + + ))} + {role.directUsers.map((u) => ( + + {u.displayName} + + ))} +
+
+
+ ); + })} +
+
+
+ {!detail ? ( +
+ Select a role to view details +
+ ) : ( + + )} +
+
+ + ); +} + +function RoleDetailView({ role }: { role: RoleDetail }) { + return ( + <> +
+ {getInitials(role.name)} +
+
+ {role.name} + {role.system && 🔒} +
+
{role.description || 'No description'}
+ +
+ ID + {role.id} +
+
+ Scope + {role.scope || 'system-wide'} +
+ {role.system && ( +
+ Type + + System role (read-only) + +
+ )} + +
+ +
+
Assigned to groups
+ {role.assignedGroups.length === 0 ? ( + Not assigned to any groups + ) : ( + role.assignedGroups.map((g) => ( + + {g.name} + + )) + )} +
+ +
+
Assigned to users (direct)
+ {role.directUsers.length === 0 ? ( + No direct user assignments + ) : ( + role.directUsers.map((u) => ( + + {u.displayName} + + )) + )} +
+ +
+
+ Effective principals via inheritance +
+ {role.effectivePrincipals.length === 0 ? ( + No effective principals + ) : ( + <> + {role.effectivePrincipals.map((u) => { + const isDirect = role.directUsers.some((du) => du.userId === u.userId); + return ( + + {u.displayName} + + ); + })} + {role.effectivePrincipals.some( + (u) => !role.directUsers.some((du) => du.userId === u.userId) + ) && ( +
+ Some principals inherit this role through group membership rather than direct + assignment. +
+ )} + + )} +
+ + ); +} diff --git a/ui/src/pages/admin/rbac/UsersTab.tsx b/ui/src/pages/admin/rbac/UsersTab.tsx new file mode 100644 index 00000000..509e1d59 --- /dev/null +++ b/ui/src/pages/admin/rbac/UsersTab.tsx @@ -0,0 +1,281 @@ +import { useState, useMemo } from 'react'; +import { useUsers } from '../../../api/queries/admin/rbac'; +import type { UserDetail, GroupDetail } from '../../../api/queries/admin/rbac'; +import { useGroups } from '../../../api/queries/admin/rbac'; +import styles from './RbacPage.module.css'; + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return name.slice(0, 2).toUpperCase(); +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + } catch { + return iso; + } +} + +function buildGroupPath(user: UserDetail, groupMap: Map): string { + if (user.directGroups.length === 0) return '(no groups)'; + const names = user.directGroups.map((g) => g.name); + // Try to find a parent -> child path + for (const g of user.directGroups) { + const detail = groupMap.get(g.id); + if (detail?.parentGroupId) { + const parent = groupMap.get(detail.parentGroupId); + if (parent) return `${parent.name} > ${g.name}`; + } + } + return names.join(', '); +} + +export function UsersTab() { + const users = useUsers(); + const groups = useGroups(); + const [selected, setSelected] = useState(null); + const [filter, setFilter] = useState(''); + + const groupMap = useMemo(() => { + const map = new Map(); + for (const g of groups.data ?? []) { + map.set(g.id, g); + } + return map; + }, [groups.data]); + + const filtered = useMemo(() => { + const list = users.data ?? []; + if (!filter) return list; + const lower = filter.toLowerCase(); + return list.filter( + (u) => + u.displayName.toLowerCase().includes(lower) || + u.email.toLowerCase().includes(lower) || + u.userId.toLowerCase().includes(lower) + ); + }, [users.data, filter]); + + const selectedUser = useMemo( + () => (users.data ?? []).find((u) => u.userId === selected) ?? null, + [users.data, selected] + ); + + if (users.isLoading) { + return
Loading...
; + } + + return ( + <> +
+
+
Users
+
+ Manage identities, group membership and direct roles +
+
+
+
+
+
+ setFilter(e.target.value)} + /> +
+
+ {filtered.map((user) => { + const isSelected = user.userId === selected; + return ( +
setSelected(user.userId)} + > +
+ {getInitials(user.displayName)} +
+
+
+ {user.displayName} + {user.provider !== 'local' && ( + {user.provider} + )} +
+
+ {user.email} · {buildGroupPath(user, groupMap)} +
+
+ {user.directRoles.map((r) => ( + + {r.name} + + ))} + {user.effectiveRoles + .filter((er) => !user.directRoles.some((dr) => dr.id === er.id)) + .map((r) => ( + + {r.name} + + ))} + {user.directGroups.map((g) => ( + + {g.name} + + ))} +
+
+
+
+ ); + })} +
+
+
+ {!selectedUser ? ( +
+ Select a user to view details +
+ ) : ( + + )} +
+
+ + ); +} + +function UserDetail({ + user, + groupMap, +}: { + user: UserDetail; + groupMap: Map; +}) { + // Build group tree for this user + const groupTree = useMemo(() => { + const tree: { name: string; depth: number; annotation: string }[] = []; + for (const g of user.directGroups) { + const detail = groupMap.get(g.id); + if (detail?.parentGroupId) { + const parent = groupMap.get(detail.parentGroupId); + if (parent && !tree.some((t) => t.name === parent.name)) { + tree.push({ name: parent.name, depth: 0, annotation: '' }); + } + tree.push({ name: g.name, depth: 1, annotation: 'child group' }); + } else { + tree.push({ name: g.name, depth: 0, annotation: '' }); + } + } + return tree; + }, [user, groupMap]); + + const inheritedRoles = user.effectiveRoles.filter( + (er) => !user.directRoles.some((dr) => dr.id === er.id) + ); + + return ( + <> +
+ {getInitials(user.displayName)} +
+
+ {user.displayName} + {user.provider !== 'local' && ( + {user.provider} + )} +
+
{user.email}
+ +
+ Status + + Active + +
+
+ ID + {user.userId} +
+
+ Created + {formatDate(user.createdAt)} +
+ +
+ +
+
+ Group membership direct only +
+ {user.directGroups.length === 0 ? ( + No group membership + ) : ( + user.directGroups.map((g) => ( + + {g.name} + + )) + )} +
+ +
+
+ Effective roles direct + inherited +
+ {user.directRoles.map((r) => ( + + {r.name} + + ))} + {inheritedRoles.map((r) => ( + + {r.name} + + {r.source ? `\u2191 ${r.source}` : ''} + + + ))} + {inheritedRoles.length > 0 && ( +
+ Dashed roles are inherited transitively through group membership. +
+ )} +
+ + {groupTree.length > 0 && ( +
+
Group tree
+ {groupTree.map((node, i) => ( +
+ {node.depth > 0 && ( +
+
+
+ )} + {node.name} + {node.annotation && ( + + {node.annotation} + + )} +
+ ))} +
+ )} + + ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index e808603f..afd19bdf 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -13,6 +13,7 @@ const SwaggerPage = lazy(() => import('./pages/swagger/SwaggerPage').then(m => ( const DatabaseAdminPage = lazy(() => import('./pages/admin/DatabaseAdminPage').then(m => ({ default: m.DatabaseAdminPage }))); const OpenSearchAdminPage = lazy(() => import('./pages/admin/OpenSearchAdminPage').then(m => ({ default: m.OpenSearchAdminPage }))); const AuditLogPage = lazy(() => import('./pages/admin/AuditLogPage').then(m => ({ default: m.AuditLogPage }))); +const RbacPage = lazy(() => import('./pages/admin/rbac/RbacPage').then(m => ({ default: m.RbacPage }))); export const router = createBrowserRouter([ { @@ -38,6 +39,7 @@ export const router = createBrowserRouter([ { path: 'admin/opensearch', element: }, { path: 'admin/audit', element: }, { path: 'admin/oidc', element: }, + { path: 'admin/rbac', element: }, { path: 'swagger', element: }, ], },