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 1c78b56ae1..ada6260be9 100644 --- a/app/decorators/person_decorator.rb +++ b/app/decorators/person_decorator.rb @@ -28,6 +28,21 @@ 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 + + # 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 new file mode 100644 index 0000000000..26f5d132a8 --- /dev/null +++ b/app/views/people/_multiple_user_accounts_warning.html.erb @@ -0,0 +1,34 @@ +<% decorated = person.decorate %> +<% user_accounts = decorated.user_accounts %> +<% if user_accounts.size > 1 %> + <% keeper = decorated.suggested_user_account %> +
+
+
+ +
+
+

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? %> + <% 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 %> +
+
+
+
+<% end %> diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb index 0715d3a882..0285354c9e 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, return_to: "person_edit" %> + <% 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..2330973c01 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, return_to: "person_show" %> +<% end %>
<%= render accent_strip_partial %> diff --git a/config/features.yml b/config/features.yml index 095ff0ac2c..89d290ad37 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4527,13 +4527,16 @@ - "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" +- name: "Flag people with more than one user account" area: people display_status: admin_facing - released_on: 2026-10-14 - pr_number: 2689 + released_on: 2026-10-12 + pr_number: 2661 summary: >- - Admins can see and edit a person's internal notes, which hold comments - carried over from FileMaker, on the person's edit page. + A person's profile and edit pages now warn admins when the person has more + than one user account, link to each one, and offer to merge the extras into + the suggested keeper. 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." + - "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 af9c5db099..2668098ae1 100644 --- a/spec/decorators/person_decorator_spec.rb +++ b/spec/decorators/person_decorator_spec.rb @@ -1,6 +1,30 @@ 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 "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 + 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..02d805d8b0 --- /dev/null +++ b/spec/requests/people_multiple_user_accounts_warning_spec.rb @@ -0,0 +1,65 @@ +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 + + 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