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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, GroupDetail>): s
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function getDescendantIds(groupId: string, allGroups: GroupDetail[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
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<string | null>(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
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>+ Add group</button>
|
||||
</div>
|
||||
<div className={styles.split}>
|
||||
<div className={styles.listPane}>
|
||||
@@ -72,6 +106,37 @@ export function GroupsTab() {
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{showCreateForm && (
|
||||
<div className={styles.createForm}>
|
||||
<div className={styles.createFormRow}>
|
||||
<label className={styles.createFormLabel}>Name</label>
|
||||
<input className={styles.createFormInput} value={newName}
|
||||
onChange={e => { setNewName(e.target.value); setCreateError(''); }}
|
||||
placeholder="Group name" autoFocus />
|
||||
</div>
|
||||
<div className={styles.createFormRow}>
|
||||
<label className={styles.createFormLabel}>Parent</label>
|
||||
<select className={styles.createFormSelect} value={newParentId}
|
||||
onChange={e => setNewParentId(e.target.value)}>
|
||||
<option value="">(Top-level)</option>
|
||||
{(groups.data || []).map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{createError && <div className={styles.createFormError}>{createError}</div>}
|
||||
<div className={styles.createFormActions}>
|
||||
<button type="button" className={styles.createFormBtn}
|
||||
onClick={() => { setShowCreateForm(false); setNewName(''); setNewParentId(''); setCreateError(''); }}>Cancel</button>
|
||||
<button type="button" className={styles.createFormBtnPrimary}
|
||||
disabled={!newName.trim() || createGroup.isPending}
|
||||
onClick={() => {
|
||||
createGroup.mutate({ name: newName.trim(), parentGroupId: newParentId || undefined }, {
|
||||
onSuccess: () => { setShowCreateForm(false); setNewName(''); setNewParentId(''); setCreateError(''); },
|
||||
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create group'),
|
||||
});
|
||||
}}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.entityList}>
|
||||
{filtered.map((group) => {
|
||||
const isSelected = group.id === selectedId;
|
||||
@@ -116,7 +181,13 @@ export function GroupsTab() {
|
||||
<span>Select a group to view details</span>
|
||||
</div>
|
||||
) : (
|
||||
<GroupDetailView group={detail} groupMap={groupMap} />
|
||||
<GroupDetailView
|
||||
group={detail}
|
||||
groupMap={groupMap}
|
||||
allGroups={groups.data || []}
|
||||
allRoles={allRoles || []}
|
||||
onDeselect={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<string, GroupDetail>;
|
||||
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 (
|
||||
<>
|
||||
<div className={`${styles.detailAvatar} ${styles.avatarGroup}`} style={{ borderRadius: 10 }}>
|
||||
{getInitials(group.name)}
|
||||
<div className={styles.detailHeader}>
|
||||
<div className={styles.detailHeaderInfo}>
|
||||
<div className={`${styles.detailAvatar} ${styles.avatarGroup}`} style={{ borderRadius: 10 }}>
|
||||
{getInitials(group.name)}
|
||||
</div>
|
||||
<div className={styles.detailName}>{group.name}</div>
|
||||
<div className={styles.detailEmail}>{hierarchyLabel}</div>
|
||||
</div>
|
||||
<button type="button" className={styles.btnDelete}
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isBuiltIn || deleteGroup.isPending}
|
||||
title={isBuiltIn ? 'Built-in group cannot be deleted' : 'Delete group'}>Delete</button>
|
||||
</div>
|
||||
<div className={styles.detailName}>{group.name}</div>
|
||||
<div className={styles.detailEmail}>{hierarchyLabel}</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>ID</span>
|
||||
<span className={`${styles.fieldVal} ${styles.fieldMono}`}>{group.id}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>Parent</span>
|
||||
<select className={styles.parentSelect} value={group.parentGroupId || ''}
|
||||
onChange={e => updateGroup.mutate({ id: group.id, parentGroupId: e.target.value || null })}>
|
||||
<option value="">(Top-level)</option>
|
||||
{parentOptions.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr className={styles.divider} />
|
||||
|
||||
<div className={styles.detailSection}>
|
||||
@@ -216,9 +327,15 @@ function GroupDetailView({
|
||||
group.directRoles.map((r) => (
|
||||
<span key={r.id} className={`${styles.chip} ${styles.chipRole}`}>
|
||||
{r.name}
|
||||
<button type="button" className={styles.chipRemove}
|
||||
onClick={() => removeRole.mutate({ groupId: group.id, roleId: r.id })}
|
||||
disabled={removeRole.isPending} title="Remove role">x</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
<MultiSelectDropdown items={availableRoles}
|
||||
onApply={async (ids) => { await Promise.allSettled(ids.map(rid => assignRole.mutateAsync({ groupId: group.id, roleId: rid }))); }}
|
||||
placeholder="Search roles..." />
|
||||
{inheritedRoles.length > 0 && (
|
||||
<div className={styles.inheritNote}>
|
||||
{group.childGroups.length > 0
|
||||
@@ -241,6 +358,10 @@ function GroupDetailView({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteDialog isOpen={showDeleteDialog} onClose={() => setShowDeleteDialog(false)}
|
||||
onConfirm={() => { deleteGroup.mutate(group.id, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); } }); }}
|
||||
resourceName={group.name} resourceType="group" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user