From 8c6a6516e1dc5c1b4f12740a6f7b32765b530bb7 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 31 Jul 2026 17:05:05 +0200 Subject: 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. --- app/models/node_action.rb | 5 +++-- app/models/user.rb | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) (limited to 'app/models') 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 # "detached_from" -- array of unique_names, only when any # "headline_removed_from" -- array of unique_names, only when any # - # "otp_enroll" / "otp_disable" / "otp_reset" (second-factor lifecycle; - # node column nil; participants: the affected User -- the table's first + # "otp_enroll" / "otp_disable" / "otp_reset" / "user_deactivate" / + # "user_reactivate" (second-factor lifecycle; node column nil; + # participants: the affected User # User-typed subject. otp_disable is self-service; otp_reset is an # administrator clearing someone else's factor, where actor and # 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 roles.map { |r| I18n.t("users.roles.#{r}", :default => r) } end + def deactivate!(actor:) + return false if alumni? + transaction do + update_column(:roles, (roles | ["alumni"]).sort) + NodeAction.record!(:participants => [self], :user => actor, + :action => "user_deactivate", :target_login => login) + end + true + end + + def reactivate!(actor:) + return false unless alumni? + transaction do + update_column(:roles, (roles - ["alumni"]).sort) + NodeAction.record!(:participants => [self], :user => actor, + :action => "user_reactivate", :target_login => login) + end + true + end + # otp_secret present == enrolled. otp_pending_secret holds the secret # between QR display and first-code confirmation. otp_consumed_timestep # makes every accepted code single-use (replay guard within the drift -- cgit v1.3