index.twig 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. {% extends "layout.twig" %}
  2. {% set actionClasses = {
  3. 'CREATE': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
  4. 'UPDATE': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
  5. 'DELETE': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
  6. 'LOGIN': 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
  7. 'LOGOUT': 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
  8. 'LOGIN_FAILED': 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
  9. 'BOOTSTRAP_ADMIN': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
  10. } %}
  11. {% set defaultActionClass = 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200' %}
  12. {% set anyFilter = false %}
  13. {% for v in filters %}{% if v != '' and v is not null %}{% set anyFilter = true %}{% endif %}{% endfor %}
  14. {% block content %}
  15. <section class="space-y-5">
  16. <header class="flex items-end justify-between gap-4">
  17. <div>
  18. <h1 class="text-2xl font-semibold tracking-tight">Audit log</h1>
  19. <p class="text-slate-600 text-sm mt-1 dark:text-slate-400">
  20. {{ total }} matching row{{ total == 1 ? '' : 's' }}
  21. · page {{ page }} / {{ pages }}
  22. · {{ pageSize }} per page
  23. </p>
  24. </div>
  25. </header>
  26. <form method="get" action="/audit"
  27. hx-boost="true" hx-target="body" hx-push-url="true"
  28. class="rounded-lg border bg-white p-4 grid grid-cols-1 md:grid-cols-6 gap-3 dark:bg-slate-800 dark:border-slate-700">
  29. <label class="block">
  30. <span class="text-xs text-slate-600 dark:text-slate-400">User</span>
  31. <select name="user_email"
  32. 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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  33. <option value="">Any</option>
  34. {% for u in users %}
  35. <option value="{{ u }}" {{ filters.user_email == u ? 'selected' : '' }}>{{ u }}</option>
  36. {% endfor %}
  37. </select>
  38. </label>
  39. <label class="block">
  40. <span class="text-xs text-slate-600 dark:text-slate-400">Action</span>
  41. <select name="action"
  42. 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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  43. <option value="">Any</option>
  44. {% for a in actions %}
  45. <option value="{{ a }}" {{ filters.action == a ? 'selected' : '' }}>{{ a }}</option>
  46. {% endfor %}
  47. </select>
  48. </label>
  49. <label class="block">
  50. <span class="text-xs text-slate-600 dark:text-slate-400">Entity type</span>
  51. <select name="entity_type"
  52. 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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  53. <option value="">Any</option>
  54. {% for t in entityTypes %}
  55. <option value="{{ t }}" {{ filters.entity_type == t ? 'selected' : '' }}>{{ t }}</option>
  56. {% endfor %}
  57. </select>
  58. </label>
  59. <label class="block">
  60. <span class="text-xs text-slate-600 dark:text-slate-400">Entity ID (contains)</span>
  61. <input name="entity_id" type="text"
  62. value="{{ filters.entity_id }}"
  63. 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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
  64. </label>
  65. <label class="block">
  66. <span class="text-xs text-slate-600 dark:text-slate-400">From date</span>
  67. <input name="from_date" type="date"
  68. value="{{ filters.from_date }}"
  69. class="mt-1 block w-full rounded border {{ dateErrors.from_date is defined ? 'border-amber-400 dark:border-amber-500' : 'border-slate-300 dark:border-slate-600' }} px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:text-slate-100 dark:focus:ring-slate-500">
  70. {% if dateErrors.from_date is defined %}
  71. <span class="block mt-1 text-xs text-amber-700 dark:text-amber-300">{{ dateErrors.from_date }}</span>
  72. {% endif %}
  73. </label>
  74. <label class="block">
  75. <span class="text-xs text-slate-600 dark:text-slate-400">To date</span>
  76. <input name="to_date" type="date"
  77. value="{{ filters.to_date }}"
  78. class="mt-1 block w-full rounded border {{ dateErrors.to_date is defined ? 'border-amber-400 dark:border-amber-500' : 'border-slate-300 dark:border-slate-600' }} px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:text-slate-100 dark:focus:ring-slate-500">
  79. {% if dateErrors.to_date is defined %}
  80. <span class="block mt-1 text-xs text-amber-700 dark:text-amber-300">{{ dateErrors.to_date }}</span>
  81. {% endif %}
  82. </label>
  83. {% if dateErrors is not empty %}
  84. <div class="md:col-span-6 -mt-1 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-600 dark:bg-amber-900/30 dark:text-amber-200">
  85. Invalid date filter ignored. Use the YYYY-MM-DD format (e.g. 2026-01-31).
  86. </div>
  87. {% endif %}
  88. <div class="md:col-span-6 flex items-center justify-end gap-2">
  89. {% if anyFilter %}
  90. <a href="/audit" class="text-sm text-slate-600 hover:underline dark:text-slate-400 dark:hover:text-slate-200">Clear</a>
  91. {% endif %}
  92. <button type="submit"
  93. class="rounded bg-slate-900 text-white px-4 py-1.5 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">
  94. Apply
  95. </button>
  96. </div>
  97. </form>
  98. <div class="rounded-lg border bg-white overflow-hidden dark:bg-slate-800 dark:border-slate-700">
  99. {% if rows is empty %}
  100. <div class="p-8 text-center text-slate-500 text-sm dark:text-slate-400">No audit rows match.</div>
  101. {% else %}
  102. <table class="min-w-full text-sm">
  103. <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider dark:bg-slate-700 dark:text-slate-300">
  104. <tr>
  105. <th class="text-left px-3 py-2 font-semibold">When (UTC)</th>
  106. <th class="text-left px-3 py-2 font-semibold">User</th>
  107. <th class="text-left px-3 py-2 font-semibold">Action</th>
  108. <th class="text-left px-3 py-2 font-semibold">Entity</th>
  109. <th class="text-left px-3 py-2 font-semibold">Diff</th>
  110. <th class="text-left px-3 py-2 font-semibold">Origin</th>
  111. </tr>
  112. </thead>
  113. <tbody class="divide-y divide-slate-100 align-top dark:divide-slate-700">
  114. {% for r in rows %}
  115. <tr>
  116. <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">{{ r.occurred_at }}</td>
  117. <td class="px-3 py-2">
  118. {% if r.user_email is not null and r.user_email != '' %}{{ r.user_email }}{% else %}<span class="text-slate-400 dark:text-slate-500">—</span>{% endif %}
  119. </td>
  120. <td class="px-3 py-2">
  121. <span class="inline-block px-1.5 py-0.5 rounded text-xs font-mono {{ actionClasses[r.action]|default(defaultActionClass) }}">
  122. {{ r.action }}
  123. </span>
  124. </td>
  125. <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
  126. {{ r.entity_type }}
  127. {% if r.entity_id is not null %}
  128. <span class="text-slate-500 dark:text-slate-400">/</span>
  129. {{ r.entity_id }}
  130. {% endif %}
  131. </td>
  132. <td class="px-3 py-2">
  133. {% set b = pretty_json(r.before_json|default(null)) %}
  134. {% set a = pretty_json(r.after_json|default(null)) %}
  135. {% if b == '' and a == '' %}
  136. <span class="text-slate-400 text-xs dark:text-slate-500">—</span>
  137. {% else %}
  138. <details class="text-xs">
  139. <summary class="cursor-pointer text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100">
  140. {% if b != '' and a != '' %}before / after{% elseif b != '' %}before only{% else %}after only{% endif %}
  141. </summary>
  142. {% if b != '' %}
  143. <div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">before</div>
  144. <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto dark:bg-slate-900 dark:text-slate-200">{{ b }}</pre>
  145. {% endif %}
  146. {% if a != '' %}
  147. <div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">after</div>
  148. <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto dark:bg-slate-900 dark:text-slate-200">{{ a }}</pre>
  149. {% endif %}
  150. </details>
  151. {% endif %}
  152. </td>
  153. <td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap dark:text-slate-400">
  154. {{ r.ip_address|default('') }}
  155. {% if r.user_agent is defined and r.user_agent %}
  156. <span class="text-slate-300 dark:text-slate-500" title="{{ r.user_agent }}">(UA)</span>
  157. {% endif %}
  158. </td>
  159. </tr>
  160. {% endfor %}
  161. </tbody>
  162. </table>
  163. {% endif %}
  164. </div>
  165. {% if pages > 1 %}
  166. <nav class="flex items-center justify-between text-sm">
  167. {% set prevQs = query_string(filters, 'page', { page: max(1, page - 1) }) %}
  168. {% set nextQs = query_string(filters, 'page', { page: min(pages, page + 1) }) %}
  169. <a href="/audit{{ prevQs }}"
  170. class="{{ page <= 1 ? 'pointer-events-none text-slate-300 dark:text-slate-600' : 'text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300' }}">
  171. ← Previous
  172. </a>
  173. <span class="text-slate-600 dark:text-slate-400">Page {{ page }} of {{ pages }}</span>
  174. <a href="/audit{{ nextQs }}"
  175. class="{{ page >= pages ? 'pointer-events-none text-slate-300 dark:text-slate-600' : 'text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300' }}">
  176. Next →
  177. </a>
  178. </nav>
  179. {% endif %}
  180. </section>
  181. {% endblock %}