summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-08-03 00:53:35 +0200
committererdgeist <erdgeist@erdgeist.org>2026-08-03 00:53:35 +0200
commit751261c2ffdc90e93869192555ae0d5e4070ff79 (patch)
tree5023240ca3a10653fe560a259d0e46e6c6a7240f
parentf2f2b31832ec320889f2178e7e08838723961a14 (diff)
Display stale accounts in users#index
-rw-r--r--app/models/user.rb10
-rw-r--r--app/views/users/_user.html.erb9
-rw-r--r--config/locales/de.yml3
-rw-r--r--config/locales/en.yml3
-rw-r--r--lib/tasks/users.rake22
-rw-r--r--public/stylesheets/admin.css9
-rw-r--r--test/models/user_test.rb45
7 files changed, 88 insertions, 13 deletions
diff --git a/app/models/user.rb b/app/models/user.rb
index 49c22584..adfdc564 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -10,6 +10,8 @@ class User < ApplicationRecord
10 include Authentication::ByPassword 10 include Authentication::ByPassword
11 11
12 ROLES = %w[redaktion admin alumni].freeze 12 ROLES = %w[redaktion admin alumni].freeze
13 STALE_AMBER_YEARS = 3
14 STALE_RED_YEARS = 10
13 15
14 # Validations 16 # Validations
15 validates_presence_of :login 17 validates_presence_of :login
@@ -243,6 +245,14 @@ class User < ApplicationRecord
243 true 245 true
244 end 246 end
245 247
248 def staleness_tier(now = Time.zone.now)
249 return nil if alumni?
250 return :never if last_login_at.nil?
251
252 return :red if last_login_at <= now - STALE_RED_YEARS.years
253 return :amber if roles.any? && last_login_at <= now - STALE_AMBER_YEARS.years
254 nil
255 end
246 private 256 private
247 257
248 def roles_are_known 258 def roles_are_known
diff --git a/app/views/users/_user.html.erb b/app/views/users/_user.html.erb
index d944fff0..37009027 100644
--- a/app/views/users/_user.html.erb
+++ b/app/views/users/_user.html.erb
@@ -15,6 +15,15 @@
15 <%= icon("shield-off", library: "tabler", "aria-hidden": true) %> 15 <%= icon("shield-off", library: "tabler", "aria-hidden": true) %>
16 </span> 16 </span>
17 <% end %> 17 <% end %>
18 <% if (tier = user.staleness_tier) %>
19 <% hint = tier == :never ? t(".stale_never")
20 : t(".stale_#{tier}", :year => user.last_login_at.year) %>
21 <span role="img" class="stale_flag stale_flag_<%= tier %>"
22 title="<%= hint %>" aria-label="<%= hint %>">
23 <%= icon(tier == :amber ? "clock-exclamation" : "alert-triangle",
24 library: "tabler", "aria-hidden": true) %>
25 </span>
26 <% end %>
18 </span> 27 </span>
19 <%= user.login %> 28 <%= user.login %>
20 </td> 29 </td>
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 907a3dbf..179e42c3 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -323,6 +323,9 @@ de:
323 otp_pending: "Zweiter Faktor begonnen, aber nicht bestätigt" 323 otp_pending: "Zweiter Faktor begonnen, aber nicht bestätigt"
324 otp_missing: "Kein zweiter Faktor" 324 otp_missing: "Kein zweiter Faktor"
325 grant_redaktion_blocked: "In die Redaktion aufnehmen — erst möglich, wenn das Konto einen zweiten Faktor eingerichtet hat" 325 grant_redaktion_blocked: "In die Redaktion aufnehmen — erst möglich, wenn das Konto einen zweiten Faktor eingerichtet hat"
326 stale_amber: "Seit %{year} nicht angemeldet, Rollen entziehen?"
327 stale_red: "Seit %{year} nicht angemeldet, deaktivieren?"
328 stale_never: "Noch nie angemeldet, nachfassen?"
326 index: 329 index:
327 title: "Benutzerkonten" 330 title: "Benutzerkonten"
328 create_editor: "Editor-Konto anlegen" 331 create_editor: "Editor-Konto anlegen"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index c494a7f4..39f0e600 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -270,6 +270,9 @@ en:
270 otp_pending: "Second factor started but not confirmed" 270 otp_pending: "Second factor started but not confirmed"
271 otp_missing: "No second factor" 271 otp_missing: "No second factor"
272 grant_redaktion_blocked: "Add to Redaktion — only possible once the account has enrolled a second factor" 272 grant_redaktion_blocked: "Add to Redaktion — only possible once the account has enrolled a second factor"
273 stale_amber: "Not signed in since %{year}, remove roles?"
274 stale_red: "Not signed in since %{year}, deactivate?"
275 stale_never: "Never signed in, chase it up?"
273 276
274 index: 277 index:
275 title: "User accounts" 278 title: "User accounts"
diff --git a/lib/tasks/users.rake b/lib/tasks/users.rake
index ee5da7dc..56e5bb6a 100644
--- a/lib/tasks/users.rake
+++ b/lib/tasks/users.rake
@@ -26,10 +26,13 @@ namespace :users do
26 desc "Seed last_login_at from whatever the database still remembers. " \ 26 desc "Seed last_login_at from whatever the database still remembers. " \
27 "There is no login history, so the column is reconstructed once " \ 27 "There is no login history, so the column is reconstructed once " \
28 "from the newest trace each account left: log entries, authorship, " \ 28 "from the newest trace each account left: log entries, authorship, " \
29 "editing, tagging. Accounts with no trace fall back to their own " \ 29 "editing, tagging. An account with no trace is left nil, which the " \
30 "created_at, which tells an ancient untraceable account apart from " \ 30 "roster renders as never signed in -- do not substitute created_at, " \
31 "one made yesterday. Dry run unless WRITE=1; FORCE=1 is required " \ 31 "which is evidence that an account exists, not that anyone used it, " \
32 "once any real login has been recorded." 32 "and shadows exactly the signal worth seeing. Dry run unless " \
33 "WRITE=1. FORCE=1 is required once the column holds anything, and " \
34 "re-running then overwrites real logins: an account that signs in " \
35 "regularly but never edits leaves no trace and would be reset."
33 task :seed_last_login => :environment do 36 task :seed_last_login => :environment do
34 write = ENV["WRITE"] == "1" 37 write = ENV["WRITE"] == "1"
35 force = ENV["FORCE"] == "1" 38 force = ENV["FORCE"] == "1"
@@ -64,17 +67,10 @@ namespace :users do
64 users.each do |user| 67 users.each do |user|
65 clues = newest.transform_values { |by_id| by_id[user.id] }.compact 68 clues = newest.transform_values { |by_id| by_id[user.id] }.compact
66 source, date = clues.max_by { |_, at| at } 69 source, date = clues.max_by { |_, at| at }
67 source, date = "created", user.created_at if date.nil?
68 source, date = "floor", floor if date.nil?
69
70 if date.nil?
71 puts format("%-18s %-12s %-8s %s", user.login, "SKIPPED", "none", user.roles.join(","))
72 next
73 end
74 70
75 user.update_columns(:last_login_at => date) if write 71 user.update_columns(:last_login_at => date) if write
76 puts format("%-18s %-12s %-8s %s", user.login, date.to_date, source, user.roles.join(",")) 72 puts format("%-18s %-12s %-8s %s", user.login,
73 date ? date.to_date : "never", source || "-", user.roles.join(","))
77 end 74 end
78 end 75 end
79
80end 76end
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index 82d1d381..97df120a 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -930,6 +930,15 @@ table.revisions_table tr:hover {
930 color: var(--accent); 930 color: var(--accent);
931} 931}
932 932
933.user_table .user_login .stale_flag_amber svg {
934 color: var(--accent);
935}
936
937.user_table .user_login .stale_flag_red svg,
938.user_table .user_login .stale_flag_never svg {
939 color: var(--danger);
940}
941
933.user_group_heading { 942.user_group_heading {
934 margin-top: 1.5rem; 943 margin-top: 1.5rem;
935} 944}
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index 9942385c..62552ee7 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -142,6 +142,51 @@ class UserTest < ActiveSupport::TestCase
142 assert redaktion.may_change_live?(plain) 142 assert redaktion.may_change_live?(plain)
143 assert redaktion.may_change_live?(restricted) 143 assert redaktion.may_change_live?(restricted)
144 end 144 end
145
146 test "amber needs a role to remove, red does not" do
147 now = Time.zone.parse("2026-08-03")
148 roled = users(:redella)
149 plain = users(:quentin)
150
151 [roled, plain].each { |u| u.update_column(:last_login_at, now - 4.years) }
152
153 assert_equal :amber, roled.staleness_tier(now)
154 assert_nil plain.staleness_tier(now)
155
156 [roled, plain].each { |u| u.update_column(:last_login_at, now - 11.years) }
157
158 assert_equal :red, roled.staleness_tier(now)
159 assert_equal :red, plain.staleness_tier(now), "dormant credentials are dormant whatever the roles"
160 end
161
162 test "staleness_tier boundaries" do
163 now = Time.zone.parse("2026-08-03")
164 user = users(:redella)
165
166 user.update_column(:last_login_at, now - 2.years - 11.months)
167 assert_nil user.staleness_tier(now)
168
169 user.update_column(:last_login_at, now - 3.years)
170 assert_equal :amber, user.staleness_tier(now)
171
172 user.update_column(:last_login_at, now - 9.years - 11.months)
173 assert_equal :amber, user.staleness_tier(now)
174
175 user.update_column(:last_login_at, now - 10.years)
176 assert_equal :red, user.staleness_tier(now)
177 end
178
179 test "alumni are exempt, an account that never signed in is not" do
180 now = Time.zone.parse("2026-08-03")
181
182 alufa = users(:alufa)
183 alufa.update_column(:last_login_at, now - 20.years)
184 assert_nil alufa.staleness_tier(now), "alumni are the outcome, not a candidate"
185
186 redella = users(:redella)
187 redella.update_column(:last_login_at, nil)
188 assert_equal :never, redella.staleness_tier(now)
189 end
145 190
146protected 191protected
147 def create_user(options = {}) 192 def create_user(options = {})