+
+
+
+ Downloads all personal data associated with the authenticated user account as a JSON file. This includes:
+
+
+
+ Profile information (email, name, timezone)
+ WebAuthn credentials and passkeys
+ Organizations and membership roles
+ Devices across all organizations
+ Recent alerts (last 90 days)
+ Audit logs (up to 1,000 most recent)
+
+
+
+
+ Privacy Note:
+ Sensitive data like SNMP community strings are redacted from the export for security.
+
+
+
+
+ Authentication
+
+
+ Requires active browser session with CSRF token. Cannot be accessed via API tokens.
+
+
+
+
+
+
+
+
<%= 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"
+""") %>
+
+
+
+
+
<%= 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"
+ }
+}
+""") %>
+
+
+
+
Response Headers
+
<%= raw(~S"""
+Content-Type: application/json
+Content-Disposition: attachment; filename="towerops-data-{user_id}-{timestamp}.json"
""") %>
diff --git a/lib/towerops_web/controllers/user_registration_controller.ex b/lib/towerops_web/controllers/user_registration_controller.ex
index 40f9a5f5..837911af 100644
--- a/lib/towerops_web/controllers/user_registration_controller.ex
+++ b/lib/towerops_web/controllers/user_registration_controller.ex
@@ -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
diff --git a/lib/towerops_web/controllers/user_registration_html/new.html.heex b/lib/towerops_web/controllers/user_registration_html/new.html.heex
index 1fed3d15..0aaedd5d 100644
--- a/lib/towerops_web/controllers/user_registration_html/new.html.heex
+++ b/lib/towerops_web/controllers/user_registration_html/new.html.heex
@@ -2,7 +2,11 @@
<.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 @@
-
-
- Free tier includes:
-
-
- ✓ Monitor up to 10 devices
- ✓ Real-time alerts and notifications
- ✓ Performance charts and historical data
- ✓ No credit card required
-
-
+ <%= if @invitation do %>
+
+
+ You've been invited to join
+ {@invitation.organization.name}
+ as
+ a {@invitation.role}.
+
+
+ <% else %>
+
+
+ Free tier includes:
+
+
+ ✓ Monitor up to 10 devices
+ ✓ Real-time alerts and notifications
+ ✓ Performance charts and historical data
+ ✓ No credit card required
+
+
+ <% end %>
<.form :let={f} for={@form} action={~p"/users/register"}>
+ <%= if @invitation_token do %>
+
+ <% 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"}
diff --git a/lib/towerops_web/live/account_live/my_data.ex b/lib/towerops_web/live/account_live/my_data.ex
index ce61aeba..d6ca6cde 100644
--- a/lib/towerops_web/live/account_live/my_data.ex
+++ b/lib/towerops_web/live/account_live/my_data.ex
@@ -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
-
Label
- {credential.label}
+ Name
+ {credential.name}
Created
@@ -143,175 +153,179 @@ defmodule ToweropsWeb.AccountLive.MyData do
<% end %>
-
-
-
-
-
- Organizations
-
-
- Organizations you own or are a member of
-
-
-
- <%= if Enum.empty?(@user_data.organizations) do %>
-
No organization memberships.
- <% else %>
-
- <%= for org <- @user_data.organizations do %>
-
-
-
-
Name
- {org.name}
-
-
-
Role
-
- {if org.is_owner, do: "Owner", else: "Member"}
-
-
-
-
Slug
- {org.slug}
-
-
-
Created
-
- {Calendar.strftime(org.inserted_at, "%B %d, %Y")}
-
-
-
-
- <% end %>
-
- <% end %>
-
-
-
+
+ <%= if @is_owner do %>
+
+
+
+
+ Organizations
+
+
+ Organizations you own or are a member of
+
+
+
+ <%= if Enum.empty?(@user_data.organizations) do %>
+
No organization memberships.
+ <% else %>
+
+ <%= for org <- @user_data.organizations do %>
+
+
+
+
Name
+ {org.name}
+
+
+
Role
+
+ {if org.is_owner, do: "Owner", else: "Member"}
+
+
+
+
Slug
+ {org.slug}
+
+
+
+ Created
+
+
+ {Calendar.strftime(org.inserted_at, "%B %d, %Y")}
+
+
+
+
+ <% end %>
+
+ <% end %>
+
+
+
-
-
-
- Devices
-
-
- Network devices monitored by your organizations
-
-
-
- <%= if Enum.empty?(@user_data.devices) do %>
-
No devices configured.
- <% else %>
-
-
-
-
-
- Name
-
-
- IP Address
-
-
- Organization
-
-
- Created
-
-
-
-
- <%= for device <- @user_data.devices do %>
-
-
- {device.name}
-
-
- {device.ip_address}
-
-
- {device.organization.name}
-
-
- {Calendar.strftime(device.inserted_at, "%b %d, %Y")}
-
-
- <% end %>
-
-
-
-
- Total devices: {length(@user_data.devices)}
+
+
+
+ Devices
+
+
+ Network devices monitored by your organizations
- <% end %>
-
-
-
+
+
+ <%= if Enum.empty?(@user_data.devices) do %>
+
No devices configured.
+ <% else %>
+
+
+
+
+
+ Name
+
+
+ IP Address
+
+
+ Organization
+
+
+ Created
+
+
+
+
+ <%= for device <- @user_data.devices do %>
+
+
+ {device.name}
+
+
+ {device.ip_address}
+
+
+ {device.site.organization.name}
+
+
+ {Calendar.strftime(device.inserted_at, "%b %d, %Y")}
+
+
+ <% end %>
+
+
+
+
+ Total devices: {length(@user_data.devices)}
+
+ <% end %>
+
+
+
-
-
-
- Recent Alerts
-
-
- Alerts generated for your devices (last 90 days)
-
-
-
- <%= if Enum.empty?(@user_data.alerts) do %>
-
No alerts in the last 90 days.
- <% else %>
-
-
-
-
-
- Device
-
-
- Type
-
-
- Severity
-
-
- Created
-
-
-
-
- <%= for alert <- Enum.take(@user_data.alerts, 50) do %>
-
-
- {alert.device.name}
-
-
- {alert.alert_type}
-
-
-
- {alert.severity}
-
-
-
- {Calendar.strftime(alert.inserted_at, "%b %d, %Y %I:%M %p")}
-
-
- <% end %>
-
-
-
-
- Showing {min(50, length(@user_data.alerts))} of {length(@user_data.alerts)} alerts
+
+
+
+ Recent Alerts
+
+
+ Alerts generated for your devices (last 90 days)
- <% end %>
-
-
+
+
+ <%= if Enum.empty?(@user_data.alerts) do %>
+
No alerts in the last 90 days.
+ <% else %>
+
+
+
+
+
+ Device
+
+
+ Type
+
+
+ Status
+
+
+ Created
+
+
+
+
+ <%= for alert <- Enum.take(@user_data.alerts, 50) do %>
+
+
+ {alert.device.name}
+
+
+ {alert.alert_type}
+
+
+
+ {alert_status_text(alert)}
+
+
+
+ {Calendar.strftime(alert.inserted_at, "%b %d, %Y %I:%M %p")}
+
+
+ <% end %>
+
+
+
+
+ Showing {min(50, length(@user_data.alerts))} of {length(@user_data.alerts)} alerts
+
+ <% end %>
+
+
+ <% end %>
@@ -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
diff --git a/lib/towerops_web/live/user_settings_live.ex b/lib/towerops_web/live/user_settings_live.ex
index 151573d8..d756a823 100644
--- a/lib/towerops_web/live/user_settings_live.ex
+++ b/lib/towerops_web/live/user_settings_live.ex
@@ -347,6 +347,14 @@ defmodule ToweropsWeb.UserSettingsLive do
Notifications
+
+ <.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
+
+
diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex
index b62757e9..04804de9 100644
--- a/lib/towerops_web/user_auth.ex
+++ b/lib/towerops_web/user_auth.ex
@@ -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
diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt
index 44a1d575..27ee1ee7 100644
--- a/priv/static/changelog.txt
+++ b/priv/static/changelog.txt
@@ -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
diff --git a/test/towerops_web/live/my_data_live_test.exs b/test/towerops_web/live/my_data_live_test.exs
new file mode 100644
index 00000000..122fc874
--- /dev/null
+++ b/test/towerops_web/live/my_data_live_test.exs
@@ -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
diff --git a/test/towerops_web/user_auth_test.exs b/test/towerops_web/user_auth_test.exs
index 80f140f4..19a80013 100644
--- a/test/towerops_web/user_auth_test.exs
+++ b/test/towerops_web/user_auth_test.exs
@@ -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