Apply patterns from testingliveview.com — replace render_click/submit/change event-name calls with element/form-targeted equivalents that validate the actual DOM wiring, not just handler logic. Adds stable test selectors (id="...") to interactive elements across 10 LiveView templates. Surfaces (and fixes) several real issues that the bypass-style tests masked: - Removes dead regenerate_token handler in agent_live/index.ex (no UI ever triggered it) and its corresponding test. - Removes dead refresh_topology test in network_map_live_test (event was referenced only in the test — no handler, no button, no JS anywhere). - Corrects "shows notification tab with add device modal" — modal lives on the security tab; original test mounted ?tab=notifications and only worked because direct event calls bypassed tab gating. - Corrects form input shape mismatches in agent_live/index_test that the handler's defensive case clauses had been masking. - Expands setup data for org/settings_live_test (snmp_community, default_agent_token_id, multi-org membership) so the gated buttons actually render. 11 direct render_click/submit/change calls remain — all intentional, documented server-side guard tests (tampered POSTs, race conditions, defensive idempotent handlers). Full LiveView test suite: 1300 tests, 0 failures.
420 lines
16 KiB
Elixir
420 lines
16 KiB
Elixir
defmodule ToweropsWeb.Admin.AuditLive.Index do
|
|
@moduledoc """
|
|
Admin interface for viewing and searching audit logs.
|
|
Provides comprehensive search, filtering, and export capabilities.
|
|
"""
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Admin
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, t("Audit Logs"))
|
|
|> assign(:search_email, "")
|
|
|> assign(:search_action, "")
|
|
|> assign(:date_from, "")
|
|
|> assign(:date_to, "")
|
|
|> assign(:page, 1)
|
|
|> assign(:per_page, 50)
|
|
|> load_audit_logs()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("search", params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:search_email, params["email"] || "")
|
|
|> assign(:search_action, params["action"] || "")
|
|
|> assign(:date_from, params["date_from"] || "")
|
|
|> assign(:date_to, params["date_to"] || "")
|
|
|> assign(:page, 1)
|
|
|> load_audit_logs()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_filters", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:search_email, "")
|
|
|> assign(:search_action, "")
|
|
|> assign(:date_from, "")
|
|
|> assign(:date_to, "")
|
|
|> assign(:page, 1)
|
|
|> load_audit_logs()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("export", _params, socket) do
|
|
# Generate CSV export
|
|
logs = fetch_audit_logs(socket, limit: :all)
|
|
csv_content = generate_csv(logs)
|
|
|
|
{:noreply,
|
|
push_event(socket, "download", %{
|
|
filename: "audit-logs-#{Date.utc_today()}.csv",
|
|
content: csv_content,
|
|
mime_type: "text/csv"
|
|
})}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("next_page", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:page, socket.assigns.page + 1)
|
|
|> load_audit_logs()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("prev_page", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:page, max(1, socket.assigns.page - 1))
|
|
|> load_audit_logs()}
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.admin flash={@flash} timezone={@current_scope.user.timezone}>
|
|
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
|
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
|
Audit Logs
|
|
</h1>
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
View and search all system audit logs. Records are retained for 3 years.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Search Filters -->
|
|
<div class="mt-8 bg-white dark:bg-gray-900 shadow rounded-lg p-6">
|
|
<form phx-submit="search" class="space-y-4">
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<div>
|
|
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
User Email
|
|
</label>
|
|
<input
|
|
type="text"
|
|
name="email"
|
|
id="email"
|
|
value={@search_email}
|
|
placeholder="user@example.com"
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="action" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Action
|
|
</label>
|
|
<select
|
|
name="action"
|
|
id="action"
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600 dark:text-white"
|
|
>
|
|
<option value="">All Actions</option>
|
|
<%= for action <- available_actions() do %>
|
|
<option value={action} selected={@search_action == action}>
|
|
{format_action(action)}
|
|
</option>
|
|
<% end %>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="date_from"
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
>
|
|
From Date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
name="date_from"
|
|
id="date_from"
|
|
value={@date_from}
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="date_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
To Date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
name="date_to"
|
|
id="date_to"
|
|
value={@date_to}
|
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3">
|
|
<button
|
|
type="submit"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
>
|
|
<.icon name="hero-magnifying-glass" class="h-4 w-4 mr-2" /> Search
|
|
</button>
|
|
<button
|
|
id="audit-clear-filters"
|
|
type="button"
|
|
phx-click="clear_filters"
|
|
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
>
|
|
<.icon name="hero-x-mark" class="h-4 w-4 mr-2" /> Clear
|
|
</button>
|
|
<button
|
|
type="button"
|
|
phx-click="export"
|
|
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
>
|
|
<.icon name="hero-arrow-down-tray" class="h-4 w-4 mr-2" /> Export CSV
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Results -->
|
|
<div class="mt-8">
|
|
<%= if Enum.empty?(@audit_logs) do %>
|
|
<div class="text-center py-12 bg-white dark:bg-gray-900 rounded-lg shadow">
|
|
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
No audit logs found
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Try adjusting your search filters.
|
|
</p>
|
|
</div>
|
|
<% else %>
|
|
<div class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead class="bg-gray-50 dark:bg-gray-800">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Timestamp
|
|
</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Action
|
|
</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Actor
|
|
</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Target User
|
|
</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
IP Address
|
|
</th>
|
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Details
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
<%= for log <- @audit_logs do %>
|
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
|
{ToweropsWeb.TimeHelpers.format_datetime(
|
|
log.inserted_at,
|
|
@current_scope.timezone,
|
|
@current_scope.time_format
|
|
)}
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<span class={[
|
|
"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset",
|
|
action_badge_class(log.action)
|
|
]}>
|
|
{format_action(log.action)}
|
|
</span>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
|
<%= if log.superuser do %>
|
|
{log.superuser.email}
|
|
<% else %>
|
|
<span class="text-gray-400">System</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
|
<%= if log.target_user do %>
|
|
{log.target_user.email}
|
|
<% else %>
|
|
<span class="text-gray-400">-</span>
|
|
<% end %>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
|
{log.ip_address || "-"}
|
|
</td>
|
|
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
|
<%= if log.request_path do %>
|
|
<div class="text-xs">
|
|
<code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">
|
|
{log.request_path}
|
|
</code>
|
|
</div>
|
|
<% end %>
|
|
<%= if log.metadata && map_size(log.metadata) > 0 do %>
|
|
<details class="mt-1">
|
|
<summary class="cursor-pointer text-xs text-blue-600 dark:text-blue-400 hover:underline">
|
|
Metadata
|
|
</summary>
|
|
<pre class="mt-1 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto max-w-md">
|
|
{Jason.encode!(log.metadata, pretty: true)}
|
|
</pre>
|
|
</details>
|
|
<% end %>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div class="bg-gray-50 dark:bg-gray-800 px-6 py-4 flex items-center justify-between border-t border-gray-200 dark:border-gray-700">
|
|
<div class="text-sm text-gray-700 dark:text-gray-300">
|
|
Page {@page} • Showing {length(@audit_logs)} records
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button
|
|
id="audit-prev-page"
|
|
type="button"
|
|
phx-click="prev_page"
|
|
disabled={@page == 1}
|
|
class="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
id="audit-next-page"
|
|
type="button"
|
|
phx-click="next_page"
|
|
disabled={length(@audit_logs) < @per_page}
|
|
class="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</Layouts.admin>
|
|
"""
|
|
end
|
|
|
|
# Private functions
|
|
|
|
defp load_audit_logs(socket) do
|
|
logs = fetch_audit_logs(socket, limit: socket.assigns.per_page)
|
|
assign(socket, :audit_logs, logs)
|
|
end
|
|
|
|
defp fetch_audit_logs(socket, opts) do
|
|
limit = Keyword.get(opts, :limit, socket.assigns.per_page)
|
|
offset = (socket.assigns.page - 1) * socket.assigns.per_page
|
|
|
|
Admin.list_audit_logs_with_filters(
|
|
limit: limit,
|
|
offset: offset,
|
|
email: socket.assigns.search_email,
|
|
action: socket.assigns.search_action,
|
|
date_from: socket.assigns.date_from,
|
|
date_to: socket.assigns.date_to
|
|
)
|
|
end
|
|
|
|
defp generate_csv(logs) do
|
|
headers = "Timestamp,Action,Actor Email,Target User Email,IP Address,Request Path,Metadata\n"
|
|
|
|
rows =
|
|
Enum.map_join(logs, "\n", fn log ->
|
|
Enum.map_join(
|
|
[
|
|
Calendar.strftime(log.inserted_at, "%Y-%m-%d %H:%M:%S"),
|
|
log.action,
|
|
if(log.superuser, do: log.superuser.email, else: "System"),
|
|
if(log.target_user, do: log.target_user.email, else: ""),
|
|
log.ip_address || "",
|
|
log.request_path || "",
|
|
Jason.encode!(log.metadata || %{})
|
|
],
|
|
",",
|
|
&escape_csv_field/1
|
|
)
|
|
end)
|
|
|
|
headers <> rows
|
|
end
|
|
|
|
defp escape_csv_field(field) when is_binary(field) do
|
|
if String.contains?(field, [",", "\"", "\n"]) do
|
|
"\"#{String.replace(field, "\"", "\"\"")}\""
|
|
else
|
|
field
|
|
end
|
|
end
|
|
|
|
defp escape_csv_field(_), do: ""
|
|
|
|
defp available_actions do
|
|
[
|
|
"impersonate_start",
|
|
"impersonate_end",
|
|
"user_delete",
|
|
"org_delete",
|
|
"user_data_viewed",
|
|
"user_data_exported",
|
|
"user_profile_updated",
|
|
"org_data_accessed",
|
|
"device_created",
|
|
"device_updated",
|
|
"device_deleted",
|
|
"monitoring_data_queried",
|
|
"failed_access_attempt",
|
|
"privilege_escalation"
|
|
]
|
|
end
|
|
|
|
defp action_badge_class(action) do
|
|
action
|
|
|> audit_action_category()
|
|
|> audit_badge_class_for_category()
|
|
end
|
|
|
|
defp audit_action_category(action) do
|
|
cond do
|
|
action in ["user_data_exported", "device_created"] -> :success
|
|
action == "device_updated" -> :warning
|
|
action in ["device_deleted", "failed_access_attempt"] -> :danger
|
|
action in ["impersonate_start", "impersonate_end"] -> :impersonation
|
|
action == "user_data_viewed" -> :info
|
|
true -> :default
|
|
end
|
|
end
|
|
|
|
defp audit_badge_class_for_category(category) do
|
|
case category do
|
|
:success -> "bg-green-50 text-green-700 ring-green-600/20"
|
|
:warning -> "bg-yellow-50 text-yellow-700 ring-yellow-600/20"
|
|
:danger -> "bg-red-50 text-red-700 ring-red-600/20"
|
|
:impersonation -> "bg-orange-50 text-orange-700 ring-orange-600/20"
|
|
:info -> "bg-blue-50 text-blue-700 ring-blue-600/20"
|
|
:default -> "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 ring-gray-600/20"
|
|
end
|
|
end
|
|
|
|
defp format_action(action) do
|
|
action
|
|
|> String.replace("_", " ")
|
|
|> String.capitalize()
|
|
end
|
|
end
|