From d025919f8d74ae0899b1a1bda501820f25ccf665 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:35:04 +0100 Subject: [PATCH] feat: add group create, delete, role assignment, and parent dropdown - Add inline create form with name and parent group selection - Add delete button with confirmation dialog (protected for built-in Admins group) - Add role assignment with MultiSelectDropdown and remove buttons on chips - Add parent group dropdown with cycle prevention (excludes self and descendants) Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/admin/rbac/GroupsTab.tsx | 133 ++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 6 deletions(-) diff --git a/ui/src/pages/admin/rbac/GroupsTab.tsx b/ui/src/pages/admin/rbac/GroupsTab.tsx index 262026f5..8eb76041 100644 --- a/ui/src/pages/admin/rbac/GroupsTab.tsx +++ b/ui/src/pages/admin/rbac/GroupsTab.tsx @@ -1,6 +1,17 @@ import { useState, useMemo } from 'react'; -import { useGroups, useGroup } from '../../../api/queries/admin/rbac'; +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 { @@ -24,10 +35,32 @@ function getGroupMeta(group: GroupDetail, groupMap: Map): 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); @@ -61,6 +94,7 @@ export function GroupsTab() { Organise users in nested hierarchies; roles propagate to all members +
@@ -72,6 +106,37 @@ export function GroupsTab() { onChange={(e) => setFilter(e.target.value)} />
+ {showCreateForm && ( +
+
+ + { setNewName(e.target.value); setCreateError(''); }} + placeholder="Group name" autoFocus /> +
+
+ + +
+ {createError &&
{createError}
} +
+ + +
+
+ )}
{filtered.map((group) => { const isSelected = group.id === selectedId; @@ -116,7 +181,13 @@ export function GroupsTab() { Select a group to view details
) : ( - + setSelectedId(null)} + /> )}
@@ -124,13 +195,29 @@ export function GroupsTab() { ); } +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'; @@ -139,6 +226,13 @@ function GroupDetailView({ (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 }[] = []; @@ -162,17 +256,34 @@ function GroupDetailView({ return ( <> -
- {getInitials(group.name)} +
+
+
+ {getInitials(group.name)} +
+
{group.name}
+
{hierarchyLabel}
+
+
-
{group.name}
-
{hierarchyLabel}
ID {group.id}
+
+ Parent + +
+
@@ -216,9 +327,15 @@ function GroupDetailView({ 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 @@ -241,6 +358,10 @@ function GroupDetailView({
))}
+ + setShowDeleteDialog(false)} + onConfirm={() => { deleteGroup.mutate(group.id, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); } }); }} + resourceName={group.name} resourceType="group" /> ); }