refactor: admin section UX/UI redesign
All checks were successful
Build & Publish / publish (push) Successful in 43s
All checks were successful
Build & Publish / publish (push) Successful in 43s
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<AuditEvent>[] = [
|
||||
{
|
||||
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'} />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
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<number | null>(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 (
|
||||
<AdminLayout title="Audit Log">
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>Audit Log</h2>
|
||||
<Badge label={`${filtered.length} events`} color="primary" />
|
||||
</div>
|
||||
|
||||
<div className={styles.filters}>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={(r) => { setDateRange(r); setPage(1) }}
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Filter by user..."
|
||||
value={userFilter}
|
||||
onChange={(e) => { setUserFilter(e.target.value); setPage(1) }}
|
||||
onClear={() => { setUserFilter(''); setPage(1) }}
|
||||
onChange={(e) => setUserFilter(e.target.value)}
|
||||
onClear={() => setUserFilter('')}
|
||||
className={styles.filterInput}
|
||||
/>
|
||||
<Select
|
||||
options={CATEGORIES}
|
||||
value={categoryFilter}
|
||||
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1) }}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className={styles.filterSelect}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search action or target..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => { setSearchFilter(e.target.value); setPage(1) }}
|
||||
onClear={() => { setSearchFilter(''); setPage(1) }}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
onClear={() => setSearchFilter('')}
|
||||
className={styles.filterInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.th} style={{ width: 170 }}>Timestamp</th>
|
||||
<th className={styles.th}>User</th>
|
||||
<th className={styles.th} style={{ width: 100 }}>Category</th>
|
||||
<th className={styles.th}>Action</th>
|
||||
<th className={styles.th}>Target</th>
|
||||
<th className={styles.th} style={{ width: 80 }}>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageEvents.map((event) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
expanded={expandedId === event.id}
|
||||
onToggle={() => setExpandedId(expandedId === event.id ? null : event.id)}
|
||||
/>
|
||||
))}
|
||||
{pageEvents.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className={styles.empty}>No events match the current filters.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className={styles.pagination}>
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Audit Log</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>
|
||||
{filtered.length} events
|
||||
</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
data={filtered}
|
||||
sortable
|
||||
flush
|
||||
pageSize={10}
|
||||
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>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ event, expanded, onToggle }: { event: AuditEvent; expanded: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<tr className={styles.row} onClick={onToggle}>
|
||||
<td className={styles.td}>
|
||||
<MonoText size="xs">{formatTimestamp(event.timestamp)}</MonoText>
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.userCell}`}>{event.username}</td>
|
||||
<td className={styles.td}>
|
||||
<Badge label={event.category} color="auto" />
|
||||
</td>
|
||||
<td className={styles.td}>{event.action}</td>
|
||||
<td className={styles.td}>
|
||||
<span className={styles.target}>{event.target}</span>
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<Badge
|
||||
label={event.result}
|
||||
color={event.result === 'SUCCESS' ? 'success' : 'error'}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className={styles.detailRow}>
|
||||
<td colSpan={6} className={styles.detailCell}>
|
||||
<div className={styles.detailGrid}>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>IP Address</span>
|
||||
<MonoText size="xs">{event.ipAddress}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>User Agent</span>
|
||||
<span className={styles.detailValue}>{event.userAgent}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailJson}>
|
||||
<span className={styles.detailLabel}>Detail</span>
|
||||
<CodeBlock content={JSON.stringify(event.detail, null, 2)} language="json" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user