2026-03-17 16:11:16 +01:00
|
|
|
import { useState } from 'react';
|
|
|
|
|
import { useAuthStore } from '../../auth/auth-store';
|
|
|
|
|
import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit';
|
2026-03-17 19:30:38 +01:00
|
|
|
import layout from '../../styles/AdminLayout.module.css';
|
2026-03-17 16:11:16 +01:00
|
|
|
import styles from './AuditLogPage.module.css';
|
|
|
|
|
|
|
|
|
|
function defaultFrom(): string {
|
|
|
|
|
const d = new Date();
|
|
|
|
|
d.setDate(d.getDate() - 7);
|
|
|
|
|
return d.toISOString().slice(0, 10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function defaultTo(): string {
|
|
|
|
|
return new Date().toISOString().slice(0, 10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function AuditLogPage() {
|
|
|
|
|
const roles = useAuthStore((s) => s.roles);
|
|
|
|
|
|
|
|
|
|
if (!roles.includes('ADMIN')) {
|
|
|
|
|
return (
|
2026-03-17 19:30:38 +01:00
|
|
|
<div className={layout.page}>
|
|
|
|
|
<div className={layout.accessDenied}>
|
|
|
|
|
Access Denied -- this page requires the ADMIN role.
|
2026-03-17 16:11:16 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <AuditLogContent />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function AuditLogContent() {
|
|
|
|
|
const [from, setFrom] = useState(defaultFrom);
|
|
|
|
|
const [to, setTo] = useState(defaultTo);
|
|
|
|
|
const [username, setUsername] = useState('');
|
|
|
|
|
const [category, setCategory] = useState('');
|
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
const [page, setPage] = useState(0);
|
2026-03-17 19:30:38 +01:00
|
|
|
const [selectedEventId, setSelectedEventId] = useState<number | null>(null);
|
|
|
|
|
const [filtersVisible, setFiltersVisible] = useState(true);
|
2026-03-17 16:11:16 +01:00
|
|
|
const pageSize = 25;
|
|
|
|
|
|
|
|
|
|
const params: AuditLogParams = {
|
|
|
|
|
from: from || undefined,
|
|
|
|
|
to: to || undefined,
|
|
|
|
|
username: username || undefined,
|
|
|
|
|
category: category || undefined,
|
|
|
|
|
search: search || undefined,
|
|
|
|
|
page,
|
|
|
|
|
size: pageSize,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const audit = useAuditLog(params);
|
|
|
|
|
const data = audit.data;
|
2026-03-17 16:36:11 +01:00
|
|
|
const totalPages = data?.totalPages ?? 0;
|
|
|
|
|
const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0;
|
|
|
|
|
const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
|
2026-03-17 16:11:16 +01:00
|
|
|
|
2026-03-17 19:30:38 +01:00
|
|
|
const selectedEvent = data?.items.find((e) => e.id === selectedEventId) ?? null;
|
2026-03-17 16:11:16 +01:00
|
|
|
|
2026-03-17 19:30:38 +01:00
|
|
|
return (
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
2026-03-17 16:11:16 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-17 19:30:38 +01:00
|
|
|
<div className={layout.split}>
|
|
|
|
|
{/* Left pane — event list */}
|
|
|
|
|
<div className={layout.listPane}>
|
|
|
|
|
{/* Collapsible filter bar */}
|
|
|
|
|
<div style={{ flexShrink: 0 }}>
|
2026-03-17 16:11:16 +01:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-03-17 19:30:38 +01:00
|
|
|
className={styles.filterToggle}
|
|
|
|
|
onClick={() => setFiltersVisible((v) => !v)}
|
2026-03-17 16:11:16 +01:00
|
|
|
>
|
2026-03-17 19:30:38 +01:00
|
|
|
{filtersVisible ? 'Hide Filters' : 'Show Filters'}
|
2026-03-17 16:11:16 +01:00
|
|
|
</button>
|
2026-03-17 19:30:38 +01:00
|
|
|
<div
|
|
|
|
|
className={`${styles.filters} ${!filtersVisible ? styles.filtersCollapsed : ''}`}
|
2026-03-17 16:11:16 +01:00
|
|
|
>
|
2026-03-17 19:30:38 +01:00
|
|
|
<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>
|
|
|
|
|
))
|
|
|
|
|
)}
|
2026-03-17 16:11:16 +01:00
|
|
|
</div>
|
2026-03-17 19:30:38 +01:00
|
|
|
|
|
|
|
|
{/* 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
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{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={layout.detailSection}>
|
|
|
|
|
<div className={layout.detailSectionTitle}>Detail Payload</div>
|
|
|
|
|
<pre className={styles.detailJson}>
|
|
|
|
|
{JSON.stringify(selectedEvent.detail, null, 2)}
|
|
|
|
|
</pre>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-17 16:11:16 +01:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatTimestamp(iso: string): string {
|
|
|
|
|
try {
|
|
|
|
|
const d = new Date(iso);
|
|
|
|
|
return d.toLocaleString(undefined, {
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
second: '2-digit',
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
return iso;
|
|
|
|
|
}
|
|
|
|
|
}
|