feat: replace UI with design system example pages wired to real API
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
86
ui/src/pages/Admin/AuditLogPage.module.css
Normal file
86
ui/src/pages/Admin/AuditLogPage.module.css
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user