feat: replace UI with design system example pages wired to real API
Some checks failed
CI / build (push) Successful in 1m18s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 55s
CI / deploy-feature (push) Has been cancelled
CI / deploy (push) Has been cancelled

Migrate all page components from the @cameleer/design-system v0.0.3
example UI, replacing mock data with real backend API hooks. This brings
richer visuals (KpiStrip, GroupCard, RouteFlow, ProcessorTimeline,
DateRangePicker, expandable rows) while preserving all existing API
integration, auth, and routing infrastructure.

Pages migrated: Dashboard, RoutesMetrics, RouteDetail, ExchangeDetail,
AgentHealth, AgentInstance, OidcConfig, AuditLog, RBAC (Users/Groups/Roles).
Also enhanced LayoutShell CommandPalette with real search data from catalog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-24 16:42:16 +01:00
parent dafd7adb00
commit 81f85aa82d
23 changed files with 4439 additions and 2542 deletions

View File

@@ -20,7 +20,9 @@ export default function AdminLayout() {
active={location.pathname}
onChange={(path) => navigate(path)}
/>
<Outlet />
<div style={{ padding: '20px 24px 40px' }}>
<Outlet />
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
.filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.filterInput {
width: 200px;
}
.filterSelect {
width: 160px;
}
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
.tableMeta {
font-size: 11px;
color: var(--text-muted);
}
.target {
display: inline-block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.expandedDetail {
padding: 4px 0;
}
.detailGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.detailField {
display: flex;
flex-direction: column;
gap: 4px;
}
.detailLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
font-family: var(--font-body);
}
.detailValue {
font-size: 12px;
color: var(--text-secondary);
}

View File

@@ -1,59 +1,148 @@
import { useState, useMemo } from 'react';
import { DataTable, Badge, Input, Select, MonoText, CodeBlock } from '@cameleer/design-system';
import {
Badge, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useAuditLog } from '../../api/queries/admin/audit';
import { useAuditLog, type AuditEvent } from '../../api/queries/admin/audit';
import styles from './AuditLogPage.module.css';
const CATEGORIES = [
{ value: '', label: 'All categories' },
{ value: 'INFRA', label: 'INFRA' },
{ value: 'AUTH', label: 'AUTH' },
{ value: 'USER_MGMT', label: 'USER_MGMT' },
{ value: 'CONFIG', label: 'CONFIG' },
{ value: 'RBAC', label: 'RBAC' },
];
function formatTimestamp(iso: string): string {
return new Date(iso).toLocaleString('en-GB', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
});
}
type AuditRow = Omit<AuditEvent, 'id'> & { id: string };
const COLUMNS: Column<AuditRow>[] = [
{
key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true,
render: (_, row) => <MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>,
},
{
key: 'username', header: 'User', sortable: true,
render: (_, row) => <span style={{ fontWeight: 500 }}>{row.username}</span>,
},
{
key: 'category', header: 'Category', width: '110px', sortable: true,
render: (_, row) => <Badge label={row.category} color="auto" />,
},
{ key: 'action', header: 'Action' },
{
key: 'target', header: 'Target',
render: (_, row) => <span className={styles.target}>{row.target}</span>,
},
{
key: 'result', header: 'Result', width: '90px', sortable: true,
render: (_, row) => (
<Badge label={row.result} color={row.result === 'SUCCESS' ? 'success' : 'error'} />
),
},
];
export default function AuditLogPage() {
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [dateRange, setDateRange] = useState({
start: new Date(Date.now() - 7 * 24 * 3600_000),
end: new Date(),
});
const [userFilter, setUserFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [searchFilter, setSearchFilter] = useState('');
const [page, setPage] = useState(0);
const { data, isLoading } = useAuditLog({ search, category: category || undefined, page, size: 25 });
const { data } = useAuditLog({
username: userFilter || undefined,
category: categoryFilter || undefined,
search: searchFilter || undefined,
from: dateRange.start.toISOString(),
to: dateRange.end.toISOString(),
page,
size: 25,
});
const columns: Column<any>[] = [
{ key: 'timestamp', header: 'Time', sortable: true, render: (v) => new Date(v as string).toLocaleString() },
{ key: 'username', header: 'User', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'action', header: 'Action' },
{ key: 'category', header: 'Category', render: (v) => <Badge label={String(v)} color="auto" /> },
{ key: 'target', header: 'Target', render: (v) => v ? <MonoText size="sm">{String(v)}</MonoText> : null },
{ key: 'result', header: 'Result', render: (v) => <Badge label={String(v)} color={v === 'SUCCESS' ? 'success' : 'error'} /> },
];
const rows = useMemo(() =>
(data?.items || []).map((item: any) => ({ ...item, id: String(item.id) })),
const rows: AuditRow[] = useMemo(
() => (data?.items || []).map((item) => ({ ...item, id: String(item.id) })),
[data],
);
const totalCount = data?.totalCount ?? 0;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Audit Log</h2>
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} />
<div className={styles.filters}>
<DateRangePicker
value={dateRange}
onChange={(range) => { setDateRange(range); setPage(0); }}
/>
<Input
placeholder="Filter by user..."
value={userFilter}
onChange={(e) => { setUserFilter(e.target.value); setPage(0); }}
onClear={() => { setUserFilter(''); setPage(0); }}
className={styles.filterInput}
/>
<Select
options={[
{ value: '', label: 'All Categories' },
{ value: 'AUTH', label: 'Auth' },
{ value: 'CONFIG', label: 'Config' },
{ value: 'RBAC', label: 'RBAC' },
{ value: 'INFRA', label: 'Infra' },
]}
value={category}
onChange={(e) => setCategory(e.target.value)}
options={CATEGORIES}
value={categoryFilter}
onChange={(e) => { setCategoryFilter(e.target.value); setPage(0); }}
className={styles.filterSelect}
/>
<Input
placeholder="Search action or target..."
value={searchFilter}
onChange={(e) => { setSearchFilter(e.target.value); setPage(0); }}
onClear={() => { setSearchFilter(''); setPage(0); }}
className={styles.filterInput}
/>
</div>
<DataTable
columns={columns}
data={rows}
sortable
pageSize={25}
expandedContent={(row) => (
<div style={{ padding: '0.75rem' }}>
<CodeBlock content={JSON.stringify(row.detail, null, 2)} />
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Audit Log</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{totalCount} events
</span>
<Badge label="LIVE" color="success" />
</div>
)}
/>
</div>
<DataTable
columns={COLUMNS}
data={rows}
sortable
flush
pageSize={25}
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
expandedContent={(row) => (
<div className={styles.expandedDetail}>
<div className={styles.detailGrid}>
<div className={styles.detailField}>
<span className={styles.detailLabel}>IP Address</span>
<MonoText size="xs">{row.ipAddress}</MonoText>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>User Agent</span>
<span className={styles.detailValue}>{row.userAgent}</span>
</div>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>Detail</span>
<CodeBlock content={JSON.stringify(row.detail, null, 2)} language="json" />
</div>
</div>
)}
/>
</div>
</div>
);
}

View File

@@ -1,15 +1,20 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
Avatar,
Badge,
Button,
Input,
MonoText,
Tag,
Select,
ConfirmDialog,
Spinner,
MonoText,
SectionHeader,
Tag,
InlineEdit,
MultiSelect,
ConfirmDialog,
AlertDialog,
SplitPane,
EntityList,
Spinner,
useToast,
} from '@cameleer/design-system';
import {
@@ -25,26 +30,31 @@ import {
useUsers,
useRoles,
} from '../../api/queries/admin/rbac';
import type { GroupDetail } from '../../api/queries/admin/rbac';
import styles from './UserManagement.module.css';
const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010';
export default function GroupsTab() {
const [search, setSearch] = useState('');
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [newGroupParentId, setNewGroupParentId] = useState<string>('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [addMemberUserId, setAddMemberUserId] = useState<string>('');
const [addRoleId, setAddRoleId] = useState<string>('');
const { toast } = useToast();
const { data: groups = [], isLoading: groupsLoading } = useGroups();
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedGroupId);
const { data: users = [] } = useUsers();
const { data: roles = [] } = useRoles();
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<GroupDetail | null>(null);
const [removeRoleTarget, setRemoveRoleTarget] = useState<string | null>(null);
// Create form state
const [newName, setNewName] = useState('');
const [newParent, setNewParent] = useState('');
// Detail query
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedId);
// Mutations
const createGroup = useCreateGroup();
const updateGroup = useUpdateGroup();
const deleteGroup = useDeleteGroup();
@@ -53,350 +63,385 @@ export default function GroupsTab() {
const addUserToGroup = useAddUserToGroup();
const removeUserFromGroup = useRemoveUserFromGroup();
const filteredGroups = groups.filter((g) =>
g.name.toLowerCase().includes(search.toLowerCase())
);
const filtered = useMemo(() => {
if (!search) return groups;
const q = search.toLowerCase();
return groups.filter((g) => g.name.toLowerCase().includes(q));
}, [groups, search]);
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
const parentOptions = [
{ value: '', label: 'Top-level' },
...groups.map((g) => ({ value: g.id, label: g.name })),
...groups
.filter((g) => g.id !== selectedId)
.map((g) => ({ value: g.id, label: g.name })),
];
const parentName = (parentGroupId: string | null) => {
const duplicateGroupName =
newName.trim() !== '' &&
groups.some(
(g) => g.name.toLowerCase() === newName.trim().toLowerCase(),
);
// Derived data for the detail pane
const children = selectedGroup?.childGroups ?? [];
const members = selectedGroup?.members ?? [];
const parentGroup = selectedGroup?.parentGroupId
? groups.find((g) => g.id === selectedGroup.parentGroupId)
: null;
const memberUserIds = new Set(members.map((m) => m.userId));
const assignedRoleIds = new Set(
(selectedGroup?.directRoles ?? []).map((r) => r.id),
);
const availableRoles = roles
.filter((r) => !assignedRoleIds.has(r.id))
.map((r) => ({ value: r.id, label: r.name }));
const availableMembers = users
.filter((u) => !memberUserIds.has(u.userId))
.map((u) => ({ value: u.userId, label: u.displayName }));
function parentName(parentGroupId: string | null): string {
if (!parentGroupId) return 'Top-level';
const parent = groups.find((g) => g.id === parentGroupId);
return parent ? parent.name : parentGroupId;
};
}
const handleCreate = async () => {
const name = newGroupName.trim();
if (!name) return;
async function handleCreate() {
if (!newName.trim()) return;
try {
await createGroup.mutateAsync({
name,
parentGroupId: newGroupParentId || null,
name: newName.trim(),
parentGroupId: newParent || null,
});
toast({ title: 'Group created', variant: 'success' });
setNewGroupName('');
setNewGroupParentId('');
setShowCreate(false);
toast({ title: 'Group created', description: newName.trim(), variant: 'success' });
setCreating(false);
setNewName('');
setNewParent('');
} catch {
toast({ title: 'Failed to create group', variant: 'error' });
}
};
}
const handleRename = async (newName: string) => {
async function handleDelete() {
if (!deleteTarget) return;
try {
await deleteGroup.mutateAsync(deleteTarget.id);
toast({
title: 'Group deleted',
description: deleteTarget.name,
variant: 'warning',
});
if (selectedId === deleteTarget.id) setSelectedId(null);
setDeleteTarget(null);
} catch {
toast({ title: 'Failed to delete group', variant: 'error' });
setDeleteTarget(null);
}
}
async function handleRename(newNameVal: string) {
if (!selectedGroup) return;
try {
await updateGroup.mutateAsync({
id: selectedGroup.id,
name: newName,
name: newNameVal,
parentGroupId: selectedGroup.parentGroupId,
});
toast({ title: 'Group renamed', variant: 'success' });
} catch {
toast({ title: 'Failed to rename group', variant: 'error' });
}
};
}
const handleDelete = async () => {
async function handleRemoveMember(userId: string) {
if (!selectedGroup) return;
try {
await deleteGroup.mutateAsync(selectedGroup.id);
toast({ title: 'Group deleted', variant: 'success' });
setSelectedGroupId(null);
setDeleteOpen(false);
} catch {
toast({ title: 'Failed to delete group', variant: 'error' });
}
};
const handleAddMember = async () => {
if (!selectedGroup || !addMemberUserId) return;
try {
await addUserToGroup.mutateAsync({
userId: addMemberUserId,
await removeUserFromGroup.mutateAsync({
userId,
groupId: selectedGroup.id,
});
toast({ title: 'Member added', variant: 'success' });
setAddMemberUserId('');
} catch {
toast({ title: 'Failed to add member', variant: 'error' });
}
};
const handleRemoveMember = async (userId: string) => {
if (!selectedGroup) return;
try {
await removeUserFromGroup.mutateAsync({ userId, groupId: selectedGroup.id });
toast({ title: 'Member removed', variant: 'success' });
} catch {
toast({ title: 'Failed to remove member', variant: 'error' });
}
};
}
const handleAddRole = async () => {
if (!selectedGroup || !addRoleId) return;
try {
await assignRoleToGroup.mutateAsync({
groupId: selectedGroup.id,
roleId: addRoleId,
});
toast({ title: 'Role assigned', variant: 'success' });
setAddRoleId('');
} catch {
toast({ title: 'Failed to assign role', variant: 'error' });
async function handleAddMembers(userIds: string[]) {
if (!selectedGroup) return;
for (const userId of userIds) {
try {
await addUserToGroup.mutateAsync({
userId,
groupId: selectedGroup.id,
});
toast({ title: 'Member added', variant: 'success' });
} catch {
toast({ title: 'Failed to add member', variant: 'error' });
}
}
};
}
const handleRemoveRole = async (roleId: string) => {
async function handleAddRoles(roleIds: string[]) {
if (!selectedGroup) return;
for (const roleId of roleIds) {
try {
await assignRoleToGroup.mutateAsync({
groupId: selectedGroup.id,
roleId,
});
toast({ title: 'Role assigned', variant: 'success' });
} catch {
toast({ title: 'Failed to assign role', variant: 'error' });
}
}
}
async function handleRemoveRole(roleId: string) {
if (!selectedGroup) return;
try {
await removeRoleFromGroup.mutateAsync({ groupId: selectedGroup.id, roleId });
await removeRoleFromGroup.mutateAsync({
groupId: selectedGroup.id,
roleId,
});
toast({ title: 'Role removed', variant: 'success' });
} catch {
toast({ title: 'Failed to remove role', variant: 'error' });
}
};
}
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
// Build sets for quick lookup of already-assigned items
const memberUserIds = new Set((selectedGroup?.members ?? []).map((m) => m.userId));
const assignedRoleIds = new Set((selectedGroup?.directRoles ?? []).map((r) => r.id));
const availableUsers = users.filter((u) => !memberUserIds.has(u.userId));
const availableRoles = roles.filter((r) => !assignedRoleIds.has(r.id));
if (groupsLoading) return <Spinner size="md" />;
return (
<div className={styles.splitPane}>
{/* Left pane */}
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search groups..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch('')}
/>
<Button
size="sm"
variant="secondary"
onClick={() => setShowCreate((v) => !v)}
>
+ Add Group
</Button>
</div>
{showCreate && (
<div className={styles.createForm}>
<Input
placeholder="Group name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
<div style={{ marginTop: 8 }}>
<Select
options={parentOptions}
value={newGroupParentId}
onChange={(e) => setNewGroupParentId(e.target.value)}
/>
</div>
<div className={styles.createFormActions}>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowCreate(false);
setNewGroupName('');
setNewGroupParentId('');
}}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
loading={createGroup.isPending}
onClick={handleCreate}
disabled={!newGroupName.trim()}
>
Create
</Button>
</div>
</div>
)}
{groupsLoading ? (
<Spinner />
) : (
<div className={styles.entityList} role="listbox">
{filteredGroups.map((group) => {
const isSelected = group.id === selectedGroupId;
return (
<div
key={group.id}
role="option"
aria-selected={isSelected}
className={
styles.entityItem +
(isSelected ? ' ' + styles.entityItemSelected : '')
}
onClick={() => setSelectedGroupId(group.id)}
>
<Avatar name={group.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{group.name}</div>
<div className={styles.entityMeta}>
{group.parentGroupId
? `Child of ${parentName(group.parentGroupId)}`
: 'Top-level'}
</div>
</div>
<>
<SplitPane
list={
<>
{creating && (
<div className={styles.createForm}>
<Input
placeholder="Group name *"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
{duplicateGroupName && (
<span style={{ color: 'var(--error)', fontSize: 11 }}>
Group name already exists
</span>
)}
<Select
options={parentOptions}
value={newParent}
onChange={(e) => setNewParent(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button
size="sm"
variant="ghost"
onClick={() => setCreating(false)}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
onClick={handleCreate}
loading={createGroup.isPending}
disabled={!newName.trim() || duplicateGroupName}
>
Create
</Button>
</div>
);
})}
</div>
)}
</div>
{/* Right pane */}
<div className={styles.detailPane}>
{!selectedGroupId ? (
<div className={styles.emptyDetail}>Select a group to view details</div>
) : detailLoading ? (
<Spinner />
) : selectedGroup ? (
<div>
{/* Header */}
<div className={styles.detailHeader}>
<Avatar name={selectedGroup.name} size="md" />
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEdit
value={selectedGroup.name}
onSave={handleRename}
disabled={isBuiltinAdmins}
/>
<div className={styles.entityMeta}>
{selectedGroup.parentGroupId
? `Child of ${parentName(selectedGroup.parentGroupId)}`
: 'Top-level'}
</div>
</div>
<Button
variant="danger"
size="sm"
disabled={isBuiltinAdmins}
onClick={() => setDeleteOpen(true)}
>
Delete
</Button>
</div>
{/* Metadata */}
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>Group ID</span>
<MonoText size="xs">{selectedGroup.id}</MonoText>
<span className={styles.metaLabel}>Parent</span>
<span>{parentName(selectedGroup.parentGroupId)}</span>
</div>
{/* Members */}
<div className={styles.sectionTitle}>Members</div>
<div className={styles.sectionTags}>
{(selectedGroup.members ?? []).map((member) => (
<Tag
key={member.userId}
label={member.displayName}
onRemove={() => handleRemoveMember(member.userId)}
/>
))}
{(selectedGroup.members ?? []).length === 0 && (
<span className={styles.inheritedNote}>No members</span>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Select
options={[
{ value: '', label: 'Add member...' },
...availableUsers.map((u) => ({
value: u.userId,
label: u.displayName,
})),
]}
value={addMemberUserId}
onChange={(e) => setAddMemberUserId(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
onClick={handleAddMember}
disabled={!addMemberUserId || addUserToGroup.isPending}
>
Add
</Button>
</div>
{/* Assigned roles */}
<div className={styles.sectionTitle}>Assigned Roles</div>
<div className={styles.sectionTags}>
{(selectedGroup.directRoles ?? []).map((role) => (
<Badge
key={role.id}
label={role.name}
variant="outlined"
onRemove={() => handleRemoveRole(role.id)}
/>
))}
{(selectedGroup.directRoles ?? []).length === 0 && (
<span className={styles.inheritedNote}>No roles assigned</span>
)}
</div>
{(selectedGroup.effectiveRoles ?? []).length >
(selectedGroup.directRoles ?? []).length && (
<div className={styles.inheritedNote}>
+
{(selectedGroup.effectiveRoles ?? []).length -
(selectedGroup.directRoles ?? []).length}{' '}
inherited role(s)
</div>
)}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Select
options={[
{ value: '', label: 'Assign role...' },
...availableRoles.map((r) => ({
value: r.id,
label: r.name,
})),
]}
value={addRoleId}
onChange={(e) => setAddRoleId(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
onClick={handleAddRole}
disabled={!addRoleId || assignRoleToGroup.isPending}
>
Add
</Button>
</div>
</div>
) : null}
</div>
{/* Delete confirmation */}
<EntityList
items={filtered}
renderItem={(group) => {
const groupChildren = groups.filter(
(g) => g.parentGroupId === group.id,
);
const groupParent = group.parentGroupId
? groups.find((g) => g.id === group.parentGroupId)
: null;
return (
<>
<Avatar name={group.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{group.name}</div>
<div className={styles.entityMeta}>
{groupParent
? `Child of ${groupParent.name}`
: 'Top-level'}
{' \u00b7 '}
{groupChildren.length} children
{' \u00b7 '}
{(group.members ?? []).length} members
</div>
<div className={styles.entityTags}>
{(group.directRoles ?? []).map((r) => (
<Badge key={r.id} label={r.name} color="warning" />
))}
</div>
</div>
</>
);
}}
getItemId={(group) => group.id}
selectedId={selectedId ?? undefined}
onSelect={setSelectedId}
searchPlaceholder="Search groups..."
onSearch={setSearch}
addLabel="+ Add group"
onAdd={() => setCreating(true)}
emptyMessage="No groups match your search"
/>
</>
}
detail={
selectedId && detailLoading ? (
<Spinner size="md" />
) : selectedGroup ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selectedGroup.name} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>
{isBuiltinAdmins ? (
selectedGroup.name
) : (
<InlineEdit
value={selectedGroup.name}
onSave={handleRename}
/>
)}
</div>
<div className={styles.detailEmail}>
{parentGroup
? `${parentGroup.name} > ${selectedGroup.name}`
: 'Top-level group'}
{isBuiltinAdmins && ' (built-in)'}
</div>
</div>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(selectedGroup)}
disabled={isBuiltinAdmins}
>
Delete
</Button>
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selectedGroup.id}</MonoText>
</div>
{parentGroup && (
<>
<SectionHeader>Member of</SectionHeader>
<div className={styles.sectionTags}>
<Tag label={parentGroup.name} color="auto" />
</div>
</>
)}
<SectionHeader>Members (direct)</SectionHeader>
<div className={styles.sectionTags}>
{members.map((u) => (
<Tag
key={u.userId}
label={u.displayName}
color="auto"
onRemove={() => handleRemoveMember(u.userId)}
/>
))}
{members.length === 0 && (
<span className={styles.inheritedNote}>(no members)</span>
)}
<MultiSelect
options={availableMembers}
value={[]}
onChange={handleAddMembers}
placeholder="+ Add"
/>
</div>
{children.length > 0 && (
<span className={styles.inheritedNote}>
+ all members of {children.map((c) => c.name).join(', ')}
</span>
)}
<SectionHeader>Child groups</SectionHeader>
<div className={styles.sectionTags}>
{children.map((c) => (
<Tag key={c.id} label={c.name} color="success" />
))}
{children.length === 0 && (
<span className={styles.inheritedNote}>
(no child groups)
</span>
)}
</div>
<SectionHeader>Assigned roles</SectionHeader>
<div className={styles.sectionTags}>
{(selectedGroup.directRoles ?? []).map((r) => (
<Tag
key={r.id}
label={r.name}
color="warning"
onRemove={() => {
if (members.length > 0) {
setRemoveRoleTarget(r.id);
} else {
handleRemoveRole(r.id);
}
}}
/>
))}
{(selectedGroup.directRoles ?? []).length === 0 && (
<span className={styles.inheritedNote}>(no roles)</span>
)}
<MultiSelect
options={availableRoles}
value={[]}
onChange={handleAddRoles}
placeholder="+ Add"
/>
</div>
</>
) : null
}
emptyMessage="Select a group to view details"
/>
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
title="Delete Group"
message={`Delete group "${selectedGroup?.name}"? This action cannot be undone.`}
confirmText="DELETE"
variant="danger"
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
confirmText={deleteTarget?.name ?? ''}
loading={deleteGroup.isPending}
/>
</div>
<AlertDialog
open={removeRoleTarget !== null}
onClose={() => setRemoveRoleTarget(null)}
onConfirm={() => {
if (removeRoleTarget && selectedGroup) {
handleRemoveRole(removeRoleTarget);
}
setRemoveRoleTarget(null);
}}
title="Remove role from group"
description={`Removing this role will affect ${members.length} member(s) who inherit it. Continue?`}
confirmLabel="Remove"
variant="warning"
/>
</>
);
}

View File

@@ -1,28 +1,53 @@
.page {
max-width: 640px;
margin: 0 auto;
}
.toolbar {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-bottom: 20px;
}
.section {
display: grid;
gap: 0.5rem;
margin-bottom: 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.section h3 {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
.toggleRow {
display: flex;
align-items: center;
gap: 12px;
}
.tagRow {
.hint {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-body);
}
.tagList {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 2rem;
align-items: center;
gap: 6px;
}
.addRow {
.noRoles {
font-size: 12px;
color: var(--text-faint);
font-style: italic;
font-family: var(--font-body);
}
.addRoleRow {
display: flex;
gap: 0.5rem;
gap: 8px;
align-items: center;
}
.addRow input {
flex: 1;
.roleInput {
width: 200px;
}

View File

@@ -1,110 +1,226 @@
import { useEffect, useState } from 'react';
import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader, Tag, ConfirmDialog } from '@cameleer/design-system';
import {
Button, Input, Toggle, FormField, SectionHeader, Tag, ConfirmDialog, Alert,
} from '@cameleer/design-system';
import { useToast } from '@cameleer/design-system';
import { adminFetch } from '../../api/queries/admin/admin-api';
import styles from './OidcConfigPage.module.css';
interface OidcConfig {
interface OidcFormData {
enabled: boolean;
autoSignup: boolean;
issuerUri: string;
clientId: string;
clientSecret: string;
rolesClaim: string;
defaultRoles: string[];
autoSignup: boolean;
displayNameClaim: string;
defaultRoles: string[];
}
const EMPTY_CONFIG: OidcFormData = {
enabled: false,
autoSignup: true,
issuerUri: '',
clientId: '',
clientSecret: '',
rolesClaim: 'roles',
displayNameClaim: 'name',
defaultRoles: ['VIEWER'],
};
export default function OidcConfigPage() {
const [config, setConfig] = useState<OidcConfig | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [form, setForm] = useState<OidcFormData | null>(null);
const [newRole, setNewRole] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
useEffect(() => {
adminFetch<OidcConfig>('/oidc')
.then(setConfig)
.catch(() => setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' }));
adminFetch<OidcFormData>('/oidc')
.then(setForm)
.catch(() => setForm(EMPTY_CONFIG));
}, []);
const handleSave = async () => {
if (!config) return;
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
setForm((prev) => prev ? { ...prev, [key]: value } : prev);
}
function addRole() {
if (!form) return;
const role = newRole.trim().toUpperCase();
if (role && !form.defaultRoles.includes(role)) {
update('defaultRoles', [...form.defaultRoles, role]);
setNewRole('');
}
}
function removeRole(role: string) {
if (!form) return;
update('defaultRoles', form.defaultRoles.filter((r) => r !== role));
}
async function handleSave() {
if (!form) return;
setSaving(true);
setError(null);
try {
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(config) });
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(form) });
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' });
} catch (e: any) {
setError(e.message);
toast({ title: 'Save failed', description: e.message, variant: 'error' });
} finally {
setSaving(false);
}
};
}
const handleDelete = async () => {
async function handleTest() {
if (!form) return;
setTesting(true);
setError(null);
try {
await adminFetch('/oidc', { method: 'DELETE' });
setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' });
const result = await adminFetch<{ status: string; authorizationEndpoint?: string }>('/oidc/test', { method: 'POST' });
toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' });
} catch (e: any) {
setError(e.message);
toast({ title: 'Connection test failed', description: e.message, variant: 'error' });
} finally {
setTesting(false);
}
};
}
if (!config) return null;
async function handleDelete() {
setDeleteOpen(false);
setError(null);
try {
await adminFetch('/oidc', { method: 'DELETE' });
setForm(EMPTY_CONFIG);
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' });
} catch (e: any) {
setError(e.message);
toast({ title: 'Delete failed', description: e.message, variant: 'error' });
}
}
if (!form) return null;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>OIDC Configuration</h2>
<Card>
<div style={{ padding: '1.5rem', display: 'grid', gap: '1rem' }}>
<Toggle checked={config.enabled} onChange={(e) => setConfig({ ...config, enabled: e.target.checked })} label="Enable OIDC" />
<FormField label="Issuer URI"><Input value={config.issuerUri} onChange={(e) => setConfig({ ...config, issuerUri: e.target.value })} /></FormField>
<FormField label="Client ID"><Input value={config.clientId} onChange={(e) => setConfig({ ...config, clientId: e.target.value })} /></FormField>
<FormField label="Client Secret"><Input type="password" value={config.clientSecret} onChange={(e) => setConfig({ ...config, clientSecret: e.target.value })} /></FormField>
<FormField label="Roles Claim"><Input value={config.rolesClaim} onChange={(e) => setConfig({ ...config, rolesClaim: e.target.value })} /></FormField>
<FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField>
<Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" />
<div className={styles.page}>
<div className={styles.toolbar}>
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri || testing}>
{testing ? 'Testing...' : 'Test Connection'}
</Button>
<Button size="sm" variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
<div className={styles.section}>
<h3>Default Roles</h3>
<div className={styles.tagRow}>
{(config.defaultRoles || []).map(role => (
<Tag key={role} label={role} onRemove={() => {
setConfig(prev => ({ ...prev!, defaultRoles: prev!.defaultRoles.filter(r => r !== role) }));
}} />
))}
</div>
<div className={styles.addRow}>
<Input placeholder="Add role..." value={newRole} onChange={e => setNewRole(e.target.value)} />
<Button onClick={() => {
if (newRole.trim() && !config.defaultRoles?.includes(newRole.trim())) {
setConfig(prev => ({ ...prev!, defaultRoles: [...(prev!.defaultRoles || []), newRole.trim()] }));
setNewRole('');
}
}}>Add</Button>
</div>
</div>
{error && <div style={{ marginBottom: 16 }}><Alert variant="error">{error}</Alert></div>}
<div style={{ display: 'flex', gap: '0.75rem' }}>
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
<Button variant="danger" onClick={() => setDeleteOpen(true)}>Delete Configuration</Button>
</div>
{error && <Alert variant="error">{error}</Alert>}
{success && <Alert variant="success">Configuration saved</Alert>}
<section className={styles.section}>
<SectionHeader>Behavior</SectionHeader>
<div className={styles.toggleRow}>
<Toggle
label="Enabled"
checked={form.enabled}
onChange={(e) => update('enabled', e.target.checked)}
/>
</div>
</Card>
<div className={styles.toggleRow}>
<Toggle
label="Auto Sign-Up"
checked={form.autoSignup}
onChange={(e) => update('autoSignup', e.target.checked)}
/>
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
</div>
</section>
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDelete}
title="Delete OIDC Configuration"
message="Delete OIDC configuration? All OIDC users will lose access."
confirmText="DELETE"
/>
<section className={styles.section}>
<SectionHeader>Provider Settings</SectionHeader>
<FormField label="Issuer URI" htmlFor="issuer">
<Input
id="issuer"
type="url"
placeholder="https://idp.example.com/realms/my-realm"
value={form.issuerUri}
onChange={(e) => update('issuerUri', e.target.value)}
/>
</FormField>
<FormField label="Client ID" htmlFor="client-id">
<Input
id="client-id"
value={form.clientId}
onChange={(e) => update('clientId', e.target.value)}
/>
</FormField>
<FormField label="Client Secret" htmlFor="client-secret">
<Input
id="client-secret"
type="password"
value={form.clientSecret}
onChange={(e) => update('clientSecret', e.target.value)}
/>
</FormField>
</section>
<section className={styles.section}>
<SectionHeader>Claim Mapping</SectionHeader>
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
<Input
id="roles-claim"
value={form.rolesClaim}
onChange={(e) => update('rolesClaim', e.target.value)}
/>
</FormField>
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
<Input
id="name-claim"
value={form.displayNameClaim}
onChange={(e) => update('displayNameClaim', e.target.value)}
/>
</FormField>
</section>
<section className={styles.section}>
<SectionHeader>Default Roles</SectionHeader>
<div className={styles.tagList}>
{form.defaultRoles.map((role) => (
<Tag key={role} label={role} color="primary" onRemove={() => removeRole(role)} />
))}
{form.defaultRoles.length === 0 && (
<span className={styles.noRoles}>No default roles configured</span>
)}
</div>
<div className={styles.addRoleRow}>
<Input
placeholder="Add role..."
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }}
className={styles.roleInput}
/>
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
Add
</Button>
</div>
</section>
<section className={styles.section}>
<SectionHeader>Danger Zone</SectionHeader>
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
Delete OIDC Configuration
</Button>
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDelete}
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
confirmText="delete oidc"
/>
</section>
</div>
);
}

View File

@@ -6,30 +6,29 @@ import UsersTab from './UsersTab';
import GroupsTab from './GroupsTab';
import RolesTab from './RolesTab';
const TABS = [
{ label: 'Users', value: 'users' },
{ label: 'Groups', value: 'groups' },
{ label: 'Roles', value: 'roles' },
];
export default function RbacPage() {
const { data: stats } = useRbacStats();
const [tab, setTab] = useState('users');
return (
<div>
<h2 style={{ margin: '0 0 16px' }}>User Management</h2>
<div className={styles.statStrip}>
<StatCard label="Users" value={stats?.userCount ?? 0} />
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
</div>
<Tabs
tabs={[
{ label: 'Users', value: 'users' },
{ label: 'Groups', value: 'groups' },
{ label: 'Roles', value: 'roles' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab />}
{tab === 'roles' && <RolesTab />}
<Tabs tabs={TABS} active={tab} onChange={setTab} />
<div className={styles.tabContent}>
{tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab />}
{tab === 'roles' && <RolesTab />}
</div>
</div>
);
}

View File

@@ -1,13 +1,16 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
Avatar,
Badge,
Button,
ConfirmDialog,
Input,
MonoText,
Spinner,
SectionHeader,
Tag,
ConfirmDialog,
SplitPane,
EntityList,
Spinner,
useToast,
} from '@cameleer/design-system';
import {
@@ -20,33 +23,54 @@ import type { RoleDetail } from '../../api/queries/admin/rbac';
import styles from './UserManagement.module.css';
export default function RolesTab() {
const { toast } = useToast();
const { data: roles, isLoading } = useRoles();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [newDescription, setNewDescription] = useState('');
const [confirmDelete, setConfirmDelete] = useState(false);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<RoleDetail | null>(null);
// Create form state
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
// Detail query
const { data: detail, isLoading: detailLoading } = useRole(selectedId);
// Mutations
const createRole = useCreateRole();
const deleteRole = useDeleteRole();
const { toast } = useToast();
const filtered = (roles ?? []).filter((r) =>
r.name.toLowerCase().includes(search.toLowerCase()),
);
const filtered = useMemo(() => {
const list = roles ?? [];
if (!search) return list;
const q = search.toLowerCase();
return list.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.description.toLowerCase().includes(q),
);
}, [roles, search]);
const duplicateRoleName =
newName.trim() !== '' &&
(roles ?? []).some((r) => r.name === newName.trim().toUpperCase());
function handleCreate() {
if (!newName.trim()) return;
createRole.mutate(
{ name: newName.trim(), description: newDescription.trim() || undefined },
{ name: newName.trim().toUpperCase(), description: newDesc.trim() || undefined },
{
onSuccess: () => {
toast({ title: 'Role created', variant: 'success' });
setShowCreate(false);
toast({
title: 'Role created',
description: newName.trim().toUpperCase(),
variant: 'success',
});
setCreating(false);
setNewName('');
setNewDescription('');
setNewDesc('');
},
onError: () => {
toast({ title: 'Failed to create role', variant: 'error' });
@@ -56,152 +80,144 @@ export default function RolesTab() {
}
function handleDelete() {
if (!selectedId) return;
deleteRole.mutate(selectedId, {
if (!deleteTarget) return;
deleteRole.mutate(deleteTarget.id, {
onSuccess: () => {
toast({ title: 'Role deleted', variant: 'success' });
setSelectedId(null);
setConfirmDelete(false);
toast({
title: 'Role deleted',
description: deleteTarget.name,
variant: 'warning',
});
if (selectedId === deleteTarget.id) setSelectedId(null);
setDeleteTarget(null);
},
onError: () => {
toast({ title: 'Failed to delete role', variant: 'error' });
setConfirmDelete(false);
setDeleteTarget(null);
},
});
}
function getAssignmentCount(role: RoleDetail): number {
return (
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0)
);
}
if (isLoading) return <Spinner size="md" />;
return (
<div className={styles.splitPane}>
{/* Left pane — list */}
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search roles…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setShowCreate((v) => !v)}
>
+ Add Role
</Button>
</div>
<>
<SplitPane
list={
<>
{creating && (
<div className={styles.createForm}>
<Input
placeholder="Role name *"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
{duplicateRoleName && (
<span style={{ color: 'var(--error)', fontSize: 11 }}>
Role name already exists
</span>
)}
<Input
placeholder="Description"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button
size="sm"
variant="ghost"
onClick={() => setCreating(false)}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
onClick={handleCreate}
loading={createRole.isPending}
disabled={!newName.trim() || duplicateRoleName}
>
Create
</Button>
</div>
</div>
)}
{showCreate && (
<div className={styles.createForm}>
<Input
placeholder="Role name (e.g. EDITOR)"
value={newName}
onChange={(e) => setNewName(e.target.value.toUpperCase())}
style={{ marginBottom: 8 }}
/>
<Input
placeholder="Description (optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowCreate(false);
setNewName('');
setNewDescription('');
}}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
loading={createRole.isPending}
disabled={!newName.trim()}
onClick={handleCreate}
>
Create
</Button>
</div>
</div>
)}
{isLoading ? (
<Spinner />
) : (
<div className={styles.entityList} role="listbox">
{filtered.map((role) => {
const assignmentCount =
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0);
return (
<div
key={role.id}
className={
styles.entityItem +
(selectedId === role.id ? ' ' + styles.entityItemSelected : '')
}
role="option"
aria-selected={selectedId === role.id}
onClick={() => setSelectedId(role.id)}
>
<EntityList
items={filtered}
renderItem={(role) => (
<>
<Avatar name={role.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{role.name}
{role.system && <Badge label="system" variant="outlined" />}
{role.system && (
<Badge
label="system"
color="auto"
variant="outlined"
className={styles.providerBadge}
/>
)}
</div>
<div className={styles.entityMeta}>
{role.description || ''} · {assignmentCount} assignment
{assignmentCount !== 1 ? 's' : ''}
{role.description || '\u2014'} \u00b7{' '}
{getAssignmentCount(role)} assignments
</div>
<div className={styles.entityTags}>
{(role.assignedGroups ?? []).map((g) => (
<Badge key={g.id} label={g.name} color="success" />
))}
{(role.directUsers ?? []).map((u) => (
<Badge
key={u.userId}
label={u.displayName}
color="auto"
/>
))}
</div>
{((role.assignedGroups?.length ?? 0) > 0 ||
(role.directUsers?.length ?? 0) > 0) && (
<div className={styles.entityTags}>
{(role.assignedGroups ?? []).map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))}
{(role.directUsers ?? []).map((u) => (
<Tag key={u.userId} label={u.displayName} />
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</>
)}
getItemId={(role) => role.id}
selectedId={selectedId ?? undefined}
onSelect={setSelectedId}
searchPlaceholder="Search roles..."
onSearch={setSearch}
addLabel="+ Add role"
onAdd={() => setCreating(true)}
emptyMessage="No roles match your search"
/>
</>
}
detail={
selectedId && (detailLoading || !detail) ? (
<Spinner size="md" />
) : detail ? (
<RoleDetailPanel
role={detail}
onDeleteRequest={() => setDeleteTarget(detail)}
/>
) : null
}
emptyMessage="Select a role to view details"
/>
{/* Right pane — detail */}
<div className={styles.detailPane}>
{!selectedId ? (
<div className={styles.emptyDetail}>Select a role to view details</div>
) : detailLoading || !detail ? (
<Spinner />
) : (
<RoleDetailPanel
role={detail}
onDeleteRequest={() => setConfirmDelete(true)}
/>
)}
</div>
{detail && (
<ConfirmDialog
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={handleDelete}
title="Delete role"
message={`Delete role "${detail.name}"? This cannot be undone.`}
confirmText={detail.name}
confirmLabel="Delete"
variant="danger"
loading={deleteRole.isPending}
/>
)}
</div>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
confirmText={deleteTarget?.name ?? ''}
loading={deleteRole.isPending}
/>
</>
);
}
@@ -213,93 +229,93 @@ interface RoleDetailPanelProps {
}
function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) {
// Build a set of directly-assigned user IDs for distinguishing inherited principals
const directUserIds = new Set((role.directUsers ?? []).map((u) => u.userId));
const directUserIds = new Set(
(role.directUsers ?? []).map((u) => u.userId),
);
const assignedGroups = role.assignedGroups ?? [];
const directUsers = role.directUsers ?? [];
const effectivePrincipals = role.effectivePrincipals ?? [];
return (
<div>
{/* Header */}
<>
<div className={styles.detailHeader}>
<Avatar name={role.name} size="md" />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 16 }}>{role.name}</div>
<Avatar name={role.name} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>{role.name}</div>
{role.description && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{role.description}
</div>
<div className={styles.detailEmail}>{role.description}</div>
)}
</div>
<Button
variant="danger"
size="sm"
disabled={role.system}
onClick={onDeleteRequest}
>
Delete
</Button>
{!role.system && (
<Button size="sm" variant="danger" onClick={onDeleteRequest}>
Delete
</Button>
)}
</div>
{/* Metadata */}
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{role.id}</MonoText>
<span className={styles.metaLabel}>Scope</span>
<span>{role.scope || ''}</span>
<span className={styles.metaLabel}>Type</span>
<span>{role.system ? 'System role (read-only)' : 'Custom role'}</span>
</div>
{/* Assigned to groups */}
<div className={styles.sectionTitle}>Assigned to groups</div>
<div className={styles.sectionTags}>
{(role.assignedGroups ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.assignedGroups ?? []).map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))
<span className={styles.metaValue}>{role.scope || '\u2014'}</span>
{role.system && (
<>
<span className={styles.metaLabel}>Type</span>
<span className={styles.metaValue}>System role (read-only)</span>
</>
)}
</div>
{/* Assigned to users (direct) */}
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
<SectionHeader>Assigned to groups</SectionHeader>
<div className={styles.sectionTags}>
{(role.directUsers ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.directUsers ?? []).map((u) => (
<Tag key={u.userId} label={u.displayName} />
))
{assignedGroups.map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))}
{assignedGroups.length === 0 && (
<span className={styles.inheritedNote}>(none)</span>
)}
</div>
{/* Effective principals */}
<div className={styles.sectionTitle}>Effective principals</div>
<SectionHeader>Assigned to users (direct)</SectionHeader>
<div className={styles.sectionTags}>
{(role.effectivePrincipals ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.effectivePrincipals ?? []).map((u) => {
const isDirect = directUserIds.has(u.userId);
return isDirect ? (
<Badge key={u.userId} label={u.displayName} variant="filled" />
) : (
<Badge
key={u.userId}
label={`${u.displayName}`}
variant="dashed"
/>
);
})
{directUsers.map((u) => (
<Tag key={u.userId} label={u.displayName} color="auto" />
))}
{directUsers.length === 0 && (
<span className={styles.inheritedNote}>(none)</span>
)}
</div>
{(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && (
<div className={styles.inheritedNote}>
<SectionHeader>Effective principals</SectionHeader>
<div className={styles.sectionTags}>
{effectivePrincipals.map((u) => {
const isDirect = directUserIds.has(u.userId);
return isDirect ? (
<Badge
key={u.userId}
label={u.displayName}
color="auto"
variant="filled"
/>
) : (
<Badge
key={u.userId}
label={u.displayName}
color="auto"
variant="dashed"
/>
);
})}
{effectivePrincipals.length === 0 && (
<span className={styles.inheritedNote}>(none)</span>
)}
</div>
{effectivePrincipals.some((u) => !directUserIds.has(u.userId)) && (
<span className={styles.inheritedNote}>
Dashed entries inherit this role through group membership
</div>
</span>
)}
</div>
</>
);
}

View File

@@ -5,187 +5,149 @@
margin-bottom: 16px;
}
.splitPane {
display: grid;
grid-template-columns: 52fr 48fr;
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-height: 500px;
box-shadow: var(--shadow-card);
}
.listPane {
background: var(--bg-surface);
display: flex;
flex-direction: column;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
}
.detailPane {
background: var(--bg-surface);
overflow-y: auto;
padding: 20px;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}
.listHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.listHeader input { flex: 1; }
.entityList {
flex: 1;
overflow-y: auto;
}
.entityItem {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid var(--border-subtle);
}
.entityItem:last-child {
border-bottom: none;
}
.entityItem:hover {
background: var(--bg-hover);
}
.entityItemSelected {
background: var(--bg-raised);
.tabContent {
margin-top: 16px;
}
.entityInfo {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.entityName {
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-body);
}
.entityMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-body);
margin-top: 2px;
}
.entityTags {
display: flex;
gap: 4px;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.createForm {
background: var(--bg-raised);
border-bottom: 1px solid var(--border-subtle);
padding: 12px;
}
.createFormActions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.detailHeader {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-subtle);
}
.detailHeaderInfo {
flex: 1;
min-width: 0;
}
.detailName {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-body);
}
.detailEmail {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-body);
}
.metaGrid {
display: grid;
grid-template-columns: 100px 1fr;
gap: 6px 12px;
font-size: 13px;
grid-template-columns: auto 1fr;
gap: 6px 16px;
margin-bottom: 16px;
font-size: 12px;
font-family: var(--font-body);
}
.metaLabel {
font-weight: 700;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
font-weight: 500;
}
.sectionTitle {
font-size: 13px;
font-weight: 700;
.metaValue {
color: var(--text-primary);
margin-bottom: 8px;
margin-top: 16px;
}
.sectionTags {
display: flex;
gap: 4px;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
margin-bottom: 8px;
}
.createForm {
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-raised);
display: flex;
flex-direction: column;
gap: 8px;
}
.createFormRow {
display: flex;
gap: 8px;
}
.createFormActions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.inheritedNote {
font-size: 11px;
font-style: italic;
color: var(--text-muted);
font-style: italic;
font-family: var(--font-body);
margin-top: 4px;
}
.providerBadge {
margin-left: 6px;
}
.inherited {
opacity: 0.65;
}
.securitySection {
padding: 12px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
margin-bottom: 16px;
margin-top: 8px;
margin-bottom: 8px;
}
.securityRow {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
font-family: var(--font-body);
color: var(--text-primary);
}
.passwordDots {
font-family: var(--font-mono);
letter-spacing: 2px;
}
.resetForm {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.emptyDetail {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
}
.emptySearch {
padding: 20px;
text-align: center;
color: var(--text-muted);
font-size: 12px;
}
.providerBadge {
font-size: 9px;
.resetInput {
width: 200px;
}

File diff suppressed because it is too large Load Diff