diff --git a/ui/src/pages/Admin/GroupsTab.tsx b/ui/src/pages/Admin/GroupsTab.tsx new file mode 100644 index 00000000..c9b65bb6 --- /dev/null +++ b/ui/src/pages/Admin/GroupsTab.tsx @@ -0,0 +1,402 @@ +import { useState } from 'react'; +import { + Avatar, + Badge, + Button, + Input, + MonoText, + Tag, + Select, + ConfirmDialog, + Spinner, + InlineEdit, + useToast, +} from '@cameleer/design-system'; +import { + useGroups, + useGroup, + useCreateGroup, + useUpdateGroup, + useDeleteGroup, + useAssignRoleToGroup, + useRemoveRoleFromGroup, + useAddUserToGroup, + useRemoveUserFromGroup, + useUsers, + useRoles, +} from '../../api/queries/admin/rbac'; +import styles from './UserManagement.module.css'; + +const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010'; + +export default function GroupsTab() { + const [search, setSearch] = useState(''); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [newGroupName, setNewGroupName] = useState(''); + const [newGroupParentId, setNewGroupParentId] = useState(''); + const [deleteOpen, setDeleteOpen] = useState(false); + const [addMemberUserId, setAddMemberUserId] = useState(''); + const [addRoleId, setAddRoleId] = useState(''); + + const { toast } = useToast(); + const { data: groups = [], isLoading: groupsLoading } = useGroups(); + const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedGroupId); + const { data: users = [] } = useUsers(); + const { data: roles = [] } = useRoles(); + + const createGroup = useCreateGroup(); + const updateGroup = useUpdateGroup(); + const deleteGroup = useDeleteGroup(); + const assignRoleToGroup = useAssignRoleToGroup(); + const removeRoleFromGroup = useRemoveRoleFromGroup(); + const addUserToGroup = useAddUserToGroup(); + const removeUserFromGroup = useRemoveUserFromGroup(); + + const filteredGroups = groups.filter((g) => + g.name.toLowerCase().includes(search.toLowerCase()) + ); + + const parentOptions = [ + { value: '', label: 'Top-level' }, + ...groups.map((g) => ({ value: g.id, label: g.name })), + ]; + + const parentName = (parentGroupId: string | null) => { + if (!parentGroupId) return 'Top-level'; + const parent = groups.find((g) => g.id === parentGroupId); + return parent ? parent.name : parentGroupId; + }; + + const handleCreate = async () => { + const name = newGroupName.trim(); + if (!name) return; + try { + await createGroup.mutateAsync({ + name, + parentGroupId: newGroupParentId || null, + }); + toast({ title: 'Group created', variant: 'success' }); + setNewGroupName(''); + setNewGroupParentId(''); + setShowCreate(false); + } catch { + toast({ title: 'Failed to create group', variant: 'error' }); + } + }; + + const handleRename = async (newName: string) => { + if (!selectedGroup) return; + try { + await updateGroup.mutateAsync({ + id: selectedGroup.id, + name: newName, + parentGroupId: selectedGroup.parentGroupId, + }); + toast({ title: 'Group renamed', variant: 'success' }); + } catch { + toast({ title: 'Failed to rename group', variant: 'error' }); + } + }; + + const handleDelete = async () => { + if (!selectedGroup) return; + try { + await deleteGroup.mutateAsync(selectedGroup.id); + toast({ title: 'Group deleted', variant: 'success' }); + setSelectedGroupId(null); + setDeleteOpen(false); + } catch { + toast({ title: 'Failed to delete group', variant: 'error' }); + } + }; + + const handleAddMember = async () => { + if (!selectedGroup || !addMemberUserId) return; + try { + await addUserToGroup.mutateAsync({ + userId: addMemberUserId, + groupId: selectedGroup.id, + }); + toast({ title: 'Member added', variant: 'success' }); + setAddMemberUserId(''); + } catch { + toast({ title: 'Failed to add member', variant: 'error' }); + } + }; + + const handleRemoveMember = async (userId: string) => { + if (!selectedGroup) return; + try { + await removeUserFromGroup.mutateAsync({ userId, groupId: selectedGroup.id }); + toast({ title: 'Member removed', variant: 'success' }); + } catch { + toast({ title: 'Failed to remove member', variant: 'error' }); + } + }; + + const handleAddRole = async () => { + if (!selectedGroup || !addRoleId) return; + try { + await assignRoleToGroup.mutateAsync({ + groupId: selectedGroup.id, + roleId: addRoleId, + }); + toast({ title: 'Role assigned', variant: 'success' }); + setAddRoleId(''); + } catch { + toast({ title: 'Failed to assign role', variant: 'error' }); + } + }; + + const handleRemoveRole = async (roleId: string) => { + if (!selectedGroup) return; + try { + await removeRoleFromGroup.mutateAsync({ groupId: selectedGroup.id, roleId }); + toast({ title: 'Role removed', variant: 'success' }); + } catch { + toast({ title: 'Failed to remove role', variant: 'error' }); + } + }; + + const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID; + + // Build sets for quick lookup of already-assigned items + const memberUserIds = new Set((selectedGroup?.members ?? []).map((m) => m.userId)); + const assignedRoleIds = new Set((selectedGroup?.directRoles ?? []).map((r) => r.id)); + + const availableUsers = users.filter((u) => !memberUserIds.has(u.userId)); + const availableRoles = roles.filter((r) => !assignedRoleIds.has(r.id)); + + return ( +
+ {/* Left pane */} +
+
+ setSearch(e.target.value)} + onClear={() => setSearch('')} + /> + +
+ + {showCreate && ( +
+ setNewGroupName(e.target.value)} + /> +
+ ({ + value: u.userId, + label: u.displayName, + })), + ]} + value={addMemberUserId} + onChange={(e) => setAddMemberUserId(e.target.value)} + /> + +
+ + {/* Assigned roles */} +
Assigned Roles
+
+ {(selectedGroup.directRoles ?? []).map((role) => ( + handleRemoveRole(role.id)} + /> + ))} + {(selectedGroup.directRoles ?? []).length === 0 && ( + No roles assigned + )} +
+ {(selectedGroup.effectiveRoles ?? []).length > + (selectedGroup.directRoles ?? []).length && ( +
+ + + {(selectedGroup.effectiveRoles ?? []).length - + (selectedGroup.directRoles ?? []).length}{' '} + inherited role(s) +
+ )} +
+