feat: harmonize admin pages to split-pane layout with shared CSS
All checks were successful
CI / build (push) Successful in 1m12s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 52s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 35s

Extract shared admin layout styles into AdminLayout.module.css and
convert all admin pages to consistent patterns: Database/OpenSearch/
Audit Log use split-pane master/detail, OIDC uses full-width detail-only
with unified panelHeader treatment across all pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-17 19:30:38 +01:00
parent 6f5b5b8655
commit 6d650cdf34
9 changed files with 944 additions and 747 deletions

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useAuthStore } from '../../auth/auth-store';
import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit';
import layout from '../../styles/AdminLayout.module.css';
import styles from './AuditLogPage.module.css';
function defaultFrom(): string {
@@ -18,9 +19,9 @@ export function AuditLogPage() {
if (!roles.includes('ADMIN')) {
return (
<div className={styles.page}>
<div className={styles.accessDenied}>
Access Denied this page requires the ADMIN role.
<div className={layout.page}>
<div className={layout.accessDenied}>
Access Denied -- this page requires the ADMIN role.
</div>
</div>
);
@@ -36,7 +37,8 @@ function AuditLogContent() {
const [category, setCategory] = useState('');
const [search, setSearch] = useState('');
const [page, setPage] = useState(0);
const [expandedRow, setExpandedRow] = useState<number | null>(null);
const [selectedEventId, setSelectedEventId] = useState<number | null>(null);
const [filtersVisible, setFiltersVisible] = useState(true);
const pageSize = 25;
const params: AuditLogParams = {
@@ -55,155 +57,229 @@ function AuditLogContent() {
const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0;
const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
const selectedEvent = data?.items.find((e) => e.id === selectedEventId) ?? null;
return (
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.pageTitle}>Audit Log</h1>
{data && (
<span className={styles.totalCount}>{data.totalCount.toLocaleString()} events</span>
)}
</div>
<div className={styles.filters}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>From</label>
<input
type="date"
className={styles.filterInput}
value={from}
onChange={(e) => { setFrom(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>To</label>
<input
type="date"
className={styles.filterInput}
value={to}
onChange={(e) => { setTo(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>User</label>
<input
type="text"
className={styles.filterInput}
placeholder="Username..."
value={username}
onChange={(e) => { setUsername(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Category</label>
<select
className={styles.filterSelect}
value={category}
onChange={(e) => { setCategory(e.target.value); setPage(0); }}
>
<option value="">All</option>
<option value="INFRA">INFRA</option>
<option value="AUTH">AUTH</option>
<option value="USER_MGMT">USER_MGMT</option>
<option value="CONFIG">CONFIG</option>
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Search</label>
<input
type="text"
className={styles.filterInput}
placeholder="Search..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
<div className={layout.page}>
<div className={layout.panelHeader}>
<div>
<div className={layout.panelTitle}>Audit Log</div>
{data && (
<div className={layout.panelSubtitle}>
{data.totalCount.toLocaleString()} events
</div>
)}
</div>
</div>
{audit.isLoading ? (
<div className={styles.loading}>Loading...</div>
) : !data || data.items.length === 0 ? (
<div className={styles.emptyState}>No audit events found for the selected filters.</div>
) : (
<>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>Timestamp</th>
<th>User</th>
<th>Category</th>
<th>Action</th>
<th>Target</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{data.items.map((event) => (
<>
<tr
key={event.id}
className={`${styles.eventRow} ${expandedRow === event.id ? styles.eventRowExpanded : ''}`}
onClick={() =>
setExpandedRow((prev) => (prev === event.id ? null : event.id))
}
<div className={layout.split}>
{/* Left pane — event list */}
<div className={layout.listPane}>
{/* Collapsible filter bar */}
<div style={{ flexShrink: 0 }}>
<button
type="button"
className={styles.filterToggle}
onClick={() => setFiltersVisible((v) => !v)}
>
{filtersVisible ? 'Hide Filters' : 'Show Filters'}
</button>
<div
className={`${styles.filters} ${!filtersVisible ? styles.filtersCollapsed : ''}`}
>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>From</label>
<input
type="date"
className={styles.filterInput}
value={from}
onChange={(e) => { setFrom(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>To</label>
<input
type="date"
className={styles.filterInput}
value={to}
onChange={(e) => { setTo(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>User</label>
<input
type="text"
className={styles.filterInput}
placeholder="Username..."
value={username}
onChange={(e) => { setUsername(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Category</label>
<select
className={styles.filterSelect}
value={category}
onChange={(e) => { setCategory(e.target.value); setPage(0); }}
>
<option value="">All</option>
<option value="INFRA">INFRA</option>
<option value="AUTH">AUTH</option>
<option value="USER_MGMT">USER_MGMT</option>
<option value="CONFIG">CONFIG</option>
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Search</label>
<input
type="text"
className={styles.filterInput}
placeholder="Search..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
</div>
</div>
</div>
{/* Event list */}
<div className={layout.entityList}>
{audit.isLoading ? (
<div className={layout.loading}>Loading...</div>
) : !data || data.items.length === 0 ? (
<div className={layout.loading}>No events found.</div>
) : (
data.items.map((event) => (
<div
key={event.id}
className={`${layout.entityItem} ${
selectedEventId === event.id ? layout.entityItemSelected : ''
}`}
onClick={() => setSelectedEventId(event.id)}
>
<div className={layout.entityInfo}>
<div className={styles.eventTimestamp}>
{formatTimestamp(event.timestamp)}
</div>
<div className={styles.eventCompact}>
<span>{event.username}</span>
<span className={styles.eventAction}>{event.action}</span>
</div>
<div className={styles.eventCompact}>
<span
className={`${styles.resultBadge} ${
event.result === 'SUCCESS'
? styles.resultSuccess
: styles.resultFailure
}`}
>
{event.result}
</span>
</div>
</div>
</div>
))
)}
</div>
{/* Pagination */}
{data && data.totalCount > 0 && (
<div className={layout.pagination}>
<button
type="button"
className={layout.pageBtn}
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className={layout.pageInfo}>
{showingFrom}-{showingTo} of {data.totalCount.toLocaleString()}
</span>
<button
type="button"
className={layout.pageBtn}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</div>
{/* Right pane — detail view */}
<div className={layout.detailPane}>
{!selectedEvent ? (
<div className={layout.detailEmpty}>
Select an event to view details
</div>
) : (
<>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Event Info</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>Timestamp</span>
<span className={`${layout.fieldVal} ${styles.mono}`}>
{formatTimestamp(selectedEvent.timestamp)}
</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>User</span>
<span className={layout.fieldVal}>{selectedEvent.username}</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>Category</span>
<span className={layout.fieldVal}>
<span className={styles.categoryBadge}>{selectedEvent.category}</span>
</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>Action</span>
<span className={layout.fieldVal}>{selectedEvent.action}</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>Target</span>
<span className={`${layout.fieldVal} ${styles.mono}`}>
{selectedEvent.target}
</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>Result</span>
<span className={layout.fieldVal}>
<span
className={`${styles.resultBadge} ${
selectedEvent.result === 'SUCCESS'
? styles.resultSuccess
: styles.resultFailure
}`}
>
<td className={styles.mono}>
{formatTimestamp(event.timestamp)}
</td>
<td>{event.username}</td>
<td>
<span className={styles.categoryBadge}>{event.category}</span>
</td>
<td>{event.action}</td>
<td className={styles.mono}>{event.target}</td>
<td>
<span
className={`${styles.resultBadge} ${
event.result === 'SUCCESS' ? styles.resultSuccess : styles.resultFailure
}`}
>
{event.result}
</span>
</td>
</tr>
{expandedRow === event.id && (
<tr key={`${event.id}-detail`} className={styles.detailRow}>
<td colSpan={6}>
<pre className={styles.detailJson}>
{JSON.stringify(event.detail, null, 2)}
</pre>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
{selectedEvent.result}
</span>
</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>IP Address</span>
<span className={`${layout.fieldVal} ${styles.mono}`}>
{selectedEvent.ipAddress}
</span>
</div>
<div className={layout.fieldRow}>
<span className={layout.fieldLabel}>User Agent</span>
<span className={layout.fieldVal}>{selectedEvent.userAgent}</span>
</div>
</div>
<div className={styles.pagination}>
<button
type="button"
className={styles.pageBtn}
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className={styles.pageInfo}>
Showing {showingFrom}-{showingTo} of {data.totalCount.toLocaleString()}
</span>
<button
type="button"
className={styles.pageBtn}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</>
)}
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Detail Payload</div>
<pre className={styles.detailJson}>
{JSON.stringify(selectedEvent.detail, null, 2)}
</pre>
</div>
</>
)}
</div>
</div>
</div>
);
}