diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-31 17:05:05 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-31 17:05:05 +0200 |
| commit | 8c6a6516e1dc5c1b4f12740a6f7b32765b530bb7 (patch) | |
| tree | e40a1da656bedef0662f0e984b4e3b00b374bc1d | |
| parent | 464dd4266bdc433805010b5dca428f4cb75c2a81 (diff) | |
Replace user deletion with deactivation
Deactivation adds the alumni role and leaves the others in place, so
reactivation is lossless and nobody has to remember what an account held.
login_from_session checks alumni? on every request, so a signed-in user is
locked out on their next one without any session invalidation. Guards
prevent deactivating yourself or the last active admin, and both verbs are
witnessed in the action log.
| -rw-r--r-- | app/controllers/users_controller.rb | 23 | ||||
| -rw-r--r-- | app/helpers/node_actions_helper.rb | 14 | ||||
| -rw-r--r-- | app/models/node_action.rb | 5 | ||||
| -rw-r--r-- | app/models/user.rb | 20 | ||||
| -rw-r--r-- | app/views/users/_user.html.erb | 22 | ||||
| -rw-r--r-- | config/locales/de.yml | 8 | ||||
| -rw-r--r-- | config/locales/en.yml | 8 | ||||
| -rw-r--r-- | config/routes.rb | 4 | ||||
| -rw-r--r-- | test/controllers/users_controller_test.rb | 40 |
9 files changed, 118 insertions, 26 deletions
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 95dff220..7bf23f17 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb | |||
| @@ -4,7 +4,7 @@ class UsersController < ApplicationController | |||
| 4 | # Private | 4 | # Private |
| 5 | 5 | ||
| 6 | before_action :login_required | 6 | before_action :login_required |
| 7 | before_action :find_user, :only => [:show, :edit, :update, :destroy, :reset_otp] | 7 | before_action :find_user, :only => [:show, :edit, :update, :reset_otp, :deactivate, :reactivate] |
| 8 | before_action :verify_status, :except => [:index, :show] | 8 | before_action :verify_status, :except => [:index, :show] |
| 9 | 9 | ||
| 10 | layout 'admin' | 10 | layout 'admin' |
| @@ -53,8 +53,25 @@ class UsersController < ApplicationController | |||
| 53 | def show | 53 | def show |
| 54 | end | 54 | end |
| 55 | 55 | ||
| 56 | def destroy | 56 | def deactivate |
| 57 | @user.destroy if @user | 57 | return deny_user_access unless current_user.is_admin? |
| 58 | |||
| 59 | if @user == current_user | ||
| 60 | flash[:error] = t("flash.users.cannot_deactivate_self") | ||
| 61 | elsif @user.deactivate!(:actor => current_user) | ||
| 62 | flash[:notice] = t("flash.users.deactivated", :login => @user.login) | ||
| 63 | end | ||
| 64 | |||
| 65 | redirect_to users_path | ||
| 66 | end | ||
| 67 | |||
| 68 | def reactivate | ||
| 69 | return deny_user_access unless current_user.is_admin? | ||
| 70 | |||
| 71 | if @user.reactivate!(:actor => current_user) | ||
| 72 | flash[:notice] = t("flash.users.reactivated", :login => @user.login) | ||
| 73 | end | ||
| 74 | |||
| 58 | redirect_to users_path | 75 | redirect_to users_path |
| 59 | end | 76 | end |
| 60 | 77 | ||
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb index 4cbe741c..f57ef84f 100644 --- a/app/helpers/node_actions_helper.rb +++ b/app/helpers/node_actions_helper.rb | |||
| @@ -18,7 +18,9 @@ module NodeActionsHelper | |||
| 18 | "asset_destroy" => "file-x", | 18 | "asset_destroy" => "file-x", |
| 19 | "otp_enroll" => "shield-lock", | 19 | "otp_enroll" => "shield-lock", |
| 20 | "otp_disable" => "shield-off", | 20 | "otp_disable" => "shield-off", |
| 21 | "otp_reset" => "shield-x" | 21 | "otp_reset" => "shield-x", |
| 22 | "user_deactivate" => "user-off", | ||
| 23 | "user_reactivate" => "user-check" | ||
| 22 | }.freeze | 24 | }.freeze |
| 23 | 25 | ||
| 24 | def verb_icon action | 26 | def verb_icon action |
| @@ -271,4 +273,14 @@ module NodeActionsHelper | |||
| 271 | t("node_actions.otp_reset", :actor => actor_ref(action), | 273 | t("node_actions.otp_reset", :actor => actor_ref(action), |
| 272 | :target => user_participant_ref(action)).html_safe | 274 | :target => user_participant_ref(action)).html_safe |
| 273 | end | 275 | end |
| 276 | |||
| 277 | def summarize_user_deactivate action | ||
| 278 | t("node_actions.user_deactivate", :actor => actor_ref(action), | ||
| 279 | :target => user_participant_ref(action)).html_safe | ||
| 280 | end | ||
| 281 | |||
| 282 | def summarize_user_reactivate action | ||
| 283 | t("node_actions.user_reactivate", :actor => actor_ref(action), | ||
| 284 | :target => user_participant_ref(action)).html_safe | ||
| 285 | end | ||
| 274 | end | 286 | end |
diff --git a/app/models/node_action.rb b/app/models/node_action.rb index fec5a062..d619aac5 100644 --- a/app/models/node_action.rb +++ b/app/models/node_action.rb | |||
| @@ -98,8 +98,9 @@ class NodeAction < ApplicationRecord | |||
| 98 | # "detached_from" -- array of unique_names, only when any | 98 | # "detached_from" -- array of unique_names, only when any |
| 99 | # "headline_removed_from" -- array of unique_names, only when any | 99 | # "headline_removed_from" -- array of unique_names, only when any |
| 100 | # | 100 | # |
| 101 | # "otp_enroll" / "otp_disable" / "otp_reset" (second-factor lifecycle; | 101 | # "otp_enroll" / "otp_disable" / "otp_reset" / "user_deactivate" / |
| 102 | # node column nil; participants: the affected User -- the table's first | 102 | # "user_reactivate" (second-factor lifecycle; node column nil; |
| 103 | # participants: the affected User | ||
| 103 | # User-typed subject. otp_disable is self-service; otp_reset is an | 104 | # User-typed subject. otp_disable is self-service; otp_reset is an |
| 104 | # administrator clearing someone else's factor, where actor and | 105 | # administrator clearing someone else's factor, where actor and |
| 105 | # participant differ): | 106 | # participant differ): |
diff --git a/app/models/user.rb b/app/models/user.rb index 2e9da86c..1728521a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb | |||
| @@ -105,6 +105,26 @@ class User < ApplicationRecord | |||
| 105 | roles.map { |r| I18n.t("users.roles.#{r}", :default => r) } | 105 | roles.map { |r| I18n.t("users.roles.#{r}", :default => r) } |
| 106 | end | 106 | end |
| 107 | 107 | ||
| 108 | def deactivate!(actor:) | ||
| 109 | return false if alumni? | ||
| 110 | transaction do | ||
| 111 | update_column(:roles, (roles | ["alumni"]).sort) | ||
| 112 | NodeAction.record!(:participants => [self], :user => actor, | ||
| 113 | :action => "user_deactivate", :target_login => login) | ||
| 114 | end | ||
| 115 | true | ||
| 116 | end | ||
| 117 | |||
| 118 | def reactivate!(actor:) | ||
| 119 | return false unless alumni? | ||
| 120 | transaction do | ||
| 121 | update_column(:roles, (roles - ["alumni"]).sort) | ||
| 122 | NodeAction.record!(:participants => [self], :user => actor, | ||
| 123 | :action => "user_reactivate", :target_login => login) | ||
| 124 | end | ||
| 125 | true | ||
| 126 | end | ||
| 127 | |||
| 108 | # otp_secret present == enrolled. otp_pending_secret holds the secret | 128 | # otp_secret present == enrolled. otp_pending_secret holds the secret |
| 109 | # between QR display and first-code confirmation. otp_consumed_timestep | 129 | # between QR display and first-code confirmation. otp_consumed_timestep |
| 110 | # makes every accepted code single-use (replay guard within the drift | 130 | # makes every accepted code single-use (replay guard within the drift |
diff --git a/app/views/users/_user.html.erb b/app/views/users/_user.html.erb index 04884be8..ff9d4e37 100644 --- a/app/views/users/_user.html.erb +++ b/app/views/users/_user.html.erb | |||
| @@ -9,14 +9,22 @@ | |||
| 9 | <% end %> | 9 | <% end %> |
| 10 | </td> | 10 | </td> |
| 11 | <td><%= link_to t("admin.common.show"), user_path(user) %></td> | 11 | <td><%= link_to t("admin.common.show"), user_path(user) %></td> |
| 12 | <% if current_user.admin? || current_user == user %> | ||
| 13 | <td><%= link_to t("admin.common.edit"), edit_user_path(user) %></td> | ||
| 14 | <td> | 12 | <td> |
| 15 | <%= button_to user_path(user), method: :delete, | 13 | <% if current_user.admin? || current_user == user %> |
| 16 | form: { data: { confirm: t(".confirm_destroy", :login => user.login) }, class: 'button_to destructive' } do %> | 14 | <%= link_to t("admin.common.edit"), edit_user_path(user) %> |
| 17 | <%= icon("trash", library: "tabler", "aria-hidden": true) %> <%= t("admin.common.destroy") %> | 15 | <% end %> |
| 18 | <% end %> | 16 | </td> |
| 17 | <td> | ||
| 18 | <% if current_user.admin? && current_user != user %> | ||
| 19 | <% if user.alumni? %> | ||
| 20 | <%= button_to t(".reactivate"), reactivate_user_path(user), method: :put, | ||
| 21 | form: { class: 'button_to state_changing' } %> | ||
| 22 | <% else %> | ||
| 23 | <%= button_to t(".deactivate"), deactivate_user_path(user), method: :put, | ||
| 24 | form: { data: { confirm: t(".confirm_deactivate", :login => user.login) }, | ||
| 25 | class: 'button_to destructive' } %> | ||
| 26 | <% end %> | ||
| 27 | <% end %> | ||
| 19 | </td> | 28 | </td> |
| 20 | <% end %> | ||
| 21 | </tr> | 29 | </tr> |
| 22 | <% end %> | 30 | <% end %> |
diff --git a/config/locales/de.yml b/config/locales/de.yml index e15a08d2..8aae7ca5 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml | |||
| @@ -219,6 +219,8 @@ de: | |||
| 219 | otp_enroll: "%{actor} hat einen zweiten Faktor eingerichtet" | 219 | otp_enroll: "%{actor} hat einen zweiten Faktor eingerichtet" |
| 220 | otp_disable: "%{actor} hat den zweiten Faktor entfernt" | 220 | otp_disable: "%{actor} hat den zweiten Faktor entfernt" |
| 221 | otp_reset: "%{actor} hat den zweiten Faktor von %{target} zurückgesetzt" | 221 | otp_reset: "%{actor} hat den zweiten Faktor von %{target} zurückgesetzt" |
| 222 | user_deactivate: "%{actor} hat %{target} deaktiviert" | ||
| 223 | user_reactivate: "%{actor} hat %{target} reaktiviert" | ||
| 222 | 224 | ||
| 223 | open_gallery: "Gallerie anzeigen" | 225 | open_gallery: "Gallerie anzeigen" |
| 224 | asset_licenses: | 226 | asset_licenses: |
| @@ -281,6 +283,9 @@ de: | |||
| 281 | user: | 283 | user: |
| 282 | confirm_destroy: "Benutzer %{login} wirklich löschen?" | 284 | confirm_destroy: "Benutzer %{login} wirklich löschen?" |
| 283 | no_roles: "—" | 285 | no_roles: "—" |
| 286 | deactivate: "Deaktivieren" | ||
| 287 | reactivate: "Reaktivieren" | ||
| 288 | confirm_deactivate: "%{login} deaktivieren? Die Anmeldung wird sofort verweigert, Zuschreibungen bleiben erhalten." | ||
| 284 | index: | 289 | index: |
| 285 | title: "Benutzerkonten" | 290 | title: "Benutzerkonten" |
| 286 | create_editor: "Editor-Konto anlegen" | 291 | create_editor: "Editor-Konto anlegen" |
| @@ -578,6 +583,9 @@ de: | |||
| 578 | created: "Benutzer %{login} angelegt" | 583 | created: "Benutzer %{login} angelegt" |
| 579 | updated: "Benutzer %{login} aktualisiert" | 584 | updated: "Benutzer %{login} aktualisiert" |
| 580 | otp_reset: "Zweiter Faktor von %{login} zurückgesetzt" | 585 | otp_reset: "Zweiter Faktor von %{login} zurückgesetzt" |
| 586 | deactivated: "%{login} ist jetzt alumni und kann sich nicht mehr anmelden." | ||
| 587 | reactivated: "%{login} kann sich wieder anmelden." | ||
| 588 | cannot_deactivate_self: "Das eigene Konto kann nicht deaktiviert werden." | ||
| 581 | assets: | 589 | assets: |
| 582 | created: "Asset wurde angelegt." | 590 | created: "Asset wurde angelegt." |
| 583 | updated: "Asset wurde aktualisiert." | 591 | updated: "Asset wurde aktualisiert." |
diff --git a/config/locales/en.yml b/config/locales/en.yml index f8b94a00..e434538f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml | |||
| @@ -167,6 +167,8 @@ en: | |||
| 167 | otp_enroll: "%{actor} set up a second factor" | 167 | otp_enroll: "%{actor} set up a second factor" |
| 168 | otp_disable: "%{actor} removed their second factor" | 168 | otp_disable: "%{actor} removed their second factor" |
| 169 | otp_reset: "%{actor} reset the second factor of %{target}" | 169 | otp_reset: "%{actor} reset the second factor of %{target}" |
| 170 | user_deactivate: "%{actor} deactivated %{target}" | ||
| 171 | user_reactivate: "%{actor} reactivated %{target}" | ||
| 170 | 172 | ||
| 171 | open_gallery: "Open gallery" | 173 | open_gallery: "Open gallery" |
| 172 | asset_licenses: | 174 | asset_licenses: |
| @@ -229,6 +231,9 @@ en: | |||
| 229 | user: | 231 | user: |
| 230 | confirm_destroy: "Do you really want to destroy user %{login}?" | 232 | confirm_destroy: "Do you really want to destroy user %{login}?" |
| 231 | no_roles: "—" | 233 | no_roles: "—" |
| 234 | deactivate: "Deactivate" | ||
| 235 | reactivate: "Reactivate" | ||
| 236 | confirm_deactivate: "Deactivate %{login}? Sign-in is refused immediately; attributions are preserved." | ||
| 232 | index: | 237 | index: |
| 233 | title: "User accounts" | 238 | title: "User accounts" |
| 234 | create_editor: "Create editor account" | 239 | create_editor: "Create editor account" |
| @@ -541,6 +546,9 @@ en: | |||
| 541 | created: "User created %{login}" | 546 | created: "User created %{login}" |
| 542 | updated: "Updated user %{login}" | 547 | updated: "Updated user %{login}" |
| 543 | otp_reset: "Second factor reset for %{login}" | 548 | otp_reset: "Second factor reset for %{login}" |
| 549 | deactivated: "%{login} is now an alumnus and can no longer sign in." | ||
| 550 | reactivated: "%{login} can sign in again." | ||
| 551 | cannot_deactivate_self: "You cannot deactivate your own account." | ||
| 544 | assets: | 552 | assets: |
| 545 | created: "Asset was successfully created." | 553 | created: "Asset was successfully created." |
| 546 | updated: "Asset was successfully updated." | 554 | updated: "Asset was successfully updated." |
diff --git a/config/routes.rb b/config/routes.rb index 4b5d15c9..1898dbbb 100644 --- a/config/routes.rb +++ b/config/routes.rb | |||
| @@ -97,9 +97,11 @@ Cccms::Application.routes.draw do | |||
| 97 | match '/login' => 'sessions#new', :as => :login, :via => :get | 97 | match '/login' => 'sessions#new', :as => :login, :via => :get |
| 98 | match 'search' => 'search#index', :as => :search, :via => :get | 98 | match 'search' => 'search#index', :as => :search, :via => :get |
| 99 | 99 | ||
| 100 | resources :users do | 100 | resources :users, :except => :destroy do |
| 101 | member do | 101 | member do |
| 102 | put :reset_otp | 102 | put :reset_otp |
| 103 | put :deactivate | ||
| 104 | put :reactivate | ||
| 103 | end | 105 | end |
| 104 | end | 106 | end |
| 105 | resource :otp_enrollment, :only => [:show, :create, :update, :destroy] | 107 | resource :otp_enrollment, :only => [:show, :create, :update, :destroy] |
diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 1c5d16fc..14133029 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb | |||
| @@ -13,7 +13,7 @@ class UsersControllerTest < ActionController::TestCase | |||
| 13 | login_as :aaron | 13 | login_as :aaron |
| 14 | get :index | 14 | get :index |
| 15 | assert_response :success | 15 | assert_response :success |
| 16 | assert_select "button[type=submit]", I18n.t("admin.common.destroy") | 16 | assert_select "button[type=submit]", I18n.t("users.user.deactivate") |
| 17 | assert_select "a", I18n.t("admin.common.show") | 17 | assert_select "a", I18n.t("admin.common.show") |
| 18 | end | 18 | end |
| 19 | 19 | ||
| @@ -146,22 +146,38 @@ class UsersControllerTest < ActionController::TestCase | |||
| 146 | 146 | ||
| 147 | test "destroying an user being logged in as regular user wont work" do | 147 | test "destroying an user being logged in as regular user wont work" do |
| 148 | login_as :quentin | 148 | login_as :quentin |
| 149 | assert_no_difference "User.count" do | 149 | put :deactivate, params: { :id => users(:quentin).id } |
| 150 | delete :destroy, params: { :id => User.find_by_login("aaron").id } | 150 | |
| 151 | end | ||
| 152 | assert_redirected_to users_path | 151 | assert_redirected_to users_path |
| 153 | assert_equal( | 152 | assert_not users(:quentin).reload.alumni? |
| 154 | I18n.t("flash.common.admin_required"), | ||
| 155 | flash[:notice] | ||
| 156 | ) | ||
| 157 | end | 153 | end |
| 158 | 154 | ||
| 159 | test "destroying an user being logged in as admin user" do | 155 | test "an admin deactivates another user, who can no longer sign in" do |
| 160 | login_as :aaron | 156 | login_as :aaron |
| 161 | assert_difference "User.count", -1 do | 157 | user = users(:quentin) |
| 162 | delete :destroy, params: { :id => User.find_by_login("quentin").id } | 158 | |
| 163 | end | 159 | put :deactivate, params: { :id => user.id } |
| 160 | |||
| 164 | assert_redirected_to users_path | 161 | assert_redirected_to users_path |
| 162 | assert user.reload.alumni? | ||
| 163 | assert_equal ["admin", "alumni"].sort, user.roles.sort if user.is_admin? | ||
| 164 | end | ||
| 165 | |||
| 166 | test "reactivation restores the other roles untouched" do | ||
| 167 | login_as :aaron | ||
| 168 | user = users(:quentin) | ||
| 169 | user.update_column(:roles, ["alumni", "redaktion"]) | ||
| 170 | |||
| 171 | put :reactivate, params: { :id => user.id } | ||
| 172 | |||
| 173 | assert_not user.reload.alumni? | ||
| 174 | assert_equal ["redaktion"], user.roles | ||
| 175 | end | ||
| 176 | |||
| 177 | test "an admin cannot deactivate their own account" do | ||
| 178 | login_as :aaron | ||
| 179 | put :deactivate, params: { :id => users(:aaron).id } | ||
| 180 | assert_not users(:aaron).reload.alumni? | ||
| 165 | end | 181 | end |
| 166 | 182 | ||
| 167 | test "enrolled user gets a working my account" do | 183 | test "enrolled user gets a working my account" do |
