From 24242a370190f89aa2fdfed2d2b4e516f2afeffb Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Wed, 15 Jul 2026 15:49:06 +0300 Subject: [PATCH 1/6] Allow user profiles to be undeleted if they are deleted --- app/controllers/users_controller.rb | 16 +++++++++++-- app/models/community_user.rb | 14 ++++++++++++ app/views/users/mod.html.erb | 9 ++++++-- config/routes.rb | 1 + test/controllers/users_controller_test.rb | 28 +++++++++++++++++++++++ 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index d3339edcf..a41bf4d30 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -12,9 +12,9 @@ class UsersController < ApplicationController before_action :redirect_to_sign_in, only: [:filters], unless: [:user_signed_in?, :json_request?] - before_action :verify_moderator, only: [:mod, :destroy, :soft_delete, :role_toggle, :full_log, + before_action :verify_moderator, only: [:mod, :destroy, :soft_delete, :undelete, :role_toggle, :full_log, :annotate, :annotations, :mod_privileges, :mod_privilege_action] - before_action :set_user, only: [:show, :mod, :destroy, :soft_delete, :posts, :role_toggle, :full_log, :activity, + before_action :set_user, only: [:show, :mod, :destroy, :soft_delete, :undelete, :posts, :role_toggle, :full_log, :activity, :annotate, :annotations, :mod_privileges, :mod_privilege_action, :vote_summary, :network, :avatar] before_action :check_deleted, only: [:show, :posts, :activity] @@ -355,6 +355,18 @@ def soft_delete render json: { status: 'success', user: @user.id } end + def undelete + status = @user.community_user&.undelete(current_user) + + if status + render json: { status: 'success', user: @user.id } + else + render json: { status: 'failed', + message: 'Failed to undelete user profile', + user: @user.id } + end + end + def edit_profile render layout: 'without_sidebar' end diff --git a/app/models/community_user.rb b/app/models/community_user.rb index 8997ee350..d2ae56b0a 100644 --- a/app/models/community_user.rb +++ b/app/models/community_user.rb @@ -203,4 +203,18 @@ def soft_delete(attribute_to) update(deleted: true, deleted_by: attribute_to, deleted_at: DateTime.now) end + + # Undeletes the community user if it's soft-deleted + # @param attribute_to [User] user to attribute the action to + # @return [Boolean] whether the community user has been undeleted + def undelete(attribute_to) + return true unless deleted? + + AuditLog.moderator_audit(event_type: 'profile_undelete', + related: self, + user: attribute_to, + comment: attributes_print(join: "\n")) + + update(deleted: false, deleted_by: nil, deleted_at: nil) + end end diff --git a/app/views/users/mod.html.erb b/app/views/users/mod.html.erb index 0ba0b4fc6..d44a465a2 100644 --- a/app/views/users/mod.html.erb +++ b/app/views/users/mod.html.erb @@ -26,8 +26,13 @@

Take care! Actions in this section may not be reversible, and you will not be asked to confirm after initiating an action.

- <%= link_to 'Delete community profile', soft_delete_user_path(@user.id, type: 'profile'), remote: true, - method: :delete, class: 'js-soft-delete button is-danger is-filled', role: 'button' %> + <% is_deleted = @user.community_user&.deleted? || false %> + <%= link_to "#{is_deleted ? 'Undelete' : 'Delete'} community profile", + is_deleted ? undelete_user_path(@user.id) : soft_delete_user_path(@user.id, type: 'profile'), + remote: true, + method: is_deleted ? :post : :delete, + class: 'js-soft-delete button is-danger is-filled', + role: 'button' %> <% if current_user.is_global_moderator || current_user.is_global_admin %> <%= link_to 'Delete user network-wide', soft_delete_user_path(@user.id, type: 'user'), remote: true, method: :delete, class: 'js-soft-delete button is-danger is-filled', role: 'button' %> diff --git a/config/routes.rb b/config/routes.rb index a278e064c..de5476dd8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,6 +35,7 @@ post 'settings/:name', to: 'site_settings#update', as: :update_site_setting delete 'users/delete/:id', to: 'users#soft_delete', as: :soft_delete_user + post 'users/undelete/:id', to: 'users#undelete', as: :undelete_user get 'privileges', to: 'admin#privileges', as: :admin_privileges get 'privileges/:name', to: 'admin#show_privilege', as: :admin_privilege diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 1b67ba5a5..703de0c51 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -98,6 +98,29 @@ class UsersControllerTest < ActionController::TestCase end end + test 'should require authentication to undelete user profiles' do + del_usr = users(:deleted_profile) + + try_undelete_user(del_usr) + + assert_json_failure(:not_found) + end + + test 'moderators and higher should be able to undelete user profiles' do + del_usr = users(:deleted_profile) + + users.select(&:at_least_moderator?).each do |user| + sign_in(user) + + try_undelete_user(del_usr) + @user = assigns(:user) + + assert_json_success + assert_not_nil @user + assert_not @user.community_user.deleted? + end + end + test 'should soft-delete user' do sign_in users(:global_admin) @@ -636,6 +659,11 @@ def try_soft_delete_user(type, user) type: type, format: :json } end + # @param user [User] user to undelete + def try_undelete_user(user) + post :undelete, params: { id: user.id, + format: :json } + end def try_save_preference(name, value, community: nil) post :set_preference, params: { From 4d4bcf5776af514686b4f46c4b8c423ac4919ff3 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Wed, 15 Jul 2026 15:54:20 +0300 Subject: [PATCH 2/6] Fix server error when trying to view an ability for a user with non-existent communty user --- app/views/abilities/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/abilities/show.html.erb b/app/views/abilities/show.html.erb index c35fb5201..6599e9dc9 100644 --- a/app/views/abilities/show.html.erb +++ b/app/views/abilities/show.html.erb @@ -24,7 +24,7 @@ <%= @user.username %> has not yet earned this ability. <% end %> - <% if @user.community_user.privilege? 'mod' %> + <% if @user.at_least_moderator? %> <% if @user.id == current_user&.id %> However, as a moderator you still have all abilities. <% else %> From 39f7c3837d11982dd35356de42306be76aaac5b5 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Wed, 15 Jul 2026 16:02:46 +0300 Subject: [PATCH 3/6] Minor rubocop fixes --- app/controllers/users_controller.rb | 3 ++- test/controllers/users_controller_test.rb | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index a41bf4d30..e73141f31 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -14,7 +14,8 @@ class UsersController < ApplicationController before_action :verify_moderator, only: [:mod, :destroy, :soft_delete, :undelete, :role_toggle, :full_log, :annotate, :annotations, :mod_privileges, :mod_privilege_action] - before_action :set_user, only: [:show, :mod, :destroy, :soft_delete, :undelete, :posts, :role_toggle, :full_log, :activity, + before_action :set_user, only: [:show, :mod, :destroy, :soft_delete, :undelete, :posts, + :role_toggle, :full_log, :activity, :annotate, :annotations, :mod_privileges, :mod_privilege_action, :vote_summary, :network, :avatar] before_action :check_deleted, only: [:show, :posts, :activity] diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 703de0c51..4ad83850d 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -659,6 +659,7 @@ def try_soft_delete_user(type, user) type: type, format: :json } end + # @param user [User] user to undelete def try_undelete_user(user) post :undelete, params: { id: user.id, From 2d21a0ca8938ee0556cafdafe9b59e8b369a9b36 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Sun, 19 Jul 2026 20:03:27 +0300 Subject: [PATCH 4/6] Ensure network-deleted users cannot be undeleted (backend-only for now) --- app/controllers/users_controller.rb | 7 +++++++ test/controllers/users_controller_test.rb | 14 ++++++++++++++ test/fixtures/community_users.yml | 3 +++ 3 files changed, 24 insertions(+) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index e73141f31..4acc6487c 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -19,6 +19,7 @@ class UsersController < ApplicationController :annotate, :annotations, :mod_privileges, :mod_privilege_action, :vote_summary, :network, :avatar] before_action :check_deleted, only: [:show, :posts, :activity] + before_action :verify_user_not_deleted, only: [:undelete] def index @sort_param = { reputation: :reputation, age: :created_at }[params[:sort]&.to_sym] || :reputation @@ -712,5 +713,11 @@ def check_deleted not_found! end end + + # Explicitly checks that the requested used is not deleted + # NOTE: This guard is not enough to guarantee that the user is not deleted on the current community + def verify_user_not_deleted + not_found! if @user.deleted? + end end # rubocop:enable Metrics/ClassLength diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 4ad83850d..2a5091f2a 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -121,6 +121,20 @@ class UsersControllerTest < ActionController::TestCase end end + test 'users that are deleted network-wide should not be undeletable' do + del_usr = users(:deleted_account) + + users.select(&:at_least_moderator?).each do |user| + sign_in(user) + + try_undelete_user(del_usr) + del_usr.reload + + assert_json_failure(:not_found) + assert del_usr.community_user.deleted? + end + end + test 'should soft-delete user' do sign_in users(:global_admin) diff --git a/test/fixtures/community_users.yml b/test/fixtures/community_users.yml index a333d475a..cd45f77ec 100644 --- a/test/fixtures/community_users.yml +++ b/test/fixtures/community_users.yml @@ -87,6 +87,9 @@ sample_deleted_account: community: sample is_admin: false is_moderator: false + deleted: true + deleted_at: 2020-01-02T00:00:00.000000Z + deleted_by: global_admin reputation: 1 sample_deleted_profile: From bb849d55824fd6eb6e433b5f6b1b696c6fd9d916 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Mon, 20 Jul 2026 02:59:00 +0300 Subject: [PATCH 5/6] Disable network-wide user delete button if the user is already deleted --- app/views/users/mod.html.erb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/views/users/mod.html.erb b/app/views/users/mod.html.erb index d44a465a2..0c7995e50 100644 --- a/app/views/users/mod.html.erb +++ b/app/views/users/mod.html.erb @@ -33,9 +33,15 @@ method: is_deleted ? :post : :delete, class: 'js-soft-delete button is-danger is-filled', role: 'button' %> - <% if current_user.is_global_moderator || current_user.is_global_admin %> - <%= link_to 'Delete user network-wide', soft_delete_user_path(@user.id, type: 'user'), remote: true, - method: :delete, class: 'js-soft-delete button is-danger is-filled', role: 'button' %> + <% if current_user.at_least_global_moderator? %> + <%= link_to 'Delete user network-wide', + soft_delete_user_path(@user.id, type: 'user'), + remote: true, + method: :delete, + class: 'js-soft-delete button is-danger is-filled', + disabled: @user.deleted?, + title: @user.deleted? ? 'The user is already deleted' : '', + role: 'button' %> <% end %> <% if current_user.is_global_admin %> <%= link_to 'Feed to STAT (180 days)', hellban_user_path(@user), method: :post, class: 'button is-danger is-filled', role: 'button' %> From bc2aa2c9c2ccae09b9d0558ab872b595c41207a4 Mon Sep 17 00:00:00 2001 From: Oleg Valter Date: Mon, 20 Jul 2026 03:04:12 +0300 Subject: [PATCH 6/6] Disable profile delete button if the user is deleted network-wide It's impossible to do anything about it --- app/views/users/mod.html.erb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/views/users/mod.html.erb b/app/views/users/mod.html.erb index 0c7995e50..42e9638d4 100644 --- a/app/views/users/mod.html.erb +++ b/app/views/users/mod.html.erb @@ -32,6 +32,8 @@ remote: true, method: is_deleted ? :post : :delete, class: 'js-soft-delete button is-danger is-filled', + disabled: @user.deleted?, + title: @user.deleted? ? 'The user is deleted network-wide' : '', role: 'button' %> <% if current_user.at_least_global_moderator? %> <%= link_to 'Delete user network-wide',