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