feat: add RBAC management UI with dashboard, users, groups, and roles tabs
All checks were successful
CI / build (push) Successful in 1m14s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 54s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 35s

Tab-based admin page at /admin/rbac with split-pane entity views,
inheritance visualization, OIDC badges, and role/group management.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-17 17:58:24 +01:00
parent 01295c84d8
commit ebe97bd386
9 changed files with 1760 additions and 0 deletions

View File

@@ -0,0 +1,246 @@
import { useState, useMemo } from 'react';
import { useGroups, useGroup } from '../../../api/queries/admin/rbac';
import type { GroupDetail } from '../../../api/queries/admin/rbac';
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(' · ');
}
export function GroupsTab() {
const groups = useGroups();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filter, setFilter] = useState('');
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>
</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>
<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} />
)}
</div>
</div>
</>
);
}
function GroupDetailView({
group,
groupMap,
}: {
group: GroupDetail;
groupMap: Map<string, GroupDetail>;
}) {
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)
);
// 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.detailAvatar} ${styles.avatarGroup}`} style={{ borderRadius: 10 }}>
{getInitials(group.name)}
</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>
<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}
</span>
))
)}
{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>
</>
);
}