summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-31 17:05:05 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-31 17:05:05 +0200
commit8c6a6516e1dc5c1b4f12740a6f7b32765b530bb7 (patch)
treee40a1da656bedef0662f0e984b4e3b00b374bc1d /app
parent464dd4266bdc433805010b5dca428f4cb75c2a81 (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.rb23
-rw-r--r--app/helpers/node_actions_helper.rb14
-rw-r--r--app/models/node_action.rb5
-rw-r--r--app/models/user.rb20
-rw-r--r--app/views/users/_user.html.erb22
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
274end 286end
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 %>