- 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
442 lines
17 KiB
Elixir
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
|