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,281 @@
import { useState, useMemo } from 'react';
import { useUsers } from '../../../api/queries/admin/rbac';
import type { UserDetail, GroupDetail } from '../../../api/queries/admin/rbac';
import { useGroups } 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 formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
} catch {
return iso;
}
}
function buildGroupPath(user: UserDetail, groupMap: Map<string, GroupDetail>): string {
if (user.directGroups.length === 0) return '(no groups)';
const names = user.directGroups.map((g) => g.name);
// Try to find a parent -> child path
for (const g of user.directGroups) {
const detail = groupMap.get(g.id);
if (detail?.parentGroupId) {
const parent = groupMap.get(detail.parentGroupId);
if (parent) return `${parent.name} > ${g.name}`;
}
}
return names.join(', ');
}
export function UsersTab() {
const users = useUsers();
const groups = useGroups();
const [selected, setSelected] = useState<string | null>(null);
const [filter, setFilter] = useState('');
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 = users.data ?? [];
if (!filter) return list;
const lower = filter.toLowerCase();
return list.filter(
(u) =>
u.displayName.toLowerCase().includes(lower) ||
u.email.toLowerCase().includes(lower) ||
u.userId.toLowerCase().includes(lower)
);
}, [users.data, filter]);
const selectedUser = useMemo(
() => (users.data ?? []).find((u) => u.userId === selected) ?? null,
[users.data, selected]
);
if (users.isLoading) {
return <div className={styles.loading}>Loading...</div>;
}
return (
<>
<div className={styles.panelHeader}>
<div>
<div className={styles.panelTitle}>Users</div>
<div className={styles.panelSubtitle}>
Manage identities, group membership and direct roles
</div>
</div>
</div>
<div className={styles.split}>
<div className={styles.listPane}>
<div className={styles.searchBar}>
<input
className={styles.searchInput}
placeholder="Search users..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className={styles.entityList}>
{filtered.map((user) => {
const isSelected = user.userId === selected;
return (
<div
key={user.userId}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => setSelected(user.userId)}
>
<div className={`${styles.avatar} ${styles.avatarUser}`}>
{getInitials(user.displayName)}
</div>
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{user.displayName}
{user.provider !== 'local' && (
<span className={styles.oidcBadge}>{user.provider}</span>
)}
</div>
<div className={styles.entityMeta}>
{user.email} · {buildGroupPath(user, groupMap)}
</div>
<div className={styles.tagList}>
{user.directRoles.map((r) => (
<span key={r.id} className={`${styles.tag} ${styles.tagRole}`}>
{r.name}
</span>
))}
{user.effectiveRoles
.filter((er) => !user.directRoles.some((dr) => dr.id === er.id))
.map((r) => (
<span
key={r.id}
className={`${styles.tag} ${styles.tagRole} ${styles.tagInherited}`}
>
{r.name}
</span>
))}
{user.directGroups.map((g) => (
<span key={g.id} className={`${styles.tag} ${styles.tagGroup}`}>
{g.name}
</span>
))}
</div>
</div>
<div
className={`${styles.statusDot} ${styles.statusActive}`}
/>
</div>
);
})}
</div>
</div>
<div className={styles.detailPane}>
{!selectedUser ? (
<div className={styles.detailEmpty}>
<span>Select a user to view details</span>
</div>
) : (
<UserDetail user={selectedUser} groupMap={groupMap} />
)}
</div>
</div>
</>
);
}
function UserDetail({
user,
groupMap,
}: {
user: UserDetail;
groupMap: Map<string, GroupDetail>;
}) {
// Build group tree for this user
const groupTree = useMemo(() => {
const tree: { name: string; depth: number; annotation: string }[] = [];
for (const g of user.directGroups) {
const detail = groupMap.get(g.id);
if (detail?.parentGroupId) {
const parent = groupMap.get(detail.parentGroupId);
if (parent && !tree.some((t) => t.name === parent.name)) {
tree.push({ name: parent.name, depth: 0, annotation: '' });
}
tree.push({ name: g.name, depth: 1, annotation: 'child group' });
} else {
tree.push({ name: g.name, depth: 0, annotation: '' });
}
}
return tree;
}, [user, groupMap]);
const inheritedRoles = user.effectiveRoles.filter(
(er) => !user.directRoles.some((dr) => dr.id === er.id)
);
return (
<>
<div className={`${styles.detailAvatar} ${styles.avatarUser}`}>
{getInitials(user.displayName)}
</div>
<div className={styles.detailName}>
{user.displayName}
{user.provider !== 'local' && (
<span className={styles.oidcBadge}>{user.provider}</span>
)}
</div>
<div className={styles.detailEmail}>{user.email}</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Status</span>
<span className={styles.fieldVal} style={{ color: 'var(--green)', fontSize: 12 }}>
Active
</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>ID</span>
<span className={`${styles.fieldVal} ${styles.fieldMono}`}>{user.userId}</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Created</span>
<span className={styles.fieldVal}>{formatDate(user.createdAt)}</span>
</div>
<hr className={styles.divider} />
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>
Group membership <span>direct only</span>
</div>
{user.directGroups.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No group membership</span>
) : (
user.directGroups.map((g) => (
<span key={g.id} className={`${styles.chip} ${styles.chipGroup}`}>
{g.name}
</span>
))
)}
</div>
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>
Effective roles <span>direct + inherited</span>
</div>
{user.directRoles.map((r) => (
<span key={r.id} className={`${styles.chip} ${styles.chipRole}`}>
{r.name}
</span>
))}
{inheritedRoles.map((r) => (
<span key={r.id} className={`${styles.chip} ${styles.chipRole} ${styles.chipInherited}`}>
{r.name}
<span className={styles.chipSource}>
{r.source ? `\u2191 ${r.source}` : ''}
</span>
</span>
))}
{inheritedRoles.length > 0 && (
<div className={styles.inheritNote}>
Dashed roles are inherited transitively through group membership.
</div>
)}
</div>
{groupTree.length > 0 && (
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>Group tree</div>
{groupTree.map((node, i) => (
<div key={i} className={styles.treeRow}>
{node.depth > 0 && (
<div className={styles.treeIndent}>
<div className={styles.treeCorner} />
</div>
)}
{node.name}
{node.annotation && (
<span style={{ fontSize: 10, color: 'var(--text-muted)', marginLeft: 4 }}>
{node.annotation}
</span>
)}
</div>
))}
</div>
)}
</>
);
}