import { useState, useMemo } from 'react'; import { useUsers, useGroups, useRoles, useDeleteUser, useAddUserToGroup, useRemoveUserFromGroup, useAssignRoleToUser, useRemoveRoleFromUser } from '../../../api/queries/admin/rbac'; import type { UserDetail, GroupDetail, RoleDetail } from '../../../api/queries/admin/rbac'; import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog'; import { MultiSelectDropdown } from './components/MultiSelectDropdown'; import { useAuthStore } from '../../../auth/auth-store'; 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 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 { data: allRoles } = useRoles(); 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
) : ( setSelected(null)} /> )}
); } function UserDetailView({ user, groupMap, allGroups, allRoles, onDeselect, }: { user: UserDetail; groupMap: Map; allGroups: GroupDetail[]; allRoles: RoleDetail[]; onDeselect: () => void; }) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const deleteUserMut = useDeleteUser(); const addToGroup = useAddUserToGroup(); const removeFromGroup = useRemoveUserFromGroup(); const assignRole = useAssignRoleToUser(); const removeRole = useRemoveRoleFromUser(); const accessToken = useAuthStore((s) => s.accessToken); const currentUserId = accessToken ? JSON.parse(atob(accessToken.split('.')[1])).sub : null; const isSelf = currentUserId === user.userId; // 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) ); const availableGroups = allGroups .filter((g) => !user.directGroups.some((dg) => dg.id === g.id)) .map((g) => ({ id: g.id, label: g.name })); const availableRoles = allRoles .filter((r) => !user.directRoles.some((dr) => dr.id === r.id)) .map((r) => ({ id: r.id, label: r.name })); return ( <>
{getInitials(user.displayName)}
{user.displayName} {user.provider !== 'local' && ( {user.provider} )}
{user.email}
Status Active
ID {user.userId}
Created {new Date(user.createdAt).toLocaleString()}

Group membership direct only
{user.directGroups.length === 0 ? ( No group membership ) : ( user.directGroups.map((g) => ( {g.name} )) )} { await Promise.allSettled( ids.map((gid) => addToGroup.mutateAsync({ userId: user.userId, groupId: gid })) ); }} placeholder="Search groups..." />
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.
)} { await Promise.allSettled( ids.map((rid) => assignRole.mutateAsync({ userId: user.userId, roleId: rid })) ); }} placeholder="Search roles..." />
{groupTree.length > 0 && (
Group tree
{groupTree.map((node, i) => (
{node.depth > 0 && (
)} {node.name} {node.annotation && ( {node.annotation} )}
))}
)} setShowDeleteDialog(false)} onConfirm={() => { deleteUserMut.mutate(user.userId, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); }, }); }} resourceName={user.displayName || user.userId} resourceType="user" /> ); }