feat: add RBAC management UI with dashboard, users, groups, and roles tabs
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:
276
ui/src/api/queries/admin/rbac.ts
Normal file
276
ui/src/api/queries/admin/rbac.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
117
ui/src/pages/admin/rbac/DashboardTab.tsx
Normal file
117
ui/src/pages/admin/rbac/DashboardTab.tsx
Normal 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}>→</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}>→</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>
|
||||
);
|
||||
}
|
||||
246
ui/src/pages/admin/rbac/GroupsTab.tsx
Normal file
246
ui/src/pages/admin/rbac/GroupsTab.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
570
ui/src/pages/admin/rbac/RbacPage.module.css
Normal file
570
ui/src/pages/admin/rbac/RbacPage.module.css
Normal 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;
|
||||
}
|
||||
66
ui/src/pages/admin/rbac/RbacPage.tsx
Normal file
66
ui/src/pages/admin/rbac/RbacPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
ui/src/pages/admin/rbac/RolesTab.tsx
Normal file
201
ui/src/pages/admin/rbac/RolesTab.tsx
Normal 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}>🔒</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}>🔒</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
281
ui/src/pages/admin/rbac/UsersTab.tsx
Normal file
281
ui/src/pages/admin/rbac/UsersTab.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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> },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user