|
@@ -0,0 +1,218 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+/** @var array{user_email:string,action:string,entity_type:string,entity_id:string,from_date:string,to_date:string} $filters */
|
|
|
|
|
+/** @var int $page */
|
|
|
|
|
+/** @var int $pages */
|
|
|
|
|
+/** @var int $pageSize */
|
|
|
|
|
+/** @var int $total */
|
|
|
|
|
+/** @var list<array<string,mixed>> $rows */
|
|
|
|
|
+/** @var list<string> $actions */
|
|
|
|
|
+/** @var list<string> $entityTypes */
|
|
|
|
|
+/** @var list<string> $users */
|
|
|
|
|
+use function App\Http\e;
|
|
|
|
|
+
|
|
|
|
|
+/** Pretty-print JSON for display; tolerate non-JSON values gracefully. */
|
|
|
|
|
+$prettyJson = static function (?string $raw): string {
|
|
|
|
|
+ if ($raw === null || $raw === '') { return ''; }
|
|
|
|
|
+ try {
|
|
|
|
|
+ $v = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
|
|
|
|
|
+ } catch (\JsonException) {
|
|
|
|
|
+ return $raw;
|
|
|
|
|
+ }
|
|
|
|
|
+ return json_encode($v, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $raw;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** Build the current query string minus one key (for pagination links). */
|
|
|
|
|
+$qsWithout = static function (array $filters, string $drop, array $extra = []): string {
|
|
|
|
|
+ $params = array_filter(array_merge($filters, $extra), fn($v) => $v !== '' && $v !== null);
|
|
|
|
|
+ unset($params[$drop]);
|
|
|
|
|
+ return $params === [] ? '' : '?' . http_build_query($params);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+$anyFilter = array_filter($filters, fn($v) => $v !== '' && $v !== null);
|
|
|
|
|
+?>
|
|
|
|
|
+<section class="space-y-5">
|
|
|
|
|
+ <header class="flex items-end justify-between gap-4">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h1 class="text-2xl font-semibold tracking-tight">Audit log</h1>
|
|
|
|
|
+ <p class="text-slate-600 text-sm mt-1">
|
|
|
|
|
+ <?= (int) $total ?> matching row<?= $total === 1 ? '' : 's' ?>
|
|
|
|
|
+ · page <?= (int) $page ?> / <?= (int) $pages ?>
|
|
|
|
|
+ · <?= (int) $pageSize ?> per page
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Filter form -->
|
|
|
|
|
+ <form method="get" action="/audit"
|
|
|
|
|
+ class="rounded-lg border bg-white p-4 grid grid-cols-1 md:grid-cols-6 gap-3">
|
|
|
|
|
+ <label class="block">
|
|
|
|
|
+ <span class="text-xs text-slate-600">User</span>
|
|
|
|
|
+ <select name="user_email"
|
|
|
|
|
+ class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
|
|
|
|
|
+ <option value="">Any</option>
|
|
|
|
|
+ <?php foreach ($users as $u): ?>
|
|
|
|
|
+ <option value="<?= e($u) ?>" <?= $filters['user_email'] === $u ? 'selected' : '' ?>><?= e($u) ?></option>
|
|
|
|
|
+ <?php endforeach; ?>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </label>
|
|
|
|
|
+
|
|
|
|
|
+ <label class="block">
|
|
|
|
|
+ <span class="text-xs text-slate-600">Action</span>
|
|
|
|
|
+ <select name="action"
|
|
|
|
|
+ class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
|
|
|
|
|
+ <option value="">Any</option>
|
|
|
|
|
+ <?php foreach ($actions as $a): ?>
|
|
|
|
|
+ <option value="<?= e($a) ?>" <?= $filters['action'] === $a ? 'selected' : '' ?>><?= e($a) ?></option>
|
|
|
|
|
+ <?php endforeach; ?>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </label>
|
|
|
|
|
+
|
|
|
|
|
+ <label class="block">
|
|
|
|
|
+ <span class="text-xs text-slate-600">Entity type</span>
|
|
|
|
|
+ <select name="entity_type"
|
|
|
|
|
+ class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
|
|
|
|
|
+ <option value="">Any</option>
|
|
|
|
|
+ <?php foreach ($entityTypes as $t): ?>
|
|
|
|
|
+ <option value="<?= e($t) ?>" <?= $filters['entity_type'] === $t ? 'selected' : '' ?>><?= e($t) ?></option>
|
|
|
|
|
+ <?php endforeach; ?>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </label>
|
|
|
|
|
+
|
|
|
|
|
+ <label class="block">
|
|
|
|
|
+ <span class="text-xs text-slate-600">Entity ID (contains)</span>
|
|
|
|
|
+ <input name="entity_id" type="text"
|
|
|
|
|
+ value="<?= e($filters['entity_id']) ?>"
|
|
|
|
|
+ class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">
|
|
|
|
|
+ </label>
|
|
|
|
|
+
|
|
|
|
|
+ <label class="block">
|
|
|
|
|
+ <span class="text-xs text-slate-600">From date</span>
|
|
|
|
|
+ <input name="from_date" type="date"
|
|
|
|
|
+ value="<?= e($filters['from_date']) ?>"
|
|
|
|
|
+ class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">
|
|
|
|
|
+ </label>
|
|
|
|
|
+
|
|
|
|
|
+ <label class="block">
|
|
|
|
|
+ <span class="text-xs text-slate-600">To date</span>
|
|
|
|
|
+ <input name="to_date" type="date"
|
|
|
|
|
+ value="<?= e($filters['to_date']) ?>"
|
|
|
|
|
+ class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">
|
|
|
|
|
+ </label>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="md:col-span-6 flex items-center justify-end gap-2">
|
|
|
|
|
+ <?php if ($anyFilter): ?>
|
|
|
|
|
+ <a href="/audit" class="text-sm text-slate-600 hover:underline">Clear</a>
|
|
|
|
|
+ <?php endif; ?>
|
|
|
|
|
+ <button type="submit"
|
|
|
|
|
+ class="rounded bg-slate-900 text-white px-4 py-1.5 text-sm font-medium hover:bg-slate-800">
|
|
|
|
|
+ Apply
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Rows -->
|
|
|
|
|
+ <div class="rounded-lg border bg-white overflow-hidden">
|
|
|
|
|
+ <?php if ($rows === []): ?>
|
|
|
|
|
+ <div class="p-8 text-center text-slate-500 text-sm">No audit rows match.</div>
|
|
|
|
|
+ <?php else: ?>
|
|
|
|
|
+ <table class="min-w-full text-sm">
|
|
|
|
|
+ <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider">
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <th class="text-left px-3 py-2 font-semibold">When (UTC)</th>
|
|
|
|
|
+ <th class="text-left px-3 py-2 font-semibold">User</th>
|
|
|
|
|
+ <th class="text-left px-3 py-2 font-semibold">Action</th>
|
|
|
|
|
+ <th class="text-left px-3 py-2 font-semibold">Entity</th>
|
|
|
|
|
+ <th class="text-left px-3 py-2 font-semibold">Diff</th>
|
|
|
|
|
+ <th class="text-left px-3 py-2 font-semibold">Origin</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody class="divide-y divide-slate-100 align-top">
|
|
|
|
|
+ <?php foreach ($rows as $r): ?>
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
|
|
|
|
|
+ <?= e((string) $r['occurred_at']) ?>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="px-3 py-2">
|
|
|
|
|
+ <?= $r['user_email'] !== null && $r['user_email'] !== ''
|
|
|
|
|
+ ? e((string) $r['user_email'])
|
|
|
|
|
+ : '<span class="text-slate-400">—</span>' ?>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="px-3 py-2">
|
|
|
|
|
+ <span class="inline-block px-1.5 py-0.5 rounded text-xs font-mono
|
|
|
|
|
+ <?php
|
|
|
|
|
+ $action = (string) $r['action'];
|
|
|
|
|
+ echo match ($action) {
|
|
|
|
|
+ 'CREATE' => 'bg-green-100 text-green-800',
|
|
|
|
|
+ 'UPDATE' => 'bg-blue-100 text-blue-800',
|
|
|
|
|
+ 'DELETE' => 'bg-red-100 text-red-800',
|
|
|
|
|
+ 'LOGIN' => 'bg-slate-100 text-slate-700',
|
|
|
|
|
+ 'LOGOUT' => 'bg-slate-100 text-slate-700',
|
|
|
|
|
+ 'LOGIN_FAILED' => 'bg-amber-100 text-amber-800',
|
|
|
|
|
+ 'BOOTSTRAP_ADMIN' => 'bg-purple-100 text-purple-800',
|
|
|
|
|
+ default => 'bg-slate-100 text-slate-700',
|
|
|
|
|
+ };
|
|
|
|
|
+ ?>">
|
|
|
|
|
+ <?= e($action) ?>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
|
|
|
|
|
+ <?= e((string) $r['entity_type']) ?>
|
|
|
|
|
+ <?php if ($r['entity_id'] !== null): ?>
|
|
|
|
|
+ <span class="text-slate-500">/</span>
|
|
|
|
|
+ <?= e((string) $r['entity_id']) ?>
|
|
|
|
|
+ <?php endif; ?>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="px-3 py-2">
|
|
|
|
|
+ <?php $b = $prettyJson($r['before_json'] ?? null); $a = $prettyJson($r['after_json'] ?? null); ?>
|
|
|
|
|
+ <?php if ($b === '' && $a === ''): ?>
|
|
|
|
|
+ <span class="text-slate-400 text-xs">—</span>
|
|
|
|
|
+ <?php else: ?>
|
|
|
|
|
+ <details class="text-xs">
|
|
|
|
|
+ <summary class="cursor-pointer text-slate-600 hover:text-slate-900">
|
|
|
|
|
+ <?= $b !== '' && $a !== '' ? 'before / after'
|
|
|
|
|
+ : ($b !== '' ? 'before only' : 'after only') ?>
|
|
|
|
|
+ </summary>
|
|
|
|
|
+ <?php if ($b !== ''): ?>
|
|
|
|
|
+ <div class="mt-1 text-[11px] text-slate-500">before</div>
|
|
|
|
|
+ <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto"><?= e($b) ?></pre>
|
|
|
|
|
+ <?php endif; ?>
|
|
|
|
|
+ <?php if ($a !== ''): ?>
|
|
|
|
|
+ <div class="mt-1 text-[11px] text-slate-500">after</div>
|
|
|
|
|
+ <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto"><?= e($a) ?></pre>
|
|
|
|
|
+ <?php endif; ?>
|
|
|
|
|
+ </details>
|
|
|
|
|
+ <?php endif; ?>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">
|
|
|
|
|
+ <?= e((string) ($r['ip_address'] ?? '')) ?>
|
|
|
|
|
+ <?php if (!empty($r['user_agent'])): ?>
|
|
|
|
|
+ <span class="text-slate-300"
|
|
|
|
|
+ title="<?= e((string) $r['user_agent']) ?>">(UA)</span>
|
|
|
|
|
+ <?php endif; ?>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ <?php endforeach; ?>
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ <?php endif; ?>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Pagination -->
|
|
|
|
|
+ <?php if ($pages > 1): ?>
|
|
|
|
|
+ <nav class="flex items-center justify-between text-sm">
|
|
|
|
|
+ <?php
|
|
|
|
|
+ $prevQs = $qsWithout($filters, 'page', ['page' => max(1, $page - 1)]);
|
|
|
|
|
+ $nextQs = $qsWithout($filters, 'page', ['page' => min($pages, $page + 1)]);
|
|
|
|
|
+ ?>
|
|
|
|
|
+ <a href="/audit<?= e($prevQs) ?>"
|
|
|
|
|
+ class="<?= $page <= 1 ? 'pointer-events-none text-slate-300' : 'text-blue-700 hover:underline' ?>">
|
|
|
|
|
+ ← Previous
|
|
|
|
|
+ </a>
|
|
|
|
|
+ <span class="text-slate-600">Page <?= (int) $page ?> of <?= (int) $pages ?></span>
|
|
|
|
|
+ <a href="/audit<?= e($nextQs) ?>"
|
|
|
|
|
+ class="<?= $page >= $pages ? 'pointer-events-none text-slate-300' : 'text-blue-700 hover:underline' ?>">
|
|
|
|
|
+ Next →
|
|
|
|
|
+ </a>
|
|
|
|
|
+ </nav>
|
|
|
|
|
+ <?php endif; ?>
|
|
|
|
|
+</section>
|