From cf4a8be285dc03623eb62d9b2b32ec6f3c0124d1 Mon Sep 17 00:00:00 2001 From: w3lld1 <42353747+w3lld1@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:26:37 +0200 Subject: [PATCH 1/3] fix: handle existing names in partner invites --- app/controllers/partner_users_controller.rb | 7 +++- .../controllers/existing_user_controller.js | 37 +++++++++++++++++++ app/services/user_invite_service.rb | 3 +- app/views/partner_users/_form.html.erb | 21 +++++++++-- config/routes.rb | 3 ++ spec/requests/partner_users_requests_spec.rb | 17 +++++++++ spec/services/user_invite_service_spec.rb | 31 +++++++++++++++- spec/system/partner_system_spec.rb | 23 ++++++++++++ 8 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 app/javascript/controllers/existing_user_controller.js diff --git a/app/controllers/partner_users_controller.rb b/app/controllers/partner_users_controller.rb index c0df52cfe5..45915f0992 100644 --- a/app/controllers/partner_users_controller.rb +++ b/app/controllers/partner_users_controller.rb @@ -2,13 +2,18 @@ class PartnerUsersController < ApplicationController before_action :authorize_admin - before_action :set_partner, only: %i[index create destroy resend_invitation] + before_action :set_partner, only: %i[index lookup create destroy resend_invitation] def index @users = @partner.users @user = User.new(name: "") end + def lookup + user = User.find_by("LOWER(email) = ?", params[:email].to_s.downcase) + render json: {exists: user.present?} + end + def create @user = UserInviteService.invite( email: user_params[:email], diff --git a/app/javascript/controllers/existing_user_controller.js b/app/javascript/controllers/existing_user_controller.js new file mode 100644 index 0000000000..fcdfc7d94e --- /dev/null +++ b/app/javascript/controllers/existing_user_controller.js @@ -0,0 +1,37 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["email", "name", "message"] + static values = { lookupUrl: String } + + async lookup() { + const email = this.emailTarget.value.trim() + + if (email === "") { + this.resetNameField() + return + } + + try { + const response = await fetch(`${this.lookupUrlValue}?email=${encodeURIComponent(email)}`, { + headers: { "Accept": "application/json" } + }) + if (!response.ok || this.emailTarget.value.trim() !== email) return + + const user = await response.json() + if (!user.exists) { + this.resetNameField() + } else { + this.nameTarget.disabled = false + this.messageTarget.textContent = "This user already exists. The submitted name will only be used if their profile has no name yet." + } + } catch (_error) { + this.resetNameField() + } + } + + resetNameField() { + this.nameTarget.disabled = false + this.messageTarget.textContent = "" + } +} diff --git a/app/services/user_invite_service.rb b/app/services/user_invite_service.rb index ef5f2462be..6a3bb92378 100644 --- a/app/services/user_invite_service.rb +++ b/app/services/user_invite_service.rb @@ -20,7 +20,7 @@ def self.invite(email:, resource:, name: nil, roles: [], force: false) roles.append(Role::ORG_USER) end - user = User.find_by(email: email) + user = User.find_by("LOWER(email) = ?", email.to_s.downcase) # return if user already has all the roles we're trying to add if !force && user && roles.all? { |role| user.has_role?(role, resource) } @@ -28,6 +28,7 @@ def self.invite(email:, resource:, name: nil, roles: [], force: false) end if user + user.update!(name: name) if user.name.blank? && name.present? add_roles(user, resource: resource, roles: roles) if force user.invite! diff --git a/app/views/partner_users/_form.html.erb b/app/views/partner_users/_form.html.erb index 74aa605602..b1b7327a4d 100644 --- a/app/views/partner_users/_form.html.erb +++ b/app/views/partner_users/_form.html.erb @@ -3,13 +3,28 @@

Invite New User

- <%= simple_form_for user, url: partner_users_path(partner) do |f| %> + <%= simple_form_for user, + url: partner_users_path(partner), + html: { + data: { + controller: "existing-user", + existing_user_lookup_url_value: lookup_partner_users_path(partner) + } + } do |f| %>
- <%= f.input :name, label: "Name", placeholder: "Name", required: true %> + <%= f.input :name, label: "Name", placeholder: "Name", required: true, + input_html: {data: {existing_user_target: "name"}} %>
- <%= f.input :email, label: "Email", placeholder: "Email", required: true %> + <%= f.input :email, label: "Email", placeholder: "Email", required: true, + input_html: { + data: { + existing_user_target: "email", + action: "blur->existing-user#lookup" + } + } %> +
diff --git a/config/routes.rb b/config/routes.rb index 74bf981a79..1016e786a6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -207,6 +207,9 @@ def set_up_flipper resources :partners do resources :users, only: [:index, :create, :destroy], controller: 'partner_users' do + collection do + get :lookup + end member do post :resend_invitation post :reset_password diff --git a/spec/requests/partner_users_requests_spec.rb b/spec/requests/partner_users_requests_spec.rb index 8492284b71..e3750a9cec 100644 --- a/spec/requests/partner_users_requests_spec.rb +++ b/spec/requests/partner_users_requests_spec.rb @@ -36,6 +36,23 @@ end end + describe "GET #lookup" do + before do + sign_in(org_admin) + end + + it "reports when a user already exists" do + existing_user = create(:user, name: nil, email: "existing@example.com") + + get lookup_partner_users_path( + default_params.merge(partner_id: partner, email: existing_user.email.upcase) + ) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq("exists" => true) + end + end + describe "POST #create" do let(:valid_user_params) do { diff --git a/spec/services/user_invite_service_spec.rb b/spec/services/user_invite_service_spec.rb index 8e2fd5dc00..a6f6c0fb35 100644 --- a/spec/services/user_invite_service_spec.rb +++ b/spec/services/user_invite_service_spec.rb @@ -49,13 +49,42 @@ end it "should add roles to existing user" do - described_class.invite(email: "email@email.com", + described_class.invite(email: "EMAIL@EMAIL.COM", roles: [Role::ORG_USER, Role::ORG_ADMIN], resource: organization) expect(user).to have_role(Role::ORG_USER, organization) expect(user).to have_role(Role::ORG_ADMIN, organization) expect(user).not_to have_role(Role::PARTNER, :any) end + + it "should find an existing user case-insensitively" do + uppercase_email = user.email.upcase + target_partner = partner + expect(User.find_by("LOWER(email) = ?", uppercase_email.downcase)).to eq(user) + + expect { + described_class.invite( + email: uppercase_email, + roles: [Role::PARTNER], + resource: target_partner + ) + }.not_to change(User, :count) + + expect(user.reload).to have_role(Role::PARTNER, target_partner) + end + + it "should add a submitted name when the existing user has no name" do + user.update!(name: nil) + + described_class.invite( + name: "Existing User", + email: user.email, + roles: [Role::PARTNER], + resource: partner + ) + + expect(user.reload.name).to eq("Existing User") + end end context "with a new user" do diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 2e525e4136..19995e1790 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -589,6 +589,29 @@ expect(page).to have_content(partner_user.name) expect(page).to have_content(partner_user.email) end + + it 'checks an existing user when leaving the email field', js: true do + existing_user = create(:user, name: nil, email: "existing@example.com") + visit subject + + fill_in "Email", with: existing_user.email + find_field("Email").send_keys(:tab) + + expect(page).to have_content("This user already exists. The submitted name will only be used if their profile has no name yet.") + expect(page).to have_field("Name", disabled: false) + end + + it 'keeps the name of an existing named user', js: true do + existing_user = create(:user, name: "Existing Name", email: "named@example.com") + visit subject + + fill_in "Name", with: "Replacement Name" + fill_in "Email", with: existing_user.email + find_field("Email").send_keys(:tab) + + expect(page).to have_content("This user already exists. The submitted name will only be used if their profile has no name yet.") + expect(page).to have_field("Name", with: "Replacement Name", disabled: false) + end end context "when partner has :awaiting_review status" do From f74d5649f32f8d12c4cf79cfdd82cdab74101a80 Mon Sep 17 00:00:00 2001 From: w3lld1 <42353747+w3lld1@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:30:49 +0200 Subject: [PATCH 2/3] fix: address existing-user lookup review --- .../controllers/existing_user_controller.js | 16 ++++++++++++---- spec/requests/partner_users_requests_spec.rb | 9 +++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/javascript/controllers/existing_user_controller.js b/app/javascript/controllers/existing_user_controller.js index fcdfc7d94e..09cc186f2f 100644 --- a/app/javascript/controllers/existing_user_controller.js +++ b/app/javascript/controllers/existing_user_controller.js @@ -4,7 +4,14 @@ export default class extends Controller { static targets = ["email", "name", "message"] static values = { lookupUrl: String } + disconnect() { + this.abortController?.abort() + } + async lookup() { + this.abortController?.abort() + this.abortController = new AbortController() + const email = this.emailTarget.value.trim() if (email === "") { @@ -14,7 +21,8 @@ export default class extends Controller { try { const response = await fetch(`${this.lookupUrlValue}?email=${encodeURIComponent(email)}`, { - headers: { "Accept": "application/json" } + headers: { "Accept": "application/json" }, + signal: this.abortController.signal }) if (!response.ok || this.emailTarget.value.trim() !== email) return @@ -23,10 +31,10 @@ export default class extends Controller { this.resetNameField() } else { this.nameTarget.disabled = false - this.messageTarget.textContent = "This user already exists. The submitted name will only be used if their profile has no name yet." + this.messageTarget.textContent = "This user already exists. Their current profile name will be kept; the submitted name is used only if their profile has no name yet." } - } catch (_error) { - this.resetNameField() + } catch (error) { + if (error.name !== "AbortError") this.messageTarget.textContent = "" } } diff --git a/spec/requests/partner_users_requests_spec.rb b/spec/requests/partner_users_requests_spec.rb index e3750a9cec..cb7aef0bc6 100644 --- a/spec/requests/partner_users_requests_spec.rb +++ b/spec/requests/partner_users_requests_spec.rb @@ -51,6 +51,15 @@ expect(response).to have_http_status(:ok) expect(response.parsed_body).to eq("exists" => true) end + + it "reports when a user does not exist" do + get lookup_partner_users_path( + default_params.merge(partner_id: partner, email: "missing@example.com") + ) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq("exists" => false) + end end describe "POST #create" do From ed93353baf804b509c2021c1d052413b786a56ee Mon Sep 17 00:00:00 2001 From: w3lld1 <42353747+w3lld1@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:36:00 +0200 Subject: [PATCH 3/3] fix: clarify existing user name handling --- app/controllers/partner_users_controller.rb | 4 ++-- .../controllers/existing_user_controller.js | 4 +++- app/views/partner_users/_form.html.erb | 2 +- spec/requests/partner_users_requests_spec.rb | 17 ++++++++++++++--- spec/system/partner_system_spec.rb | 4 ++-- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/controllers/partner_users_controller.rb b/app/controllers/partner_users_controller.rb index 45915f0992..1223c900b2 100644 --- a/app/controllers/partner_users_controller.rb +++ b/app/controllers/partner_users_controller.rb @@ -11,7 +11,7 @@ def index def lookup user = User.find_by("LOWER(email) = ?", params[:email].to_s.downcase) - render json: {exists: user.present?} + render json: {exists: user.present?, has_name: user&.name.present?} end def create @@ -69,7 +69,7 @@ def reset_password private def set_partner - @partner = Partner.find(params[:partner_id]) + @partner = current_organization.partners.find(params[:partner_id]) end def user_params diff --git a/app/javascript/controllers/existing_user_controller.js b/app/javascript/controllers/existing_user_controller.js index 09cc186f2f..1ef1e7dfb7 100644 --- a/app/javascript/controllers/existing_user_controller.js +++ b/app/javascript/controllers/existing_user_controller.js @@ -31,7 +31,9 @@ export default class extends Controller { this.resetNameField() } else { this.nameTarget.disabled = false - this.messageTarget.textContent = "This user already exists. Their current profile name will be kept; the submitted name is used only if their profile has no name yet." + this.messageTarget.textContent = user.has_name + ? "This user already exists. Their current profile name will be kept." + : "This user already exists. The submitted name will be used because their profile has no name yet." } } catch (error) { if (error.name !== "AbortError") this.messageTarget.textContent = "" diff --git a/app/views/partner_users/_form.html.erb b/app/views/partner_users/_form.html.erb index b1b7327a4d..e0160851d9 100644 --- a/app/views/partner_users/_form.html.erb +++ b/app/views/partner_users/_form.html.erb @@ -24,7 +24,7 @@ action: "blur->existing-user#lookup" } } %> - + diff --git a/spec/requests/partner_users_requests_spec.rb b/spec/requests/partner_users_requests_spec.rb index cb7aef0bc6..98a75b48ae 100644 --- a/spec/requests/partner_users_requests_spec.rb +++ b/spec/requests/partner_users_requests_spec.rb @@ -1,7 +1,7 @@ # spec/requests/partner_users_controller_spec.rb RSpec.describe PartnerUsersController, type: :request do - let!(:partner) { create(:partner) } # Assuming you have a factory for creating partners + let!(:partner) { create(:partner, organization: organization) } let(:organization) { create(:organization) } let(:user) { create(:user, organization: organization) } let(:org_admin) { create(:organization_admin, organization: organization) } @@ -49,7 +49,18 @@ ) expect(response).to have_http_status(:ok) - expect(response.parsed_body).to eq("exists" => true) + expect(response.parsed_body).to eq("exists" => true, "has_name" => false) + end + + it "reports when an existing user has a name" do + existing_user = create(:user, name: "Existing Name", email: "named@example.com") + + get lookup_partner_users_path( + default_params.merge(partner_id: partner, email: existing_user.email) + ) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq("exists" => true, "has_name" => true) end it "reports when a user does not exist" do @@ -58,7 +69,7 @@ ) expect(response).to have_http_status(:ok) - expect(response.parsed_body).to eq("exists" => false) + expect(response.parsed_body).to eq("exists" => false, "has_name" => false) end end diff --git a/spec/system/partner_system_spec.rb b/spec/system/partner_system_spec.rb index 19995e1790..9d75756bfd 100644 --- a/spec/system/partner_system_spec.rb +++ b/spec/system/partner_system_spec.rb @@ -597,7 +597,7 @@ fill_in "Email", with: existing_user.email find_field("Email").send_keys(:tab) - expect(page).to have_content("This user already exists. The submitted name will only be used if their profile has no name yet.") + expect(page).to have_content("This user already exists. The submitted name will be used because their profile has no name yet.") expect(page).to have_field("Name", disabled: false) end @@ -609,7 +609,7 @@ fill_in "Email", with: existing_user.email find_field("Email").send_keys(:tab) - expect(page).to have_content("This user already exists. The submitted name will only be used if their profile has no name yet.") + expect(page).to have_content("This user already exists. Their current profile name will be kept.") expect(page).to have_field("Name", with: "Replacement Name", disabled: false) end end