fix(auth): reconcile smbCloud user by email when the auth_user id differs
find_or_create_from_smbcloud keyed only on smbcloud_id, so when smbCloud's auth_user id for an email differed from an older local record (e.g. a GitHub social login resolved to a different auth_user than the user's existing email/password record), it tried to create a duplicate User and failed on the unique email — surfacing as "An unexpected error occurred" on the GitHub callback (and would wedge email/password sign-in too). Now, when the smbcloud_id is unseen but the authenticated email already belongs to a local user, adopt that record and move it onto the new smbcloud_id (smbCloud is the identity authority). Adds model specs for the reconcile path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seto Elkahfi committed
Jun 30, 2026 at 21:16 UTC
1b415d7ee06f0c3641d1859cfde9058e1116c0de
2 files changed
+61
app/models/user.rb
+15
index 40c8b9a..54cb9d2 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -98,6 +98,21 @@ class User < ApplicationRecord
user = find_or_initialize_by(smbcloud_id: smbcloud_id)
+ # smbCloud is the identity authority. If we've never seen this smbcloud_id
+ # but the authenticated email already belongs to a local user, adopt that
+ # record and move it onto the new smbcloud_id rather than failing on the
+ # unique email constraint. The smbCloud auth_user id for an email can change
+ # (e.g. the account was recreated, or a social login resolved to a different
+ # auth_user than an older local record), and this can otherwise wedge both
+ # GitHub and email/password sign-in for that user.
+ if user.new_record? && email.present?
+ existing = find_by(email: email)
+ if existing
+ user = existing
+ user.smbcloud_id = smbcloud_id
+ end
+ end
+
# Always sync email and token in case they changed on the auth server.
user.email = email if email.present?
user.access_token = access_token if access_token.present?
spec/models/user_smbcloud_spec.rb
+46
new file mode 100644
index 0000000..87f2919
--- /dev/null
+++ b/spec/models/user_smbcloud_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe User, ".find_or_create_from_smbcloud" do
+ it "creates a new user when the smbcloud_id is unseen and the email is free" do
+ expect do
+ user = User.find_or_create_from_smbcloud({ id: 100, email: "new@example.com" })
+ expect(user.smbcloud_id).to eq(100)
+ expect(user.email).to eq("new@example.com")
+ end.to change(User, :count).by(1)
+ end
+
+ it "returns the same record for a known smbcloud_id" do
+ existing = User.create!(smbcloud_id: 200, email: "known@example.com", username: "known")
+ expect do
+ user = User.find_or_create_from_smbcloud({ id: 200, email: "known@example.com" })
+ expect(user.id).to eq(existing.id)
+ end.not_to change(User, :count)
+ end
+
+ # The regression: smbCloud's auth_user id for an email can differ from the id
+ # stored on an older local record (e.g. a social login resolves to a different
+ # auth_user). Keying only on smbcloud_id would try to create a duplicate and
+ # fail on the unique email, wedging sign-in.
+ it "adopts an existing user by email and moves it to the new smbcloud_id" do
+ existing = User.create!(smbcloud_id: 18, email: "person@example.com", username: "person")
+
+ user = nil
+ expect do
+ user = User.find_or_create_from_smbcloud({ id: 86, email: "person@example.com" })
+ end.not_to change(User, :count)
+
+ expect(user.id).to eq(existing.id)
+ expect(user.smbcloud_id).to eq(86)
+ expect(user.username).to eq("person") # username preserved
+ expect(existing.reload.smbcloud_id).to eq(86)
+ end
+
+ it "matches the existing email case-insensitively" do
+ User.create!(smbcloud_id: 18, email: "mixed@example.com", username: "mixed")
+ user = User.find_or_create_from_smbcloud({ id: 99, email: "MIXED@example.com" })
+ expect(user.smbcloud_id).to eq(99)
+ expect(User.where("lower(email) = ?", "mixed@example.com").count).to eq(1)
+ end
+end