From f075968e66d7d9777f774caa540102d5aab5e7cf Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:44:19 +0100 Subject: [PATCH] refactor: admin section UX/UI redesign - Fix critical --bg-base token bug (dark mode broken), replace with --bg-surface - Replace hand-rolled admin nav with Tabs composite (proper ARIA) - Migrate AuditLog from custom table to DataTable with sorting, row accents, card wrapper - Remove duplicate h2 page titles (breadcrumb + tab already identify the page) - Rework user creation with provider-aware form (Local/OIDC RadioGroup) - Add Security section with password reset for local users, OIDC info for external - Add toast notifications to all RBAC mutations (create/delete/add/remove) - Add confirmation dialogs for cascading removals (group/role) - Add keyboard accessibility to entity lists (role/tabIndex/aria-selected) - Add empty search states, duplicate name validation - Replace lock emoji with Badge, fix radii/shadow/padding consistency - Badge dashed variant keeps background color - Inherited roles shown with dashed outline + reduced opacity - Inline MultiSelect (+Add) for groups, roles, members, child groups - Center OIDC form, replace inline styles with CSS modules Co-Authored-By: Claude Opus 4.6 (1M context) --- .../primitives/Badge/Badge.module.css | 1 - src/pages/Admin/Admin.module.css | 33 +-- src/pages/Admin/Admin.tsx | 24 +-- src/pages/Admin/AuditLog/AuditLog.module.css | 105 +++------ src/pages/Admin/AuditLog/AuditLog.tsx | 173 ++++++--------- src/pages/Admin/AuditLog/auditMocks.ts | 52 ++--- .../Admin/OidcConfig/OidcConfig.module.css | 20 +- src/pages/Admin/OidcConfig/OidcConfig.tsx | 17 +- src/pages/Admin/UserManagement/GroupsTab.tsx | 132 +++++++++--- src/pages/Admin/UserManagement/RolesTab.tsx | 20 +- .../UserManagement/UserManagement.module.css | 57 ++++- .../Admin/UserManagement/UserManagement.tsx | 3 +- src/pages/Admin/UserManagement/UsersTab.tsx | 199 ++++++++++++++---- 13 files changed, 480 insertions(+), 356 deletions(-) diff --git a/src/design-system/primitives/Badge/Badge.module.css b/src/design-system/primitives/Badge/Badge.module.css index baf9777..efb8c05 100644 --- a/src/design-system/primitives/Badge/Badge.module.css +++ b/src/design-system/primitives/Badge/Badge.module.css @@ -20,7 +20,6 @@ } .dashed { - background: transparent !important; border-style: dashed; } diff --git a/src/pages/Admin/Admin.module.css b/src/pages/Admin/Admin.module.css index 440dff1..ea4a94f 100644 --- a/src/pages/Admin/Admin.module.css +++ b/src/pages/Admin/Admin.module.css @@ -1,36 +1,5 @@ -.adminNav { - display: flex; - gap: 0; - border-bottom: 1px solid var(--border-subtle); - padding: 0 20px; - background: var(--bg-base); -} - -.adminTab { - padding: 10px 16px; - border: none; - background: none; - color: var(--text-secondary); - font-family: var(--font-body); - font-size: 13px; - font-weight: 500; - cursor: pointer; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - transition: color 0.15s, border-color 0.15s; -} - -.adminTab:hover { - color: var(--text-primary); -} - -.adminTabActive { - color: var(--amber); - border-bottom-color: var(--amber); -} - .adminContent { flex: 1; overflow-y: auto; - padding: 20px; + padding: 20px 24px 40px; } diff --git a/src/pages/Admin/Admin.tsx b/src/pages/Admin/Admin.tsx index ff5e9e3..b048a43 100644 --- a/src/pages/Admin/Admin.tsx +++ b/src/pages/Admin/Admin.tsx @@ -2,14 +2,15 @@ import { useNavigate, useLocation } from 'react-router-dom' import { AppShell } from '../../design-system/layout/AppShell/AppShell' import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' import { TopBar } from '../../design-system/layout/TopBar/TopBar' +import { Tabs } from '../../design-system/composites/Tabs/Tabs' import { SIDEBAR_APPS } from '../../mocks/sidebar' import styles from './Admin.module.css' import type { ReactNode } from 'react' const ADMIN_TABS = [ - { label: 'User Management', path: '/admin/rbac' }, - { label: 'Audit Log', path: '/admin/audit' }, - { label: 'OIDC', path: '/admin/oidc' }, + { label: 'User Management', value: '/admin/rbac' }, + { label: 'Audit Log', value: '/admin/audit' }, + { label: 'OIDC', value: '/admin/oidc' }, ] interface AdminLayoutProps { @@ -31,18 +32,11 @@ export function AdminLayout({ title, children }: AdminLayoutProps) { environment="PRODUCTION" user={{ name: 'hendrik' }} /> - + navigate(path)} + />
{children}
diff --git a/src/pages/Admin/AuditLog/AuditLog.module.css b/src/pages/Admin/AuditLog/AuditLog.module.css index 5d34aec..a0de051 100644 --- a/src/pages/Admin/AuditLog/AuditLog.module.css +++ b/src/pages/Admin/AuditLog/AuditLog.module.css @@ -1,18 +1,3 @@ -.header { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 16px; -} - -.title { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0; - font-family: var(--font-body); -} - .filters { display: flex; gap: 10px; @@ -28,52 +13,37 @@ width: 160px; } -.tableWrap { - overflow-x: auto; +.tableSection { + background: var(--bg-surface); border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; } -.table { - width: 100%; - border-collapse: collapse; - font-family: var(--font-body); - font-size: 12px; +.tableHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); } -.th { - text-align: left; - padding: 10px 12px; +.tableTitle { + font-size: 13px; font-weight: 600; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-muted); - background: var(--bg-raised); - border-bottom: 1px solid var(--border-subtle); - position: sticky; - top: 0; - z-index: 1; -} - -.row { - cursor: pointer; - transition: background 0.1s; -} - -.row:hover { - background: var(--bg-hover); -} - -.td { - padding: 8px 12px; - border-bottom: 1px solid var(--border-subtle); color: var(--text-primary); - vertical-align: middle; } -.userCell { - font-weight: 500; +.tableRight { + display: flex; + align-items: center; + gap: 10px; +} + +.tableMeta { + font-size: 11px; + color: var(--text-muted); } .target { @@ -84,19 +54,8 @@ white-space: nowrap; } -.empty { - padding: 32px; - text-align: center; - color: var(--text-faint); -} - -.detailRow { - background: var(--bg-raised); -} - -.detailCell { - padding: 16px 20px; - border-bottom: 1px solid var(--border-subtle); +.expandedDetail { + padding: 4px 0; } .detailGrid { @@ -113,10 +72,10 @@ } .detailLabel { - font-size: 11px; + font-size: 10px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.8px; color: var(--text-muted); font-family: var(--font-body); } @@ -125,15 +84,3 @@ font-size: 12px; color: var(--text-secondary); } - -.detailJson { - display: flex; - flex-direction: column; - gap: 6px; -} - -.pagination { - display: flex; - justify-content: center; - margin-top: 16px; -} diff --git a/src/pages/Admin/AuditLog/AuditLog.tsx b/src/pages/Admin/AuditLog/AuditLog.tsx index 2ec8bc8..e708bde 100644 --- a/src/pages/Admin/AuditLog/AuditLog.tsx +++ b/src/pages/Admin/AuditLog/AuditLog.tsx @@ -6,7 +6,8 @@ import { Input } from '../../../design-system/primitives/Input/Input' import { Select } from '../../../design-system/primitives/Select/Select' import { MonoText } from '../../../design-system/primitives/MonoText/MonoText' import { CodeBlock } from '../../../design-system/primitives/CodeBlock/CodeBlock' -import { Pagination } from '../../../design-system/primitives/Pagination/Pagination' +import { DataTable } from '../../../design-system/composites/DataTable/DataTable' +import type { Column } from '../../../design-system/composites/DataTable/types' import type { DateRange } from '../../../design-system/utils/timePresets' import { AUDIT_EVENTS, type AuditEvent } from './auditMocks' import styles from './AuditLog.module.css' @@ -19,8 +20,6 @@ const CATEGORIES = [ { value: 'CONFIG', label: 'CONFIG' }, ] -const PAGE_SIZE = 10 - function formatTimestamp(iso: string): string { return new Date(iso).toLocaleString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit', @@ -29,6 +28,32 @@ function formatTimestamp(iso: string): string { }) } +const COLUMNS: Column[] = [ + { + key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true, + render: (_, row) => {formatTimestamp(row.timestamp)}, + }, + { + key: 'username', header: 'User', sortable: true, + render: (_, row) => {row.username}, + }, + { + key: 'category', header: 'Category', width: '110px', sortable: true, + render: (_, row) => , + }, + { key: 'action', header: 'Action' }, + { + key: 'target', header: 'Target', + render: (_, row) => {row.target}, + }, + { + key: 'result', header: 'Result', width: '90px', sortable: true, + render: (_, row) => ( + + ), + }, +] + const now = Date.now() const INITIAL_RANGE: DateRange = { from: new Date(now - 7 * 24 * 3600_000).toISOString().slice(0, 16), @@ -40,8 +65,6 @@ export function AuditLog() { const [userFilter, setUserFilter] = useState('') const [categoryFilter, setCategoryFilter] = useState('') const [searchFilter, setSearchFilter] = useState('') - const [page, setPage] = useState(1) - const [expandedId, setExpandedId] = useState(null) const filtered = useMemo(() => { const from = new Date(dateRange.from).getTime() @@ -59,128 +82,72 @@ export function AuditLog() { }) }, [dateRange, userFilter, categoryFilter, searchFilter]) - const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) - const pageEvents = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) - return ( -
-

Audit Log

- -
-
{ setDateRange(r); setPage(1) }} + onChange={setDateRange} /> { setUserFilter(e.target.value); setPage(1) }} - onClear={() => { setUserFilter(''); setPage(1) }} + onChange={(e) => setUserFilter(e.target.value)} + onClear={() => setUserFilter('')} className={styles.filterInput} /> { setSearchFilter(e.target.value); setPage(1) }} - onClear={() => { setSearchFilter(''); setPage(1) }} + onChange={(e) => setSearchFilter(e.target.value)} + onClear={() => setSearchFilter('')} className={styles.filterInput} />
-
- - - - - - - - - - - - - {pageEvents.map((event) => ( - setExpandedId(expandedId === event.id ? null : event.id)} - /> - ))} - {pageEvents.length === 0 && ( - - - - )} - -
TimestampUserCategoryActionTargetResult
No events match the current filters.
-
- - {totalPages > 1 && ( -
- +
+
+ Audit Log +
+ + {filtered.length} events + + +
- )} + row.result === 'FAILURE' ? 'error' : undefined} + expandedContent={(row) => ( +
+
+
+ IP Address + {row.ipAddress} +
+
+ User Agent + {row.userAgent} +
+
+
+ Detail + +
+
+ )} + /> +
) } - -function EventRow({ event, expanded, onToggle }: { event: AuditEvent; expanded: boolean; onToggle: () => void }) { - return ( - <> - - - {formatTimestamp(event.timestamp)} - - {event.username} - - - - {event.action} - - {event.target} - - - - - - {expanded && ( - - -
-
- IP Address - {event.ipAddress} -
-
- User Agent - {event.userAgent} -
-
-
- Detail - -
- - - )} - - ) -} diff --git a/src/pages/Admin/AuditLog/auditMocks.ts b/src/pages/Admin/AuditLog/auditMocks.ts index 6751bf0..8d54a47 100644 --- a/src/pages/Admin/AuditLog/auditMocks.ts +++ b/src/pages/Admin/AuditLog/auditMocks.ts @@ -1,5 +1,5 @@ export interface AuditEvent { - id: number + id: string timestamp: string username: string category: 'INFRA' | 'AUTH' | 'USER_MGMT' | 'CONFIG' @@ -17,175 +17,175 @@ const day = 24 * hour export const AUDIT_EVENTS: AuditEvent[] = [ { - id: 1, timestamp: new Date(now - 0.5 * hour).toISOString(), + id: 'audit-1', timestamp: new Date(now - 0.5 * hour).toISOString(), username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_USER', target: 'users/alice', result: 'SUCCESS', detail: { displayName: 'Alice Johnson', roles: ['VIEWER'] }, ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125', }, { - id: 2, timestamp: new Date(now - 1.2 * hour).toISOString(), + id: 'audit-2', timestamp: new Date(now - 1.2 * hour).toISOString(), username: 'system', category: 'INFRA', action: 'POOL_RESIZE', target: 'db/primary', result: 'SUCCESS', detail: { oldSize: 10, newSize: 20, reason: 'auto-scale' }, ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0', }, { - id: 3, timestamp: new Date(now - 2 * hour).toISOString(), + id: 'audit-3', timestamp: new Date(now - 2 * hour).toISOString(), username: 'alice', category: 'AUTH', action: 'LOGIN', target: 'sessions/abc123', result: 'SUCCESS', detail: { method: 'OIDC', provider: 'keycloak' }, ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126', }, { - id: 4, timestamp: new Date(now - 2.5 * hour).toISOString(), + id: 'audit-4', timestamp: new Date(now - 2.5 * hour).toISOString(), username: 'unknown', category: 'AUTH', action: 'LOGIN', target: 'sessions', result: 'FAILURE', detail: { method: 'local', reason: 'invalid_credentials' }, ipAddress: '203.0.113.50', userAgent: 'curl/8.1', }, { - id: 5, timestamp: new Date(now - 3 * hour).toISOString(), + id: 'audit-5', timestamp: new Date(now - 3 * hour).toISOString(), username: 'hendrik', category: 'CONFIG', action: 'UPDATE_THRESHOLD', target: 'thresholds/pool-connections', result: 'SUCCESS', detail: { field: 'maxConnections', oldValue: 50, newValue: 100 }, ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125', }, { - id: 6, timestamp: new Date(now - 4 * hour).toISOString(), + id: 'audit-6', timestamp: new Date(now - 4 * hour).toISOString(), username: 'hendrik', category: 'USER_MGMT', action: 'ASSIGN_ROLE', target: 'users/bob', result: 'SUCCESS', detail: { role: 'EDITOR', method: 'direct' }, ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125', }, { - id: 7, timestamp: new Date(now - 5 * hour).toISOString(), + id: 'audit-7', timestamp: new Date(now - 5 * hour).toISOString(), username: 'system', category: 'INFRA', action: 'INDEX_REBUILD', target: 'opensearch/exchanges', result: 'SUCCESS', detail: { documents: 15420, duration: '12.3s' }, ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0', }, { - id: 8, timestamp: new Date(now - 6 * hour).toISOString(), + id: 'audit-8', timestamp: new Date(now - 6 * hour).toISOString(), username: 'bob', category: 'AUTH', action: 'LOGIN', target: 'sessions/def456', result: 'SUCCESS', detail: { method: 'local' }, ipAddress: '10.0.2.15', userAgent: 'Mozilla/5.0 Safari/17', }, { - id: 9, timestamp: new Date(now - 8 * hour).toISOString(), + id: 'audit-9', timestamp: new Date(now - 8 * hour).toISOString(), username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_GROUP', target: 'groups/developers', result: 'SUCCESS', detail: { parent: null }, ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125', }, { - id: 10, timestamp: new Date(now - 10 * hour).toISOString(), + id: 'audit-10', timestamp: new Date(now - 10 * hour).toISOString(), username: 'system', category: 'INFRA', action: 'BACKUP', target: 'db/primary', result: 'SUCCESS', detail: { sizeBytes: 524288000, duration: '45s' }, ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0', }, { - id: 11, timestamp: new Date(now - 12 * hour).toISOString(), + id: 'audit-11', timestamp: new Date(now - 12 * hour).toISOString(), username: 'hendrik', category: 'CONFIG', action: 'UPDATE_OIDC', target: 'config/oidc', result: 'SUCCESS', detail: { field: 'autoSignup', oldValue: false, newValue: true }, ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125', }, { - id: 12, timestamp: new Date(now - 1 * day).toISOString(), + id: 'audit-12', timestamp: new Date(now - 1 * day).toISOString(), username: 'alice', category: 'AUTH', action: 'LOGOUT', target: 'sessions/abc123', result: 'SUCCESS', detail: { reason: 'user_initiated' }, ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126', }, { - id: 13, timestamp: new Date(now - 1 * day - 2 * hour).toISOString(), + id: 'audit-13', timestamp: new Date(now - 1 * day - 2 * hour).toISOString(), username: 'hendrik', category: 'USER_MGMT', action: 'DELETE_USER', target: 'users/temp-user', result: 'SUCCESS', detail: { reason: 'cleanup' }, ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125', }, { - id: 14, timestamp: new Date(now - 1 * day - 4 * hour).toISOString(), + id: 'audit-14', timestamp: new Date(now - 1 * day - 4 * hour).toISOString(), username: 'system', category: 'INFRA', action: 'POOL_RESIZE', target: 'db/primary', result: 'FAILURE', detail: { oldSize: 20, newSize: 50, error: 'max_connections_exceeded' }, ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0', }, { - id: 15, timestamp: new Date(now - 1 * day - 6 * hour).toISOString(), + id: 'audit-15', timestamp: new Date(now - 1 * day - 6 * hour).toISOString(), username: 'hendrik', category: 'USER_MGMT', action: 'UPDATE_GROUP', target: 'groups/admins', result: 'SUCCESS', detail: { addedMembers: ['alice'], removedMembers: [] }, ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125', }, { - id: 16, timestamp: new Date(now - 2 * day).toISOString(), + id: 'audit-16', timestamp: new Date(now - 2 * day).toISOString(), username: 'bob', category: 'AUTH', action: 'PASSWORD_CHANGE', target: 'users/bob', result: 'SUCCESS', detail: { method: 'self_service' }, ipAddress: '10.0.2.15', userAgent: 'Mozilla/5.0 Safari/17', }, { - id: 17, timestamp: new Date(now - 2 * day - 3 * hour).toISOString(), + id: 'audit-17', timestamp: new Date(now - 2 * day - 3 * hour).toISOString(), username: 'system', category: 'INFRA', action: 'VACUUM', target: 'db/primary/exchanges', result: 'SUCCESS', detail: { reclaimedBytes: 1048576, duration: '3.2s' }, ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0', }, { - id: 18, timestamp: new Date(now - 2 * day - 5 * hour).toISOString(), + id: 'audit-18', timestamp: new Date(now - 2 * day - 5 * hour).toISOString(), username: 'hendrik', category: 'CONFIG', action: 'UPDATE_THRESHOLD', target: 'thresholds/latency-p99', result: 'SUCCESS', detail: { field: 'warningMs', oldValue: 500, newValue: 300 }, ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125', }, { - id: 19, timestamp: new Date(now - 3 * day).toISOString(), + id: 'audit-19', timestamp: new Date(now - 3 * day).toISOString(), username: 'attacker', category: 'AUTH', action: 'LOGIN', target: 'sessions', result: 'FAILURE', detail: { method: 'local', reason: 'account_locked', attempts: 5 }, ipAddress: '198.51.100.23', userAgent: 'python-requests/2.31', }, { - id: 20, timestamp: new Date(now - 3 * day - 2 * hour).toISOString(), + id: 'audit-20', timestamp: new Date(now - 3 * day - 2 * hour).toISOString(), username: 'hendrik', category: 'USER_MGMT', action: 'ASSIGN_ROLE', target: 'groups/developers', result: 'SUCCESS', detail: { role: 'EDITOR', method: 'group_assignment' }, ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125', }, { - id: 21, timestamp: new Date(now - 4 * day).toISOString(), + id: 'audit-21', timestamp: new Date(now - 4 * day).toISOString(), username: 'system', category: 'INFRA', action: 'BACKUP', target: 'db/primary', result: 'FAILURE', detail: { error: 'disk_full', sizeBytes: 0 }, ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0', }, { - id: 22, timestamp: new Date(now - 4 * day - 1 * hour).toISOString(), + id: 'audit-22', timestamp: new Date(now - 4 * day - 1 * hour).toISOString(), username: 'alice', category: 'CONFIG', action: 'VIEW_CONFIG', target: 'config/oidc', result: 'SUCCESS', detail: { section: 'provider_settings' }, ipAddress: '192.168.1.100', userAgent: 'Mozilla/5.0 Firefox/126', }, { - id: 23, timestamp: new Date(now - 5 * day).toISOString(), + id: 'audit-23', timestamp: new Date(now - 5 * day).toISOString(), username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_ROLE', target: 'roles/OPERATOR', result: 'SUCCESS', detail: { scope: 'custom', description: 'Pipeline operator' }, ipAddress: '10.0.1.42', userAgent: 'Mozilla/5.0 Chrome/125', }, { - id: 24, timestamp: new Date(now - 5 * day - 3 * hour).toISOString(), + id: 'audit-24', timestamp: new Date(now - 5 * day - 3 * hour).toISOString(), username: 'system', category: 'INFRA', action: 'INDEX_REBUILD', target: 'opensearch/agents', result: 'SUCCESS', detail: { documents: 230, duration: '1.1s' }, ipAddress: '10.0.0.1', userAgent: 'cameleer-scheduler/1.0', }, { - id: 25, timestamp: new Date(now - 6 * day).toISOString(), + id: 'audit-25', timestamp: new Date(now - 6 * day).toISOString(), username: 'hendrik', category: 'USER_MGMT', action: 'CREATE_USER', target: 'users/bob', result: 'SUCCESS', detail: { displayName: 'Bob Smith', roles: ['VIEWER'] }, diff --git a/src/pages/Admin/OidcConfig/OidcConfig.module.css b/src/pages/Admin/OidcConfig/OidcConfig.module.css index af3729b..fe6b86e 100644 --- a/src/pages/Admin/OidcConfig/OidcConfig.module.css +++ b/src/pages/Admin/OidcConfig/OidcConfig.module.css @@ -1,25 +1,13 @@ .page { max-width: 640px; + margin: 0 auto; } -.header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 20px; -} - -.title { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0; - font-family: var(--font-body); -} - -.headerActions { +.toolbar { display: flex; gap: 8px; + justify-content: flex-end; + margin-bottom: 20px; } .section { diff --git a/src/pages/Admin/OidcConfig/OidcConfig.tsx b/src/pages/Admin/OidcConfig/OidcConfig.tsx index be43ed3..3f5ef70 100644 --- a/src/pages/Admin/OidcConfig/OidcConfig.tsx +++ b/src/pages/Admin/OidcConfig/OidcConfig.tsx @@ -71,16 +71,13 @@ export function OidcConfig() { return (
-
-

OIDC Configuration

-
- - -
+
+ +
diff --git a/src/pages/Admin/UserManagement/GroupsTab.tsx b/src/pages/Admin/UserManagement/GroupsTab.tsx index 1143414..4714ea9 100644 --- a/src/pages/Admin/UserManagement/GroupsTab.tsx +++ b/src/pages/Admin/UserManagement/GroupsTab.tsx @@ -10,15 +10,19 @@ import { Tag } from '../../../design-system/primitives/Tag/Tag' import { InlineEdit } from '../../../design-system/primitives/InlineEdit/InlineEdit' import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect' import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog' +import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog' +import { useToast } from '../../../design-system/composites/Toast/Toast' import { MOCK_GROUPS, MOCK_USERS, MOCK_ROLES, getChildGroups, type MockGroup } from './rbacMocks' import styles from './UserManagement.module.css' export function GroupsTab() { + const { toast } = useToast() const [groups, setGroups] = useState(MOCK_GROUPS) const [search, setSearch] = useState('') const [selectedId, setSelectedId] = useState(null) const [creating, setCreating] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) + const [removeRoleTarget, setRemoveRoleTarget] = useState(null) const [newName, setNewName] = useState('') const [newParent, setNewParent] = useState('') @@ -45,6 +49,7 @@ export function GroupsTab() { setCreating(false) setNewName(''); setNewParent('') setSelectedId(newGroup.id) + toast({ title: 'Group created', description: newGroup.name, variant: 'success' }) } function handleDelete() { @@ -52,17 +57,24 @@ export function GroupsTab() { setGroups((prev) => prev.filter((g) => g.id !== deleteTarget.id)) if (selectedId === deleteTarget.id) setSelectedId(null) setDeleteTarget(null) + toast({ title: 'Group deleted', description: deleteTarget.name, variant: 'warning' }) } function updateGroup(id: string, patch: Partial) { setGroups((prev) => prev.map((g) => g.id === id ? { ...g, ...patch } : g)) } - const children = selected ? getChildGroups(selected.id) : [] + const duplicateGroupName = newName.trim() !== '' && groups.some((g) => g.name.toLowerCase() === newName.trim().toLowerCase()) + + const children = selected ? groups.filter((g) => g.parentId === selected.id) : [] const members = selected ? MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)) : [] const parent = selected?.parentId ? groups.find((g) => g.id === selected.parentId) : null const availableRoles = MOCK_ROLES.filter((r) => !selected?.directRoles.includes(r.name)) .map((r) => ({ value: r.name, label: r.name })) + const availableMembers = MOCK_USERS.filter((u) => !selected || !u.directGroups.includes(selected.id)) + .map((u) => ({ value: u.id, label: u.displayName })) + const availableChildGroups = groups.filter((g) => selected && g.id !== selected.id && g.parentId !== selected.id && !children.some((c) => c.id === g.id)) + .map((g) => ({ value: g.id, label: g.name })) const parentOptions = [ { value: '', label: 'Top-level' }, @@ -89,6 +101,7 @@ export function GroupsTab() { {creating && (
setNewName(e.target.value)} /> + {duplicateGroupName && Group name already exists} setNewName(e.target.value)} /> + {duplicateRoleName && Role name already exists} setNewDesc(e.target.value)} />
- +
)} -
+
{filtered.map((role) => (
setSelectedId(role.id)} + role="option" + tabIndex={0} + aria-selected={selectedId === role.id} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(role.id) } }} >
{role.name} - {role.system && 🔒} + {role.system && }
{role.description} · {getAssignmentCount(role)} assignments @@ -124,6 +135,9 @@ export function RolesTab() {
))} + {filtered.length === 0 && ( +
No roles match your search
+ )}
diff --git a/src/pages/Admin/UserManagement/UserManagement.module.css b/src/pages/Admin/UserManagement/UserManagement.module.css index ce8b2eb..699ac16 100644 --- a/src/pages/Admin/UserManagement/UserManagement.module.css +++ b/src/pages/Admin/UserManagement/UserManagement.module.css @@ -4,22 +4,23 @@ gap: 1px; background: var(--border-subtle); border: 1px solid var(--border-subtle); - border-radius: var(--radius-md); + border-radius: var(--radius-lg); min-height: 500px; + box-shadow: var(--shadow-card); } .listPane { - background: var(--bg-base); + background: var(--bg-surface); display: flex; flex-direction: column; - border-radius: var(--radius-md) 0 0 var(--radius-md); + border-radius: var(--radius-lg) 0 0 var(--radius-lg); } .detailPane { - background: var(--bg-base); + background: var(--bg-surface); overflow-y: auto; padding: 20px; - border-radius: 0 var(--radius-md) var(--radius-md) 0; + border-radius: 0 var(--radius-lg) var(--radius-lg) 0; } .listHeader { @@ -180,3 +181,49 @@ .providerBadge { margin-left: 6px; } + +.inherited { + opacity: 0.65; +} + +.tabContent { + margin-top: 16px; +} + +.emptySearch { + padding: 32px; + text-align: center; + color: var(--text-faint); + font-size: 12px; + font-family: var(--font-body); +} + +.securitySection { + 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; +} + +.resetInput { + width: 200px; +} diff --git a/src/pages/Admin/UserManagement/UserManagement.tsx b/src/pages/Admin/UserManagement/UserManagement.tsx index 6561b8c..2d1bf1b 100644 --- a/src/pages/Admin/UserManagement/UserManagement.tsx +++ b/src/pages/Admin/UserManagement/UserManagement.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import styles from './UserManagement.module.css' import { AdminLayout } from '../Admin' import { Tabs } from '../../../design-system/composites/Tabs/Tabs' import { UsersTab } from './UsersTab' @@ -17,7 +18,7 @@ export function UserManagement() { return ( -
+
{tab === 'users' && } {tab === 'groups' && } {tab === 'roles' && } diff --git a/src/pages/Admin/UserManagement/UsersTab.tsx b/src/pages/Admin/UserManagement/UsersTab.tsx index 9e54061..ba5e0fb 100644 --- a/src/pages/Admin/UserManagement/UsersTab.tsx +++ b/src/pages/Admin/UserManagement/UsersTab.tsx @@ -7,23 +7,32 @@ import { MonoText } from '../../../design-system/primitives/MonoText/MonoText' import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader' import { Tag } from '../../../design-system/primitives/Tag/Tag' import { InlineEdit } from '../../../design-system/primitives/InlineEdit/InlineEdit' +import { RadioGroup, RadioItem } from '../../../design-system/primitives/Radio/Radio' +import { InfoCallout } from '../../../design-system/primitives/InfoCallout/InfoCallout' import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect' import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog' +import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog' +import { useToast } from '../../../design-system/composites/Toast/Toast' import { MOCK_USERS, MOCK_GROUPS, MOCK_ROLES, getEffectiveRoles, type MockUser } from './rbacMocks' import styles from './UserManagement.module.css' export function UsersTab() { + const { toast } = useToast() const [users, setUsers] = useState(MOCK_USERS) const [search, setSearch] = useState('') const [selectedId, setSelectedId] = useState(null) const [creating, setCreating] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) + const [removeGroupTarget, setRemoveGroupTarget] = useState(null) // Create form state const [newUsername, setNewUsername] = useState('') const [newDisplay, setNewDisplay] = useState('') const [newEmail, setNewEmail] = useState('') const [newPassword, setNewPassword] = useState('') + const [newProvider, setNewProvider] = useState<'local' | 'oidc'>('local') + const [resettingPassword, setResettingPassword] = useState(false) + const [newPw, setNewPw] = useState('') const filtered = useMemo(() => { if (!search) return users @@ -39,20 +48,23 @@ export function UsersTab() { function handleCreate() { if (!newUsername.trim()) return + if (newProvider === 'local' && !newPassword.trim()) return const newUser: MockUser = { id: `usr-${Date.now()}`, username: newUsername.trim(), displayName: newDisplay.trim() || newUsername.trim(), email: newEmail.trim(), - provider: 'local', + provider: newProvider, createdAt: new Date().toISOString(), directRoles: [], directGroups: [], } setUsers((prev) => [...prev, newUser]) setCreating(false) - setNewUsername(''); setNewDisplay(''); setNewEmail(''); setNewPassword('') + setNewUsername(''); setNewDisplay(''); setNewEmail(''); setNewPassword(''); setNewProvider('local') setSelectedId(newUser.id) + setResettingPassword(false) + toast({ title: 'User created', description: newUser.displayName, variant: 'success' }) } function handleDelete() { @@ -60,12 +72,15 @@ export function UsersTab() { setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id)) if (selectedId === deleteTarget.id) setSelectedId(null) setDeleteTarget(null) + toast({ title: 'User deleted', description: deleteTarget.username, variant: 'warning' }) } function updateUser(id: string, patch: Partial) { setUsers((prev) => prev.map((u) => u.id === id ? { ...u, ...patch } : u)) } + const duplicateUsername = newUsername.trim() !== '' && users.some((u) => u.username.toLowerCase() === newUsername.trim().toLowerCase()) + const effectiveRoles = selected ? getEffectiveRoles(selected) : [] const availableGroups = MOCK_GROUPS.filter((g) => !selected?.directGroups.includes(g.id)) .map((g) => ({ value: g.id, label: g.name })) @@ -99,27 +114,48 @@ export function UsersTab() { {creating && (
+ setNewProvider(v as 'local' | 'oidc')} orientation="horizontal"> + + +
setNewUsername(e.target.value)} /> setNewDisplay(e.target.value)} />
-
- setNewEmail(e.target.value)} /> - setNewPassword(e.target.value)} /> -
+ {duplicateUsername && Username already exists} + setNewEmail(e.target.value)} /> + {newProvider === 'local' && ( + setNewPassword(e.target.value)} /> + )} + {newProvider === 'oidc' && ( + + OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login. + + )}
- +
)} -
+
{filtered.map((user) => (
setSelectedId(user.id)} + onClick={() => { setSelectedId(user.id); setResettingPassword(false) }} + role="option" + tabIndex={0} + aria-selected={selectedId === user.id} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(user.id); setResettingPassword(false) } }} >
@@ -142,6 +178,9 @@ export function UsersTab() {
))} + {filtered.length === 0 && ( +
No users match your search
+ )}
@@ -169,9 +208,12 @@ export function UsersTab() {
+ Status +
+ +
+
- Status - ID {selected.id} Created @@ -180,6 +222,53 @@ export function UsersTab() { {selected.provider}
+ Security +
+ {selected.provider === 'local' ? ( + <> +
+ Password + •••••••• + {!resettingPassword && ( + + )} +
+ {resettingPassword && ( +
+ setNewPw(e.target.value)} + className={styles.resetInput} + /> + + +
+ )} + + ) : ( + <> +
+ Authentication + OIDC ({selected.provider}) +
+ + Password managed by the identity provider. + + + )} +
+ Group membership (direct only)
{selected.directGroups.map((gId) => { @@ -189,58 +278,73 @@ export function UsersTab() { key={gId} label={g.name} color="success" - onRemove={() => updateUser(selected.id, { - directGroups: selected.directGroups.filter((id) => id !== gId), - })} + onRemove={() => { + const group = MOCK_GROUPS.find((gr) => gr.id === gId) + if (group && group.directRoles.length > 0) { + setRemoveGroupTarget(gId) + } else { + updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== gId) }) + toast({ title: 'Group removed', variant: 'success' }) + } + }} /> ) : null })} {selected.directGroups.length === 0 && ( (no groups) )} -
-
updateUser(selected.id, { - directGroups: [...selected.directGroups, ...ids], - })} - placeholder="Add groups..." + onChange={(ids) => { + updateUser(selected.id, { directGroups: [...selected.directGroups, ...ids] }) + toast({ title: `${ids.length} group(s) added`, variant: 'success' }) + }} + placeholder="+ Add" />
Effective roles (direct + inherited)
- {effectiveRoles.map(({ role, source }) => ( - updateUser(selected.id, { - directRoles: selected.directRoles.filter((r) => r !== role), - }) : undefined} - /> - ))} + {effectiveRoles.map(({ role, source }) => + source === 'direct' ? ( + { + updateUser(selected.id, { directRoles: selected.directRoles.filter((r) => r !== role) }) + toast({ title: 'Role removed', description: role, variant: 'success' }) + }} + /> + ) : ( + + ) + )} {effectiveRoles.length === 0 && ( (no roles) )} + { + updateUser(selected.id, { directRoles: [...selected.directRoles, ...roles] }) + toast({ title: `${roles.length} role(s) added`, variant: 'success' }) + }} + placeholder="+ Add" + />
{effectiveRoles.some((r) => r.source !== 'direct') && ( Roles with ↑ are inherited through group membership )} -
- updateUser(selected.id, { - directRoles: [...selected.directRoles, ...roles], - })} - placeholder="Add roles..." - /> -
) : (
Select a user to view details
@@ -255,6 +359,21 @@ export function UsersTab() { message={`Delete user "${deleteTarget?.username}"? This cannot be undone.`} confirmText={deleteTarget?.username ?? ''} /> + setRemoveGroupTarget(null)} + onConfirm={() => { + if (removeGroupTarget && selected) { + updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== removeGroupTarget) }) + toast({ title: 'Group removed', variant: 'success' }) + } + setRemoveGroupTarget(null) + }} + title="Remove group membership" + description={`Removing this group will also revoke inherited roles: ${MOCK_GROUPS.find((g) => g.id === removeGroupTarget)?.directRoles.join(', ') ?? ''}. Continue?`} + confirmLabel="Remove" + variant="warning" + /> ) }