403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|