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 import Ecto.Query alias Towerops.Repo @impl true def mount(_params, _session, socket) do {:ok, socket |> assign(:page_title, "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
{Calendar.strftime(log.inserted_at, "%Y-%m-%d %H:%M:%S")} {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 query = from(a in Towerops.Admin.AuditLog, preload: [:superuser, :target_user], order_by: [desc: a.inserted_at] ) query = apply_filters(query, socket) if limit == :all do Repo.all(query) else query |> limit(^limit) |> offset(^offset) |> Repo.all() end end defp apply_filters(query, socket) do query |> filter_by_email(socket.assigns.search_email) |> filter_by_action(socket.assigns.search_action) |> filter_by_date_range(socket.assigns.date_from, socket.assigns.date_to) end defp filter_by_email(query, ""), do: query defp filter_by_email(query, email) do from(a in query, left_join: su in assoc(a, :superuser), left_join: tu in assoc(a, :target_user), where: ilike(su.email, ^"%#{email}%") or ilike(tu.email, ^"%#{email}%") ) end defp filter_by_action(query, ""), do: query defp filter_by_action(query, action), do: where(query, [a], a.action == ^action) defp filter_by_date_range(query, "", ""), do: query defp filter_by_date_range(query, from_date, to_date) do query = if from_date == "" do query else {:ok, from_datetime} = NaiveDateTime.new(Date.from_iso8601!(from_date), ~T[00:00:00]) where(query, [a], a.inserted_at >= ^from_datetime) end if to_date == "" do query else {:ok, to_datetime} = NaiveDateTime.new(Date.from_iso8601!(to_date), ~T[23:59:59]) where(query, [a], a.inserted_at <= ^to_datetime) end 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 text-gray-700 ring-gray-600/20" end end defp format_action(action) do action |> String.replace("_", " ") |> String.capitalize() end end