From aed15dd6a1c7d679cd2b08a612eace69ddfac365 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 28 Jan 2026 14:41:27 -0600 Subject: [PATCH] impersonate fixes --- lib/towerops/organizations/invitation.ex | 11 + lib/towerops_web/components/layouts.ex | 12 +- .../api/account_data_controller.ex | 7 +- .../controllers/api_docs_html/index.html.heex | 212 ++++++++++ .../user_registration_controller.ex | 66 ++- .../user_registration_html/new.html.heex | 45 +- lib/towerops_web/live/account_live/my_data.ex | 389 ++++++++++-------- lib/towerops_web/live/user_settings_live.ex | 8 + lib/towerops_web/user_auth.ex | 6 +- priv/static/changelog.txt | 5 +- test/towerops_web/live/my_data_live_test.exs | 61 +++ test/towerops_web/user_auth_test.exs | 6 +- 12 files changed, 615 insertions(+), 213 deletions(-) create mode 100644 test/towerops_web/live/my_data_live_test.exs diff --git a/lib/towerops/organizations/invitation.ex b/lib/towerops/organizations/invitation.ex index 37a28043..29100a38 100644 --- a/lib/towerops/organizations/invitation.ex +++ b/lib/towerops/organizations/invitation.ex @@ -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 diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 9a84a416..2b566744 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -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"""
@@ -334,6 +330,14 @@ defmodule ToweropsWeb.Layouts do > <.icon name="hero-user-circle" class="w-4 h-4" /> User Settings + <.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 role="menuitem" href={~p"/users/log-out"} diff --git a/lib/towerops_web/controllers/api/account_data_controller.ex b/lib/towerops_web/controllers/api/account_data_controller.ex index 8190035d..db684527 100644 --- a/lib/towerops_web/controllers/api/account_data_controller.ex +++ b/lib/towerops_web/controllers/api/account_data_controller.ex @@ -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 } diff --git a/lib/towerops_web/controllers/api_docs_html/index.html.heex b/lib/towerops_web/controllers/api_docs_html/index.html.heex index 137eb78c..2d597a7c 100644 --- a/lib/towerops_web/controllers/api_docs_html/index.html.heex +++ b/lib/towerops_web/controllers/api_docs_html/index.html.heex @@ -67,6 +67,15 @@ Devices +
  • + + Account Data (GDPR) + +
  • @@ -1104,6 +1113,209 @@ curl -X DELETE https://towerops.net/api/v1/devices/650e8400-e29b-41d4-a716-44665 { "success": true } +""") %> +
    + + + + + +
    + + +
    +

    + Account Data (GDPR) +

    +

    + 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. +

    + +
    +
    +
    + + + +
    +
    +

    + Authentication Difference +

    +
    +

    + This endpoint requires browser session authentication, not API tokens. Users must be logged into their Towerops account via the web interface to access this endpoint. +

    +
    +
    +
    +
    + + +
    +
    +

    + Export all account data +

    + + GET + + + /api/v1/account/data + +
    + +
    + +
    +

    + 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. +

    +
    + + +
    +
    +
    +

    Request (Browser)

    +
    +
    <%= 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"
    +""") %>
    +
    + +
    +
    +

    Response

    +
    +
    <%= 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 %> -
    - - - - - - - - - - - <%= for device <- @user_data.devices do %> - - - - - - - <% end %> - -
    - Name - - IP Address - - Organization - - Created -
    - {device.name} - - {device.ip_address} - - {device.organization.name} - - {Calendar.strftime(device.inserted_at, "%b %d, %Y")} -
    -
    -

    - 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 %> +
    + + + + + + + + + + + <%= for device <- @user_data.devices do %> + + + + + + + <% end %> + +
    + Name + + IP Address + + Organization + + Created +
    + {device.name} + + {device.ip_address} + + {device.site.organization.name} + + {Calendar.strftime(device.inserted_at, "%b %d, %Y")} +
    +
    +

    + 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 %> -
    - - - - - - - - - - - <%= for alert <- Enum.take(@user_data.alerts, 50) do %> - - - - - - - <% end %> - -
    - Device - - Type - - Severity - - Created -
    - {alert.device.name} - - {alert.alert_type} - - - {alert.severity} - - - {Calendar.strftime(alert.inserted_at, "%b %d, %Y %I:%M %p")} -
    -
    -

    - 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 %> +
    + + + + + + + + + + + <%= for alert <- Enum.take(@user_data.alerts, 50) do %> + + + + + + + <% end %> + +
    + Device + + Type + + Status + + Created +
    + {alert.device.name} + + {alert.alert_type} + + + {alert_status_text(alert)} + + + {Calendar.strftime(alert.inserted_at, "%b %d, %Y %I:%M %p")} +
    +
    +

    + 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