impersonate fixes

This commit is contained in:
Graham McIntire 2026-01-28 14:41:27 -06:00
parent a4ea96bebc
commit aed15dd6a1
12 changed files with 615 additions and 213 deletions

View file

@ -57,6 +57,7 @@ defmodule Towerops.Organizations.Invitation do
:expires_at,
:accepted_at
])
|> set_default_role()
|> validate_required([:email, :role, :organization_id, :invited_by_id])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/)
|> generate_token()
@ -67,6 +68,16 @@ defmodule Towerops.Organizations.Invitation do
|> foreign_key_constraint(:invited_by_id)
end
defp set_default_role(changeset) do
# Default to :member role if not specified
# Only organization creators can be owners, not invited users
if get_field(changeset, :role) do
changeset
else
put_change(changeset, :role, :member)
end
end
defp generate_token(changeset) do
# Only generate token if not already provided
if get_field(changeset, :token) do

View file

@ -129,10 +129,6 @@ defmodule ToweropsWeb.Layouts do
requires_cookie_consent = Process.get(:requires_cookie_consent, false)
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
Logger.info(
"Layout authenticated/1: requires_cookie_consent = #{inspect(assigns.requires_cookie_consent)}, process dict = #{inspect(Process.get(:requires_cookie_consent))}"
)
~H"""
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
<!-- Impersonation Banner -->
@ -334,6 +330,14 @@ defmodule ToweropsWeb.Layouts do
>
<.icon name="hero-user-circle" class="w-4 h-4" /> User Settings
</.link>
<.link
role="menuitem"
navigate={~p"/account/my-data"}
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-document-arrow-down" class="w-4 h-4" /> My Data
</.link>
<.link
role="menuitem"
href={~p"/users/log-out"}

View file

@ -56,7 +56,7 @@ defmodule ToweropsWeb.Api.AccountDataController do
# Build credentials data
defp build_credentials_data(user) do
user
user.id
|> Accounts.list_user_credentials()
|> Enum.map(fn credential ->
%{
@ -75,11 +75,14 @@ defmodule ToweropsWeb.Api.AccountDataController do
user.id
|> Organizations.list_user_organizations()
|> Enum.map(fn org ->
# Organization has the user's membership preloaded (only their membership)
[membership] = org.memberships
%{
id: org.id,
name: org.name,
slug: org.slug,
role: if(org.owner_id == user.id, do: "owner", else: "member"),
role: if(membership.role == :owner, do: "owner", else: "member"),
inserted_at: org.inserted_at,
updated_at: org.updated_at
}

View file

@ -67,6 +67,15 @@
Devices
</a>
</li>
<li>
<a
href="#account-data"
data-nav-link="account-data"
class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
Account Data (GDPR)
</a>
</li>
</ul>
</li>
</ul>
@ -1104,6 +1113,209 @@ curl -X DELETE https://towerops.net/api/v1/devices/650e8400-e29b-41d4-a716-44665
{
"success": true
}
""") %></code></pre>
</div>
</div>
</div>
</div>
</section>
<hr class="my-16 border-gray-200 dark:border-white/10" />
<!-- Account Data Resource (GDPR) -->
<section id="account-data" class="scroll-mt-24 mb-16">
<h2 class="text-3xl font-bold tracking-tight text-zinc-900 dark:text-white">
Account Data (GDPR)
</h2>
<p class="mt-4 text-base text-gray-600 dark:text-gray-400">
GDPR Right to Access endpoint that allows users to download all their personal data in JSON format. This endpoint complies with Article 15 of GDPR.
</p>
<div class="mt-6 rounded-lg bg-amber-50 dark:bg-amber-900/10 p-4 border border-amber-200 dark:border-amber-800">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">
Authentication Difference
</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>
This endpoint requires <strong>browser session authentication</strong>, not API tokens. Users must be logged into their Towerops account via the web interface to access this endpoint.
</p>
</div>
</div>
</div>
</div>
<!-- Export all account data -->
<div class="mt-12">
<div class="flex items-center gap-x-3">
<h3 class="text-xl font-semibold text-zinc-900 dark:text-white">
Export all account data
</h3>
<span class="inline-flex items-center rounded-md bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-400/10 dark:text-emerald-400 dark:ring-emerald-400/30">
GET
</span>
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">
/api/v1/account/data
</span>
</div>
<div class="mt-6 grid grid-cols-1 gap-x-12 gap-y-8 lg:grid-cols-2">
<!-- Left column - description -->
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Downloads all personal data associated with the authenticated user account as a JSON file. This includes:
</p>
<ul class="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400 list-disc list-inside">
<li>Profile information (email, name, timezone)</li>
<li>WebAuthn credentials and passkeys</li>
<li>Organizations and membership roles</li>
<li>Devices across all organizations</li>
<li>Recent alerts (last 90 days)</li>
<li>Audit logs (up to 1,000 most recent)</li>
</ul>
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/10 p-4 border border-blue-200 dark:border-blue-800">
<p class="text-sm text-blue-700 dark:text-blue-300">
<strong>Privacy Note:</strong>
Sensitive data like SNMP community strings are redacted from the export for security.
</p>
</div>
<h4 class="mt-6 text-sm font-semibold text-zinc-900 dark:text-white">
Authentication
</h4>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Requires active browser session with CSRF token. Cannot be accessed via API tokens.
</p>
</div>
<!-- Right column - code example -->
<div>
<div class="overflow-hidden rounded-lg bg-gray-900 dark:bg-gray-950">
<div class="border-b border-zinc-800 px-4 py-2">
<p class="text-xs font-medium text-zinc-400">Request (Browser)</p>
</div>
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
# This endpoint requires browser authentication
# Access via logged-in browser session:
GET https://towerops.net/api/v1/account/data
# Or via curl with session cookie:
curl https://towerops.net/api/v1/account/data \
-H "Cookie: _towerops_key=YOUR_SESSION_COOKIE"
""") %></code></pre>
</div>
<div class="mt-4 overflow-hidden rounded-lg bg-gray-900 dark:bg-gray-950">
<div class="border-b border-zinc-800 px-4 py-2">
<p class="text-xs font-medium text-zinc-400">Response</p>
</div>
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
{
"profile": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name": "John Doe",
"timezone": "America/New_York",
"is_superuser": false,
"confirmed_at": "2026-01-15T10:30:00Z",
"inserted_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-01-20T14:22:00Z"
},
"credentials": [
{
"id": "750e8400-e29b-41d4-a716-446655440002",
"label": "MacBook Pro Touch ID",
"public_key_spki": "MFkwEwYHKoZIzj0CAQYIKoZI...",
"last_used_at": "2026-01-28T12:15:00Z",
"inserted_at": "2026-01-15T10:35:00Z"
}
],
"organizations": [
{
"id": "850e8400-e29b-41d4-a716-446655440003",
"name": "Acme Networks",
"slug": "acme-networks",
"role": "owner",
"inserted_at": "2026-01-15T10:32:00Z",
"updated_at": "2026-01-15T10:32:00Z"
}
],
"devices": [
{
"id": "650e8400-e29b-41d4-a716-446655440001",
"name": "Core Router",
"ip_address": "192.168.1.1",
"snmp_community": "[REDACTED]",
"monitoring_enabled": true,
"snmp_enabled": true,
"status": "up",
"organization": {
"id": "850e8400-e29b-41d4-a716-446655440003",
"name": "Acme Networks"
},
"site": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Main Office"
},
"inserted_at": "2026-01-16T09:00:00Z",
"updated_at": "2026-01-28T08:30:00Z"
}
],
"alerts": [
{
"id": "950e8400-e29b-41d4-a716-446655440004",
"alert_type": "equipment_down",
"severity": "critical",
"message": "Device is not responding to ping",
"triggered_at": "2026-01-27T15:45:00Z",
"resolved_at": "2026-01-27T16:10:00Z",
"acknowledged_at": null,
"device": {
"id": "650e8400-e29b-41d4-a716-446655440001",
"name": "Core Router"
},
"inserted_at": "2026-01-27T15:45:00Z"
}
],
"audit_logs": [
{
"id": "150e8400-e29b-41d4-a716-446655440005",
"action": "user.login",
"actor_email": "user@example.com",
"target_user_id": "550e8400-e29b-41d4-a716-446655440000",
"metadata": {
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0..."
},
"inserted_at": "2026-01-28T08:00:00Z"
}
],
"export_info": {
"exported_at": "2026-01-28T19:50:00Z",
"format": "JSON",
"gdpr_article": "Article 15 - Right to Access"
}
}
""") %></code></pre>
</div>
<div class="mt-4 rounded-lg bg-gray-900 dark:bg-gray-950 p-4">
<p class="text-xs font-medium text-zinc-400 mb-2">Response Headers</p>
<pre class="text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
Content-Type: application/json
Content-Disposition: attachment; filename="towerops-data-{user_id}-{timestamp}.json"
""") %></code></pre>
</div>
</div>

View file

@ -5,14 +5,36 @@ defmodule ToweropsWeb.UserRegistrationController do
alias Towerops.Accounts
alias Towerops.Accounts.User
alias Towerops.Organizations
alias ToweropsWeb.UserAuth
def new(conn, _params) do
def new(conn, params) do
# Check if registering via invitation
invitation_token = params["invitation_token"] || params["token"]
invitation = invitation_token && Organizations.get_invitation_by_token(invitation_token)
changeset = Accounts.change_user_registration(%User{})
render(conn, :new, form: Phoenix.Component.to_form(changeset, as: "user"))
render(conn, :new,
form: Phoenix.Component.to_form(changeset, as: "user"),
invitation: invitation,
invitation_token: invitation_token
)
end
def create(conn, %{"user" => user_params}) do
invitation_token = user_params["invitation_token"] || conn.params["invitation_token"]
if invitation_token do
# Registration via invitation - don't create personal org
create_via_invitation(conn, user_params, invitation_token)
else
# Normal registration - create personal org
create_with_organization(conn, user_params)
end
end
defp create_with_organization(conn, user_params) do
case Accounts.register_user_with_organization(user_params) do
{:ok, user} ->
conn
@ -20,7 +42,45 @@ defmodule ToweropsWeb.UserRegistrationController do
|> UserAuth.log_in_user(user)
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, form: Phoenix.Component.to_form(changeset, as: "user"))
render(conn, :new,
form: Phoenix.Component.to_form(changeset, as: "user"),
invitation: nil,
invitation_token: nil
)
end
end
defp create_via_invitation(conn, user_params, invitation_token) do
with {:ok, invitation} <- get_valid_invitation(invitation_token),
{:ok, user} <- Accounts.register_user(user_params),
{:ok, _membership} <- Organizations.accept_invitation(invitation, user.id) do
conn
|> put_flash(:info, "Account created successfully. Welcome to #{invitation.organization.name}!")
|> UserAuth.log_in_user(user)
else
{:error, :invalid_invitation} ->
changeset = Accounts.change_user_registration(%User{}, user_params)
conn
|> put_flash(:error, "Invalid or expired invitation link.")
|> render(:new,
form: Phoenix.Component.to_form(changeset, as: "user"),
invitation: nil,
invitation_token: nil
)
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new,
form: Phoenix.Component.to_form(changeset, as: "user"),
invitation_token: invitation_token
)
end
end
defp get_valid_invitation(token) do
case Organizations.get_invitation_by_token(token) do
nil -> {:error, :invalid_invitation}
invitation -> {:ok, invitation}
end
end
end

View file

@ -2,7 +2,11 @@
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
Register for an account
<%= if @invitation do %>
Join {@invitation.organization.name}
<% else %>
Register for an account
<% end %>
<:subtitle>
Already registered?
<.link
@ -15,21 +19,36 @@
</:subtitle>
</.header>
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800">
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
Free tier includes:
</p>
<ul class="mt-2 text-sm text-blue-800 dark:text-blue-200 space-y-1">
<li>✓ Monitor up to 10 devices</li>
<li>✓ Real-time alerts and notifications</li>
<li>✓ Performance charts and historical data</li>
<li>✓ No credit card required</li>
</ul>
</div>
<%= if @invitation do %>
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800">
<p class="text-sm text-blue-900 dark:text-blue-100">
You've been invited to join
<span class="font-semibold">{@invitation.organization.name}</span>
as
a {@invitation.role}.
</p>
</div>
<% else %>
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800">
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
Free tier includes:
</p>
<ul class="mt-2 text-sm text-blue-800 dark:text-blue-200 space-y-1">
<li>✓ Monitor up to 10 devices</li>
<li>✓ Real-time alerts and notifications</li>
<li>✓ Performance charts and historical data</li>
<li>✓ No credit card required</li>
</ul>
</div>
<% end %>
</div>
<div class="mt-8">
<.form :let={f} for={@form} action={~p"/users/register"}>
<%= if @invitation_token do %>
<input type="hidden" name="user[invitation_token]" value={@invitation_token} />
<% end %>
<.input
field={f[:email]}
type="email"
@ -47,7 +66,7 @@
/>
<.button phx-disable-with="Creating account..." class="w-full" variant="primary">
Create an account
{if @invitation, do: "Accept invitation and create account", else: "Create an account"}
</.button>
</.form>
</div>

View file

@ -14,17 +14,27 @@ defmodule ToweropsWeb.AccountLive.MyData do
def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user
# Gather all user data
# 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),
organizations: get_user_organizations(user),
devices: get_user_devices(user),
alerts: get_user_alerts(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)
}
{:ok, assign(socket, user_data: user_data, page_title: "My Data")}
{:ok,
socket
|> assign(:user_data, user_data)
|> assign(:page_title, "My Data")
|> assign(:is_owner, is_owner)
|> assign(:current_organization, default_organization)}
end
@impl true
@ -115,8 +125,8 @@ defmodule ToweropsWeb.AccountLive.MyData 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">Label</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{credential.label}</dd>
<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>
@ -143,175 +153,179 @@ defmodule ToweropsWeb.AccountLive.MyData do
<% end %>
</div>
</section>
<!-- 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>
<%= 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.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)}
<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>
<% end %>
</div>
</section>
</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">
Severity
</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_severity_class(alert.severity)
]}>
{alert.severity}
</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
<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>
<% end %>
</div>
</section>
</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">
@ -441,7 +455,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
# Helper function to get user's WebAuthn credentials
defp get_user_credentials(user) do
Accounts.list_user_credentials(user)
Accounts.list_user_credentials(user.id)
end
# Helper function to get user's organizations
@ -449,10 +463,13 @@ defmodule ToweropsWeb.AccountLive.MyData 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: org.owner_id == user.id,
is_owner: membership.role == :owner,
inserted_at: org.inserted_at
}
end)
@ -470,7 +487,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
else
org_ids
|> Devices.list_devices_for_organizations()
|> Towerops.Repo.preload(:organization)
|> Towerops.Repo.preload(site: :organization)
end
end
@ -497,12 +514,20 @@ defmodule ToweropsWeb.AccountLive.MyData do
Admin.list_audit_logs_for_user(user.id)
end
# Helper function for alert severity badge styling
defp alert_severity_class("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
# 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_severity_class("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
defp alert_severity_class("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
defp alert_severity_class(_), do: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400"
defp alert_status_text(alert) do
cond do
alert.resolved_at -> "Resolved"
alert.acknowledged_at -> "Acknowledged"
true -> "Active"
end
end
end

View file

@ -347,6 +347,14 @@ defmodule ToweropsWeb.UserSettingsLive do
Notifications
</a>
</li>
<li>
<.link
navigate={~p"/account/my-data"}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
My Data
</.link>
</li>
</ul>
</nav>
</header>

View file

@ -192,17 +192,12 @@ defmodule ToweropsWeb.UserAuth do
#
defp renew_session(conn, _user) do
delete_csrf_token()
user_return_to = get_session(conn, :user_return_to)
conn
|> configure_session(renew: true)
|> clear_session()
|> maybe_restore_return_to(user_return_to)
end
defp maybe_restore_return_to(conn, nil), do: conn
defp maybe_restore_return_to(conn, path), do: put_session(conn, :user_return_to, path)
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
do: write_remember_me_cookie(conn, token)
@ -675,6 +670,7 @@ defmodule ToweropsWeb.UserAuth do
|> put_session(:target_user_id, target_user.id)
|> put_session(:impersonating, true)
|> assign(:current_scope, Scope.for_impersonation(superuser, target_user))
|> maybe_set_default_organization(target_user)
|> put_flash(:info, "Now impersonating #{target_user.email}")
|> redirect(to: ~p"/orgs")
end

View file

@ -4,7 +4,10 @@ Devices Working
2026-01-28
* Major SNMP Discovery overhaul and improvements
* GDPR: Cookie consent banner
* GDPR: Cookie consent banner for EU/EEA users
* GDPR: Right to Access - My Data page and API endpoint
* GDPR: API documentation for account data export
* GDPR: My Data links in user settings and navigation
* Bug fix: SNMP Community when using remote agent
2025-01-27

View file

@ -0,0 +1,61 @@
defmodule ToweropsWeb.AccountLive.MyDataTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
setup :register_and_log_in_user
describe "My Data page" do
test "renders for organization owner without additional data", %{conn: conn} do
# register_and_log_in_user creates a user with a Personal org (as owner)
{:ok, _view, html} = live(conn, ~p"/account/my-data")
assert html =~ "My Data"
assert html =~ "Profile Information"
assert html =~ "Security Credentials"
assert html =~ "Organizations"
assert html =~ "Devices"
assert html =~ "Audit Log"
end
test "renders for organization owner with full data", %{conn: conn, user: user} do
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.1",
site_id: site.id,
monitoring_enabled: true
})
# Create an alert
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
{:ok, _view, html} = live(conn, ~p"/account/my-data")
assert html =~ "My Data"
assert html =~ "Profile Information"
assert html =~ "Security Credentials"
assert html =~ "Organizations"
assert html =~ "Devices"
assert html =~ "Recent Alerts"
assert html =~ "Audit Log"
assert html =~ "Test Org"
assert html =~ "Test Device"
assert html =~ "192.168.1.1"
end
end
end

View file

@ -1244,15 +1244,15 @@ defmodule ToweropsWeb.UserAuthTest do
end
describe "log_in_user/3 with return_to" do
test "restores user_return_to after login and keeps it in session", %{conn: conn, user: user} do
test "uses user_return_to for redirect and clears it from session", %{conn: conn, user: user} do
conn =
conn
|> put_session(:user_return_to, "/devices")
|> UserAuth.log_in_user(user)
assert redirected_to(conn) == "/devices"
# The return_to is restored in the session by renew_session
assert get_session(conn, :user_return_to) == "/devices"
# The return_to is used for redirect but cleared from session to allow new paths to be stored
assert get_session(conn, :user_return_to) == nil
end
test "does not clear session when already logged in with same user", %{conn: conn, user: user} do