714 lines
30 KiB
Elixir
714 lines
30 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, "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, "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),
|
|
credentials: get_user_credentials(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, "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"
|
|
>
|
|
<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"}
|
|
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"}
|
|
phx-click={JS.push("switch_tab", value: %{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"}
|
|
phx-click={JS.push("switch_tab", value: %{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/settings"}
|
|
phx-click={JS.push("switch_tab", value: %{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"}
|
|
phx-click={JS.push("switch_tab", value: %{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"}
|
|
phx-click={JS.push("switch_tab", value: %{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/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">Name</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
{@user_data.profile.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">
|
|
{Calendar.strftime(@user_data.profile.inserted_at, "%B %d, %Y at %I:%M %p")}
|
|
</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">
|
|
({Calendar.strftime(@user_data.profile.confirmed_at, "%B %d, %Y")})
|
|
</span>
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Superuser</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
{if @user_data.profile.is_superuser, do: "Yes", else: "No"}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- WebAuthn Credentials -->
|
|
<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">
|
|
Security Credentials
|
|
</h2>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
WebAuthn passkeys registered to your account
|
|
</p>
|
|
</div>
|
|
<div class="px-4 py-5 sm:p-6">
|
|
<%= if Enum.empty?(@user_data.credentials) do %>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
No WebAuthn credentials registered.
|
|
</p>
|
|
<% else %>
|
|
<div class="space-y-4">
|
|
<%= for credential <- @user_data.credentials 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">{credential.name}</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">
|
|
{Calendar.strftime(credential.inserted_at, "%B %d, %Y")}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
Last Used
|
|
</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
<%= if credential.last_used_at do %>
|
|
{Calendar.strftime(credential.last_used_at, "%B %d, %Y at %I:%M %p")}
|
|
<% else %>
|
|
Never
|
|
<% end %>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</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">
|
|
{Calendar.strftime(consent.granted_at, "%B %d, %Y at %I:%M %p")}
|
|
</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 {Calendar.strftime(consent.revoked_at, "%B %d, %Y")}
|
|
</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">
|
|
{Calendar.strftime(org.inserted_at, "%B %d, %Y")}
|
|
</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">
|
|
{Calendar.strftime(device.inserted_at, "%b %d, %Y")}
|
|
</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">
|
|
{Calendar.strftime(alert.inserted_at, "%b %d, %Y %I:%M %p")}
|
|
</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">
|
|
{Calendar.strftime(log.inserted_at, "%B %d, %Y at %I:%M %p")}
|
|
</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,
|
|
name: user.name,
|
|
timezone: user.timezone,
|
|
inserted_at: user.inserted_at,
|
|
confirmed_at: user.confirmed_at,
|
|
is_superuser: user.is_superuser
|
|
}
|
|
end
|
|
|
|
# Helper function to get user's WebAuthn credentials
|
|
defp get_user_credentials(user) do
|
|
Accounts.list_user_credentials(user.id)
|
|
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
|
|
org_ids
|
|
|> Devices.list_devices_for_organizations()
|
|
|> Towerops.Repo.preload(site: :organization)
|
|
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)
|
|
|
|
org_ids
|
|
|> Alerts.list_alerts_for_organizations(since: ninety_days_ago)
|
|
|> Towerops.Repo.preload(:device)
|
|
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
|