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."
+ )}
+
+
+
+ {t("Delete My Account")}
+
+
+
+
+
+
+
<% end %>
@@ -1850,4 +1894,156 @@
<% end %>
+
+
+ <%= if @show_delete_modal do %>
+
+
+
+
+
+
+
+
+
+
+
+ <.icon
+ name="hero-exclamation-triangle"
+ class="h-6 w-6 text-red-600 dark:text-red-400"
+ />
+
+
+
+ {t("Are you sure you want to delete your account?")}
+
+
+
+ {t(
+ "This action cannot be undone. All of your data will be permanently removed."
+ )}
+
+
+
+
+
+ <%= if @sole_owner_orgs != [] do %>
+
+
+
+ <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" />
+
+
+
+ {t("You are the sole owner of these organizations:")}
+
+
+
+ <%= for org <- @sole_owner_orgs do %>
+ {org.name}
+ <% end %>
+
+
+ {t(
+ "Please transfer ownership or delete these organizations before deleting your account."
+ )}
+
+
+
+
+
+
+
+
+ {t("Close")}
+
+
+ <% else %>
+ <.form
+ for={@delete_form}
+ phx-submit="delete_account"
+ id="delete_account_form"
+ class="mt-4"
+ >
+
+
+
+ {t("Enter your password")}
+
+
+
+
+
+
+
+ {t("Type DELETE to confirm")}
+
+
+
+
+
+
+
+
+
+ {t("Permanently Delete Account")}
+
+
+ {t("Cancel")}
+
+
+
+ <% end %>
+
+
+
+ <% 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