diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex index 61db99ea..54db7cdf 100644 --- a/lib/towerops/accounts.ex +++ b/lib/towerops/accounts.ex @@ -1634,4 +1634,90 @@ defmodule Towerops.Accounts do _ -> :error end end + + ## Account Deletion (GDPR Right to Erasure) + + @doc """ + Returns organizations where the user is the sole owner. + + Used to check if a user can safely delete their account without + orphaning organizations. + """ + @spec sole_owner_organizations(String.t()) :: [Towerops.Organizations.Organization.t()] + def sole_owner_organizations(user_id) do + alias Towerops.Organizations.Membership + alias Towerops.Organizations.Organization + + # Find orgs where user is owner + user_owner_org_ids = + Membership + |> where([m], m.user_id == ^user_id and m.role == :owner) + |> select([m], m.organization_id) + |> Repo.all() + + # For each org, check if there are other owners + user_owner_org_ids + |> Enum.filter(fn org_id -> + owner_count = + Membership + |> where([m], m.organization_id == ^org_id and m.role == :owner) + |> Repo.aggregate(:count, :id) + + owner_count == 1 + end) + |> then(fn org_ids -> + Organization + |> where([o], o.id in ^org_ids) + |> Repo.all() + end) + end + + @doc """ + Deletes a user's account (self-service, GDPR Article 17). + + Verifies the user's password before proceeding. Checks for sole-owner + organizations and blocks deletion if found. + + Within a transaction: + 1. Creates an audit log entry + 2. Anonymizes login history + 3. Anonymizes browser sessions + 4. Deletes the user (cascades to memberships, tokens) + + Returns `{:ok, user}` on success, or an error tuple. + """ + @spec delete_account(User.t(), String.t()) :: + {:ok, User.t()} + | {:error, :invalid_password} + | {:error, :sole_owner, [String.t()]} + def delete_account(%User{} = user, password) when is_binary(password) do + if User.valid_password?(user, password) do + do_delete_account(user) + else + {:error, :invalid_password} + end + end + + defp do_delete_account(user) do + case sole_owner_organizations(user.id) do + [] -> + Repo.transaction(fn -> + {:ok, _} = + Towerops.Admin.create_audit_log(%{ + action: "account_deletion_requested", + superuser_id: user.id, + target_user_id: user.id, + metadata: %{email: user.email, self_service: true} + }) + + anonymize_user_login_history(user.id) + anonymize_user_browser_sessions(user.id) + Repo.delete!(user) + end) + + sole_owner_orgs -> + org_names = Enum.map(sole_owner_orgs, & &1.name) + {:error, :sole_owner, org_names} + end + end end diff --git a/lib/towerops/accounts/user_notifier.ex b/lib/towerops/accounts/user_notifier.ex index 8c0ad86d..0b66dd71 100644 --- a/lib/towerops/accounts/user_notifier.ex +++ b/lib/towerops/accounts/user_notifier.ex @@ -192,6 +192,31 @@ defmodule Towerops.Accounts.UserNotifier do end end + @doc """ + Deliver a notification confirming account deletion. + + Sent after the account has been deleted to the user's email address. + """ + def deliver_account_deleted_notification(email, name) do + subject = t_email("Your Towerops account has been deleted") + + body = + t_email( + """ + Hi %{name}, + + Your Towerops account has been permanently deleted as requested. + + All your personal data has been removed from our systems. + + If you did not request this deletion, please contact us immediately at hi@towerops.net. + """, + name: name || email + ) + + deliver(email, subject, body) + end + @doc """ Deliver instructions to reset a user password. """ diff --git a/lib/towerops/admin/audit_log.ex b/lib/towerops/admin/audit_log.ex index 75c72da7..e1b6cdd8 100644 --- a/lib/towerops/admin/audit_log.ex +++ b/lib/towerops/admin/audit_log.ex @@ -65,7 +65,9 @@ defmodule Towerops.Admin.AuditLog do "privilege_escalation", "sudo_mode_granted", # Billing overrides - "org_billing_override_updated" + "org_billing_override_updated", + # Self-service account actions + "account_deletion_requested" ]) end end diff --git a/lib/towerops_web/live/user_settings_live.ex b/lib/towerops_web/live/user_settings_live.ex index 3470526a..600e9080 100644 --- a/lib/towerops_web/live/user_settings_live.ex +++ b/lib/towerops_web/live/user_settings_live.ex @@ -8,6 +8,7 @@ defmodule ToweropsWeb.UserSettingsLive do alias Towerops.Accounts alias Towerops.Accounts.HIBP + alias Towerops.Accounts.UserNotifier alias Towerops.Admin.AuditLogger alias ToweropsWeb.UserSettingsLive.ApiTokenManager alias ToweropsWeb.UserSettingsLive.Helpers @@ -46,6 +47,9 @@ defmodule ToweropsWeb.UserSettingsLive do |> assign(:new_device_qr_code, nil) |> assign(:generated_recovery_codes, nil) |> assign(:password_breach_count, nil) + |> assign(:show_delete_modal, false) + |> assign(:sole_owner_orgs, []) + |> assign(:delete_form, to_form(%{"password" => "", "confirmation" => ""}, as: :delete)) {:ok, socket} end @@ -71,6 +75,7 @@ defmodule ToweropsWeb.UserSettingsLive do |> assign(:show_add_device_modal, modal == "add_device") |> assign(:show_device_qr_modal, modal == "device_qr") |> assign(:show_recovery_codes_modal, modal == "recovery_codes") + |> assign(:show_delete_modal, modal == "delete_account") |> assign_login_history() {:noreply, socket} @@ -270,6 +275,65 @@ defmodule ToweropsWeb.UserSettingsLive do {:noreply, TotpManager.delete_device(socket, device_id)} end + # Account deletion + @impl true + def handle_event("show_delete_modal", _params, socket) do + user = socket.assigns.current_scope.user + sole_owner_orgs = Accounts.sole_owner_organizations(user.id) + + socket = + socket + |> assign(:sole_owner_orgs, sole_owner_orgs) + |> assign(:delete_form, to_form(%{"password" => "", "confirmation" => ""}, as: :delete)) + + {:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "delete_account"}))} + end + + @impl true + def handle_event("close_delete_modal", _params, socket) do + {:noreply, push_patch(socket, to: build_settings_path(socket, %{}))} + end + + @impl true + def handle_event("delete_account", %{"password" => password, "confirmation" => confirmation}, socket) do + user = socket.assigns.current_scope.user + + if confirmation == "DELETE" do + case Accounts.delete_account(user, password) do + {:ok, _} -> + # Send farewell email after deletion + UserNotifier.deliver_account_deleted_notification(user.email, user.first_name) + + socket = + socket + |> put_flash(:info, t("Your account has been permanently deleted.")) + |> redirect(to: ~p"/") + + {:noreply, socket} + + {:error, :invalid_password} -> + socket = + socket + |> put_flash(:error, t("Incorrect password")) + |> assign(:delete_form, to_form(%{"password" => "", "confirmation" => ""}, as: :delete)) + + {:noreply, socket} + + {:error, :sole_owner, _org_names} -> + socket = put_flash(socket, :error, t("Cannot delete account while you are the sole owner of an organization.")) + + {:noreply, socket} + end + else + socket = + socket + |> put_flash(:error, t("Please type DELETE to confirm")) + |> assign(:delete_form, to_form(%{"password" => "", "confirmation" => ""}, as: :delete)) + + {:noreply, socket} + end + end + # Recovery codes - delegated to TotpManager @impl true def handle_event("regenerate_recovery_codes", _params, socket) do diff --git a/lib/towerops_web/live/user_settings_live.html.heex b/lib/towerops_web/live/user_settings_live.html.heex index d820732e..d4052051 100644 --- a/lib/towerops_web/live/user_settings_live.html.heex +++ b/lib/towerops_web/live/user_settings_live.html.heex @@ -602,6 +602,50 @@ + + +
+
+

+ {t("Delete Account")} +

+

+ {t( + "Permanently delete your account and all associated data. This action cannot be undone." + )} +

+
+ +
+
+
+ <.icon + name="hero-exclamation-triangle" + class="h-6 w-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" + /> +
+

+ {t("Danger Zone")} +

+

+ {t( + "Once you delete your account, all of your data will be permanently removed. Before deleting, you can export your data from the My Data page." + )} +

+
+ +
+
+
+
+
+
<% end %> @@ -1850,4 +1894,156 @@ <% end %> + + + <%= if @show_delete_modal do %> + + <% end %> diff --git a/test/towerops/accounts/account_deletion_test.exs b/test/towerops/accounts/account_deletion_test.exs new file mode 100644 index 00000000..335b2af1 --- /dev/null +++ b/test/towerops/accounts/account_deletion_test.exs @@ -0,0 +1,174 @@ +defmodule Towerops.Accounts.AccountDeletionTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Accounts + alias Towerops.Accounts.BrowserSession + alias Towerops.Admin.AuditLog + alias Towerops.Repo + + describe "delete_account/2" do + test "deletes user with correct password" do + user = user_fixture() + password = valid_user_password() + + assert {:ok, _} = Accounts.delete_account(user, password) + assert Accounts.get_user(user.id) == nil + end + + test "returns error with wrong password" do + user = user_fixture() + + assert {:error, :invalid_password} = Accounts.delete_account(user, "wrongpassword") + assert Accounts.get_user(user.id) + end + + test "blocks deletion when user is sole owner of an organization" do + user = user_fixture() + org = organization_fixture(user.id, %{name: "My Org"}) + + assert {:error, :sole_owner, org_names} = Accounts.delete_account(user, valid_user_password()) + assert "My Org" in org_names + + # User should still exist + assert Accounts.get_user(user.id) + # Org should still exist + assert Repo.get(Towerops.Organizations.Organization, org.id) + end + + test "allows deletion when org has other owners" do + user = user_fixture() + other_user = user_fixture() + org = organization_fixture(user.id, %{name: "Shared Org"}) + + # Add another owner + membership_fixture(org.id, other_user.id, :owner) + + assert {:ok, _} = Accounts.delete_account(user, valid_user_password()) + assert Accounts.get_user(user.id) == nil + end + + test "anonymizes login history" do + user = user_fixture() + + # Create a login attempt + {:ok, _} = + Accounts.record_login_attempt(%{ + user_id: user.id, + email: user.email, + success: true, + method: "password", + ip_address: "192.168.1.1" + }) + + assert {:ok, _} = Accounts.delete_account(user, valid_user_password()) + + # Login history should be anonymized (user_id set to nil) + attempts = + Towerops.Accounts.LoginAttempt + |> Ecto.Query.where([la], la.email == ^user.email) + |> Repo.all() + + assert [_ | _] = attempts + assert Enum.all?(attempts, &is_nil(&1.user_id)) + assert Enum.all?(attempts, &(not is_nil(&1.anonymized_at))) + end + + test "anonymizes browser sessions before deletion" do + user = user_fixture() + + # Create a browser session + {_token, user_token} = Accounts.generate_user_session_token_with_record(user) + + {:ok, session} = + Accounts.create_browser_session(%{ + user_id: user.id, + user_token_id: user_token.id, + ip_address: "192.168.1.1", + user_agent: "Mozilla/5.0", + last_activity_at: DateTime.utc_now(:second), + expires_at: DateTime.add(DateTime.utc_now(:second), 14, :day) + }) + + # Verify session exists with user_id before deletion + assert Repo.get(BrowserSession, session.id) + + assert {:ok, _} = Accounts.delete_account(user, valid_user_password()) + + # Browser session is removed by cascade (user_token deleted -> browser_session deleted) + # The anonymize runs first within the transaction, then cascade cleanup happens + assert Repo.get(BrowserSession, session.id) == nil + end + + test "creates audit log entry" do + user = user_fixture() + user_email = user.email + + assert {:ok, _} = Accounts.delete_account(user, valid_user_password()) + + # Check audit log was created + # Note: superuser_id and target_user_id are nilified by FK cascade after user deletion + audit_log = + AuditLog + |> Ecto.Query.where([al], al.action == "account_deletion_requested") + |> Repo.one() + + assert audit_log + assert audit_log.metadata["email"] == user_email + assert audit_log.metadata["self_service"] == true + end + + test "user is fully deleted from database" do + user = user_fixture() + + assert {:ok, _} = Accounts.delete_account(user, valid_user_password()) + + # User should be gone + assert Repo.get(Towerops.Accounts.User, user.id) == nil + + # User tokens should be gone + token_count = + Towerops.Accounts.UserToken + |> Ecto.Query.where([t], t.user_id == ^user.id) + |> Repo.aggregate(:count, :id) + + assert token_count == 0 + end + end + + describe "sole_owner_organizations/1" do + test "returns empty list when user has no organizations" do + user = user_fixture() + assert Accounts.sole_owner_organizations(user.id) == [] + end + + test "returns orgs where user is the only owner" do + user = user_fixture() + org = organization_fixture(user.id, %{name: "Solo Org"}) + + result = Accounts.sole_owner_organizations(user.id) + assert length(result) == 1 + assert hd(result).id == org.id + end + + test "does not return orgs where other owners exist" do + user = user_fixture() + other_user = user_fixture() + org = organization_fixture(user.id, %{name: "Shared Org"}) + membership_fixture(org.id, other_user.id, :owner) + + assert Accounts.sole_owner_organizations(user.id) == [] + end + + test "does not return orgs where user is not an owner" do + owner = user_fixture() + user = user_fixture() + org = organization_fixture(owner.id, %{name: "Not My Org"}) + membership_fixture(org.id, user.id, :admin) + + assert Accounts.sole_owner_organizations(user.id) == [] + end + end +end diff --git a/test/towerops_web/live/my_data_live_test.exs b/test/towerops_web/live/my_data_live_test.exs index abf8efa0..aad644a9 100644 --- a/test/towerops_web/live/my_data_live_test.exs +++ b/test/towerops_web/live/my_data_live_test.exs @@ -2,59 +2,240 @@ defmodule ToweropsWeb.AccountLive.MyDataTest do use ToweropsWeb.ConnCase import Phoenix.LiveViewTest + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Accounts + alias Towerops.Organizations setup :register_and_log_in_user_with_sudo 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) + test "renders profile information", %{conn: conn, user: user} do {:ok, _view, html} = live(conn, ~p"/users/my-data") assert html =~ "My Data" assert html =~ "Profile Information" - assert html =~ "Organizations" - assert html =~ "Devices" - assert html =~ "Audit Log" + assert html =~ user.email 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) + test "displays user name when set", %{conn: conn, user: user} do + {:ok, _user} = Accounts.update_user_profile(user, %{first_name: "Jane", last_name: "Doe"}) + + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "Jane" + assert html =~ "Doe" + end + + test "shows consent records from registration", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "Consent Records" + # User fixture grants privacy_policy and terms_of_service consents + assert html =~ "Privacy policy" + assert html =~ "Terms of service" + assert html =~ "Active" + end + + test "shows revoke consent button for active consents", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "Revoke Consent" + end + + test "revoking consent updates the display", %{conn: conn, user: user} do + consent = Accounts.get_active_consent(user.id, "privacy_policy") + + {:ok, view, _html} = live(conn, ~p"/users/my-data") + + html = + view + |> element(~s|button[phx-click=revoke_consent][phx-value-consent-id="#{consent.id}"]|) + |> render_click() + + assert html =~ "Consent revoked successfully" + assert html =~ "Revoked" + end + + test "hides org/device/alert sections when user has no orgs", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + # User from fixture has no organizations, so owner-only sections are hidden + refute html =~ "Organizations you own" + refute html =~ "Network devices monitored" + refute html =~ "Recent Alerts" + end + + test "shows export data section with download link", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "Export Your Data" + assert html =~ "Download My Data (JSON)" + assert html =~ "/api/v1/account/data" + end + + test "shows data processing information", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "How We Use Your Data" + assert html =~ "Profile Information" + assert html =~ "Email Address" + assert html =~ "Privacy Policy" + end + + test "shows navigation links to settings tabs", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "Personal" + assert html =~ "Account" + assert html =~ "Sessions" + assert html =~ "Security" + end + end + + describe "My Data page with organization data" do + setup %{user: user} do + {:ok, org} = Organizations.create_organization(%{name: "WISP Corp"}, user.id) {:ok, site} = Towerops.Sites.create_site(%{ - name: "Test Site", - organization_id: organization.id + name: "Tower Alpha", + organization_id: org.id }) {:ok, device} = Towerops.Devices.create_device(%{ - name: "Test Device", - ip_address: "192.168.1.1", + name: "MikroTik Router", + ip_address: "10.0.0.1", site_id: site.id, - organization_id: organization.id, + organization_id: org.id, monitoring_enabled: true }) - # Create an alert + %{org: org, site: site, device: device} + end + + test "shows organizations section for owners", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "Organizations" + assert html =~ "WISP Corp" + assert html =~ "Owner" + end + + test "shows devices section with device details", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "Devices" + assert html =~ "MikroTik Router" + assert html =~ "10.0.0.1" + assert html =~ "WISP Corp" + end + + test "shows device count", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "Total devices: 1" + end + + test "shows alerts section with alert data", %{conn: conn, device: device} do {:ok, _alert} = Towerops.Alerts.create_alert(%{ device_id: device.id, alert_type: "device_down", triggered_at: DateTime.utc_now(), - message: "Device is down" + message: "Device unreachable" }) {:ok, _view, html} = live(conn, ~p"/users/my-data") - assert html =~ "My Data" - assert html =~ "Profile Information" - assert html =~ "Organizations" - assert html =~ "Devices" assert html =~ "Recent Alerts" + assert html =~ "device_down" + assert html =~ "MikroTik Router" + end + + test "shows correct alert status badges", %{conn: conn, device: device} do + # Active alert + {:ok, _} = + Towerops.Alerts.create_alert(%{ + device_id: device.id, + alert_type: "device_down", + triggered_at: DateTime.utc_now(), + message: "Still down" + }) + + # Resolved alert + {:ok, _} = + Towerops.Alerts.create_alert(%{ + device_id: device.id, + alert_type: "high_latency", + triggered_at: DateTime.add(DateTime.utc_now(), -3600, :second), + resolved_at: DateTime.utc_now(), + message: "Latency normalized" + }) + + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "Active" + assert html =~ "Resolved" + end + + test "shows no alerts message when none exist", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "No alerts in the last 90 days" + end + end + + describe "My Data page as non-owner member" do + setup %{user: user} do + # Create org owned by someone else, add test user as viewer + owner = user_fixture() + org = organization_fixture(owner.id, %{name: "Other Org"}) + membership_fixture(org.id, user.id, :viewer) + + %{org: org} + end + + test "hides org/device/alert sections for non-owner members", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + # Non-owner members should not see org-level data + refute html =~ "Organizations you own" + refute html =~ "Network devices monitored" + refute html =~ "Recent Alerts" + end + + test "still shows profile and consent sections", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "Profile Information" + assert html =~ "Consent Records" + assert html =~ "Export Your Data" + end + end + + describe "My Data page audit logs" do + test "shows audit log section", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/my-data") + assert html =~ "Audit Log" - assert html =~ "Test Org" - assert html =~ "Test Device" - assert html =~ "192.168.1.1" + end + + test "shows audit log entries when they exist", %{conn: conn, user: user} do + # The sudo_mode_granted event from setup creates an audit log + {:ok, _} = + Towerops.Admin.create_audit_log(%{ + action: "user_data_viewed", + superuser_id: user.id, + target_user_id: user.id, + metadata: %{} + }) + + {:ok, _view, html} = live(conn, ~p"/users/my-data") + + assert html =~ "user_data_viewed" end end end diff --git a/test/towerops_web/live/user_settings_live/delete_account_test.exs b/test/towerops_web/live/user_settings_live/delete_account_test.exs new file mode 100644 index 00000000..a4c93957 --- /dev/null +++ b/test/towerops_web/live/user_settings_live/delete_account_test.exs @@ -0,0 +1,97 @@ +defmodule ToweropsWeb.UserSettingsLive.DeleteAccountTest do + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Accounts + + describe "delete account danger zone" do + setup :register_and_log_in_user_with_sudo + + test "renders danger zone on account tab", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/users/settings?tab=account") + + assert html =~ "Delete Account" + assert html =~ "Permanently delete your account" + end + + test "opens delete confirmation modal", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/users/settings?tab=account") + + html = view |> element("[phx-click=show_delete_modal]") |> render_click() + + assert html =~ "Are you sure you want to delete your account?" + assert html =~ "This action cannot be undone" + end + + test "closes delete confirmation modal", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/users/settings?tab=account&modal=delete_account") + + html = view |> element("button[phx-click=close_delete_modal]") |> render_click() + + refute html =~ "Are you sure you want to delete your account?" + end + + test "successfully deletes account with correct password and confirmation", %{conn: conn, user: user} do + {:ok, view, _html} = live(conn, ~p"/users/settings?tab=account&modal=delete_account") + + assert {:error, {:redirect, %{to: "/"}}} = + view + |> form("#delete_account_form", %{ + "password" => valid_user_password(), + "confirmation" => "DELETE" + }) + |> render_submit() + + # User should be deleted + assert Accounts.get_user(user.id) == nil + end + + test "rejects deletion with wrong password", %{conn: conn, user: user} do + {:ok, view, _html} = live(conn, ~p"/users/settings?tab=account&modal=delete_account") + + html = + view + |> form("#delete_account_form", %{ + "password" => "wrongpassword", + "confirmation" => "DELETE" + }) + |> render_submit() + + assert html =~ "Incorrect password" + + # User should still exist + assert Accounts.get_user(user.id) + end + + test "rejects deletion without typing DELETE", %{conn: conn, user: user} do + {:ok, view, _html} = live(conn, ~p"/users/settings?tab=account&modal=delete_account") + + html = + view + |> form("#delete_account_form", %{ + "password" => valid_user_password(), + "confirmation" => "nope" + }) + |> render_submit() + + assert html =~ "Please type DELETE to confirm" + + # User should still exist + assert Accounts.get_user(user.id) + end + + test "shows sole owner warning and blocks deletion", %{conn: conn, user: user} do + _org = organization_fixture(user.id, %{name: "My Test Org"}) + + {:ok, view, _html} = live(conn, ~p"/users/settings?tab=account") + + html = view |> element("[phx-click=show_delete_modal]") |> render_click() + + assert html =~ "My Test Org" + assert html =~ "sole owner" + end + end +end