bb35206fff
- Created find_older_than and delete_older_than database repository methods. - Built a /auth/audit/purge POST endpoint that validates retention days (min 1 day). - Serializes and streams matching logs as an attachment JSON download for archival before database deletion. - Added a 'Purge Logs' button and modal interface in the administrator dashboard, automatically reloading the view after download.
261 lines
18 KiB
HTML
261 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
{% import "components/macros.html" as ui %}
|
|
|
|
{% block title %}Audit Logs - Stick{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="grow py-12 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto w-full">
|
|
|
|
<!-- Header -->
|
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 pb-6 border-b border-slate-900 gap-4">
|
|
<div>
|
|
<h1 class="text-3xl font-extrabold text-slate-100 tracking-tight">Audit Logs</h1>
|
|
<p class="text-slate-400 text-sm mt-1">Review, search, and audit system activities and state changes</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<button data-modal-target="purge-modal" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all px-4 py-2.5 bg-rose-950/40 border border-rose-900/30 hover:bg-rose-950/60 text-rose-400 shadow-md shadow-rose-950/10">
|
|
Purge Logs
|
|
</button>
|
|
<a href="/auth/users" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all px-4 py-2.5 bg-slate-900 border border-slate-800 hover:bg-slate-800 text-slate-300">
|
|
Manage Users
|
|
</a>
|
|
<span class="text-xs font-semibold px-3 py-1.5 rounded-xl bg-slate-900 border border-slate-800 text-slate-300">
|
|
Matches Found: {{ logs.len() }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters Form -->
|
|
<div class="bg-[#1e293b]/40 backdrop-blur-xl border border-slate-900 rounded-3xl p-6 shadow-xl mb-8">
|
|
<h3 class="text-sm font-bold text-slate-200 mb-4">Filter Log Entries</h3>
|
|
|
|
<form method="get" action="/auth/audit" class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 items-end">
|
|
<!-- User Filter -->
|
|
<div>
|
|
<label for="username" class="block text-[11px] font-semibold text-slate-400 mb-1.5">User/Username</label>
|
|
<input type="text" id="username" name="username"
|
|
value="{% if let Some(u) = query.username %}{{ u }}{% endif %}"
|
|
placeholder="Filter by user"
|
|
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-white placeholder-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
|
|
</div>
|
|
|
|
<!-- Action Type Filter -->
|
|
<div>
|
|
<label for="action_type" class="block text-[11px] font-semibold text-slate-400 mb-1.5">Event Type</label>
|
|
<select id="action_type" name="action_type"
|
|
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
|
|
<option value="all" {% if let Some(a) = query.action_type %}{% if a == "all" %}selected{% endif %}{% endif %}>All Actions</option>
|
|
<option value="Login" {% if let Some(a) = query.action_type %}{% if a == "Login" %}selected{% endif %}{% endif %}>Login</option>
|
|
<option value="Logout" {% if let Some(a) = query.action_type %}{% if a == "Logout" %}selected{% endif %}{% endif %}>Logout</option>
|
|
<option value="Register" {% if let Some(a) = query.action_type %}{% if a == "Register" %}selected{% endif %}{% endif %}>Register</option>
|
|
<option value="Create" {% if let Some(a) = query.action_type %}{% if a == "Create" %}selected{% endif %}{% endif %}>Create</option>
|
|
<option value="Update" {% if let Some(a) = query.action_type %}{% if a == "Update" %}selected{% endif %}{% endif %}>Update</option>
|
|
<option value="Delete" {% if let Some(a) = query.action_type %}{% if a == "Delete" %}selected{% endif %}{% endif %}>Delete</option>
|
|
<option value="View" {% if let Some(a) = query.action_type %}{% if a == "View" %}selected{% endif %}{% endif %}>View</option>
|
|
<option value="Search" {% if let Some(a) = query.action_type %}{% if a == "Search" %}selected{% endif %}{% endif %}>Search</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Entity Type Filter -->
|
|
<div>
|
|
<label for="entity_type" class="block text-[11px] font-semibold text-slate-400 mb-1.5">Entity Type</label>
|
|
<select id="entity_type" name="entity_type"
|
|
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
|
|
<option value="all" {% if let Some(e) = query.entity_type %}{% if e == "all" %}selected{% endif %}{% endif %}>All Entities</option>
|
|
<option value="User" {% if let Some(e) = query.entity_type %}{% if e == "User" %}selected{% endif %}{% endif %}>User</option>
|
|
<option value="Task" {% if let Some(e) = query.entity_type %}{% if e == "Task" %}selected{% endif %}{% endif %}>Task</option>
|
|
<option value="Developer" {% if let Some(e) = query.entity_type %}{% if e == "Developer" %}selected{% endif %}{% endif %}>Developer</option>
|
|
<option value="System" {% if let Some(e) = query.entity_type %}{% if e == "System" %}selected{% endif %}{% endif %}>System</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Specific Object ID -->
|
|
<div>
|
|
<label for="entity_id" class="block text-[11px] font-semibold text-slate-400 mb-1.5">Target Entity ID</label>
|
|
<input type="text" id="entity_id" name="entity_id"
|
|
value="{% if let Some(e_id) = query.entity_id %}{{ e_id }}{% endif %}"
|
|
placeholder="e.g. 6a12ab..."
|
|
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-white placeholder-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
|
|
</div>
|
|
|
|
<!-- Start Date -->
|
|
<div>
|
|
<label for="start_date" class="block text-[11px] font-semibold text-slate-400 mb-1.5">Start Date</label>
|
|
<input type="date" id="start_date" name="start_date"
|
|
value="{% if let Some(s) = query.start_date %}{{ s }}{% endif %}"
|
|
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
|
|
</div>
|
|
|
|
<!-- End Date -->
|
|
<div>
|
|
<label for="end_date" class="block text-[11px] font-semibold text-slate-400 mb-1.5">End Date</label>
|
|
<input type="date" id="end_date" name="end_date"
|
|
value="{% if let Some(e) = query.end_date %}{{ e }}{% endif %}"
|
|
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
|
|
</div>
|
|
|
|
<!-- Submit & Reset Buttons -->
|
|
<div class="md:col-span-3 lg:col-span-6 flex justify-end gap-3 mt-2">
|
|
<a href="/auth/audit" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all border border-border bg-transparent hover:bg-secondary text-slate-200 px-5 py-2.5">
|
|
Clear Filters
|
|
</a>
|
|
<button type="submit" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all px-5 py-2.5 bg-indigo-650 hover:bg-indigo-600 text-white shadow-lg shadow-indigo-650/10">
|
|
Apply Filters
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Logs Table -->
|
|
<div class="bg-[#1e293b]/40 backdrop-blur-xl border border-slate-900 rounded-3xl shadow-2xl overflow-hidden mb-8">
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-slate-800 text-left text-sm text-slate-300">
|
|
<thead>
|
|
<tr class="text-xs font-bold text-slate-400 uppercase tracking-wider bg-slate-900/30">
|
|
<th class="px-6 py-4">Timestamp</th>
|
|
<th class="px-6 py-4">User</th>
|
|
<th class="px-6 py-4">Event</th>
|
|
<th class="px-6 py-4">Target Entity</th>
|
|
<th class="px-6 py-4">IP / Details</th>
|
|
<th class="px-6 py-4 text-right">Replay Payload</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-800">
|
|
{% if logs.is_empty() %}
|
|
<tr>
|
|
<td colspan="6" class="px-6 py-12 text-center text-slate-500 font-medium">
|
|
No audit log entries matched the filter criteria.
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
{% for log in logs %}
|
|
<tr class="hover:bg-[#1e293b]/10 transition duration-150 align-top">
|
|
<!-- Timestamp -->
|
|
<td class="px-6 py-4 whitespace-nowrap text-xs font-semibold text-slate-400">
|
|
{{ log.timestamp.format("%Y-%m-%d %H:%M:%S UTC") }}
|
|
</td>
|
|
|
|
<!-- User -->
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
{% if let Some(uname) = log.username %}
|
|
<span class="text-sm font-semibold text-slate-200">{{ uname }}</span>
|
|
{% if let Some(uid) = log.user_id %}
|
|
<div class="text-[9px] font-mono text-slate-500">{{ uid.to_hex() }}</div>
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="text-xs italic text-slate-500">Anonymous</span>
|
|
{% endif %}
|
|
</td>
|
|
|
|
<!-- Event / Action Type -->
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border
|
|
{% if log.action_type == "Delete" %}
|
|
bg-rose-500/10 text-rose-400 border-rose-500/20
|
|
{% elif log.action_type == "Create" %}
|
|
bg-emerald-500/10 text-emerald-400 border-emerald-500/20
|
|
{% elif log.action_type == "Update" %}
|
|
bg-sky-500/10 text-sky-400 border-sky-500/20
|
|
{% elif log.action_type == "Login" %}
|
|
bg-indigo-500/10 text-indigo-400 border-indigo-500/20
|
|
{% elif log.action_type == "Logout" %}
|
|
bg-amber-500/10 text-amber-400 border-amber-500/20
|
|
{% else %}
|
|
bg-slate-900 text-slate-400 border-slate-800
|
|
{% endif %}">
|
|
{{ log.action_type }}
|
|
</span>
|
|
</td>
|
|
|
|
<!-- Target Entity -->
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<span class="text-sm font-semibold text-slate-300">{{ log.entity_type }}</span>
|
|
{% if let Some(e_id) = log.entity_id %}
|
|
<div class="text-[9px] font-mono text-sky-400 hover:underline cursor-pointer" onclick="document.getElementById('entity_id').value='{{ e_id.to_hex() }}';">
|
|
{{ e_id.to_hex() }}
|
|
</div>
|
|
{% endif %}
|
|
</td>
|
|
|
|
<!-- IP / Details -->
|
|
<td class="px-6 py-4">
|
|
<div class="text-xs text-slate-200">
|
|
{% if let Some(det) = log.details %}
|
|
{{ det }}
|
|
{% endif %}
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-1 text-[9px] text-slate-500 font-medium">
|
|
{% if let Some(ip) = log.ip_address %}
|
|
<span class="bg-slate-900 border border-slate-800 px-1.5 py-0.5 rounded">IP: {{ ip }}</span>
|
|
{% endif %}
|
|
{% if let Some(ua) = log.user_agent %}
|
|
<span class="truncate max-w-xs bg-slate-900 border border-slate-800 px-1.5 py-0.5 rounded" title="{{ ua }}">{{ ua }}</span>
|
|
{% endif %}
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Replay Payload -->
|
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
|
{% if !log.formatted_payload().is_empty() %}
|
|
<details class="group text-left">
|
|
<summary class="inline-flex items-center gap-1 text-[11px] font-bold text-sky-400 hover:text-sky-300 cursor-pointer list-none select-none">
|
|
<span>View Payload</span>
|
|
<svg class="w-3.5 h-3.5 transition-transform duration-200 group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
|
<polyline points="6 9 12 15 18 9"/>
|
|
</svg>
|
|
</summary>
|
|
<div class="mt-2 p-3 bg-slate-950 border border-slate-900 rounded-xl max-w-md overflow-x-auto text-[10px] font-mono text-emerald-400/90 whitespace-pre scrollbar-thin">{{ log.formatted_payload() }}</div>
|
|
</details>
|
|
{% else %}
|
|
<span class="text-xs italic text-slate-650">No payload</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
{% endif %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer Navigation -->
|
|
<div class="mt-6 text-center text-sm text-slate-400">
|
|
<a href="/auth/password" class="font-medium text-sky-400 hover:underline">← Back to Account Settings</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Purge Modal Dialog -->
|
|
<div id="purge-modal" class="modal-dialog fixed inset-0 z-50 flex items-center justify-center hidden" role="dialog" aria-modal="true">
|
|
<div class="modal-backdrop fixed inset-0 bg-[#07090e]/80 backdrop-blur-sm transition-opacity duration-300"></div>
|
|
<div class="modal-content relative z-10 w-full max-w-md scale-95 opacity-0 transition-all duration-300 border border-border bg-popover/95 backdrop-blur-xl p-6 shadow-2xl rounded-3xl">
|
|
<div class="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-rose-500/10 border border-rose-500/20 text-rose-400 mb-3">
|
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
</svg>
|
|
</div>
|
|
<h3 class="text-sm font-bold text-slate-100 text-center">Purge Audit Logs</h3>
|
|
<p class="text-xs text-slate-400 mt-2 text-center leading-relaxed">
|
|
Specify the number of days of logs to retain. This operation will permanently delete all logs older than this window. You will be forced to download a JSON archival dump of the purged logs before deletion.
|
|
</p>
|
|
|
|
<form method="POST" action="/auth/audit/purge" class="mt-4 space-y-4">
|
|
<div>
|
|
<label for="retention_days" class="block text-[11px] font-semibold text-slate-400 mb-1.5 text-left">Retention Period (Days)</label>
|
|
<input type="number" id="retention_days" name="retention_days" min="1" value="30" required
|
|
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-white placeholder-slate-650 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
|
|
<span class="text-[10px] text-slate-500 mt-1 block text-left">Minimum 1 day of logs must be retained.</span>
|
|
</div>
|
|
|
|
<div class="flex gap-3 pt-2">
|
|
<button type="button" class="modal-close flex-1 py-2.5 rounded-xl bg-secondary border border-border hover:bg-slate-800 transition text-xs font-semibold text-slate-200">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" class="flex-1 py-2.5 rounded-xl bg-rose-650 hover:bg-rose-600 text-white shadow-lg shadow-rose-600/10 transition text-xs font-bold" onclick="setTimeout(() => { window.closeModal(document.getElementById('purge-modal')); window.location.reload(); }, 1500)">
|
|
Archived Purge
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|