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,276 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api';
// ─── Types ───
export interface RoleSummary {
id: string;
name: string;
system: boolean;
source: string;
}
export interface GroupSummary {
id: string;
name: string;
}
export interface UserSummary {
userId: string;
displayName: string;
provider: string;
}
export interface UserDetail {
userId: string;
provider: string;
email: string;
displayName: string;
createdAt: string;
directRoles: RoleSummary[];
directGroups: GroupSummary[];
effectiveRoles: RoleSummary[];
effectiveGroups: GroupSummary[];
}
export interface GroupDetail {
id: string;
name: string;
parentGroupId: string | null;
createdAt: string;
directRoles: RoleSummary[];
effectiveRoles: RoleSummary[];
members: UserSummary[];
childGroups: GroupSummary[];
}
export interface RoleDetail {
id: string;
name: string;
description: string;
scope: string;
system: boolean;
createdAt: string;
assignedGroups: GroupSummary[];
directUsers: UserSummary[];
effectivePrincipals: UserSummary[];
}
export interface RbacStats {
userCount: number;
activeUserCount: number;
groupCount: number;
maxGroupDepth: number;
roleCount: number;
}
// ─── Query hooks ───
export function useUsers() {
return useQuery({
queryKey: ['admin', 'rbac', 'users'],
queryFn: () => adminFetch<UserDetail[]>('/users'),
});
}
export function useUser(userId: string | null) {
return useQuery({
queryKey: ['admin', 'rbac', 'users', userId],
queryFn: () => adminFetch<UserDetail>(`/users/${encodeURIComponent(userId!)}`),
enabled: !!userId,
});
}
export function useGroups() {
return useQuery({
queryKey: ['admin', 'rbac', 'groups'],
queryFn: () => adminFetch<GroupDetail[]>('/groups'),
});
}
export function useGroup(groupId: string | null) {
return useQuery({
queryKey: ['admin', 'rbac', 'groups', groupId],
queryFn: () => adminFetch<GroupDetail>(`/groups/${groupId}`),
enabled: !!groupId,
});
}
export function useRoles() {
return useQuery({
queryKey: ['admin', 'rbac', 'roles'],
queryFn: () => adminFetch<RoleDetail[]>('/roles'),
});
}
export function useRole(roleId: string | null) {
return useQuery({
queryKey: ['admin', 'rbac', 'roles', roleId],
queryFn: () => adminFetch<RoleDetail>(`/roles/${roleId}`),
enabled: !!roleId,
});
}
export function useRbacStats() {
return useQuery({
queryKey: ['admin', 'rbac', 'stats'],
queryFn: () => adminFetch<RbacStats>('/rbac/stats'),
});
}
// ─── Mutation hooks ───
export function useAssignRoleToUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useRemoveRoleFromUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useAddUserToGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useRemoveUserFromGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useCreateGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; parentGroupId?: string }) =>
adminFetch<{ id: string }>('/groups', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useUpdateGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; parentGroupId?: string | null }) =>
adminFetch(`/groups/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useDeleteGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
adminFetch(`/groups/${id}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useAssignRoleToGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useRemoveRoleFromGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useCreateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; description?: string; scope?: string }) =>
adminFetch<{ id: string }>('/roles', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useUpdateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; description?: string; scope?: string }) =>
adminFetch(`/roles/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useDeleteRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
adminFetch(`/roles/${id}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: string) =>
adminFetch(`/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}

View File

@@ -124,6 +124,7 @@ const ADMIN_LINKS = [
{ to: '/admin/opensearch', label: 'OpenSearch' },
{ to: '/admin/audit', label: 'Audit Log' },
{ to: '/admin/oidc', label: 'OIDC' },
{ to: '/admin/rbac', label: 'User Management' },
];
function AdminSubMenu({ collapsed: sidebarCollapsed }: { collapsed: boolean }) {

View File

@@ -0,0 +1,117 @@
import { useRbacStats, useGroups, useRoles } from '../../../api/queries/admin/rbac';
import type { GroupDetail, RoleDetail } from '../../../api/queries/admin/rbac';
import styles from './RbacPage.module.css';
export function DashboardTab() {
const stats = useRbacStats();
const groups = useGroups();
const roles = useRoles();
if (stats.isLoading) {
return <div className={styles.loading}>Loading...</div>;
}
const s = stats.data;
const groupList: GroupDetail[] = groups.data ?? [];
const roleList: RoleDetail[] = roles.data ?? [];
// Build inheritance diagram data
const topLevelGroups = groupList.filter((g) => !g.parentGroupId);
const childMap = new Map<string, GroupDetail[]>();
for (const g of groupList) {
if (g.parentGroupId) {
const children = childMap.get(g.parentGroupId) ?? [];
children.push(g);
childMap.set(g.parentGroupId, children);
}
}
// Collect unique users from all groups
const allUsers = new Map<string, string>();
for (const g of groupList) {
for (const m of g.members) {
allUsers.set(m.userId, m.displayName);
}
}
return (
<div>
<div className={styles.panelHeader}>
<div>
<div className={styles.panelTitle}>RBAC Overview</div>
<div className={styles.panelSubtitle}>Inheritance model and system summary</div>
</div>
</div>
<div className={styles.overviewGrid}>
<div className={styles.statCard}>
<div className={styles.statLabel}>Users</div>
<div className={styles.statValue}>{s?.userCount ?? 0}</div>
<div className={styles.statSub}>{s?.activeUserCount ?? 0} active</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Groups</div>
<div className={styles.statValue}>{s?.groupCount ?? 0}</div>
<div className={styles.statSub}>Nested up to {s?.maxGroupDepth ?? 0} levels</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Roles</div>
<div className={styles.statValue}>{s?.roleCount ?? 0}</div>
<div className={styles.statSub}>Direct + inherited</div>
</div>
</div>
<div className={styles.inhDiagram}>
<div className={styles.inhTitle}>Inheritance model</div>
<div className={styles.inhRow}>
<div className={styles.inhCol}>
<div className={styles.inhColTitle}>Groups</div>
{topLevelGroups.map((g) => (
<div key={g.id}>
<div className={`${styles.inhItem} ${styles.inhItemGroup}`}>{g.name}</div>
{(childMap.get(g.id) ?? []).map((child) => (
<div
key={child.id}
className={`${styles.inhItem} ${styles.inhItemGroup} ${styles.inhItemChild}`}
>
{child.name}
</div>
))}
</div>
))}
</div>
<div className={styles.inhArrow}>&rarr;</div>
<div className={styles.inhCol}>
<div className={styles.inhColTitle}>Roles on groups</div>
{roleList.map((r) => (
<div key={r.id} className={`${styles.inhItem} ${styles.inhItemRole}`}>
{r.name}
</div>
))}
</div>
<div className={styles.inhArrow}>&rarr;</div>
<div className={styles.inhCol}>
<div className={styles.inhColTitle}>Users inherit</div>
{Array.from(allUsers.entries())
.slice(0, 5)
.map(([id, name]) => (
<div key={id} className={`${styles.inhItem} ${styles.inhItemUser}`}>
{name}
</div>
))}
{allUsers.size > 5 && (
<div className={styles.inhItem} style={{ fontSize: 10, color: 'var(--text-muted)' }}>
+ {allUsers.size - 5} more...
</div>
)}
</div>
</div>
<div className={styles.inheritNote} style={{ marginTop: 12 }}>
Users inherit all roles from every group they belong to and transitively from parent
groups. Roles can also be assigned directly to users, overriding or extending inherited
permissions.
</div>
</div>
</div>
);
}

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>
</>
);
}

View File

@@ -0,0 +1,570 @@
/* ─── Page Layout ─── */
.page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.accessDenied {
text-align: center;
padding: 64px 16px;
color: var(--text-muted);
font-size: 14px;
}
/* ─── Tabs ─── */
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tab {
font-size: 13px;
padding: 10px 18px;
cursor: pointer;
color: var(--text-secondary);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: none;
border-top: none;
border-left: none;
border-right: none;
font-family: var(--font-body);
transition: color 0.15s;
}
.tab:hover {
color: var(--text-primary);
}
.tabActive {
color: var(--text-primary);
border-bottom-color: var(--green);
font-weight: 500;
}
/* ─── Split Layout ─── */
.split {
display: flex;
flex: 1;
overflow: hidden;
}
.listPane {
width: 52%;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.detailPane {
flex: 1;
overflow-y: auto;
padding: 20px;
}
/* ─── Panel Header ─── */
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.panelTitle {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
.panelSubtitle {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.btnAdd {
font-size: 12px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: transparent;
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-body);
}
.btnAdd:hover {
background: var(--bg-hover);
}
/* ─── Search Bar ─── */
.searchBar {
padding: 10px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.searchInput {
width: 100%;
padding: 7px 10px;
font-size: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-base);
color: var(--text-primary);
outline: none;
font-family: var(--font-body);
transition: border-color 0.15s;
}
.searchInput:focus {
border-color: var(--amber-dim);
}
.searchInput::placeholder {
color: var(--text-muted);
}
/* ─── Entity List ─── */
.entityList {
flex: 1;
overflow-y: auto;
}
.entityItem {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
transition: background 0.1s;
}
.entityItem:hover {
background: var(--bg-hover);
}
.entityItemSelected {
background: var(--bg-raised);
}
/* ─── Avatars ─── */
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 500;
flex-shrink: 0;
}
.avatarUser {
background: rgba(59, 130, 246, 0.15);
color: var(--blue);
}
.avatarGroup {
background: rgba(16, 185, 129, 0.15);
color: var(--green);
border-radius: 8px;
}
.avatarRole {
background: rgba(240, 180, 41, 0.15);
color: var(--amber);
border-radius: 6px;
}
/* ─── Entity Info ─── */
.entityInfo {
flex: 1;
min-width: 0;
}
.entityName {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entityMeta {
font-size: 11px;
color: var(--text-muted);
margin-top: 1px;
}
/* ─── Tags ─── */
.tagList {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
.tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
}
.tagRole {
background: var(--amber-glow);
color: var(--amber);
}
.tagGroup {
background: var(--green-glow);
color: var(--green);
}
.tagInherited {
opacity: 0.65;
font-style: italic;
}
/* ─── Status Dot ─── */
.statusDot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.statusActive {
background: var(--green);
}
.statusInactive {
background: var(--text-muted);
}
/* ─── OIDC Badge ─── */
.oidcBadge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: var(--cyan-glow);
color: var(--cyan);
margin-left: 6px;
}
/* ─── Lock Icon (system role) ─── */
.lockIcon {
font-size: 11px;
color: var(--text-muted);
margin-left: 4px;
}
/* ─── Detail Pane ─── */
.detailEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
gap: 8px;
}
.detailAvatar {
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 500;
margin-bottom: 12px;
}
.detailName {
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.detailEmail {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.divider {
border: none;
border-top: 1px solid var(--border-subtle);
margin: 12px 0;
}
.detailSection {
margin-bottom: 20px;
}
.detailSectionTitle {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-muted);
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.detailSectionTitle span {
font-size: 10px;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
}
/* ─── Chips ─── */
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
padding: 3px 8px;
border-radius: 20px;
border: 1px solid var(--border);
color: var(--text-secondary);
background: var(--bg-raised);
margin: 2px;
}
.chipRole {
border-color: var(--amber-dim);
color: var(--amber);
background: var(--amber-glow);
}
.chipGroup {
border-color: var(--green);
color: var(--green);
background: var(--green-glow);
}
.chipUser {
border-color: var(--blue);
color: var(--blue);
background: rgba(59, 130, 246, 0.1);
}
.chipInherited {
border-style: dashed;
opacity: 0.75;
}
.chipSource {
font-size: 9px;
opacity: 0.6;
margin-left: 2px;
}
/* ─── Inherit Note ─── */
.inheritNote {
font-size: 11px;
color: var(--text-secondary);
font-style: italic;
margin-top: 6px;
padding: 8px 10px;
background: var(--bg-surface);
border-radius: var(--radius-sm);
border-left: 2px solid var(--green);
}
/* ─── Field Rows ─── */
.fieldRow {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.fieldLabel {
font-size: 11px;
color: var(--text-muted);
width: 70px;
flex-shrink: 0;
}
.fieldVal {
font-size: 12px;
color: var(--text-primary);
}
.fieldMono {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
/* ─── Tree ─── */
.treeRow {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 0;
font-size: 12px;
color: var(--text-secondary);
}
.treeIndent {
width: 16px;
flex-shrink: 0;
display: flex;
justify-content: center;
}
.treeCorner {
width: 10px;
height: 10px;
border-left: 1px solid var(--border);
border-bottom: 1px solid var(--border);
border-bottom-left-radius: 2px;
}
/* ─── Overview / Dashboard ─── */
.overviewGrid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
padding: 16px 20px;
}
.statCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 14px;
}
.statLabel {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 6px;
}
.statValue {
font-size: 22px;
font-weight: 500;
color: var(--text-primary);
line-height: 1;
}
.statSub {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
/* ─── Inheritance Diagram ─── */
.inhDiagram {
margin: 16px 20px 0;
}
.inhTitle {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-muted);
margin-bottom: 10px;
}
.inhRow {
display: flex;
align-items: flex-start;
gap: 0;
}
.inhCol {
flex: 1;
}
.inhColTitle {
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
text-align: center;
}
.inhArrow {
width: 40px;
display: flex;
align-items: center;
justify-content: center;
padding-top: 22px;
color: var(--text-muted);
font-size: 14px;
}
.inhItem {
font-size: 11px;
padding: 4px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
margin-bottom: 4px;
color: var(--text-secondary);
background: var(--bg-raised);
text-align: center;
}
.inhItemGroup {
border-color: var(--green);
color: var(--green);
background: var(--green-glow);
}
.inhItemRole {
border-color: var(--amber-dim);
color: var(--amber);
background: var(--amber-glow);
}
.inhItemUser {
border-color: var(--blue);
color: var(--blue);
background: rgba(59, 130, 246, 0.1);
}
.inhItemChild {
margin-left: 10px;
font-size: 10px;
}
/* ─── Loading / Error ─── */
.loading {
text-align: center;
padding: 32px;
color: var(--text-muted);
font-size: 14px;
}
.tabContent {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}

View File

@@ -0,0 +1,66 @@
import { useSearchParams } from 'react-router';
import { useAuthStore } from '../../../auth/auth-store';
import { DashboardTab } from './DashboardTab';
import { UsersTab } from './UsersTab';
import { GroupsTab } from './GroupsTab';
import { RolesTab } from './RolesTab';
import styles from './RbacPage.module.css';
const TABS = ['dashboard', 'users', 'groups', 'roles'] as const;
type TabKey = (typeof TABS)[number];
const TAB_LABELS: Record<TabKey, string> = {
dashboard: 'Dashboard',
users: 'Users',
groups: 'Groups',
roles: 'Roles',
};
export function RbacPage() {
const roles = useAuthStore((s) => s.roles);
if (!roles.includes('ADMIN')) {
return (
<div className={styles.page}>
<div className={styles.accessDenied}>
Access Denied this page requires the ADMIN role.
</div>
</div>
);
}
return <RbacContent />;
}
function RbacContent() {
const [searchParams, setSearchParams] = useSearchParams();
const rawTab = searchParams.get('tab');
const activeTab: TabKey = TABS.includes(rawTab as TabKey) ? (rawTab as TabKey) : 'dashboard';
function setTab(tab: TabKey) {
setSearchParams({ tab }, { replace: true });
}
return (
<div className={styles.page}>
<div className={styles.tabs}>
{TABS.map((tab) => (
<button
key={tab}
type="button"
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
onClick={() => setTab(tab)}
>
{TAB_LABELS[tab]}
</button>
))}
</div>
<div className={styles.tabContent}>
{activeTab === 'dashboard' && <DashboardTab />}
{activeTab === 'users' && <UsersTab />}
{activeTab === 'groups' && <GroupsTab />}
{activeTab === 'roles' && <RolesTab />}
</div>
</div>
);
}

View File

@@ -0,0 +1,201 @@
import { useState, useMemo } from 'react';
import { useRoles, useRole } from '../../../api/queries/admin/rbac';
import type { RoleDetail } from '../../../api/queries/admin/rbac';
import styles from './RbacPage.module.css';
function getInitials(name: string): string {
return name.slice(0, 2).toUpperCase();
}
function getRoleMeta(role: RoleDetail): string {
const parts: string[] = [];
if (role.description) parts.push(role.description);
const total = role.assignedGroups.length + role.directUsers.length;
parts.push(`${total} assignment${total !== 1 ? 's' : ''}`);
return parts.join(' · ');
}
export function RolesTab() {
const roles = useRoles();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const roleDetail = useRole(selectedId);
const filtered = useMemo(() => {
const list = roles.data ?? [];
if (!filter) return list;
const lower = filter.toLowerCase();
return list.filter(
(r) =>
r.name.toLowerCase().includes(lower) ||
r.description.toLowerCase().includes(lower)
);
}, [roles.data, filter]);
if (roles.isLoading) {
return <div className={styles.loading}>Loading...</div>;
}
const detail = roleDetail.data;
return (
<>
<div className={styles.panelHeader}>
<div>
<div className={styles.panelTitle}>Roles</div>
<div className={styles.panelSubtitle}>
Define permission scopes; assign to users or groups
</div>
</div>
</div>
<div className={styles.split}>
<div className={styles.listPane}>
<div className={styles.searchBar}>
<input
className={styles.searchInput}
placeholder="Search roles..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className={styles.entityList}>
{filtered.map((role) => {
const isSelected = role.id === selectedId;
return (
<div
key={role.id}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => setSelectedId(role.id)}
>
<div className={`${styles.avatar} ${styles.avatarRole}`}>
{getInitials(role.name)}
</div>
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{role.name}
{role.system && <span className={styles.lockIcon}>&#128274;</span>}
</div>
<div className={styles.entityMeta}>{getRoleMeta(role)}</div>
<div className={styles.tagList}>
{role.assignedGroups.map((g) => (
<span key={g.id} className={`${styles.tag} ${styles.tagGroup}`}>
{g.name}
</span>
))}
{role.directUsers.map((u) => (
<span key={u.userId} className={`${styles.tag} ${styles.tagGroup}`}>
{u.displayName}
</span>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
<div className={styles.detailPane}>
{!detail ? (
<div className={styles.detailEmpty}>
<span>Select a role to view details</span>
</div>
) : (
<RoleDetailView role={detail} />
)}
</div>
</div>
</>
);
}
function RoleDetailView({ role }: { role: RoleDetail }) {
return (
<>
<div className={`${styles.detailAvatar} ${styles.avatarRole}`} style={{ borderRadius: 8 }}>
{getInitials(role.name)}
</div>
<div className={styles.detailName}>
{role.name}
{role.system && <span className={styles.lockIcon}>&#128274;</span>}
</div>
<div className={styles.detailEmail}>{role.description || 'No description'}</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>ID</span>
<span className={`${styles.fieldVal} ${styles.fieldMono}`}>{role.id}</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Scope</span>
<span className={styles.fieldVal}>{role.scope || 'system-wide'}</span>
</div>
{role.system && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Type</span>
<span className={styles.fieldVal} style={{ color: 'var(--text-muted)' }}>
System role (read-only)
</span>
</div>
)}
<hr className={styles.divider} />
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>Assigned to groups</div>
{role.assignedGroups.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>Not assigned to any groups</span>
) : (
role.assignedGroups.map((g) => (
<span key={g.id} className={`${styles.chip} ${styles.chipGroup}`}>
{g.name}
</span>
))
)}
</div>
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>Assigned to users (direct)</div>
{role.directUsers.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No direct user assignments</span>
) : (
role.directUsers.map((u) => (
<span key={u.userId} className={`${styles.chip} ${styles.chipUser}`}>
{u.displayName}
</span>
))
)}
</div>
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>
Effective principals <span>via inheritance</span>
</div>
{role.effectivePrincipals.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No effective principals</span>
) : (
<>
{role.effectivePrincipals.map((u) => {
const isDirect = role.directUsers.some((du) => du.userId === u.userId);
return (
<span
key={u.userId}
className={`${styles.chip} ${!isDirect ? styles.chipInherited : ''}`}
>
{u.displayName}
</span>
);
})}
{role.effectivePrincipals.some(
(u) => !role.directUsers.some((du) => du.userId === u.userId)
) && (
<div className={styles.inheritNote}>
Some principals inherit this role through group membership rather than direct
assignment.
</div>
)}
</>
)}
</div>
</>
);
}

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>
)}
</>
);
}

View File

@@ -13,6 +13,7 @@ const SwaggerPage = lazy(() => import('./pages/swagger/SwaggerPage').then(m => (
const DatabaseAdminPage = lazy(() => import('./pages/admin/DatabaseAdminPage').then(m => ({ default: m.DatabaseAdminPage })));
const OpenSearchAdminPage = lazy(() => import('./pages/admin/OpenSearchAdminPage').then(m => ({ default: m.OpenSearchAdminPage })));
const AuditLogPage = lazy(() => import('./pages/admin/AuditLogPage').then(m => ({ default: m.AuditLogPage })));
const RbacPage = lazy(() => import('./pages/admin/rbac/RbacPage').then(m => ({ default: m.RbacPage })));
export const router = createBrowserRouter([
{
@@ -38,6 +39,7 @@ export const router = createBrowserRouter([
{ path: 'admin/opensearch', element: <Suspense fallback={null}><OpenSearchAdminPage /></Suspense> },
{ path: 'admin/audit', element: <Suspense fallback={null}><AuditLogPage /></Suspense> },
{ path: 'admin/oidc', element: <OidcAdminPage /> },
{ path: 'admin/rbac', element: <Suspense fallback={null}><RbacPage /></Suspense> },
{ path: 'swagger', element: <Suspense fallback={null}><SwaggerPage /></Suspense> },
],
},