278 lines
11 KiB
Elixir
278 lines
11 KiB
Elixir
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"""
|
|
<Layouts.authenticated flash={@flash} current_scope={@current_scope} active_page="settings">
|
|
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
|
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
|
Activity Log
|
|
</h1>
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
View all actions performed by you and on your account data for transparency and security.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="mt-8 space-y-4">
|
|
<%= if Enum.empty?(@audit_logs) do %>
|
|
<div class="text-center py-12 bg-white dark:bg-gray-900 rounded-lg shadow">
|
|
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
|
No activity logs
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Your activity will appear here as you use the platform.
|
|
</p>
|
|
</div>
|
|
<% else %>
|
|
<div class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
|
|
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
<%= for log <- @audit_logs do %>
|
|
<li class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-3">
|
|
<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>
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
|
{action_description(log)}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="mt-2 flex flex-col gap-1 text-sm text-gray-500 dark:text-gray-400">
|
|
<%= if log.superuser do %>
|
|
<div class="flex items-center gap-1">
|
|
<.icon name="hero-user" class="h-4 w-4" />
|
|
<span>
|
|
By: <span class="font-medium">{log.superuser.email}</span>
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if log.request_path do %>
|
|
<div class="flex items-center gap-1">
|
|
<.icon name="hero-link" class="h-4 w-4" />
|
|
<code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded">
|
|
{log.request_path}
|
|
</code>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if log.ip_address do %>
|
|
<div class="flex items-center gap-1">
|
|
<.icon name="hero-globe-alt" class="h-4 w-4" />
|
|
<span>IP: {log.ip_address}</span>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if log.data_accessed && map_size(log.data_accessed) > 0 do %>
|
|
<div class="flex items-start gap-1">
|
|
<.icon name="hero-eye" class="h-4 w-4 mt-0.5" />
|
|
<div>
|
|
<span class="font-medium">Data accessed:</span>
|
|
<code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 rounded ml-1">
|
|
{inspect(log.data_accessed, pretty: true, limit: 3)}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if log.metadata && map_size(log.metadata) > 0 do %>
|
|
<details class="mt-1">
|
|
<summary class="cursor-pointer text-xs text-blue-600 dark:text-blue-400 hover:underline">
|
|
View metadata
|
|
</summary>
|
|
<pre class="mt-1 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-x-auto">
|
|
{Jason.encode!(log.metadata, pretty: true)}
|
|
</pre>
|
|
</details>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ml-4 flex-shrink-0 text-right">
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
{format_timestamp(log.inserted_at)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
<% end %>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
|
<div class="flex gap-3">
|
|
<.icon
|
|
name="hero-information-circle"
|
|
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0"
|
|
/>
|
|
<div class="text-sm text-blue-800 dark:text-blue-300">
|
|
<p class="font-medium">GDPR Transparency</p>
|
|
<p class="mt-1">
|
|
This activity log shows actions performed by you and on your account data.
|
|
Records are retained for 3 years for compliance and security purposes.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</Layouts.authenticated>
|
|
"""
|
|
end
|
|
|
|
# Helper functions
|
|
|
|
defp action_badge_class(action) do
|
|
case action do
|
|
"user_data_viewed" ->
|
|
"bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-500/10 dark:text-blue-400 dark:ring-blue-500/20"
|
|
|
|
"user_data_exported" ->
|
|
"bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20"
|
|
|
|
"user_profile_updated" ->
|
|
"bg-yellow-50 text-yellow-700 ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20"
|
|
|
|
"device_created" ->
|
|
"bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20"
|
|
|
|
"device_updated" ->
|
|
"bg-yellow-50 text-yellow-700 ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20"
|
|
|
|
"device_deleted" ->
|
|
"bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:ring-red-500/20"
|
|
|
|
"failed_access_attempt" ->
|
|
"bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:ring-red-500/20"
|
|
|
|
"privilege_escalation" ->
|
|
"bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-500/10 dark:text-purple-400 dark:ring-purple-500/20"
|
|
|
|
"impersonate_start" ->
|
|
"bg-orange-50 text-orange-700 ring-orange-600/20 dark:bg-orange-500/10 dark:text-orange-400 dark:ring-orange-500/20"
|
|
|
|
"impersonate_end" ->
|
|
"bg-orange-50 text-orange-700 ring-orange-600/20 dark:bg-orange-500/10 dark:text-orange-400 dark:ring-orange-500/20"
|
|
|
|
_ ->
|
|
"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
|
|
case log.action do
|
|
"user_data_viewed" ->
|
|
if log.superuser do
|
|
"Admin viewed your account data"
|
|
else
|
|
"You viewed your account data"
|
|
end
|
|
|
|
"user_data_exported" ->
|
|
"You exported your account data"
|
|
|
|
"user_profile_updated" ->
|
|
"You updated your profile"
|
|
|
|
"device_created" ->
|
|
device_name = get_in(log.metadata, ["device_name"]) || "device"
|
|
"You created device: #{device_name}"
|
|
|
|
"device_updated" ->
|
|
device_id = get_in(log.metadata, ["device_id"])
|
|
"You updated device (ID: #{device_id})"
|
|
|
|
"device_deleted" ->
|
|
device_name = get_in(log.metadata, ["device_name"]) || "device"
|
|
"You deleted device: #{device_name}"
|
|
|
|
"org_data_accessed" ->
|
|
org_id = get_in(log.metadata, ["organization_id"])
|
|
action_detail = get_in(log.metadata, ["action"]) || "accessed"
|
|
"You #{action_detail} organization data (ID: #{org_id})"
|
|
|
|
"failed_access_attempt" ->
|
|
resource = get_in(log.metadata, ["resource"]) || "resource"
|
|
"Failed access attempt to: #{resource}"
|
|
|
|
"privilege_escalation" ->
|
|
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}"
|
|
|
|
"impersonate_start" ->
|
|
if log.superuser do
|
|
"Admin #{log.superuser.email} started impersonating you"
|
|
else
|
|
"Impersonation started"
|
|
end
|
|
|
|
"impersonate_end" ->
|
|
if log.superuser do
|
|
"Admin #{log.superuser.email} stopped impersonating you"
|
|
else
|
|
"Impersonation ended"
|
|
end
|
|
|
|
_ ->
|
|
format_action(log.action)
|
|
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
|