Skip to content

Commit cf57e14

Browse files
authored
Merge pull request #916 from amatsuda/identities
User has_many OAuth Identities
2 parents 7785280 + e60a7b9 commit cf57e14

File tree

10 files changed

+183
-48
lines changed

10 files changed

+183
-48
lines changed

app/controllers/profiles_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def update
2222
redirect_to (session.delete(:target) || root_url)
2323
else
2424
flash.now[:danger] = "Unable to save profile. Please correct the following: #{current_user.errors.full_messages.join(', ')}."
25-
render :edit
25+
render :edit, status: :unprocessable_entity
2626
end
2727
end
2828

app/controllers/users/omniauth_callbacks_controller.rb

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
2-
before_action :check_current_user, only: [:twitter, :github, :developer]
2+
before_action :link_identity_to_current_user, only: [:twitter, :github, :developer], if: -> { current_user }
33
skip_forgery_protection only: :developer
44

55
def twitter
@@ -21,26 +21,34 @@ def failure
2121

2222
private
2323

24-
def check_current_user
25-
if current_user.present?
26-
flash[:info] = I18n.t("devise.failure.already_authenticated")
27-
redirect_to(events_url)
24+
# If an already logged in user tries to connect with another provider, integrate with the current_user's identity
25+
def link_identity_to_current_user
26+
if (identity = Identity.find_by(provider: auth_hash.provider, uid: auth_hash.uid))
27+
if identity.user_id == current_user.id
28+
flash[:info] = "#{provider_name} is already connected to your account."
29+
else
30+
flash[:danger] = "This #{provider_name} account is already connected to another user."
31+
end
32+
else
33+
current_user.identities.create!(provider: auth_hash.provider, uid: auth_hash.uid)
34+
flash[:info] = "Successfully connected #{provider_name} to your account."
2835
end
36+
37+
redirect_to edit_profile_path
2938
end
3039

3140
def authenticate_with_hash(user = nil)
3241
logger.info "Authenticating user credentials: #{auth_hash.inspect}"
3342
@user = user || User.from_omniauth(auth_hash, session[:pending_invite_email])
3443

3544
if @user.persisted?
36-
flash.now[:info] = "You have signed in with #{auth_hash['provider'].capitalize}."
45+
flash.now[:info] = "You have signed in with #{provider_name}."
3746
logger.info "Signing in user #{@user.inspect}"
3847

3948
@user.skip_confirmation!
4049
sign_in @user
4150

4251
redirect_to after_sign_in_path_for(@user)
43-
4452
else
4553
redirect_to new_user_session_url, danger: "There was an error authenticating via Auth provider: #{params[:provider]}."
4654
end
@@ -50,4 +58,7 @@ def auth_hash
5058
request.env['omniauth.auth']
5159
end
5260

61+
def provider_name
62+
{'github' => 'GitHub', 'twitter' => 'Twitter'}[auth_hash.provider]
63+
end
5364
end

app/models/identity.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
class Identity < ApplicationRecord
2+
belongs_to :user
3+
4+
validates :provider, presence: true
5+
validates :uid, presence: true, uniqueness: {scope: :provider}
6+
end
7+
8+
# == Schema Information
9+
#
10+
# Table name: identities
11+
#
12+
# id :integer not null, primary key
13+
# user_id :integer not null
14+
# provider :string not null
15+
# uid :string not null
16+
# created_at :datetime not null
17+
# updated_at :datetime not null
18+
#
19+
# Indexes
20+
#
21+
# index_identities_on_provider_and_uid (provider,uid) UNIQUE
22+
# index_identities_on_user_id (user_id)
23+
#

app/models/user.rb

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class User < ApplicationRecord
55
:recoverable, :rememberable, :trackable, :confirmable, #:validatable,
66
:omniauthable, omniauth_providers: [:twitter, :github, :developer]
77

8+
has_many :identities, dependent: :destroy
89
has_many :invitations, dependent: :destroy
910
has_many :teammates, dependent: :destroy
1011
has_many :reviewer_teammates, -> { where(role: ['reviewer', 'program team', 'organizer']) }, class_name: 'Teammate'
@@ -22,7 +23,7 @@ class User < ApplicationRecord
2223
validates :name, presence: true, allow_nil: true
2324
validates_uniqueness_of :email, allow_blank: true
2425
validates_format_of :email, with: Devise.email_regexp, allow_blank: true, if: :email_changed?
25-
validates_presence_of :email, on: :create, if: -> { provider.blank? }
26+
validates_presence_of :email, on: :create, if: -> { provider.blank? && identities.blank? }
2627
validates_presence_of :email, on: :update, if: -> { provider.blank? || unconfirmed_email.blank? }
2728
validates_presence_of :password, on: :create
2829
validates_confirmation_of :password, on: :create
@@ -34,17 +35,33 @@ class User < ApplicationRecord
3435

3536
attr_accessor :pending_invite_email
3637

37-
def self.from_omniauth(auth, invitation_email=nil)
38-
where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
39-
password = Devise.friendly_token[0,20]
40-
user.name = auth['info']['name'] if user.name.blank?
41-
user.email = invitation_email || auth['info']['email'] || '' if user.email.blank?
42-
user.password = password
43-
user.password_confirmation = password
44-
if !user.confirmed? && invitation_email.present? && user.email == invitation_email
45-
user.skip_confirmation!
46-
end
38+
def self.from_omniauth(auth, invitation_email = nil)
39+
# First, lookup in identities table
40+
identity = Identity.find_by(provider: auth.provider, uid: auth.uid)
41+
return identity.user if identity
42+
43+
# Fall back to legacy lookup in users table
44+
user = find_by(provider: auth.provider, uid: auth.uid)
45+
return user if user
46+
47+
# Create new user with identity
48+
create_from_omniauth!(auth, invitation_email)
49+
end
50+
51+
def self.create_from_omniauth!(auth, invitation_email = nil)
52+
user = new(
53+
name: auth['info']['name'],
54+
email: invitation_email || auth['info']['email'] || '',
55+
password: (password = Devise.friendly_token[0, 20]),
56+
password_confirmation: password
57+
)
58+
user.identities.build(provider: auth.provider, uid: auth.uid)
59+
60+
if invitation_email.present? && (user.email == invitation_email)
61+
user.skip_confirmation!
4762
end
63+
64+
user.tap(&:save!)
4865
end
4966

5067
def check_pending_invite_email
@@ -66,7 +83,7 @@ def gravatar_hash
6683
end
6784

6885
def connected?(provider)
69-
self.provider == provider
86+
identities.exists?(provider: provider) || (self.provider == provider)
7087
end
7188

7289
def complete?

app/views/profiles/edit.html.haml

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@
3131
.widget
3232
.widget-header
3333
%i.bi.bi-lock
34-
- if current_user.provider.present?
35-
%i.bi{class: "bi-#{current_user.provider.downcase}"}
3634
%h3 Identity Services
3735
.widget-content
3836
%p
@@ -48,6 +46,20 @@
4846
.form-group
4947
= f.label :password_confirmation
5048
= f.password_field :password_confirmation, class: 'form-control', placeholder: 'Confirm password'
49+
%hr
50+
%p Connect your social accounts to enable sign-in via these providers.
51+
.connected-accounts
52+
- %w[github twitter].each do |provider|
53+
- if current_user.connected?(provider)
54+
.service.mb-2
55+
%button.btn.btn-secondary.disabled{class: "btn-#{provider}-alt"}
56+
%i{class: "bi bi-#{provider}"}
57+
= "Connected via #{provider.capitalize}"
58+
- else
59+
.service.mb-2
60+
= button_to send("user_#{provider}_omniauth_authorize_path"), data: {turbo: false}, class: "btn btn-#{provider}", form: {style: 'display: inline-block'} do
61+
%i{class: "bi bi-#{provider}"}
62+
= "Connect #{provider.capitalize}"
5163

5264
%fieldset.col-md-6
5365
.widget
@@ -59,13 +71,6 @@
5971
Add or change your profile icon image by visiting
6072
= link_to('Gravatar', 'https://en.gravatar.com/connect/', target: "_blank")
6173

62-
- if current_user.provider.present?
63-
.service
64-
%button.btn.btn-secondary.disabled{class: "btn-#{current_user.provider.downcase}-alt"}
65-
%i{class: "bi bi-#{current_user.provider.downcase}"}
66-
| Connected via
67-
= current_user.provider
68-
6974
- if current_user.teammates.present?
7075
.widget
7176
.widget-header
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class CreateIdentities < ActiveRecord::Migration[8.1]
2+
def change
3+
create_table :identities do |t|
4+
t.references :user, null: false, foreign_key: true
5+
t.string :provider, null: false
6+
t.string :uid, null: false
7+
8+
t.timestamps
9+
end
10+
11+
add_index :identities, [:provider, :uid], unique: true
12+
end
13+
end

db/schema.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema[8.1].define(version: 2025_02_15_111232) do
13+
ActiveRecord::Schema[8.1].define(version: 2025_12_19_061613) do
1414
# These are extensions that must be enabled in order to support this database
1515
enable_extension "pg_catalog.plpgsql"
1616

@@ -77,6 +77,16 @@
7777
t.index ["slug"], name: "index_events_on_slug"
7878
end
7979

80+
create_table "identities", force: :cascade do |t|
81+
t.datetime "created_at", null: false
82+
t.string "provider", null: false
83+
t.string "uid", null: false
84+
t.datetime "updated_at", null: false
85+
t.bigint "user_id", null: false
86+
t.index ["provider", "uid"], name: "index_identities_on_provider_and_uid", unique: true
87+
t.index ["user_id"], name: "index_identities_on_user_id"
88+
end
89+
8090
create_table "invitations", force: :cascade do |t|
8191
t.datetime "created_at", precision: nil
8292
t.string "email"
@@ -392,6 +402,7 @@
392402

393403
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
394404
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
405+
add_foreign_key "identities", "users"
395406
add_foreign_key "pages", "websites"
396407
add_foreign_key "session_format_configs", "session_formats"
397408
add_foreign_key "session_format_configs", "websites"

spec/controllers/users/omniauth_callbacks_controller_spec.rb

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
Rails.application.reload_routes_unless_loaded
66
end
77

8+
let(:twitter_auth_hash) { OmniAuth.config.mock_auth[:twitter] }
9+
let(:github_auth_hash) { OmniAuth.config.mock_auth[:github] }
10+
811
describe '#twitter' do
9-
let(:twitter_auth_hash) { OmniAuth.config.mock_auth[:twitter] }
10-
let(:github_auth_hash) { OmniAuth.config.mock_auth[:github] }
1112
let(:user) { build(:user) }
12-
let(:invitation) { build(:invitation, slug: "abc123", email: '[email protected]') }
13-
let(:other_invitation) { build(:invitation, slug: "def456", email: '[email protected]') }
13+
let(:invitation) { build(:invitation, slug: 'abc123', email: '[email protected]') }
1414

1515
before :each do
1616
session[:invitation_slug] = invitation.slug
@@ -19,27 +19,80 @@
1919
allow(controller).to receive(:current_user).and_return(User.last)
2020
end
2121

22-
it "allows twitter login" do
22+
it 'allows twitter login for new user' do
2323
get :twitter
2424
expect(response).to redirect_to(edit_profile_url)
25+
expect(User.last.identities.find_by(provider: 'twitter')).to be_present
2526
end
2627
end
2728

28-
describe "GET #new" do
29-
let(:github_auth_hash) { OmniAuth.config.mock_auth[:github] }
29+
describe 'signing in' do
30+
before :each do
31+
request.env['omniauth.auth'] = github_auth_hash
32+
request.env['devise.mapping'] = Devise.mappings[:user]
33+
end
34+
35+
context 'when user exists with identity' do
36+
let!(:user) { create(:user) }
37+
let!(:identity) { user.identities.create!(provider: 'github', uid: github_auth_hash.uid) }
38+
39+
it 'finds user via identity and signs in' do
40+
expect { get :github }.not_to change { User.count }
41+
expect(controller.current_user).to eq(user)
42+
end
43+
end
3044

45+
context 'when user exists with legacy provider/uid on User' do
46+
let!(:user) { create(:user, provider: 'github', uid: github_auth_hash.uid) }
47+
48+
it 'finds user via legacy lookup and signs in' do
49+
expect { get :github }.not_to change { User.count }
50+
expect(controller.current_user).to eq(user)
51+
end
52+
end
53+
end
54+
55+
describe 'linking identity to logged-in user' do
3156
before :each do
3257
request.env['omniauth.auth'] = github_auth_hash
3358
request.env['devise.mapping'] = Devise.mappings[:user]
3459
end
3560

36-
it "redirects a user if they are currently logged in" do
37-
organizer = create(:organizer)
38-
sign_in(organizer)
39-
get :github
61+
context 'when connecting a new provider' do
62+
it 'creates identity and redirects to profile' do
63+
user = create(:user)
64+
sign_in(user)
65+
66+
expect { get :github }.to change { user.identities.count }.by(1)
67+
expect(response).to redirect_to(edit_profile_path)
68+
expect(flash[:info]).to eq('Successfully connected GitHub to your account.')
69+
end
70+
end
71+
72+
context 'when provider is already connected to current user' do
73+
it 'shows already connected message' do
74+
user = create(:user)
75+
user.identities.create!(provider: 'github', uid: github_auth_hash.uid)
76+
sign_in(user)
77+
78+
expect { get :github }.not_to change { Identity.count }
79+
expect(response).to redirect_to(edit_profile_path)
80+
expect(flash[:info]).to eq('GitHub is already connected to your account.')
81+
end
82+
end
83+
84+
context 'when provider is already connected to another user' do
85+
it 'shows error message' do
86+
other_user = create(:user)
87+
other_user.identities.create!(provider: 'github', uid: github_auth_hash.uid)
88+
89+
user = create(:user)
90+
sign_in(user)
4091

41-
expect(response).to redirect_to(events_url)
42-
expect(controller.current_user).to eq(organizer)
92+
expect { get :github }.not_to change { Identity.count }
93+
expect(response).to redirect_to(edit_profile_path)
94+
expect(flash[:danger]).to eq('This GitHub account is already connected to another user.')
95+
end
4396
end
4497
end
4598
end

spec/models/user_spec.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,14 @@
4848
}.to_not raise_error
4949
end
5050

51-
it "creates a new user and sets provider for user" do
51+
it "creates a new user with identity" do
5252
user = nil
5353
expect {
5454
user = User.from_omniauth(twitter_auth_hash)
5555
}.to change { User.count }.by(1)
56-
expect(user.provider).to eq("twitter")
57-
expect(user.uid).to eq(twitter_auth_hash.uid)
56+
expect(user.identities.count).to eq(1)
57+
expect(user.identities.first.provider).to eq('twitter')
58+
expect(user.identities.first.uid).to eq(twitter_auth_hash.uid)
5859
end
5960
end
6061

spec/system/profile_spec.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@
2929

3030
scenario "A user attempts to save their bio without email", js: true do
3131
visit(edit_profile_path)
32-
fill_in('Email', with: '')
33-
click_button 'Save'
32+
fill_in('Email', with: '', fill_options: {clear: :backspace})
33+
# Submit form via JavaScript to bypass any browser validation
34+
page.execute_script("document.querySelector('form').submit()")
3435
expect(page).to have_content("Unable to save profile. Please correct the following: Email can't be blank")
3536
end
3637

0 commit comments

Comments
 (0)