towerops/lib/towerops_web/live/account_live/my_data.ex
Graham McIntire 3a408a8dc1 Security hardening + performance fixes across codebase
CRITICAL:
- Membership: remove :role/:org_id/:user_id from mass-assignment cast; use explicit create_changeset/4 and role_update_changeset/2
- GraphQL member resolver: add authorize_invite/3 checking admin/owner role and role hierarchy
- REST invitations controller: add auth check for invite creation

HIGH:
- ApiToken: remove :organization_id/:user_id from cast; use explicit create_changeset/4

MEDIUM:
- Move 8 LiveView Ecto queries into context modules (Admin, Alerts, Coverages, OnCall, Snmp)
- Replace Process.put/Process.get with socket assigns for unresolved_alert_count (user_auth + layouts + 50 templates)
- Add batch get_utilization_for_interfaces/1 to eliminate N+1 capacity queries in device show
- Replace Process.sleep with Process.monitor/assert_receive or Process.send_after in 5 test files

LOW:
- Add handle_params/3 to UserResetPasswordLive, UserRegistrationLive, StatusPageLive
- Remove redundant Repo.preload calls; add preloads to list_site_devices/1
- Fix @impl annotations and credo nesting warnings
2026-06-21 17:40:50 -05:00

678 lines
28 KiB
Elixir

defmodule ToweropsWeb.AccountLive.MyData do
@moduledoc """
LiveView for displaying all user data (GDPR Right to Access)
"""
use ToweropsWeb, :live_view
alias Towerops.Accounts
alias Towerops.Accounts.Scope
alias Towerops.Admin
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Organizations
@impl true
def handle_event("revoke_consent", %{"consent-id" => consent_id}, socket) do
case Accounts.revoke_consent(consent_id) do
{:ok, _consent} ->
user = socket.assigns.current_scope.user
socket =
socket
|> put_flash(:info, t("Consent revoked successfully."))
|> assign(:user_data, Map.put(socket.assigns.user_data, :consents, get_user_consents(user)))
{:noreply, socket}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to revoke consent."))}
end
end
@impl true
def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user
# Get organizations and check ownership
organizations = get_user_organizations(user)
is_owner = Enum.any?(organizations, fn org -> org[:is_owner] end)
default_organization = organizations |> Enum.map(& &1[:organization]) |> List.first()
# Gather user data - only include org/devices/alerts if user is an owner
user_data = %{
profile: get_user_profile(user),
consents: get_user_consents(user),
organizations: if(is_owner, do: organizations, else: []),
devices: if(is_owner, do: get_user_devices(user), else: []),
alerts: if(is_owner, do: get_user_alerts(user), else: []),
audit_logs: get_user_audit_logs(user)
}
# Update scope with organization
scope = Scope.put_organization(socket.assigns.current_scope, default_organization)
{:ok,
socket
|> assign(:current_scope, scope)
|> assign(:user_data, user_data)
|> assign(:page_title, t("My Data"))
|> assign(:is_owner, is_owner)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="settings"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<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">
Account Settings
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Manage your account email address, password, and security settings
</p>
</div>
<header class="border-b border-gray-200 dark:border-white/5">
<nav class="flex overflow-x-auto py-4">
<ul
role="list"
class="flex min-w-full flex-none gap-x-6 px-4 text-sm/6 font-semibold text-gray-500 sm:px-6 lg:px-8 dark:text-gray-400"
>
<li>
<.link
navigate={~p"/users/settings?tab=personal"}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
Personal
</.link>
</li>
<li>
<.link
navigate={~p"/users/settings?tab=account"}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
Account
</.link>
</li>
<li>
<.link
navigate={~p"/users/settings?tab=sessions"}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
Sessions
</.link>
</li>
<li>
<.link
navigate={~p"/users/settings?tab=api"}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
API
</.link>
</li>
<li>
<.link
navigate={~p"/users/settings?tab=notifications"}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
Notifications
</.link>
</li>
<li>
<.link
navigate={~p"/users/settings?tab=security"}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
Security
</.link>
</li>
<li>
<.link
navigate={~p"/users/my-data"}
class="text-indigo-600 dark:text-indigo-400"
>
My Data
</.link>
</li>
</ul>
</nav>
</header>
<div class="space-y-8 py-8">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">
This page shows all personal data we have stored about you in compliance with GDPR Article 15 (Right to Access).
</p>
</div>
<!-- Profile Information -->
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">Profile Information</h2>
</div>
<div class="px-4 py-5 sm:p-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Email Address</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{@user_data.profile.email}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">First Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{@user_data.profile.first_name || "Not set"}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{@user_data.profile.last_name || "Not set"}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Timezone</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{@user_data.profile.timezone}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Account Created</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{ToweropsWeb.TimeHelpers.format_datetime(
@user_data.profile.inserted_at,
@current_scope.timezone,
@current_scope.time_format
)}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Email Confirmed</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{if @user_data.profile.confirmed_at, do: "Yes", else: "No"}
<%= if @user_data.profile.confirmed_at do %>
<span class="text-gray-500 dark:text-gray-400">
({ToweropsWeb.TimeHelpers.format_date(
@user_data.profile.confirmed_at,
@current_scope.timezone
)})
</span>
<% end %>
</dd>
</div>
</dl>
</div>
</section>
<!-- Consent Records -->
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
Consent Records
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Your consent to our Privacy Policy and Terms of Service
</p>
</div>
<div class="px-4 py-5 sm:p-6">
<%= if Enum.empty?(@user_data.consents) do %>
<p class="text-sm text-gray-500 dark:text-gray-400">
No consent records found.
</p>
<% else %>
<div class="space-y-4">
<%= for consent <- @user_data.consents do %>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Type</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{String.replace(consent.consent_type, "_", " ") |> String.capitalize()}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Version</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{consent.version}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Granted
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{ToweropsWeb.TimeHelpers.format_datetime(
consent.granted_at,
@current_scope.timezone,
@current_scope.time_format
)}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Status
</dt>
<dd class="mt-1 text-sm">
<%= if consent.revoked_at do %>
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 inset-ring-1 inset-ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:inset-ring-red-500/20">
Revoked {ToweropsWeb.TimeHelpers.format_date(
consent.revoked_at,
@current_scope.timezone
)}
</span>
<% else %>
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 inset-ring-1 inset-ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:inset-ring-green-500/20">
Active
</span>
<% end %>
</dd>
</div>
</dl>
<%= if !consent.revoked_at do %>
<div class="mt-4">
<button
type="button"
phx-click="revoke_consent"
phx-value-consent-id={consent.id}
data-confirm="Are you sure you want to revoke this consent? This may limit your ability to use certain features."
class="text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Revoke Consent
</button>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
</div>
</section>
<%= if @is_owner do %>
<!-- Organizations -->
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
Organizations
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Organizations you own or are a member of
</p>
</div>
<div class="px-4 py-5 sm:p-6">
<%= if Enum.empty?(@user_data.organizations) do %>
<p class="text-sm text-gray-500 dark:text-gray-400">No organization memberships.</p>
<% else %>
<div class="space-y-4">
<%= for org <- @user_data.organizations do %>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{org.name}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Role</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{if org.is_owner, do: "Owner", else: "Member"}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Slug</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{org.slug}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Created
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{ToweropsWeb.TimeHelpers.format_date(
org.inserted_at,
@current_scope.timezone
)}
</dd>
</div>
</dl>
</div>
<% end %>
</div>
<% end %>
</div>
</section>
<!-- Devices -->
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
Devices
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Network devices monitored by your organizations
</p>
</div>
<div class="px-4 py-5 sm:p-6">
<%= if Enum.empty?(@user_data.devices) do %>
<p class="text-sm text-gray-500 dark:text-gray-400">No devices configured.</p>
<% else %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Name
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
IP Address
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Organization
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Created
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<%= for device <- @user_data.devices do %>
<tr>
<td class="px-3 py-4 text-sm text-gray-900 dark:text-white">
{device.name}
</td>
<td class="px-3 py-4 text-sm text-gray-900 dark:text-white">
{device.ip_address}
</td>
<td class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
{device.site.organization.name}
</td>
<td class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
{ToweropsWeb.TimeHelpers.format_date(
device.inserted_at,
@current_scope.timezone
)}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
Total devices: {length(@user_data.devices)}
</p>
<% end %>
</div>
</section>
<!-- Alerts -->
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
Recent Alerts
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Alerts generated for your devices (last 90 days)
</p>
</div>
<div class="px-4 py-5 sm:p-6">
<%= if Enum.empty?(@user_data.alerts) do %>
<p class="text-sm text-gray-500 dark:text-gray-400">No alerts in the last 90 days.</p>
<% else %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Device
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Created
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<%= for alert <- Enum.take(@user_data.alerts, 50) do %>
<tr>
<td class="px-3 py-4 text-sm text-gray-900 dark:text-white">
{alert.device.name}
</td>
<td class="px-3 py-4 text-sm text-gray-900 dark:text-white">
{alert.alert_type}
</td>
<td class="px-3 py-4 text-sm">
<span class={[
"inline-flex rounded-full px-2 text-xs font-semibold leading-5",
alert_status_class(alert)
]}>
{alert_status_text(alert)}
</span>
</td>
<td class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
{ToweropsWeb.TimeHelpers.format_datetime(
alert.inserted_at,
@current_scope.timezone,
@current_scope.time_format
)}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
Showing {min(50, length(@user_data.alerts))} of {length(@user_data.alerts)} alerts
</p>
<% end %>
</div>
</section>
<% end %>
<!-- Audit Logs -->
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
Audit Log
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Administrative actions related to your account
</p>
</div>
<div class="px-4 py-5 sm:p-6">
<%= if Enum.empty?(@user_data.audit_logs) do %>
<p class="text-sm text-gray-500 dark:text-gray-400">No audit log entries.</p>
<% else %>
<div class="space-y-3">
<%= for log <- Enum.take(@user_data.audit_logs, 20) do %>
<div class="border-l-4 border-blue-400 bg-gray-50 dark:bg-gray-800 p-3">
<div class="flex items-start">
<div class="flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{log.action}
</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
By: {if log.superuser, do: log.superuser.email, else: "System"}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-500">
{ToweropsWeb.TimeHelpers.format_datetime(
log.inserted_at,
@current_scope.timezone,
@current_scope.time_format
)}
</p>
</div>
</div>
</div>
<% end %>
</div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
Showing {min(20, length(@user_data.audit_logs))} of {length(@user_data.audit_logs)} entries
</p>
<% end %>
</div>
</section>
<!-- Export Data -->
<section class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
<div class="flex items-start gap-3">
<.icon
name="hero-arrow-down-tray"
class="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
/>
<div class="flex-1">
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Export Your Data
</h3>
<p class="text-sm text-blue-800 dark:text-blue-300 mb-4">
You can download all your data in JSON format. This includes your profile, organizations, devices, alerts, and audit logs.
</p>
<.link
href={~p"/api/v1/account/data"}
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Download My Data (JSON)
</.link>
</div>
</div>
</section>
<!-- Data Processing Information -->
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
How We Use Your Data
</h2>
</div>
<div class="px-4 py-5 sm:p-6">
<div class="prose prose-sm dark:prose-invert max-w-none">
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<li>
<strong>Profile Information:</strong>
Used to identify your account and personalize your experience
</li>
<li>
<strong>Email Address:</strong>
Used for account notifications, password resets, and important service updates
</li>
<li>
<strong>Organizations:</strong> Determines which devices and alerts you can access
</li>
<li>
<strong>Device Data:</strong>
Stored to provide network monitoring and alerting services
</li>
<li>
<strong>Monitoring Data:</strong>
Time-series metrics used to track device health and performance
</li>
<li>
<strong>Audit Logs:</strong>
Security records of administrative actions for compliance and security purposes
</li>
</ul>
<p class="mt-4 text-sm text-gray-600 dark:text-gray-400">
For more information, please read our <.link
navigate={~p"/privacy"}
class="text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Privacy Policy
</.link>.
</p>
</div>
</div>
</section>
</div>
</Layouts.authenticated>
"""
end
# Helper function to get user profile data
defp get_user_profile(user) do
%{
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
timezone: user.timezone,
inserted_at: user.inserted_at,
confirmed_at: user.confirmed_at
}
end
# Helper function to get user's organizations
defp get_user_organizations(user) do
user.id
|> Organizations.list_user_organizations()
|> Enum.map(fn org ->
# Organization has the user's membership preloaded (only their membership)
[membership] = org.memberships
%{
name: org.name,
slug: org.slug,
is_owner: membership.role == :owner,
inserted_at: org.inserted_at
}
end)
end
# Helper function to get devices from user's organizations
defp get_user_devices(user) do
org_ids =
user.id
|> Organizations.list_user_organizations()
|> Enum.map(& &1.id)
if Enum.empty?(org_ids) do
[]
else
Devices.list_devices_for_organizations(org_ids)
end
end
# Helper function to get alerts for user's devices (last 90 days)
defp get_user_alerts(user) do
org_ids =
user.id
|> Organizations.list_user_organizations()
|> Enum.map(& &1.id)
if Enum.empty?(org_ids) do
[]
else
ninety_days_ago = DateTime.add(DateTime.utc_now(), -90, :day)
Alerts.list_alerts_for_organizations(org_ids, since: ninety_days_ago)
end
end
# Helper function to get audit logs related to the user
defp get_user_audit_logs(user) do
Admin.list_audit_logs_for_user(user.id)
end
# Helper function to get user's consent records
defp get_user_consents(user) do
Accounts.list_user_consents(user.id)
end
# Helper function for alert status badge styling
defp alert_status_class(alert) do
cond do
alert.resolved_at -> "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
alert.acknowledged_at -> "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
true -> "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
end
end
defp alert_status_text(alert) do
cond do
alert.resolved_at -> "Resolved"
alert.acknowledged_at -> "Acknowledged"
true -> "Active"
end
end
end