8000 feat(api): let people delete their own account by mwoods79 · Pull Request #703 · huddlz-hq/huddlz · GitHub
[go: up one dir, main page]

Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/huddlz/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ defmodule Huddlz.Accounts do
define :update_role, action: :update_role, args: [:role]
define :suspend_user, action: :suspend, args: [:reason]
define :restore_user, action: :restore
define :delete_account, action: :delete_account, args: [:current_password]
define :get_by_email, action: :get_by_email, args: [:email]
define :update_display_name, action: :update_display_name, args: [:display_name]

Expand Down
42 changes: 42 additions & 0 deletions lib/huddlz/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,34 @@ defmodule Huddlz.Accounts.User do
change set_attribute(:suspended_by_id, nil)
end

update :delete_account do
description """
Delete the actor's own account after they confirm their password. The
row is anonymized rather than removed, so shared history keeps its
author; see `Huddlz.Accounts.User.Changes.DeleteAccount`.
"""

require_atomic? false
accept []
argument :current_password, :string, sensitive?: true, allow_nil?: false

validate present(:current_password), message: "Enter your password to delete your account."

validate absent(:deleted_at) do
message "is already deleted"
end

change get_and_lock(:for_update)

validate {AshAuthentication.Strategy.Password.PasswordValidation,
strategy_name: :password, password_argument: :current_password} do
before_action? true
where present(:current_password)
end

change Huddlz.Accounts.User.Changes.DeleteAccount
end

update :update_theme_preference do
description "Choose whether huddlz follows the device appearance or stays light or dark"
accept [:theme_preference]
Expand Down Expand Up @@ -760,6 +788,7 @@ defmodule Huddlz.Accounts.User do
bypass actor_attribute_equals(:role, :admin) do
forbid_if action(:update_role)
forbid_if action([:suspend, :restore])
forbid_if action(:delete_account)
authorize_if always()
end

Expand Down Expand Up @@ -862,6 +891,11 @@ defmodule Huddlz.Accounts.User do
authorize_if expr(id == ^actor(:id))
end

policy action(:delete_account) do
description "People can delete only their own account"
authorize_if expr(id == ^actor(:id))
end

policy action(:set_password) do
description "Users can set their own password"
authorize_if expr(id == ^actor(:id))
Expand Down Expand Up @@ -975,6 +1009,10 @@ defmodule Huddlz.Accounts.User do
constraints max_length: 500
end

attribute :deleted_at, :utc_datetime_usec do
description "When the person deleted their account; the row is kept anonymized"
end

attribute :theme_preference, Huddlz.Accounts.ThemePreference do
description "Follow the device appearance, or always light or dark"
allow_nil? false
Expand Down Expand Up @@ -1072,6 +1110,10 @@ defmodule Huddlz.Accounts.User do
def admin?(%{role: :admin}), do: true
def admin?(_), do: false

@doc "True once the person has deleted their account. Nil-safe."
def deleted?(%{deleted_at: %DateTime{}}), do: true
def deleted?(_), do: false

@doc "True while the account is suspended. Nil-safe."
def suspended?(%{suspended_at: %DateTime{}}), do: true
def s 8000 uspended?(_), do: false
Expand Down
139 changes: 139 additions & 0 deletions lib/huddlz/accounts/user/changes/delete_account.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
defmodule Huddlz.Accounts.User.Changes.DeleteAccount do
@moduledoc """
Everything a person's own account deletion sets in motion.

The row stays so that records which must keep an author (a huddl's
creator, a past attendance, an audit version) still point somewhere, but
nothing personal remains on it: the email becomes an unreachable
placeholder, the name reads "Deleted member", and the password, home
location, pending email change and preferences are cleared. The person
can register again later with the same address.

Inside the transaction: every stored token is revoked, API keys are
destroyed, profile pictures are removed, upcoming RSVP and waitlist spots
are released, and the person leaves every group they do not own. Groups
they own are archived, so members keep read-only history; archiving needs
no upcoming huddlz, so those are cancelled first and their attendees told.

Everything a nested write records is attributed to the person through
`Huddlz.Audit.nested_opts/2`.
"""

use Ash.Resource.Change

alias Huddlz.Accounts.{ApiKey, ProfilePicture, Token, User}
alias Huddlz.Communities.{Group, GroupMember, Huddl, ReleaseSpots}

require Ash.Query

@display_name "Deleted member"
@cancellation_reason "The organizer closed their huddlz account."

@doc "The name a deleted account is shown as."
def display_name, do: @display_name

@impl true
def change(changeset, _opts, _context) do
changeset
|> Ash.Changeset.force_change_attributes(%{
email: "deleted-#{changeset.data.id}@deleted.huddlz.invalid",
display_name: @display_name,
hashed_password: nil,
pending_email_change: nil,
email_change_resends: [],
confirmation_destination: nil,
home_location: nil,
home_latitude: nil,
home_longitude: nil,
home_time_zone: nil,
notification_preferences: %{},
deleted_at: DateTime.utc_now()
})
|> Ash.Changeset.after_action(&cut_ties/2)
end

defp cut_ties(changeset, %User{} = user) do
opts = Huddlz.Audit.nested_opts(changeset)

revoke_tokens(user)
destroy_api_keys(user, opts)
remove_profile_pictures(user, opts)
ReleaseSpots.release_upcoming(user, opts)
archive_owned_groups(user, opts)
leave_groups(user, opts)
{:ok, user}
end

defp revoke_tokens(user) do
subject = AshAuthentication.user_to_subject(user)

%{status: :success} =
Token
|> Ash.bulk_update(:revoke_all_stored_for_subject, %{subject: subject},
authorize?: false,
strategy: [:atomic, :atomic_batches, :stream],
return_errors?: true,
stop_on_error?: true
)

:ok
end

defp destroy_api_keys(user, opts) do
ApiKey
|> Ash.Query.filter(user_id == ^user.id)
|> Ash.read!(authorize?: false)
|> Enum.each(fn key ->
key
|> Ash.Changeset.for_destroy(:destroy, %{}, opts)
|> Ash.destroy!(authorize?: false)
end)
end

defp remove_profile_pictures(user, opts) do
ProfilePicture
|> Ash.Query.filter(user_id == ^user.id and is_nil(deleted_at))
|> Ash.read!(authorize?: false)
|> Enum.each(fn picture ->
picture
|> Ash.Changeset.for_update(:soft_delete, %{}, opts)
|> Ash.update!(authorize?: false)
end)
end

defp archive_owned_groups(user, opts) do
Group
|> Ash.Query.filter(owner_id == ^user.id)
|> Ash.read!(authorize?: false)
|> Enum.each(fn group ->
cancel_upcoming_huddlz(group, opts)

group
|> Ash.Changeset.for_destroy(:archive, %{}, opts)
|> Ash.destroy!(authorize?: false)
end)
end

defp cancel_upcoming_huddlz(group, opts) do
Huddl
|> Ash.Query.for_read(:archive_blockers, %{}, authorize?: false)
|> Ash.Query.filter(group_id == ^group.id)
|> Ash.read!()
|> Enum.each(fn huddl ->
huddl
|> Ash.Changeset.for_update(:cancel, %{cancellation_reason: @cancellation_reason}, opts)
|> Ash.update!(authorize?: false)
end)
end

defp leave_groups(user, opts) do
GroupMember
|> Ash.Query.filter(user_id == ^user.id and role != :owner)
|> Ash.read!(authorize?: false)
|> Enum.each(fn membership ->
membership
|> Ash.Changeset.for_destroy(:leave_group, %{}, opts)
|> Ash.destroy!(authorize?: false)
end)
end
end
15 changes: 9 additions & 6 deletions lib/huddlz/notifications.ex
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ defmodule Huddlz.Notifications do
_ = Triggers.fetch!(trigger)
payload = Map.delete(payload, "virtual_link")

if reaches_suspended?(user, trigger) do
if reachable?(user, trigger) do
enqueue(user_id, trigger, payload)
else
{:ok, :skipped}
Expand Down Expand Up @@ -271,16 +271,19 @@ defmodule Huddlz.Notifications do
"""
@spec should_deliver?(User.t(), atom(), map()) :: boolean()
def should_deliver?(user, trigger, entry) do
reaches_suspended?(user, trigger) and user_can_receive?(user, entry.category) and
reachable?(user, trigger) and user_can_receive?(user, entry.category) and
preference_allows?(user, trigger, entry)
end

# A suspended account hears from huddlz exactly once, about the
# suspension itself. Nothing else reaches it, in-app or by email.
defp reaches_suspended?(%User{suspended_at: %DateTime{}}, trigger),
# A deleted account hears nothing at all. A suspended account hears from
# huddlz exactly once, about the suspension itself. Nothing else reaches
# it, in-app or by email.
defp reachable?(%User{deleted_at: %DateTime{}}, _trigger), do: false

defp reachable?(%User{suspended_at: %DateTime{}}, trigger),
do: trigger == :account_suspended

defp reaches_suspended?(_user, _trigger), do: true
defp reachable?(_user, _trigger), do: true

# Transactional security emails (password changed, account removed, etc.)
# must go through even before email confirmation — otherwise an attacker
Expand Down
35 changes: 35 additions & 0 deletions lib/huddlz_web/controllers/api/auth_controller.ex
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
defmodule HuddlzWeb.Api.AuthController do
use HuddlzWeb, :controller

alias AshAuthentication.Errors.AuthenticationFailed
alias AshAuthentication.TokenResource
alias AshRateLimiter.LimitExceeded
alias Huddlz.Accounts
alias Huddlz.Accounts.{ApiKey, Token, User}

def register(conn, params) do
Expand Down Expand Up @@ -80,6 +82,39 @@ defmodule HuddlzWeb.Api.AuthController do
end
end

def delete_account(conn, params) do
case conn.assigns[:current_user] do
%User{} = user -> destroy_account(conn, user, Map.get(params, "password"))
_ -> auth_required(conn)
end
end

defp destroy_account(conn, user, password) do
case Accounts.delete_account(user, password, actor: user) do
{:ok, _user} ->
send_resp(conn, :no_content, "")

{:error, %Ash.Error.Forbidden{} = error} ->
if authentication_failed?(error) do
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: [%{field: "password", message: "That password isn't right."}]})
else
conn
|> put_status(:forbidden)
|> json(%{error: "You can't delete this account"})
end

{:error, error} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(error)})
end
end

defp authentication_failed?(%{errors: errors}),
do: Enum.any?(errors, &match?(%AuthenticationFailed{}, &1))

def password_reset(conn, params) do
result =
case Map.get(params, "email") do
Expand Down
1 change: 1 addition & 0 deletions lib/huddlz_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ defmodule HuddlzWeb.Router do
post "/sign_in", AuthController, :sign_in
get "/me", AuthController, :me
delete "/sign_out", AuthController, :sign_out
delete "/account", AuthController, :delete_account
post "/password_reset", AuthController, :password_reset
post "/api_keys", AuthController, :create_api_key
get "/api_keys", AuthController, :list_api_keys
Expand Down
21 changes: 21 additions & 0 deletions priv/repo/migrations/20261107030000_add_account_deletion.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
defmodule Huddlz.Repo.Migrations.AddAccountDeletion do
@moduledoc """
Updates resources based on their most recent snapshots.

This file was autogenerated with `mix ash_postgres.generate_migrations`
"""

use Ecto.Migration

def up do
alter table(:users) do
add :deleted_at, :utc_datetime_usec
end
end

def down do
alter table(:users) do
remove :deleted_at
end
end
end
Loading
Loading
0