feat: add Groups tab with hierarchy management and member/role assignment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
402
ui/src/pages/Admin/GroupsTab.tsx
Normal file
402
ui/src/pages/Admin/GroupsTab.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [newGroupParentId, setNewGroupParentId] = useState<string>('');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [addMemberUserId, setAddMemberUserId] = useState<string>('');
|
||||
const [addRoleId, setAddRoleId] = useState<string>('');
|
||||
|
||||
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 (
|
||||
<div className={styles.splitPane}>
|
||||
{/* Left pane */}
|
||||
<div className={styles.listPane}>
|
||||
<div className={styles.listHeader}>
|
||||
<Input
|
||||
placeholder="Search groups..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch('')}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setShowCreate((v) => !v)}
|
||||
>
|
||||
+ Add Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className={styles.createForm}>
|
||||
<Input
|
||||
placeholder="Group name"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Select
|
||||
options={parentOptions}
|
||||
value={newGroupParentId}
|
||||
onChange={(e) => setNewGroupParentId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.createFormActions}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowCreate(false);
|
||||
setNewGroupName('');
|
||||
setNewGroupParentId('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
loading={createGroup.isPending}
|
||||
onClick={handleCreate}
|
||||
disabled={!newGroupName.trim()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupsLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className={styles.entityList} role="listbox">
|
||||
{filteredGroups.map((group) => {
|
||||
const isSelected = group.id === selectedGroupId;
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={
|
||||
styles.entityItem +
|
||||
(isSelected ? ' ' + styles.entityItemSelected : '')
|
||||
}
|
||||
onClick={() => setSelectedGroupId(group.id)}
|
||||
>
|
||||
<Avatar name={group.name} size="sm" />
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>{group.name}</div>
|
||||
<div className={styles.entityMeta}>
|
||||
{group.parentGroupId
|
||||
? `Child of ${parentName(group.parentGroupId)}`
|
||||
: 'Top-level'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right pane */}
|
||||
<div className={styles.detailPane}>
|
||||
{!selectedGroupId ? (
|
||||
<div className={styles.emptyDetail}>Select a group to view details</div>
|
||||
) : detailLoading ? (
|
||||
<Spinner />
|
||||
) : selectedGroup ? (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className={styles.detailHeader}>
|
||||
<Avatar name={selectedGroup.name} size="md" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<InlineEdit
|
||||
value={selectedGroup.name}
|
||||
onSave={handleRename}
|
||||
disabled={isBuiltinAdmins}
|
||||
/>
|
||||
<div className={styles.entityMeta}>
|
||||
{selectedGroup.parentGroupId
|
||||
? `Child of ${parentName(selectedGroup.parentGroupId)}`
|
||||
: 'Top-level'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={isBuiltinAdmins}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className={styles.metaGrid}>
|
||||
<span className={styles.metaLabel}>Group ID</span>
|
||||
<MonoText size="xs">{selectedGroup.id}</MonoText>
|
||||
<span className={styles.metaLabel}>Parent</span>
|
||||
<span>{parentName(selectedGroup.parentGroupId)}</span>
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
<div className={styles.sectionTitle}>Members</div>
|
||||
<div className={styles.sectionTags}>
|
||||
{(selectedGroup.members ?? []).map((member) => (
|
||||
<Tag
|
||||
key={member.userId}
|
||||
label={member.displayName}
|
||||
onRemove={() => handleRemoveMember(member.userId)}
|
||||
/>
|
||||
))}
|
||||
{(selectedGroup.members ?? []).length === 0 && (
|
||||
<span className={styles.inheritedNote}>No members</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Add member...' },
|
||||
...availableUsers.map((u) => ({
|
||||
value: u.userId,
|
||||
label: u.displayName,
|
||||
})),
|
||||
]}
|
||||
value={addMemberUserId}
|
||||
onChange={(e) => setAddMemberUserId(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleAddMember}
|
||||
disabled={!addMemberUserId || addUserToGroup.isPending}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Assigned roles */}
|
||||
<div className={styles.sectionTitle}>Assigned Roles</div>
|
||||
<div className={styles.sectionTags}>
|
||||
{(selectedGroup.directRoles ?? []).map((role) => (
|
||||
<Badge
|
||||
key={role.id}
|
||||
label={role.name}
|
||||
variant="outlined"
|
||||
onRemove={() => handleRemoveRole(role.id)}
|
||||
/>
|
||||
))}
|
||||
{(selectedGroup.directRoles ?? []).length === 0 && (
|
||||
<span className={styles.inheritedNote}>No roles assigned</span>
|
||||
)}
|
||||
</div>
|
||||
{(selectedGroup.effectiveRoles ?? []).length >
|
||||
(selectedGroup.directRoles ?? []).length && (
|
||||
<div className={styles.inheritedNote}>
|
||||
+
|
||||
{(selectedGroup.effectiveRoles ?? []).length -
|
||||
(selectedGroup.directRoles ?? []).length}{' '}
|
||||
inherited role(s)
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Assign role...' },
|
||||
...availableRoles.map((r) => ({
|
||||
value: r.id,
|
||||
label: r.name,
|
||||
})),
|
||||
]}
|
||||
value={addRoleId}
|
||||
onChange={(e) => setAddRoleId(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleAddRole}
|
||||
disabled={!addRoleId || assignRoleToGroup.isPending}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Group"
|
||||
message={`Delete group "${selectedGroup?.name}"? This action cannot be undone.`}
|
||||
confirmText="DELETE"
|
||||
variant="danger"
|
||||
loading={deleteGroup.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import styles from './UserManagement.module.css';
|
||||
import GroupsTab from './GroupsTab';
|
||||
import RolesTab from './RolesTab';
|
||||
|
||||
// Placeholder component for Users tab (task 17)
|
||||
const UsersTab = () => <div>Users tab — coming soon</div>;
|
||||
|
||||
export default function RbacPage() {
|
||||
const { data: stats } = useRbacStats();
|
||||
|
||||
Reference in New Issue
Block a user