auth-yes/ui/components/AuditLogPage.tsx

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>
);
};