diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 066aa912..58c6f678 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -17,6 +17,15 @@ class Settings::ProfilesController < ApplicationController end end + def destroy + if Current.user.deactivate + logout + redirect_to root_path, notice: t(".success") + else + redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence + end + end + private def user_params diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js index 9ab9e865..f25e31b6 100644 --- a/app/javascript/controllers/application.js +++ b/app/javascript/controllers/application.js @@ -10,7 +10,7 @@ Turbo.setConfirmMethod((message) => { const dialog = document.getElementById("turbo-confirm"); try { - const { title, body, accept } = JSON.parse(message); + const { title, body, accept, acceptClass } = JSON.parse(message); if (title) { document.getElementById("turbo-confirm-title").innerHTML = title; @@ -23,6 +23,10 @@ Turbo.setConfirmMethod((message) => { if (accept) { document.getElementById("turbo-confirm-accept").innerHTML = accept; } + + if (acceptClass) { + document.getElementById("turbo-confirm-accept").className = acceptClass; + } } catch (e) { document.getElementById("turbo-confirm-title").innerText = message; } diff --git a/app/jobs/user_purge_job.rb b/app/jobs/user_purge_job.rb new file mode 100644 index 00000000..ff997807 --- /dev/null +++ b/app/jobs/user_purge_job.rb @@ -0,0 +1,7 @@ +class UserPurgeJob < ApplicationJob + queue_as :default + + def perform(user) + user.purge + end +end diff --git a/app/models/user.rb b/app/models/user.rb index d5e938b6..b6f2ffb3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -38,4 +38,40 @@ class User < ApplicationRecord def has_seen_upgrade_alert?(upgrade) last_alerted_upgrade_commit_sha == upgrade.commit_sha end + + # Deactivation + validate :can_deactivate, if: -> { active_changed? && !active } + after_update_commit :purge_later, if: -> { saved_change_to_active?(from: true, to: false) } + + def deactivate + update active: false, email: deactivated_email + end + + def can_deactivate + if admin? && family.users.count > 1 + errors.add(:base, I18n.t("activerecord.errors.user.cannot_deactivate_admin_with_other_users")) + end + end + + def purge_later + UserPurgeJob.perform_later(self) + end + + def purge + if last_user_in_family? + family.destroy + else + destroy + end + end + + private + + def last_user_in_family? + family.users.count == 1 + end + + def deactivated_email + email.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@") + end end diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index 12b49e5c..cde7d2b4 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -61,9 +61,16 @@

<%= t(".delete_account") %>

<%= t(".delete_account_warning") %>

- + <%= + button_to t(".delete_account"), settings_profile_path, method: :delete, + class: "bg-red-500 text-white text-sm font-medium rounded-lg px-3 py-2", + data: { turbo_confirm: { + title: t(".confirm_delete.title"), + body: t(".confirm_delete.body"), + accept: t(".delete_account"), + acceptClass: "w-full bg-red-500 text-white rounded-xl text-center p-[10px] border mb-2" + }} + %> <% end %> diff --git a/app/views/shared/_confirm_modal.html.erb b/app/views/shared/_confirm_modal.html.erb index 8eb7e86b..5a935d22 100644 --- a/app/views/shared/_confirm_modal.html.erb +++ b/app/views/shared/_confirm_modal.html.erb @@ -11,6 +11,7 @@ <%= t(".body_html") %> - + + diff --git a/config/locales/models/user/en.yml b/config/locales/models/user/en.yml index 8fb5de87..43307ed2 100644 --- a/config/locales/models/user/en.yml +++ b/config/locales/models/user/en.yml @@ -10,3 +10,7 @@ en: last_name: Last Name password: Password password_confirmation: Password Confirmation + errors: + user: + cannot_deactivate_admin_with_other_users: Admin cannot delete account while + other users are present. Please delete all members first. diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 6837a387..714eec24 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -82,8 +82,14 @@ en: update: success: Preferences updated successfully. profiles: + destroy: + success: Account deleted successfully. show: add_member: Add Member + confirm_delete: + body: Are you sure you want to permanently delete your account? This action + is irreversible. + title: Delete account? danger_zone_title: Danger Zone delete_account: Delete Account delete_account_warning: Deleting your account will permanently remove all diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index 8ae92784..7b78ab73 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -4,6 +4,7 @@ en: confirm_modal: accept: Confirm body_html: "

You will not be able to undo this decision

" + cancel: Cancel title: Are you sure? notification: dismiss: Dismiss diff --git a/config/routes.rb b/config/routes.rb index 0133e25e..33951493 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,7 +11,7 @@ Rails.application.routes.draw do resource :password namespace :settings do - resource :profile, only: %i[show update] + resource :profile, only: %i[show update destroy] resource :preferences, only: %i[show update] resource :notifications, only: %i[show update] resource :billing, only: %i[show update] diff --git a/db/migrate/20240430111641_add_active_to_users.rb b/db/migrate/20240430111641_add_active_to_users.rb new file mode 100644 index 00000000..2441f225 --- /dev/null +++ b/db/migrate/20240430111641_add_active_to_users.rb @@ -0,0 +1,5 @@ +class AddActiveToUsers < ActiveRecord::Migration[7.2] + def change + add_column :users, :active, :boolean, default: true, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index c1a5a895..98ea25e0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_04_26_191312) do +ActiveRecord::Schema[7.2].define(version: 2024_04_30_111641) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -86,7 +86,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_26_191312) do t.uuid "accountable_id" t.decimal "balance", precision: 19, scale: 4, default: "0.0" t.string "currency", default: "USD" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Account::Loan'::character varying)::text, ('Account::Credit'::character varying)::text, ('Account::OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Account::Loan'::character varying, 'Account::Credit'::character varying, 'Account::OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.boolean "is_active", default: true, null: false t.enum "status", default: "ok", null: false, enum_type: "account_status" t.jsonb "sync_warnings", default: "[]", null: false @@ -257,6 +257,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_26_191312) do t.string "last_prompted_upgrade_commit_sha" t.string "last_alerted_upgrade_commit_sha" t.enum "role", default: "member", null: false, enum_type: "user_role" + t.boolean "active", default: true, null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" end diff --git a/test/controllers/settings/profiles_controller_test.rb b/test/controllers/settings/profiles_controller_test.rb index dac21cf0..eb14c554 100644 --- a/test/controllers/settings/profiles_controller_test.rb +++ b/test/controllers/settings/profiles_controller_test.rb @@ -4,8 +4,39 @@ class Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest setup do sign_in @user = users(:family_admin) end + test "get" do get settings_profile_url assert_response :success end + + test "member can deactivate their account" do + sign_in @member = users(:family_member) + delete settings_profile_url + + assert_redirected_to root_url + + assert_not User.find(@member.id).active? + assert_enqueued_with(job: UserPurgeJob, args: [ @member ]) + end + + test "admin prevented from deactivating when other users are present" do + sign_in @admin = users(:family_admin) + delete settings_profile_url + + assert_redirected_to settings_profile_url + assert_no_enqueued_jobs only: UserPurgeJob + assert User.find(@admin.id).active? + end + + test "admin can deactivate their account when they are the last user in the family" do + sign_in @admin = users(:family_admin) + users(:family_member).destroy + + delete settings_profile_url + + assert_redirected_to root_url + assert_not User.find(@admin.id).active? + assert_enqueued_with(job: UserPurgeJob, args: [ @admin ]) + end end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index fa2bf2f8..a78a3942 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -4,6 +4,7 @@ family_admin: last_name: Dylan email: bob@bobdylan.com password_digest: <%= BCrypt::Password.create('password') %> + role: admin family_member: family: dylan_family diff --git a/test/jobs/user_purge_job_test.rb b/test/jobs/user_purge_job_test.rb new file mode 100644 index 00000000..9fb2ae63 --- /dev/null +++ b/test/jobs/user_purge_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class UserPurgeJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end