diff options
| -rw-r--r-- | app/models/user.rb | 10 | ||||
| -rw-r--r-- | app/views/users/_user.html.erb | 9 | ||||
| -rw-r--r-- | config/locales/de.yml | 3 | ||||
| -rw-r--r-- | config/locales/en.yml | 3 | ||||
| -rw-r--r-- | lib/tasks/users.rake | 22 | ||||
| -rw-r--r-- | public/stylesheets/admin.css | 9 | ||||
| -rw-r--r-- | test/models/user_test.rb | 45 |
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 | |||
| 80 | end | 76 | end |
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 | ||
| 146 | protected | 191 | protected |
| 147 | def create_user(options = {}) | 192 | def create_user(options = {}) |
