parent
8dae83a592
commit
0c2c4cc354
8 changed files with 847 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -602,6 +602,50 @@
|
|||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
|
||||
<!-- Delete Account Danger Zone -->
|
||||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8 border-t border-red-200 dark:border-red-900/50">
|
||||
<div>
|
||||
<h2 class="text-base/7 font-semibold text-red-600 dark:text-red-400">
|
||||
{t("Delete Account")}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
"Permanently delete your account and all associated data. This action cannot be undone."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-900/50 dark:bg-red-950/20">
|
||||
<div class="flex items-start gap-3">
|
||||
<.icon
|
||||
name="hero-exclamation-triangle"
|
||||
class="h-6 w-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-red-800 dark:text-red-300">
|
||||
{t("Danger Zone")}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-red-700 dark:text-red-400">
|
||||
{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."
|
||||
)}
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="show_delete_modal"
|
||||
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400"
|
||||
>
|
||||
{t("Delete My Account")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- API Tab -->
|
||||
|
|
@ -1850,4 +1894,156 @@
|
|||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Delete Account Confirmation Modal -->
|
||||
<%= if @show_delete_modal do %>
|
||||
<div
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="delete-account-modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
|
||||
<div
|
||||
phx-click="close_delete_modal"
|
||||
class="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-950 dark:bg-opacity-75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
​
|
||||
</span>
|
||||
|
||||
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-gray-800/95 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
|
||||
<div>
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
|
||||
<.icon
|
||||
name="hero-exclamation-triangle"
|
||||
class="h-6 w-6 text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<h3
|
||||
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
|
||||
id="delete-account-modal-title"
|
||||
>
|
||||
{t("Are you sure you want to delete your account?")}
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
"This action cannot be undone. All of your data will be permanently removed."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @sole_owner_orgs != [] do %>
|
||||
<div class="mt-4 rounded-md bg-yellow-50 p-4 dark:bg-yellow-900/20">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
{t("You are the sole owner of these organizations:")}
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
<%= for org <- @sole_owner_orgs do %>
|
||||
<li>{org.name}</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<p class="mt-2">
|
||||
{t(
|
||||
"Please transfer ownership or delete these organizations before deleting your account."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="close_delete_modal"
|
||||
class="inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800"
|
||||
>
|
||||
{t("Close")}
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
<.form
|
||||
for={@delete_form}
|
||||
phx-submit="delete_account"
|
||||
id="delete_account_form"
|
||||
class="mt-4"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="delete-password"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{t("Enter your password")}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="delete-password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-red-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-red-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="delete-confirmation"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{t("Type DELETE to confirm")}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="confirmation"
|
||||
id="delete-confirmation"
|
||||
required
|
||||
placeholder="DELETE"
|
||||
autocomplete="off"
|
||||
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-red-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-red-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
phx-disable-with={t("Deleting...")}
|
||||
class="inline-flex w-full justify-center rounded-lg bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 sm:col-start-2"
|
||||
>
|
||||
{t("Permanently Delete Account")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="close_delete_modal"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800 sm:col-start-1 sm:mt-0"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</Layouts.authenticated>
|
||||
|
|
|
|||
174
test/towerops/accounts/account_deletion_test.exs
Normal file
174
test/towerops/accounts/account_deletion_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue