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}
))}
); }