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 /app | |
| 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.
Diffstat (limited to 'app')
| -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 |
5 files changed, 71 insertions, 13 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 %> |
