towerops/lib/towerops_web/live/admin/audit_live/index.ex
Graham McIntire 5a7cdc7138 refactor: apply custom color palette across entire codebase
- Replace gray->cool-steel, blue/indigo->cerulean, red->sweet-salmon, yellow/amber->wheat
- Dark sidebar: sidebar/footer use cool-steel-800 bg, text lightened for contrast
- ~11,600 color replacements across ~99 files
- Update tests for new color class names
2026-06-23 10:58:48 -05:00

442 lines
17 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
on_mount {ToweropsWeb.UserAuth, :require_superuser}
@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-cool-steel-200 pb-5 dark:border-white/5">
<h1 class="text-3xl font-semibold tracking-tight text-cool-steel-900 dark:text-white">
Audit Logs
</h1>
<p class="mt-2 text-sm text-cool-steel-500 dark:text-cool-steel-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-cool-steel-900 shadow rounded-lg p-6">
<form id="audit-search-form" phx-change="search" 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-cool-steel-700 dark:text-cool-steel-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-cool-steel-300 shadow-sm focus:border-cerulean-500 focus:ring-cerulean-500 sm:text-sm dark:bg-cool-steel-800 dark:border-cool-steel-600 dark:text-white"
/>
</div>
<div>
<label
for="action"
class="block text-sm font-medium text-cool-steel-700 dark:text-cool-steel-300"
>
Action
</label>
<select
name="action"
id="action"
class="mt-1 block w-full rounded-md border-cool-steel-300 shadow-sm focus:border-cerulean-500 focus:ring-cerulean-500 sm:text-sm dark:bg-cool-steel-800 dark:border-cool-steel-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-cool-steel-700 dark:text-cool-steel-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-cool-steel-300 shadow-sm focus:border-cerulean-500 focus:ring-cerulean-500 sm:text-sm dark:bg-cool-steel-800 dark:border-cool-steel-600 dark:text-white"
/>
</div>
<div>
<label
for="date_to"
class="block text-sm font-medium text-cool-steel-700 dark:text-cool-steel-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-cool-steel-300 shadow-sm focus:border-cerulean-500 focus:ring-cerulean-500 sm:text-sm dark:bg-cool-steel-800 dark:border-cool-steel-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-cerulean-600 hover:bg-cerulean-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cerulean-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-cool-steel-300 dark:border-cool-steel-600 text-sm font-medium rounded-md text-cool-steel-700 dark:text-cool-steel-300 bg-white dark:bg-cool-steel-800 hover:bg-cool-steel-50 dark:hover:bg-cool-steel-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cerulean-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-cool-steel-300 dark:border-cool-steel-600 text-sm font-medium rounded-md text-cool-steel-700 dark:text-cool-steel-300 bg-white dark:bg-cool-steel-800 hover:bg-cool-steel-50 dark:hover:bg-cool-steel-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cerulean-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-cool-steel-900 rounded-lg shadow">
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-cool-steel-400" />
<h3 class="mt-2 text-sm font-medium text-cool-steel-900 dark:text-white">
No audit logs found
</h3>
<p class="mt-1 text-sm text-cool-steel-500 dark:text-cool-steel-400">
Try adjusting your search filters.
</p>
</div>
<% else %>
<div class="bg-white dark:bg-cool-steel-900 shadow rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-cool-steel-200 dark:divide-cool-steel-700">
<thead class="bg-cool-steel-50 dark:bg-cool-steel-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-cool-steel-500 dark:text-cool-steel-400 uppercase tracking-wider">
Timestamp
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-cool-steel-500 dark:text-cool-steel-400 uppercase tracking-wider">
Action
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-cool-steel-500 dark:text-cool-steel-400 uppercase tracking-wider">
Actor
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-cool-steel-500 dark:text-cool-steel-400 uppercase tracking-wider">
Target User
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-cool-steel-500 dark:text-cool-steel-400 uppercase tracking-wider">
IP Address
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-cool-steel-500 dark:text-cool-steel-400 uppercase tracking-wider">
Details
</th>
</tr>
</thead>
<tbody class="divide-y divide-cool-steel-200 dark:divide-cool-steel-700">
<%= for log <- @audit_logs do %>
<tr class="hover:bg-cool-steel-50 dark:hover:bg-cool-steel-800">
<td class="px-6 py-4 whitespace-nowrap text-sm text-cool-steel-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-cool-steel-500 dark:text-cool-steel-400">
<%= if log.superuser do %>
{log.superuser.email}
<% else %>
<span class="text-cool-steel-400">System</span>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-cool-steel-500 dark:text-cool-steel-400">
<%= if log.target_user do %>
{log.target_user.email}
<% else %>
<span class="text-cool-steel-400">-</span>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-cool-steel-500 dark:text-cool-steel-400">
{log.ip_address || "-"}
</td>
<td class="px-6 py-4 text-sm text-cool-steel-500 dark:text-cool-steel-400">
<%= if log.request_path do %>
<div class="text-xs">
<code class="bg-cool-steel-100 dark:bg-cool-steel-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-cerulean-600 dark:text-cerulean-400 hover:underline">
Metadata
</summary>
<pre class="mt-1 text-xs bg-cool-steel-100 dark:bg-cool-steel-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-cool-steel-50 dark:bg-cool-steel-800 px-6 py-4 flex items-center justify-between border-t border-cool-steel-200 dark:border-cool-steel-700">
<div class="text-sm text-cool-steel-700 dark:text-cool-steel-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-cool-steel-300 dark:border-cool-steel-600 text-sm font-medium rounded-md text-cool-steel-700 dark:text-cool-steel-300 bg-white dark:bg-cool-steel-800 hover:bg-cool-steel-50 dark:hover:bg-cool-steel-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-cool-steel-300 dark:border-cool-steel-600 text-sm font-medium rounded-md text-cool-steel-700 dark:text-cool-steel-300 bg-white dark:bg-cool-steel-800 hover:bg-cool-steel-50 dark:hover:bg-cool-steel-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-wheat-50 text-wheat-700 ring-wheat-600/20"
:danger ->
"bg-sweet-salmon-50 text-sweet-salmon-700 ring-sweet-salmon-600/20"
:impersonation ->
"bg-orange-50 text-orange-700 ring-orange-600/20"
:info ->
"bg-cerulean-50 text-cerulean-700 ring-cerulean-600/20"
:default ->
"bg-cool-steel-50 dark:bg-cool-steel-800 text-cool-steel-700 dark:text-cool-steel-300 ring-cool-steel-600/20"
end
end
defp format_action(action) do
action
|> String.replace("_", " ")
|> String.capitalize()
end
end