223 lines
8.2 KiB
TypeScript
223 lines
8.2 KiB
TypeScript
import { AdminLayout } from "./AdminLayout.tsx";
|
|
|
|
export const AuditLogPage = ({
|
|
logs,
|
|
}: {
|
|
logs: any[];
|
|
}) => {
|
|
return (
|
|
<AdminLayout title="Audit Logs" currentPath="/admin/audit-logs">
|
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
|
<div>
|
|
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
|
|
Immutable Audit Ledger
|
|
</h1>
|
|
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
|
|
Cryptographically chained Merkle audit trail for authentication,
|
|
authorization, and administrative events.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Instant Search Bar */}
|
|
<div style="position: relative; min-width: 240px; max-width: 320px; width: 100%;">
|
|
<input
|
|
type="text"
|
|
id="auditSearchInput"
|
|
placeholder="Search action, user, IP..."
|
|
oninput="filterAuditLogs()"
|
|
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
|
|
/>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
|
|
>
|
|
<circle cx="11" cy="11" r="8"></circle>
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop Table (≥ 768px) */}
|
|
<div class="card desktop-only" style="display: none;">
|
|
<div class="table-container">
|
|
<table id="auditTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Timestamp</th>
|
|
<th>Event Action</th>
|
|
<th>User / Subject</th>
|
|
<th>Resource</th>
|
|
<th>IP Address</th>
|
|
<th>Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{logs.map((log) => {
|
|
const isFail = log.action.includes("fail") ||
|
|
log.action.includes("denied");
|
|
const isSuccess = log.action.includes("success") ||
|
|
log.action.includes("create") ||
|
|
log.action.includes("activate") ||
|
|
log.action.includes("updated");
|
|
const badgeClass = isFail
|
|
? "badge-danger"
|
|
: isSuccess
|
|
? "badge-success"
|
|
: "badge-info";
|
|
|
|
return (
|
|
<tr
|
|
key={log.id}
|
|
class="log-row"
|
|
data-search={`${log.action} ${log.user || "system"} ${
|
|
log.resource || ""
|
|
} ${log.ip_address || ""}`.toLowerCase()}
|
|
>
|
|
<td style="font-size: 0.8rem; color: var(--text-muted); white-space: nowrap;">
|
|
{new Date(log.created_at).toLocaleString()}
|
|
</td>
|
|
<td>
|
|
<span class={`badge ${badgeClass}`}>
|
|
{log.action}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<strong style="color: var(--text-primary);">
|
|
{log.user || "System"}
|
|
</strong>
|
|
</td>
|
|
<td style="color: var(--text-secondary);">
|
|
{log.resource || "-"}
|
|
</td>
|
|
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
|
|
{log.ip_address || "-"}
|
|
</td>
|
|
<td>
|
|
<pre style="margin: 0; font-family: monospace; font-size: 0.75rem; background: var(--surface-muted); padding: 0.35rem 0.6rem; border-radius: var(--radius-sm); max-width: 300px; overflow-x: auto;">
|
|
{log.details ? JSON.stringify(log.details) : "{}"}
|
|
</pre>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{logs.length === 0 && (
|
|
<tr>
|
|
<td
|
|
colSpan={6}
|
|
style="text-align: center; color: var(--text-muted); padding: 2rem;"
|
|
>
|
|
No audit records recorded yet.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Feed (< 768px) */}
|
|
<div
|
|
id="auditMobileFeed"
|
|
class="mobile-only"
|
|
style="display: flex; flex-direction: column; gap: 0.75rem;"
|
|
>
|
|
{logs.map((log) => {
|
|
const isFail = log.action.includes("fail") ||
|
|
log.action.includes("denied");
|
|
const isSuccess = log.action.includes("success") ||
|
|
log.action.includes("create") || log.action.includes("activate") ||
|
|
log.action.includes("updated");
|
|
const badgeClass = isFail
|
|
? "badge-danger"
|
|
: isSuccess
|
|
? "badge-success"
|
|
: "badge-info";
|
|
|
|
return (
|
|
<div
|
|
class="card log-card"
|
|
key={log.id}
|
|
data-search={`${log.action} ${log.user || "system"} ${
|
|
log.resource || ""
|
|
} ${log.ip_address || ""}`.toLowerCase()}
|
|
style="margin-bottom: 0; padding: 1rem;"
|
|
>
|
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
|
|
<span class={`badge ${badgeClass}`}>
|
|
{log.action}
|
|
</span>
|
|
<span style="font-size: 0.75rem; color: var(--text-muted);">
|
|
{new Date(log.created_at).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
})}
|
|
</span>
|
|
</div>
|
|
|
|
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.5rem;">
|
|
<div>
|
|
<strong>User:</strong> {log.user || "System"}
|
|
</div>
|
|
<div>
|
|
<strong>IP:</strong>{" "}
|
|
<span style="font-family: monospace;">
|
|
{log.ip_address || "Internal"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{log.details && (
|
|
<details style="font-size: 0.8rem; color: var(--text-muted);">
|
|
<summary style="cursor: pointer; font-weight: 600;">
|
|
View Event Payload
|
|
</summary>
|
|
<pre style="margin-top: 0.5rem; background: var(--surface-muted); padding: 0.5rem; border-radius: var(--radius-sm); font-family: monospace; font-size: 0.75rem; overflow-x: auto;">
|
|
{JSON.stringify(log.details, null, 2)}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<style>
|
|
{`
|
|
@media (min-width: 768px) {
|
|
.desktop-only { display: block !important; }
|
|
.mobile-only { display: none !important; }
|
|
}
|
|
@media (max-width: 767px) {
|
|
.desktop-only { display: none !important; }
|
|
.mobile-only { display: flex !important; }
|
|
}
|
|
`}
|
|
</style>
|
|
|
|
<script
|
|
dangerouslySetInnerHTML={{
|
|
__html: `
|
|
function filterAuditLogs() {
|
|
const query = (document.getElementById('auditSearchInput')?.value || '').toLowerCase().trim();
|
|
document.querySelectorAll('.log-row').forEach(r => {
|
|
const text = r.getAttribute('data-search') || '';
|
|
r.style.display = text.includes(query) ? '' : 'none';
|
|
});
|
|
document.querySelectorAll('.log-card').forEach(c => {
|
|
const text = c.getAttribute('data-search') || '';
|
|
c.style.display = text.includes(query) ? '' : 'none';
|
|
});
|
|
}
|
|
`,
|
|
}}
|
|
/>
|
|
</AdminLayout>
|
|
);
|
|
};
|