import { useState, useMemo } from 'react'; import { useGroups, useGroup, useCreateGroup, useDeleteGroup, useUpdateGroup, useAssignRoleToGroup, useRemoveRoleFromGroup, useRoles, } from '../../../api/queries/admin/rbac'; import type { GroupDetail } from '../../../api/queries/admin/rbac'; import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog'; import { MultiSelectDropdown } from './components/MultiSelectDropdown'; 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(' ยท '); } function getDescendantIds(groupId: string, allGroups: GroupDetail[]): Set { const ids = new Set(); function walk(id: string) { const g = allGroups.find(x => x.id === id); if (!g) return; for (const child of g.childGroups) { if (!ids.has(child.id)) { ids.add(child.id); walk(child.id); } } } walk(groupId); return ids; } export function GroupsTab() { const groups = useGroups(); const [selectedId, setSelectedId] = useState(null); const [filter, setFilter] = useState(''); const [showCreateForm, setShowCreateForm] = useState(false); const [newName, setNewName] = useState(''); const [newParentId, setNewParentId] = useState(''); const [createError, setCreateError] = useState(''); const createGroup = useCreateGroup(); const { data: allRoles } = useRoles(); 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)} />
{showCreateForm && (
{ setNewName(e.target.value); setCreateError(''); }} placeholder="Group name" autoFocus />
{createError &&
{createError}
}
)}
{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
) : ( setSelectedId(null)} /> )}
); } const ADMINS_GROUP_ID = '00000000-0000-0000-0000-000000000010'; function GroupDetailView({ group, groupMap, allGroups, allRoles, onDeselect, }: { group: GroupDetail; groupMap: Map; allGroups: GroupDetail[]; allRoles: Array<{ id: string; name: string }>; onDeselect: () => void; }) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const deleteGroup = useDeleteGroup(); const updateGroup = useUpdateGroup(); const assignRole = useAssignRoleToGroup(); const removeRole = useRemoveRoleFromGroup(); const isBuiltIn = group.id === ADMINS_GROUP_ID; 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) ); const availableRoles = (allRoles || []) .filter(r => !group.directRoles.some(dr => dr.id === r.id)) .map(r => ({ id: r.id, label: r.name })); const descendantIds = getDescendantIds(group.id, allGroups); const parentOptions = allGroups.filter(g => g.id !== group.id && !descendantIds.has(g.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}
Parent

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} )) )} { await Promise.allSettled(ids.map(rid => assignRole.mutateAsync({ groupId: group.id, roleId: rid }))); }} placeholder="Search roles..." /> {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}
))}
setShowDeleteDialog(false)} onConfirm={() => { deleteGroup.mutate(group.id, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); } }); }} resourceName={group.name} resourceType="group" /> ); }