From 8208722247e4d3c8c9f9130e088a6aa69835b75c Mon Sep 17 00:00:00 2001 From: Tony Vincent Date: Fri, 28 Feb 2025 13:49:12 +0100 Subject: [PATCH] Feat: Data "reset" button (#1913) * feat: Allow admins to delete family data * feat: Allow self-hosting users to delete cached data * Remove system tests --- .../settings/hostings_controller.rb | 10 ++++ app/controllers/users_controller.rb | 10 ++++ app/jobs/data_cache_clear_job.rb | 16 +++++++ app/jobs/family_reset_job.rb | 19 ++++++++ .../hostings/_danger_zone_settings.html.erb | 20 ++++++++ app/views/settings/hostings/show.html.erb | 4 ++ app/views/settings/profiles/show.html.erb | 48 +++++++++++++------ config/locales/views/settings/en.yml | 5 ++ config/locales/views/settings/hostings/en.yml | 9 ++++ config/locales/views/users/en.yml | 3 ++ config/routes.rb | 8 +++- .../settings/hostings_controller_test.rb | 35 ++++++++++++++ test/controllers/users_controller_test.rb | 35 ++++++++++++++ 13 files changed, 206 insertions(+), 16 deletions(-) create mode 100644 app/jobs/data_cache_clear_job.rb create mode 100644 app/jobs/family_reset_job.rb create mode 100644 app/views/settings/hostings/_danger_zone_settings.html.erb diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index fa88d23c..0740b0bc 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -2,6 +2,7 @@ class Settings::HostingsController < ApplicationController layout "settings" before_action :raise_if_not_self_hosted + before_action :ensure_admin, only: :clear_cache def show @synth_usage = Current.family.synth_usage @@ -38,6 +39,11 @@ class Settings::HostingsController < ApplicationController render :show, status: :unprocessable_entity end + def clear_cache + DataCacheClearJob.perform_later(Current.family) + redirect_to settings_hosting_path, notice: t(".cache_cleared") + end + private def hosting_params params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key) @@ -46,4 +52,8 @@ class Settings::HostingsController < ApplicationController def raise_if_not_self_hosted raise "Settings not available on non-self-hosted instance" unless self_hosted? end + + def ensure_admin + redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin? + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index d8d0de8a..9b82509d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,5 +1,6 @@ class UsersController < ApplicationController before_action :set_user + before_action :ensure_admin, only: :reset def update @user = Current.user @@ -26,6 +27,11 @@ class UsersController < ApplicationController end end + def reset + FamilyResetJob.perform_later(Current.family) + redirect_to settings_profile_path, notice: t(".success") + end + def destroy if @user.deactivate Current.session.destroy @@ -68,4 +74,8 @@ class UsersController < ApplicationController def set_user @user = Current.user end + + def ensure_admin + redirect_to settings_profile_path, alert: I18n.t("users.reset.unauthorized") unless Current.user.admin? + end end diff --git a/app/jobs/data_cache_clear_job.rb b/app/jobs/data_cache_clear_job.rb new file mode 100644 index 00000000..49e18880 --- /dev/null +++ b/app/jobs/data_cache_clear_job.rb @@ -0,0 +1,16 @@ +class DataCacheClearJob < ApplicationJob + queue_as :default + + def perform(family) + ActiveRecord::Base.transaction do + ExchangeRate.delete_all + Security::Price.delete_all + family.accounts.each do |account| + account.balances.delete_all + account.holdings.delete_all + end + + family.sync_later + end + end +end diff --git a/app/jobs/family_reset_job.rb b/app/jobs/family_reset_job.rb new file mode 100644 index 00000000..20dc2499 --- /dev/null +++ b/app/jobs/family_reset_job.rb @@ -0,0 +1,19 @@ +class FamilyResetJob < ApplicationJob + queue_as :default + + def perform(family) + # Delete all family data except users + ActiveRecord::Base.transaction do + # Delete accounts and related data + family.accounts.destroy_all + family.categories.destroy_all + family.tags.destroy_all + family.merchants.destroy_all + family.plaid_items.destroy_all + family.imports.destroy_all + family.budgets.destroy_all + + family.sync_later + end + end +end diff --git a/app/views/settings/hostings/_danger_zone_settings.html.erb b/app/views/settings/hostings/_danger_zone_settings.html.erb new file mode 100644 index 00000000..7f04c4e2 --- /dev/null +++ b/app/views/settings/hostings/_danger_zone_settings.html.erb @@ -0,0 +1,20 @@ +<% if Current.user.admin? %> +
+
+
+

<%= t("settings.hostings.show.clear_cache") %>

+

<%= t("settings.hostings.show.clear_cache_warning") %>

+
+ <%= + button_to t("settings.hostings.show.clear_cache"), clear_cache_settings_hosting_path, method: :delete, + class: "bg-orange-500 text-white text-sm font-medium rounded-lg px-4 py-2", + data: { turbo_confirm: { + title: t("settings.hostings.show.confirm_clear_cache.title"), + body: t("settings.hostings.show.confirm_clear_cache.body"), + accept: t("settings.hostings.show.clear_cache"), + acceptClass: "w-full bg-orange-500 text-white rounded-xl text-center p-[10px] border mb-2" + }} + %> +
+
+<% end %> diff --git a/app/views/settings/hostings/show.html.erb b/app/views/settings/hostings/show.html.erb index ca74914a..bd1916f3 100644 --- a/app/views/settings/hostings/show.html.erb +++ b/app/views/settings/hostings/show.html.erb @@ -11,3 +11,7 @@ <%= settings_section title: t(".invites") do %> <%= render "settings/hostings/invite_code_settings" %> <% end %> + +<%= settings_section title: t(".danger_zone") do %> + <%= render "settings/hostings/danger_zone_settings" %> +<% end %> diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index 165f9b1c..168f1472 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -127,20 +127,40 @@ <% end %> <%= settings_section title: t(".danger_zone_title") do %> -
-
-

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

-

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

+
+ <% if Current.user.admin? %> +
+
+

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

+

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

+
+ <%= + button_to t(".reset_account"), reset_user_path(@user), method: :delete, + class: "bg-orange-500 text-white text-sm font-medium rounded-lg px-4 py-2", + data: { turbo_confirm: { + title: t(".confirm_reset.title"), + body: t(".confirm_reset.body"), + accept: t(".reset_account"), + acceptClass: "w-full bg-orange-500 text-white rounded-xl text-center p-[10px] border mb-2" + }} + %> +
+ <% end %> +
+
+

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

+

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

+
+ <%= + button_to t(".delete_account"), user_path(@user), 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" + }} + %>
- <%= - button_to t(".delete_account"), user_path(@user), 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/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index e7e7d627..8596904e 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -39,6 +39,9 @@ en: body: Are you sure you want to permanently delete your account? This action is irreversible. title: Delete account? + confirm_reset: + body: Are you sure you want to reset your account? This will delete all your accounts, categories, merchants, tags, and other data. This action cannot be undone. + title: Reset account? confirm_remove_invitation: body: Are you sure you want to remove the invitation for %{email}? title: Remove Invitation @@ -49,6 +52,8 @@ en: delete_account: Delete account delete_account_warning: Deleting your account will permanently remove all your data and cannot be undone. + reset_account: Reset account + reset_account_warning: Resetting your account will delete all your accounts, categories, merchants, tags, and other data, but keep your user account intact. email: Email first_name: First Name household_form_input_placeholder: Enter household name diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml index 8cb7f6c4..bbcb8ee9 100644 --- a/config/locales/views/settings/hostings/en.yml +++ b/config/locales/views/settings/hostings/en.yml @@ -20,6 +20,12 @@ en: general: General Settings invites: Invite Codes title: Self-Hosting + danger_zone: Danger Zone + clear_cache: Clear data cache + clear_cache_warning: Clearing the data cache will remove all exchange rates, security prices, account balances, and other data. This will not delete accounts, transactions, categories, or other user-owned data. + confirm_clear_cache: + title: Clear data cache? + body: Are you sure you want to clear the data cache? This will remove all exchange rates, security prices, account balances, and other data. This action cannot be undone. synth_settings: api_calls_used: "%{used} / %{limit} API calls used (%{percentage})" description: Input the API key provided by Synth @@ -30,6 +36,8 @@ en: update: failure: Invalid setting value success: Settings updated + clear_cache: + cache_cleared: Data cache has been cleared. This may take a few moments to complete. upgrade_settings: description: Configure how your application receives updates latest_commit_description: Automatically update to the latest commit (unstable) @@ -40,3 +48,4 @@ en: manual_description: You control when to download and install updates manual_title: Manual title: Auto Upgrade + not_authorized: You are not authorized to perform this action diff --git a/config/locales/views/users/en.yml b/config/locales/views/users/en.yml index e9aae045..e04ebc8c 100644 --- a/config/locales/views/users/en.yml +++ b/config/locales/views/users/en.yml @@ -8,3 +8,6 @@ en: email_change_initiated: Please check your new email address for confirmation instructions. success: Your profile has been updated. + reset: + success: Your account has been reset. Data will be deleted in the background in some time. + unauthorized: You are not authorized to perform this action diff --git a/config/routes.rb b/config/routes.rb index 13f9f61f..30d3303e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,7 +18,9 @@ Rails.application.routes.draw do resource :password, only: %i[edit update] resource :email_confirmation, only: :new - resources :users, only: %i[update destroy] + resources :users, only: %i[update destroy] do + delete :reset, on: :member + end resource :onboarding, only: :show do collection do @@ -30,7 +32,9 @@ Rails.application.routes.draw do namespace :settings do resource :profile, only: [ :show, :destroy ] resource :preferences, only: :show - resource :hosting, only: %i[show update] + resource :hosting, only: %i[show update] do + delete :clear_cache, on: :collection + end resource :billing, only: :show resource :security, only: :show end diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 283b70bc..3ee8a226 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -45,4 +45,39 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest assert_equal NEW_RENDER_DEPLOY_HOOK, Setting.render_deploy_hook end end + + test "can clear data cache when self hosting is enabled" do + account = accounts(:investment) + holding = account.holdings.first + exchange_rate = exchange_rates(:one) + security_price = holding.security.prices.first + account_balance = account.balances.create!(date: Date.current, balance: 1000, currency: "USD") + + with_self_hosting do + perform_enqueued_jobs(only: DataCacheClearJob) do + delete clear_cache_settings_hosting_url + end + end + + assert_redirected_to settings_hosting_url + assert_equal I18n.t("settings.hostings.clear_cache.cache_cleared"), flash[:notice] + + assert_not ExchangeRate.exists?(exchange_rate.id) + assert_not Security::Price.exists?(security_price.id) + assert_not Account::Holding.exists?(holding.id) + assert_not Account::Balance.exists?(account_balance.id) + end + + test "can clear data only when admin" do + with_self_hosting do + sign_in users(:family_member) + + assert_no_enqueued_jobs do + delete clear_cache_settings_hosting_url + end + + assert_redirected_to settings_hosting_url + assert_equal I18n.t("settings.hostings.not_authorized"), flash[:alert] + end + end end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 74e217f2..bd68fe77 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -31,6 +31,41 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal "Your profile has been updated.", flash[:notice] end + test "admin can reset family data" do + account = accounts(:investment) + category = categories(:income) + tag = tags(:one) + merchant = merchants(:netflix) + import = imports(:transaction) + budget = budgets(:one) + plaid_item = plaid_items(:one) + + perform_enqueued_jobs(only: FamilyResetJob) do + delete reset_user_url(@user) + end + + assert_redirected_to settings_profile_url + assert_equal I18n.t("users.reset.success"), flash[:notice] + + assert_not Account.exists?(account.id) + assert_not Category.exists?(category.id) + assert_not Tag.exists?(tag.id) + assert_not Merchant.exists?(merchant.id) + assert_not Import.exists?(import.id) + assert_not Budget.exists?(budget.id) + assert_not PlaidItem.exists?(plaid_item.id) + end + + test "non-admin cannot reset family data" do + sign_in @member = users(:family_member) + + delete reset_user_url(@member) + + assert_redirected_to settings_profile_url + assert_equal I18n.t("users.reset.unauthorized"), flash[:alert] + assert_no_enqueued_jobs only: FamilyResetJob + end + test "member can deactivate their account" do sign_in @member = users(:family_member) delete user_url(@member)