From f1fb67bd288bc36190081c46905c41ce5d2fa55d Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 19:22:33 -0400 Subject: [PATCH 1/4] Flag people who have more than one login on their profile and edit pages Co-Authored-By: Claude Opus 5.5 (1M context) From ff6ca29bdd023275c4c407e7e77bc1126b0f3db8 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 19:24:42 -0400 Subject: [PATCH 2/4] Flag people with more than one user account and link to each A merge can leave a person with two logins. The profile shows only the most recently used one, so admins had no way to see or reach the others. The new warning mirrors the multiple-memberships flag and links to every account. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/decorators/person_decorator.rb | 10 ++++++ .../_multiple_user_accounts_warning.html.erb | 24 ++++++++++++++ app/views/people/edit.html.erb | 3 ++ app/views/people/show.html.erb | 3 ++ config/features.yml | 16 +++++++++ spec/decorators/person_decorator_spec.rb | 17 ++++++++++ ...ple_multiple_user_accounts_warning_spec.rb | 33 +++++++++++++++++++ 7 files changed, 106 insertions(+) create mode 100644 app/views/people/_multiple_user_accounts_warning.html.erb create mode 100644 spec/requests/people_multiple_user_accounts_warning_spec.rb diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index 1c78b56ae1..a1d0dc4ca4 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -28,6 +28,16 @@ def uncancelled_membership_count end end + # A merge can leave several logins on one person — flagged for admins on the profile + # and edit pages. Ordered like Person#user, so the first is the one the profile shows. + def user_accounts + @user_accounts ||= User.where(person_id: id).order(updated_at: :desc, id: :desc).to_a + end + + def multiple_user_accounts? + user_accounts.size > 1 + end + # The subscriptions index filtered to this person's News (mailing-list) topic — # where mailing-list interest now lives since the person-level consent flag retired. def news_subscriptions_path diff --git a/app/views/people/_multiple_user_accounts_warning.html.erb b/app/views/people/_multiple_user_accounts_warning.html.erb new file mode 100644 index 0000000000..4981e04fa9 --- /dev/null +++ b/app/views/people/_multiple_user_accounts_warning.html.erb @@ -0,0 +1,24 @@ +<% user_accounts = person.decorate.user_accounts %> +<% if user_accounts.size > 1 %> +
+
+
+ +
+
+

Multiple user accounts

+

+ This person has <%= user_accounts.size %> user accounts. A person should have only one — the extras are usually a leftover from a merge. +

+
    + <% user_accounts.each_with_index do |user, index| %> +
  • + <%= link_to user.email, user_path(user), class: "underline hover:text-yellow-900", data: { turbo_frame: "_top" } %> + · last signed in <%= user.decorate.last_logged_in %><%= " · shown on profile" if index.zero? %> +
  • + <% end %> +
+
+
+
+<% end %> diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb index 0715d3a882..961046481e 100644 --- a/app/views/people/edit.html.erb +++ b/app/views/people/edit.html.erb @@ -47,6 +47,9 @@ title: truncate(@person.name, length: 40), back: back, actions: actions, width: "max-w-7xl", sticky_header: true, card: false } do %> + <% if allowed_to?(:index?, User) %> + <%= render "people/multiple_user_accounts_warning", person: @person %> + <% end %>
<%= render "form" %> <%= render "shared/audit_info", resource: @person %> diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index 9369db0179..3c88e99b14 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -12,6 +12,9 @@ <% if allowed_to?(:index?, MembershipInvoice) %> <%= render "people/multiple_memberships_warning", person: @person %> <% end %> +<% if allowed_to?(:index?, User) %> + <%= render "people/multiple_user_accounts_warning", person: @person %> +<% end %>
<%= render accent_strip_partial %> diff --git a/config/features.yml b/config/features.yml index 095ff0ac2c..934bc8c533 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4537,3 +4537,19 @@ carried over from FileMaker, on the person's edit page. pro_tips: - "Add ?admin=true to the end of the edit page's address to show the Internal notes box, the same as on organizations and users." + + - "Admins see the same blue markers." + + - "Use \"Change sharing settings\" in the Hidden from public section to turn something back on." + +- name: "Flag people with more than one user account" + area: people + display_status: admin_facing + released_on: 2026-10-12 + pr_number: 2661 + summary: >- + A person's profile and edit pages now warn admins when the person has more + than one user account, and link to each one so you can review them. + pro_tips: + - "The extra account is usually left over from a merge." + - "The account marked \"shown on profile\" is the one used most recently." diff --git a/spec/decorators/person_decorator_spec.rb b/spec/decorators/person_decorator_spec.rb index af9c5db099..0f558d0f4e 100644 --- a/spec/decorators/person_decorator_spec.rb +++ b/spec/decorators/person_decorator_spec.rb @@ -1,6 +1,23 @@ require "rails_helper" RSpec.describe PersonDecorator do + describe "#user_accounts" do + let(:person) { create(:person) } + + it "lists every login on the person, most recently updated first" do + older = person.user.tap { |user| user.update_columns(updated_at: 2.days.ago) } + newer = create(:user, person: person, updated_at: 1.hour.ago) + create(:user) + + expect(person.decorate.user_accounts).to eq([ newer, older ]) + expect(person.decorate.multiple_user_accounts?).to be(true) + end + + it "does not flag a person with one login" do + expect(person.decorate.multiple_user_accounts?).to be(false) + end + end + describe "#news_subscriptions_path" do let(:person) { create(:person) } diff --git a/spec/requests/people_multiple_user_accounts_warning_spec.rb b/spec/requests/people_multiple_user_accounts_warning_spec.rb new file mode 100644 index 0000000000..808e6f57c8 --- /dev/null +++ b/spec/requests/people_multiple_user_accounts_warning_spec.rb @@ -0,0 +1,33 @@ +require "rails_helper" + +RSpec.describe "People multiple-user-accounts warning", type: :request do + let(:admin) { create(:user, :admin) } + + before { sign_in admin } + + it "flags a person with more than one user account on show and edit, linking to each" do + person = create(:person) + first_login = person.user + second_login = create(:user, person: person) + + get person_path(person) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Multiple user accounts") + expect(response.body).to include(user_path(first_login), user_path(second_login)) + + get edit_person_path(person) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Multiple user accounts") + expect(response.body).to include(user_path(first_login), user_path(second_login)) + end + + it "does not flag a person with a single user account" do + person = create(:person) + + get person_path(person) + expect(response.body).not_to include("Multiple user accounts") + + get edit_person_path(person) + expect(response.body).not_to include("Multiple user accounts") + end +end From 796b4a826729e747de6cc3b0c439f0c185e57844 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 09:09:20 -0400 Subject: [PATCH 3/4] Let admins merge a person's extra logins straight from the warning Main now has a logins deduper. Linking each extra account to its merge preview, with the deduper's suggested keeper picked, turns the warning into a fix. The preview and the finished merge return to the person page the admin started on. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/controllers/concerns/dedupable.rb | 18 ++++++++++- app/decorators/person_decorator.rb | 5 +++ app/views/dedupes/preview.html.erb | 9 ++++-- .../_multiple_user_accounts_warning.html.erb | 12 ++++++- app/views/people/edit.html.erb | 2 +- app/views/people/show.html.erb | 2 +- config/features.yml | 4 ++- spec/decorators/person_decorator_spec.rb | 7 ++++ ...ple_multiple_user_accounts_warning_spec.rb | 32 +++++++++++++++++++ 9 files changed, 83 insertions(+), 8 deletions(-) diff --git a/app/controllers/concerns/dedupable.rb b/app/controllers/concerns/dedupable.rb index 527c613e1b..175ddca897 100644 --- a/app/controllers/concerns/dedupable.rb +++ b/app/controllers/concerns/dedupable.rb @@ -3,6 +3,10 @@ module Dedupable extend ActiveSupport::Concern + included do + helper_method :dedupe_return + end + CandidateGroup = Struct.new(:label, :records, :reasons, keyword_init: true) def dedupe_index @@ -128,7 +132,7 @@ def dedupe_perform end label = mc.model_name.human.pluralize - redirect_to url_for(action: :index), + redirect_to dedupe_return&.dig(:path) || url_for(action: :index), notice: "#{label} merged successfully. '#{dedupe_record_label(config, record_to_delete)}' was merged into '#{dedupe_record_label(config, record_to_keep)}'." rescue ActionPolicy::Unauthorized raise @@ -139,6 +143,18 @@ def dedupe_perform private + # Where a merge started from outside the dedupe index returns to, with the params that carry it. + def dedupe_return + person_id = params[:person_id].presence + return unless person_id + + carry = { return_to: params[:return_to], person_id: person_id } + case params[:return_to] + when "person_show" then { label: "Back to profile", path: person_path(person_id), params: carry } + when "person_edit" then { label: "Back to edit person", path: edit_person_path(person_id), params: carry } + end + end + # The preview only renders editable_columns when a config lists them, so only those are accepted. def dedupe_editable_columns(config) config[:editable_columns].presence || (config[:model_class].column_names - %w[id created_at updated_at legacy_id]) diff --git a/app/decorators/person_decorator.rb b/app/decorators/person_decorator.rb index a1d0dc4ca4..ada6260be9 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -38,6 +38,11 @@ def multiple_user_accounts? user_accounts.size > 1 end + # Matches the logins deduper's pick (UserServices::DuplicateFinder): the most recent login that can sign in. + def suggested_user_account + user_accounts.find(&:has_access?) || user_accounts.first + end + # The subscriptions index filtered to this person's News (mailing-list) topic — # where mailing-list interest now lives since the person-level consent flag retired. def news_subscriptions_path diff --git a/app/views/dedupes/preview.html.erb b/app/views/dedupes/preview.html.erb index 1285812c40..8dfff74042 100644 --- a/app/views/dedupes/preview.html.erb +++ b/app/views/dedupes/preview.html.erb @@ -5,7 +5,7 @@
- <%= link_to "← Back to dedupe", url_for(action: :dedupe_index), + <%= link_to "← #{dedupe_return&.dig(:label) || "Back to dedupe"}", dedupe_return&.dig(:path) || url_for(action: :dedupe_index), class: "text-sm #{eyebrow_link_class} px-2 py-1" %>

Preview <%= @dedupe[:model_label].downcase %> merge @@ -15,7 +15,7 @@ move to the one on the right. Edit the keeper's fields before merging.

- <%= link_to url_for(action: :dedupe_preview, @dedupe[:delete_id_param] => @record_to_keep.id, @dedupe[:keep_id_param] => @record_to_delete.id), + <%= link_to url_for(action: :dedupe_preview, @dedupe[:delete_id_param] => @record_to_keep.id, @dedupe[:keep_id_param] => @record_to_delete.id, **dedupe_return&.dig(:params).to_h), class: button_classes(:secondary_outline, size: :sm, extra: "shrink-0") do %> Swap <% end %> @@ -82,6 +82,9 @@ turbo_confirm: "Merge these #{@dedupe[:model_label_plural].downcase}? This can't be undone." } do |f| %> <%= f.hidden_field @dedupe[:delete_id_param].to_sym, value: @record_to_delete.id %> <%= f.hidden_field @dedupe[:keep_id_param].to_sym, value: @record_to_keep.id %> + <% dedupe_return&.dig(:params)&.each do |name, value| %> + <%= hidden_field_tag name, value %> + <% end %> <%= render "dedupes/preview", domain: @dedupe[:domain], @@ -167,7 +170,7 @@ Merge disabled (see above). <% end %> - <%= link_to "Cancel", url_for(action: :dedupe_index), class: button_classes(:secondary_outline) %> + <%= link_to "Cancel", dedupe_return&.dig(:path) || url_for(action: :dedupe_index), class: button_classes(:secondary_outline) %> <%= f.submit "Merge #{@dedupe[:model_label_plural].downcase}", disabled: blocked, class: button_classes(:danger, extra: ("cursor-not-allowed opacity-50" if blocked)) %> diff --git a/app/views/people/_multiple_user_accounts_warning.html.erb b/app/views/people/_multiple_user_accounts_warning.html.erb index 4981e04fa9..26f5d132a8 100644 --- a/app/views/people/_multiple_user_accounts_warning.html.erb +++ b/app/views/people/_multiple_user_accounts_warning.html.erb @@ -1,5 +1,7 @@ -<% user_accounts = person.decorate.user_accounts %> +<% decorated = person.decorate %> +<% user_accounts = decorated.user_accounts %> <% if user_accounts.size > 1 %> + <% keeper = decorated.suggested_user_account %>
@@ -15,6 +17,14 @@
  • <%= link_to user.email, user_path(user), class: "underline hover:text-yellow-900", data: { turbo_frame: "_top" } %> · last signed in <%= user.decorate.last_logged_in %><%= " · shown on profile" if index.zero? %> + <% if user == keeper %> + Suggested keeper + <% else %> + <%= link_to "Preview merge →", + dedupe_preview_users_path(user_to_delete_id: user.id, user_to_keep_id: keeper.id, return_to: return_to, person_id: person.id), + class: "ml-1 font-medium underline hover:text-yellow-900", + data: { turbo: false } %> + <% end %>
  • <% end %> diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb index 961046481e..0285354c9e 100644 --- a/app/views/people/edit.html.erb +++ b/app/views/people/edit.html.erb @@ -48,7 +48,7 @@ back: back, actions: actions, width: "max-w-7xl", sticky_header: true, card: false } do %> <% if allowed_to?(:index?, User) %> - <%= render "people/multiple_user_accounts_warning", person: @person %> + <%= render "people/multiple_user_accounts_warning", person: @person, return_to: "person_edit" %> <% end %>
    <%= render "form" %> diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index 3c88e99b14..2330973c01 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -13,7 +13,7 @@ <%= render "people/multiple_memberships_warning", person: @person %> <% end %> <% if allowed_to?(:index?, User) %> - <%= render "people/multiple_user_accounts_warning", person: @person %> + <%= render "people/multiple_user_accounts_warning", person: @person, return_to: "person_show" %> <% end %>
    <%= render accent_strip_partial %> diff --git a/config/features.yml b/config/features.yml index 934bc8c533..cb1a5abb1c 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4549,7 +4549,9 @@ pr_number: 2661 summary: >- A person's profile and edit pages now warn admins when the person has more - than one user account, and link to each one so you can review them. + than one user account, link to each one, and offer to merge the extras into + the suggested keeper. pro_tips: - "The extra account is usually left over from a merge." + - "Preview merge opens the logins deduper with the suggested keeper picked. Its back link and the finished merge return you to the person." - "The account marked \"shown on profile\" is the one used most recently." diff --git a/spec/decorators/person_decorator_spec.rb b/spec/decorators/person_decorator_spec.rb index 0f558d0f4e..2668098ae1 100644 --- a/spec/decorators/person_decorator_spec.rb +++ b/spec/decorators/person_decorator_spec.rb @@ -13,6 +13,13 @@ expect(person.decorate.multiple_user_accounts?).to be(true) end + it "suggests keeping the most recent login that can still sign in" do + usable = person.user + create(:user, person: person, confirmed_at: nil, updated_at: 1.minute.from_now) + + expect(person.decorate.suggested_user_account).to eq(usable) + end + it "does not flag a person with one login" do expect(person.decorate.multiple_user_accounts?).to be(false) end diff --git a/spec/requests/people_multiple_user_accounts_warning_spec.rb b/spec/requests/people_multiple_user_accounts_warning_spec.rb index 808e6f57c8..02d805d8b0 100644 --- a/spec/requests/people_multiple_user_accounts_warning_spec.rb +++ b/spec/requests/people_multiple_user_accounts_warning_spec.rb @@ -30,4 +30,36 @@ get edit_person_path(person) expect(response.body).not_to include("Multiple user accounts") end + + it "links each extra account to a merge preview into the suggested keeper, returning to the page it came from" do + person = create(:person) + stale = person.user.tap { |user| user.update_columns(confirmed_at: nil, updated_at: 1.minute.from_now) } + keeper = create(:user, person: person) + + get edit_person_path(person) + + expect(response.body).to include("Suggested keeper") + expect(response.body).to include(CGI.escapeHTML(dedupe_preview_users_path(user_to_delete_id: stale.id, user_to_keep_id: keeper.id, return_to: "person_edit", person_id: person.id))) + end + + describe "returning from the logins merge" do + let(:person) { create(:person) } + let!(:keeper) { person.user } + let!(:extra) { create(:user, person: person) } + + it "points the preview's back link at the person page it came from" do + get dedupe_preview_users_path(user_to_delete_id: extra.id, user_to_keep_id: keeper.id, return_to: "person_show", person_id: person.id) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Back to profile") + expect(response.body).to include(%(href="#{person_path(person)}")) + end + + it "lands back on the person page after merging" do + post dedupe_perform_users_path, params: { user_to_delete_id: extra.id, user_to_keep_id: keeper.id, return_to: "person_edit", person_id: person.id } + + expect(response).to redirect_to(edit_person_path(person)) + expect(User.exists?(extra.id)).to be(false) + end + end end From 7b6a21539c3eae155fbd182a8a33863a861aed06 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 14 Oct 2026 20:20:42 -0400 Subject: [PATCH 4/4] Keep the hidden ?admin internal notes off the features page Internal notes sit behind an unlisted ?admin switch, so the features page shouldn't advertise them. Also drops two stale pro-tip lines an earlier rebase left in the seed. Co-Authored-By: Claude Opus 5.5 (1M context) --- config/features.yml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/config/features.yml b/config/features.yml index cb1a5abb1c..89d290ad37 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4527,21 +4527,6 @@ - "If the resubmission adds a scholarship request, the subject ends with \"(scholarship now requested)\"." - "Filter the Communications page by \"Admin FYI: duplicate event registration\" or \"Duplicate event registration received\" to find these emails." -- name: "Internal notes on people" - area: people - display_status: admin_facing - released_on: 2026-10-14 - pr_number: 2689 - summary: >- - Admins can see and edit a person's internal notes, which hold comments - carried over from FileMaker, on the person's edit page. - pro_tips: - - "Add ?admin=true to the end of the edit page's address to show the Internal notes box, the same as on organizations and users." - - - "Admins see the same blue markers." - - - "Use \"Change sharing settings\" in the Hidden from public section to turn something back on." - - name: "Flag people with more than one user account" area: people display_status: admin_facing