defmodule ToweropsWeb.AccountLive.Activity do @moduledoc """ LiveView for displaying user's audit log activity (GDPR transparency). Shows both actions performed by the user and actions performed on the user's data. """ use ToweropsWeb, :live_view alias Towerops.Admin @impl true def mount(_params, _session, socket) do user = socket.assigns.current_scope.user # Get audit logs for this user (both as actor and target) audit_logs = Admin.list_audit_logs_for_user(user.id, limit: 100) {:ok, socket |> assign(:page_title, "Activity Log") |> assign(:audit_logs, audit_logs)} end @impl true def render(assigns) do ~H"""

Activity Log

View all actions performed by you and on your account data for transparency and security.

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

No activity logs

Your activity will appear here as you use the platform.

<% else %>
    <%= for log <- @audit_logs do %>
  • {format_action(log.action)}

    {action_description(log)}

    <%= if log.superuser do %>
    <.icon name="hero-user" class="h-4 w-4" /> By: {log.superuser.email}
    <% end %> <%= if log.request_path do %>
    <.icon name="hero-link" class="h-4 w-4" /> {log.request_path}
    <% end %> <%= if log.ip_address do %>
    <.icon name="hero-globe-alt" class="h-4 w-4" /> IP: {log.ip_address}
    <% end %> <%= if log.data_accessed && map_size(log.data_accessed) > 0 do %>
    <.icon name="hero-eye" class="h-4 w-4 mt-0.5" />
    Data accessed: {inspect(log.data_accessed, pretty: true, limit: 3)}
    <% end %> <%= if log.metadata && map_size(log.metadata) > 0 do %>
    View metadata
                                  {Jason.encode!(log.metadata, pretty: true)}
                                
    <% end %>

    {format_timestamp(log.inserted_at)}

  • <% end %>
<.icon name="hero-information-circle" class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />

GDPR Transparency

This activity log shows actions performed by you and on your account data. Records are retained for 3 years for compliance and security purposes.

<% end %>
""" end # Helper functions defp action_badge_class(action) do action |> action_category() |> badge_class_for_category() end defp action_category(action) do cond do action in ["user_data_exported", "device_created"] -> :success action in ["user_profile_updated", "device_updated"] -> :warning action in ["device_deleted", "failed_access_attempt"] -> :danger action in ["impersonate_start", "impersonate_end"] -> :impersonation action == "privilege_escalation" -> :privilege action == "user_data_viewed" -> :info true -> :default end end defp badge_class_for_category(category) do case category do :success -> "bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20" :warning -> "bg-yellow-50 text-yellow-700 ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20" :danger -> "bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:ring-red-500/20" :impersonation -> "bg-orange-50 text-orange-700 ring-orange-600/20 dark:bg-orange-500/10 dark:text-orange-400 dark:ring-orange-500/20" :privilege -> "bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-500/10 dark:text-purple-400 dark:ring-purple-500/20" :info -> "bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-500/10 dark:text-blue-400 dark:ring-blue-500/20" :default -> "bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20" end end defp format_action(action) do action |> String.replace("_", " ") |> String.capitalize() end defp action_description(log) do description_handlers = %{ "user_data_viewed" => &describe_user_data_viewed/1, "user_data_exported" => fn _log -> "You exported your account data" end, "user_profile_updated" => fn _log -> "You updated your profile" end, "device_created" => &describe_device_created/1, "device_updated" => &describe_device_updated/1, "device_deleted" => &describe_device_deleted/1, "org_data_accessed" => &describe_org_data_accessed/1, "failed_access_attempt" => &describe_failed_access/1, "privilege_escalation" => &describe_privilege_escalation/1, "impersonate_start" => &describe_impersonate_start/1, "impersonate_end" => &describe_impersonate_end/1 } handler = Map.get(description_handlers, log.action) if handler do handler.(log) else format_action(log.action) end end defp describe_user_data_viewed(log) do if log.superuser do "Admin viewed your account data" else "You viewed your account data" end end defp describe_device_created(log) do device_name = get_in(log.metadata, ["device_name"]) || "device" "You created device: #{device_name}" end defp describe_device_updated(log) do device_id = get_in(log.metadata, ["device_id"]) "You updated device (ID: #{device_id})" end defp describe_device_deleted(log) do device_name = get_in(log.metadata, ["device_name"]) || "device" "You deleted device: #{device_name}" end defp describe_org_data_accessed(log) do org_id = get_in(log.metadata, ["organization_id"]) action_detail = get_in(log.metadata, ["action"]) || "accessed" "You #{action_detail} organization data (ID: #{org_id})" end defp describe_failed_access(log) do resource = get_in(log.metadata, ["resource"]) || "resource" "Failed access attempt to: #{resource}" end defp describe_privilege_escalation(log) do from_role = get_in(log.metadata, ["from_role"]) || "member" to_role = get_in(log.metadata, ["to_role"]) || "admin" "Privilege changed from #{from_role} to #{to_role}" end defp describe_impersonate_start(log) do if log.superuser do "Admin #{log.superuser.email} started impersonating you" else "Impersonation started" end end defp describe_impersonate_end(log) do if log.superuser do "Admin #{log.superuser.email} stopped impersonating you" else "Impersonation ended" end end defp format_timestamp(timestamp) do now = DateTime.utc_now() diff_seconds = DateTime.diff(now, timestamp, :second) cond do diff_seconds < 60 -> "Just now" diff_seconds < 3600 -> minutes = div(diff_seconds, 60) "#{minutes} #{if minutes == 1, do: "minute", else: "minutes"} ago" diff_seconds < 86_400 -> hours = div(diff_seconds, 3600) "#{hours} #{if hours == 1, do: "hour", else: "hours"} ago" diff_seconds < 604_800 -> days = div(diff_seconds, 86_400) "#{days} #{if days == 1, do: "day", else: "days"} ago" true -> Calendar.strftime(timestamp, "%b %d, %Y at %I:%M %p") end end end