From 751261c2ffdc90e93869192555ae0d5e4070ff79 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 3 Aug 2026 00:53:35 +0200 Subject: Display stale accounts in users#index --- app/models/user.rb | 10 ++++++++++ app/views/users/_user.html.erb | 9 +++++++++ config/locales/de.yml | 3 +++ config/locales/en.yml | 3 +++ lib/tasks/users.rake | 22 +++++++++------------ public/stylesheets/admin.css | 9 +++++++++ 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 include Authentication::ByPassword ROLES = %w[redaktion admin alumni].freeze + STALE_AMBER_YEARS = 3 + STALE_RED_YEARS = 10 # Validations validates_presence_of :login @@ -243,6 +245,14 @@ class User < ApplicationRecord true end + def staleness_tier(now = Time.zone.now) + return nil if alumni? + return :never if last_login_at.nil? + + return :red if last_login_at <= now - STALE_RED_YEARS.years + return :amber if roles.any? && last_login_at <= now - STALE_AMBER_YEARS.years + nil + end private 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 @@ <%= icon("shield-off", library: "tabler", "aria-hidden": true) %> <% end %> + <% if (tier = user.staleness_tier) %> + <% hint = tier == :never ? t(".stale_never") + : t(".stale_#{tier}", :year => user.last_login_at.year) %> + + <%= icon(tier == :amber ? "clock-exclamation" : "alert-triangle", + library: "tabler", "aria-hidden": true) %> + + <% end %> <%= user.login %> 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: otp_pending: "Zweiter Faktor begonnen, aber nicht bestätigt" otp_missing: "Kein zweiter Faktor" grant_redaktion_blocked: "In die Redaktion aufnehmen — erst möglich, wenn das Konto einen zweiten Faktor eingerichtet hat" + stale_amber: "Seit %{year} nicht angemeldet, Rollen entziehen?" + stale_red: "Seit %{year} nicht angemeldet, deaktivieren?" + stale_never: "Noch nie angemeldet, nachfassen?" index: title: "Benutzerkonten" 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: otp_pending: "Second factor started but not confirmed" otp_missing: "No second factor" grant_redaktion_blocked: "Add to Redaktion — only possible once the account has enrolled a second factor" + stale_amber: "Not signed in since %{year}, remove roles?" + stale_red: "Not signed in since %{year}, deactivate?" + stale_never: "Never signed in, chase it up?" index: 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 desc "Seed last_login_at from whatever the database still remembers. " \ "There is no login history, so the column is reconstructed once " \ "from the newest trace each account left: log entries, authorship, " \ - "editing, tagging. Accounts with no trace fall back to their own " \ - "created_at, which tells an ancient untraceable account apart from " \ - "one made yesterday. Dry run unless WRITE=1; FORCE=1 is required " \ - "once any real login has been recorded." + "editing, tagging. An account with no trace is left nil, which the " \ + "roster renders as never signed in -- do not substitute created_at, " \ + "which is evidence that an account exists, not that anyone used it, " \ + "and shadows exactly the signal worth seeing. Dry run unless " \ + "WRITE=1. FORCE=1 is required once the column holds anything, and " \ + "re-running then overwrites real logins: an account that signs in " \ + "regularly but never edits leaves no trace and would be reset." task :seed_last_login => :environment do write = ENV["WRITE"] == "1" force = ENV["FORCE"] == "1" @@ -64,17 +67,10 @@ namespace :users do users.each do |user| clues = newest.transform_values { |by_id| by_id[user.id] }.compact source, date = clues.max_by { |_, at| at } - source, date = "created", user.created_at if date.nil? - source, date = "floor", floor if date.nil? - - if date.nil? - puts format("%-18s %-12s %-8s %s", user.login, "SKIPPED", "none", user.roles.join(",")) - next - end user.update_columns(:last_login_at => date) if write - puts format("%-18s %-12s %-8s %s", user.login, date.to_date, source, user.roles.join(",")) + puts format("%-18s %-12s %-8s %s", user.login, + date ? date.to_date : "never", source || "-", user.roles.join(",")) end end - 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 { color: var(--accent); } +.user_table .user_login .stale_flag_amber svg { + color: var(--accent); +} + +.user_table .user_login .stale_flag_red svg, +.user_table .user_login .stale_flag_never svg { + color: var(--danger); +} + .user_group_heading { margin-top: 1.5rem; } 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 assert redaktion.may_change_live?(plain) assert redaktion.may_change_live?(restricted) end + + test "amber needs a role to remove, red does not" do + now = Time.zone.parse("2026-08-03") + roled = users(:redella) + plain = users(:quentin) + + [roled, plain].each { |u| u.update_column(:last_login_at, now - 4.years) } + + assert_equal :amber, roled.staleness_tier(now) + assert_nil plain.staleness_tier(now) + + [roled, plain].each { |u| u.update_column(:last_login_at, now - 11.years) } + + assert_equal :red, roled.staleness_tier(now) + assert_equal :red, plain.staleness_tier(now), "dormant credentials are dormant whatever the roles" + end + + test "staleness_tier boundaries" do + now = Time.zone.parse("2026-08-03") + user = users(:redella) + + user.update_column(:last_login_at, now - 2.years - 11.months) + assert_nil user.staleness_tier(now) + + user.update_column(:last_login_at, now - 3.years) + assert_equal :amber, user.staleness_tier(now) + + user.update_column(:last_login_at, now - 9.years - 11.months) + assert_equal :amber, user.staleness_tier(now) + + user.update_column(:last_login_at, now - 10.years) + assert_equal :red, user.staleness_tier(now) + end + + test "alumni are exempt, an account that never signed in is not" do + now = Time.zone.parse("2026-08-03") + + alufa = users(:alufa) + alufa.update_column(:last_login_at, now - 20.years) + assert_nil alufa.staleness_tier(now), "alumni are the outcome, not a candidate" + + redella = users(:redella) + redella.update_column(:last_login_at, nil) + assert_equal :never, redella.staleness_tier(now) + end protected def create_user(options = {}) -- cgit v1.3