- 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>
368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
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 {
|
|
const parts = name.trim().split(/\s+/);
|
|
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
return name.slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
function getGroupMeta(group: GroupDetail, groupMap: Map<string, GroupDetail>): string {
|
|
const parts: string[] = [];
|
|
if (group.parentGroupId) {
|
|
const parent = groupMap.get(group.parentGroupId);
|
|
parts.push(`Child of ${parent?.name ?? 'unknown'}`);
|
|
} else {
|
|
parts.push('Top-level');
|
|
}
|
|
if (group.childGroups.length > 0) {
|
|
parts.push(`${group.childGroups.length} child group${group.childGroups.length !== 1 ? 's' : ''}`);
|
|
}
|
|
parts.push(`${group.members.length} member${group.members.length !== 1 ? '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);
|
|
|
|
const groupMap = useMemo(() => {
|
|
const map = new Map<string, GroupDetail>();
|
|
for (const g of groups.data ?? []) {
|
|
map.set(g.id, g);
|
|
}
|
|
return map;
|
|
}, [groups.data]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const list = groups.data ?? [];
|
|
if (!filter) return list;
|
|
const lower = filter.toLowerCase();
|
|
return list.filter((g) => g.name.toLowerCase().includes(lower));
|
|
}, [groups.data, filter]);
|
|
|
|
if (groups.isLoading) {
|
|
return <div className={styles.loading}>Loading...</div>;
|
|
}
|
|
|
|
const detail = groupDetail.data;
|
|
|
|
return (
|
|
<>
|
|
<div className={styles.panelHeader}>
|
|
<div>
|
|
<div className={styles.panelTitle}>Groups</div>
|
|
<div className={styles.panelSubtitle}>
|
|
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}>
|
|
<div className={styles.searchBar}>
|
|
<input
|
|
className={styles.searchInput}
|
|
placeholder="Search groups..."
|
|
value={filter}
|
|
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;
|
|
return (
|
|
<div
|
|
key={group.id}
|
|
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
|
onClick={() => setSelectedId(group.id)}
|
|
>
|
|
<div className={`${styles.avatar} ${styles.avatarGroup}`}>
|
|
{getInitials(group.name)}
|
|
</div>
|
|
<div className={styles.entityInfo}>
|
|
<div className={styles.entityName}>{group.name}</div>
|
|
<div className={styles.entityMeta}>{getGroupMeta(group, groupMap)}</div>
|
|
<div className={styles.tagList}>
|
|
{group.directRoles.map((r) => (
|
|
<span key={r.id} className={`${styles.tag} ${styles.tagRole}`}>
|
|
{r.name}
|
|
</span>
|
|
))}
|
|
{group.effectiveRoles
|
|
.filter((er) => !group.directRoles.some((dr) => dr.id === er.id))
|
|
.map((r) => (
|
|
<span
|
|
key={r.id}
|
|
className={`${styles.tag} ${styles.tagRole} ${styles.tagInherited}`}
|
|
>
|
|
{r.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className={styles.detailPane}>
|
|
{!detail ? (
|
|
<div className={styles.detailEmpty}>
|
|
<span>Select a group to view details</span>
|
|
</div>
|
|
) : (
|
|
<GroupDetailView
|
|
group={detail}
|
|
groupMap={groupMap}
|
|
allGroups={groups.data || []}
|
|
allRoles={allRoles || []}
|
|
onDeselect={() => setSelectedId(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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';
|
|
|
|
const inheritedRoles = group.effectiveRoles.filter(
|
|
(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 }[] = [];
|
|
// Walk up to find root
|
|
const ancestors: GroupDetail[] = [];
|
|
let current: GroupDetail | undefined = group;
|
|
while (current?.parentGroupId) {
|
|
const parent = groupMap.get(current.parentGroupId);
|
|
if (parent) ancestors.unshift(parent);
|
|
current = parent;
|
|
}
|
|
for (let i = 0; i < ancestors.length; i++) {
|
|
rows.push({ name: ancestors[i].name, depth: i });
|
|
}
|
|
rows.push({ name: group.name, depth: ancestors.length });
|
|
for (const child of group.childGroups) {
|
|
rows.push({ name: child.name, depth: ancestors.length + 1 });
|
|
}
|
|
return rows;
|
|
}, [group, groupMap]);
|
|
|
|
return (
|
|
<>
|
|
<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.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}>
|
|
<div className={styles.detailSectionTitle}>
|
|
Members <span>direct</span>
|
|
</div>
|
|
{group.members.length === 0 ? (
|
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No direct members</span>
|
|
) : (
|
|
group.members.map((m) => (
|
|
<span key={m.userId} className={styles.chip}>
|
|
{m.displayName}
|
|
</span>
|
|
))
|
|
)}
|
|
{group.childGroups.length > 0 && (
|
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 6 }}>
|
|
+ all members of {group.childGroups.map((c) => c.name).join(', ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{group.childGroups.length > 0 && (
|
|
<div className={styles.detailSection}>
|
|
<div className={styles.detailSectionTitle}>Child groups</div>
|
|
{group.childGroups.map((c) => (
|
|
<span key={c.id} className={`${styles.chip} ${styles.chipGroup}`}>
|
|
{c.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className={styles.detailSection}>
|
|
<div className={styles.detailSectionTitle}>
|
|
Assigned roles <span>on this group</span>
|
|
</div>
|
|
{group.directRoles.length === 0 ? (
|
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No roles assigned</span>
|
|
) : (
|
|
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
|
|
? `Child groups ${group.childGroups.map((c) => c.name).join(' and ')} inherit these roles, and may additionally carry their own.`
|
|
: 'Roles are inherited from parent groups in the hierarchy.'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.detailSection}>
|
|
<div className={styles.detailSectionTitle}>Group hierarchy</div>
|
|
{tree.map((node, i) => (
|
|
<div key={i} className={styles.treeRow}>
|
|
{node.depth > 0 && (
|
|
<div className={styles.treeIndent}>
|
|
<div className={styles.treeCorner} />
|
|
</div>
|
|
)}
|
|
{node.name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<ConfirmDeleteDialog isOpen={showDeleteDialog} onClose={() => setShowDeleteDialog(false)}
|
|
onConfirm={() => { deleteGroup.mutate(group.id, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); } }); }}
|
|
resourceName={group.name} resourceType="group" />
|
|
</>
|
|
);
|
|
}
|