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"""

Audit Logs

View and search all system audit logs. Records are retained for 3 years.

<%= if Enum.empty?(@audit_logs) do %>
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" />

No audit logs found

Try adjusting your search filters.

<% else %>
<%= for log <- @audit_logs do %> <% end %>
Timestamp Action Actor Target User IP Address Details
{ToweropsWeb.TimeHelpers.format_datetime( log.inserted_at, @current_scope.timezone, @current_scope.time_format )} {format_action(log.action)} <%= if log.superuser do %> {log.superuser.email} <% else %> System <% end %> <%= if log.target_user do %> {log.target_user.email} <% else %> - <% end %> {log.ip_address || "-"} <%= if log.request_path do %>
{log.request_path}
<% end %> <%= if log.metadata && map_size(log.metadata) > 0 do %>
Metadata
                              {Jason.encode!(log.metadata, pretty: true)}
                            
<% end %>
Page {@page} • Showing {length(@audit_logs)} records
<% end %>
""" 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