index.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. <?php
  2. /** @var array{user_email:string,action:string,entity_type:string,entity_id:string,from_date:string,to_date:string} $filters */
  3. /** @var int $page */
  4. /** @var int $pages */
  5. /** @var int $pageSize */
  6. /** @var int $total */
  7. /** @var list<array<string,mixed>> $rows */
  8. /** @var list<string> $actions */
  9. /** @var list<string> $entityTypes */
  10. /** @var list<string> $users */
  11. use function App\Http\e;
  12. /** Pretty-print JSON for display; tolerate non-JSON values gracefully. */
  13. $prettyJson = static function (?string $raw): string {
  14. if ($raw === null || $raw === '') { return ''; }
  15. try {
  16. $v = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
  17. } catch (\JsonException) {
  18. return $raw;
  19. }
  20. return json_encode($v, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $raw;
  21. };
  22. /** Build the current query string minus one key (for pagination links). */
  23. $qsWithout = static function (array $filters, string $drop, array $extra = []): string {
  24. $params = array_filter(array_merge($filters, $extra), fn($v) => $v !== '' && $v !== null);
  25. unset($params[$drop]);
  26. return $params === [] ? '' : '?' . http_build_query($params);
  27. };
  28. $anyFilter = array_filter($filters, fn($v) => $v !== '' && $v !== null);
  29. ?>
  30. <section class="space-y-5">
  31. <header class="flex items-end justify-between gap-4">
  32. <div>
  33. <h1 class="text-2xl font-semibold tracking-tight">Audit log</h1>
  34. <p class="text-slate-600 text-sm mt-1">
  35. <?= (int) $total ?> matching row<?= $total === 1 ? '' : 's' ?>
  36. · page <?= (int) $page ?> / <?= (int) $pages ?>
  37. · <?= (int) $pageSize ?> per page
  38. </p>
  39. </div>
  40. </header>
  41. <!-- Filter form -->
  42. <form method="get" action="/audit"
  43. class="rounded-lg border bg-white p-4 grid grid-cols-1 md:grid-cols-6 gap-3">
  44. <label class="block">
  45. <span class="text-xs text-slate-600">User</span>
  46. <select name="user_email"
  47. 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">
  48. <option value="">Any</option>
  49. <?php foreach ($users as $u): ?>
  50. <option value="<?= e($u) ?>" <?= $filters['user_email'] === $u ? 'selected' : '' ?>><?= e($u) ?></option>
  51. <?php endforeach; ?>
  52. </select>
  53. </label>
  54. <label class="block">
  55. <span class="text-xs text-slate-600">Action</span>
  56. <select name="action"
  57. 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">
  58. <option value="">Any</option>
  59. <?php foreach ($actions as $a): ?>
  60. <option value="<?= e($a) ?>" <?= $filters['action'] === $a ? 'selected' : '' ?>><?= e($a) ?></option>
  61. <?php endforeach; ?>
  62. </select>
  63. </label>
  64. <label class="block">
  65. <span class="text-xs text-slate-600">Entity type</span>
  66. <select name="entity_type"
  67. 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">
  68. <option value="">Any</option>
  69. <?php foreach ($entityTypes as $t): ?>
  70. <option value="<?= e($t) ?>" <?= $filters['entity_type'] === $t ? 'selected' : '' ?>><?= e($t) ?></option>
  71. <?php endforeach; ?>
  72. </select>
  73. </label>
  74. <label class="block">
  75. <span class="text-xs text-slate-600">Entity ID (contains)</span>
  76. <input name="entity_id" type="text"
  77. value="<?= e($filters['entity_id']) ?>"
  78. 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">
  79. </label>
  80. <label class="block">
  81. <span class="text-xs text-slate-600">From date</span>
  82. <input name="from_date" type="date"
  83. value="<?= e($filters['from_date']) ?>"
  84. 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">
  85. </label>
  86. <label class="block">
  87. <span class="text-xs text-slate-600">To date</span>
  88. <input name="to_date" type="date"
  89. value="<?= e($filters['to_date']) ?>"
  90. 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">
  91. </label>
  92. <div class="md:col-span-6 flex items-center justify-end gap-2">
  93. <?php if ($anyFilter): ?>
  94. <a href="/audit" class="text-sm text-slate-600 hover:underline">Clear</a>
  95. <?php endif; ?>
  96. <button type="submit"
  97. class="rounded bg-slate-900 text-white px-4 py-1.5 text-sm font-medium hover:bg-slate-800">
  98. Apply
  99. </button>
  100. </div>
  101. </form>
  102. <!-- Rows -->
  103. <div class="rounded-lg border bg-white overflow-hidden">
  104. <?php if ($rows === []): ?>
  105. <div class="p-8 text-center text-slate-500 text-sm">No audit rows match.</div>
  106. <?php else: ?>
  107. <table class="min-w-full text-sm">
  108. <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider">
  109. <tr>
  110. <th class="text-left px-3 py-2 font-semibold">When (UTC)</th>
  111. <th class="text-left px-3 py-2 font-semibold">User</th>
  112. <th class="text-left px-3 py-2 font-semibold">Action</th>
  113. <th class="text-left px-3 py-2 font-semibold">Entity</th>
  114. <th class="text-left px-3 py-2 font-semibold">Diff</th>
  115. <th class="text-left px-3 py-2 font-semibold">Origin</th>
  116. </tr>
  117. </thead>
  118. <tbody class="divide-y divide-slate-100 align-top">
  119. <?php foreach ($rows as $r): ?>
  120. <tr>
  121. <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
  122. <?= e((string) $r['occurred_at']) ?>
  123. </td>
  124. <td class="px-3 py-2">
  125. <?= $r['user_email'] !== null && $r['user_email'] !== ''
  126. ? e((string) $r['user_email'])
  127. : '<span class="text-slate-400">—</span>' ?>
  128. </td>
  129. <td class="px-3 py-2">
  130. <span class="inline-block px-1.5 py-0.5 rounded text-xs font-mono
  131. <?php
  132. $action = (string) $r['action'];
  133. echo match ($action) {
  134. 'CREATE' => 'bg-green-100 text-green-800',
  135. 'UPDATE' => 'bg-blue-100 text-blue-800',
  136. 'DELETE' => 'bg-red-100 text-red-800',
  137. 'LOGIN' => 'bg-slate-100 text-slate-700',
  138. 'LOGOUT' => 'bg-slate-100 text-slate-700',
  139. 'LOGIN_FAILED' => 'bg-amber-100 text-amber-800',
  140. 'BOOTSTRAP_ADMIN' => 'bg-purple-100 text-purple-800',
  141. default => 'bg-slate-100 text-slate-700',
  142. };
  143. ?>">
  144. <?= e($action) ?>
  145. </span>
  146. </td>
  147. <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
  148. <?= e((string) $r['entity_type']) ?>
  149. <?php if ($r['entity_id'] !== null): ?>
  150. <span class="text-slate-500">/</span>
  151. <?= e((string) $r['entity_id']) ?>
  152. <?php endif; ?>
  153. </td>
  154. <td class="px-3 py-2">
  155. <?php $b = $prettyJson($r['before_json'] ?? null); $a = $prettyJson($r['after_json'] ?? null); ?>
  156. <?php if ($b === '' && $a === ''): ?>
  157. <span class="text-slate-400 text-xs">—</span>
  158. <?php else: ?>
  159. <details class="text-xs">
  160. <summary class="cursor-pointer text-slate-600 hover:text-slate-900">
  161. <?= $b !== '' && $a !== '' ? 'before / after'
  162. : ($b !== '' ? 'before only' : 'after only') ?>
  163. </summary>
  164. <?php if ($b !== ''): ?>
  165. <div class="mt-1 text-[11px] text-slate-500">before</div>
  166. <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto"><?= e($b) ?></pre>
  167. <?php endif; ?>
  168. <?php if ($a !== ''): ?>
  169. <div class="mt-1 text-[11px] text-slate-500">after</div>
  170. <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto"><?= e($a) ?></pre>
  171. <?php endif; ?>
  172. </details>
  173. <?php endif; ?>
  174. </td>
  175. <td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">
  176. <?= e((string) ($r['ip_address'] ?? '')) ?>
  177. <?php if (!empty($r['user_agent'])): ?>
  178. <span class="text-slate-300"
  179. title="<?= e((string) $r['user_agent']) ?>">(UA)</span>
  180. <?php endif; ?>
  181. </td>
  182. </tr>
  183. <?php endforeach; ?>
  184. </tbody>
  185. </table>
  186. <?php endif; ?>
  187. </div>
  188. <!-- Pagination -->
  189. <?php if ($pages > 1): ?>
  190. <nav class="flex items-center justify-between text-sm">
  191. <?php
  192. $prevQs = $qsWithout($filters, 'page', ['page' => max(1, $page - 1)]);
  193. $nextQs = $qsWithout($filters, 'page', ['page' => min($pages, $page + 1)]);
  194. ?>
  195. <a href="/audit<?= e($prevQs) ?>"
  196. class="<?= $page <= 1 ? 'pointer-events-none text-slate-300' : 'text-blue-700 hover:underline' ?>">
  197. ← Previous
  198. </a>
  199. <span class="text-slate-600">Page <?= (int) $page ?> of <?= (int) $pages ?></span>
  200. <a href="/audit<?= e($nextQs) ?>"
  201. class="<?= $page >= $pages ? 'pointer-events-none text-slate-300' : 'text-blue-700 hover:underline' ?>">
  202. Next →
  203. </a>
  204. </nav>
  205. <?php endif; ?>
  206. </section>