Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ VAPID_PRIVATE_KEY=

# Vimeo access token required for Library downloads. Required scopes: "private video_files public"
VIMEO_ACCESS_TOKEN=

# Twitter OAuth Configuration
TWITTER_CLIENT_ID=your_twitter_client_id_here
TWITTER_CLIENT_SECRET=your_twitter_client_secret_here
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ gem "kredis"
gem "platform_agent"
gem "thruster"
gem "faraday"
gem "omniauth"
gem "omniauth-twitter2"
gem "omniauth-rails_csrf_protection"

group :development, :test do
gem "debug"
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,30 @@ And if you're not part of the [Small Bets](https://smallbets.com) community yet,
bin/setup
bin/rails server

### Twitter/X OAuth Setup (Optional)

To enable Twitter/X login and account linking:

1. Create a Twitter Developer account at https://developer.twitter.com
2. Create a new app and get your Client ID and Client Secret
3. Add these to your environment:
```
TWITTER_CLIENT_ID=your_client_id
TWITTER_CLIENT_SECRET=your_client_secret
```
### Running Tests

To run the OAuth-related tests:

rails test test/controllers/oauth_controller_test.rb
rails test test/models/user_oauth_test.rb
rails test test/system/oauth_system_test.rb
rails test test/integration/oauth_routes_test.rb

Or run all tests:

rails test

The `bin/setup` script will install dependencies, prepare the database, and configure the application.

## Running in production
Expand Down
30 changes: 30 additions & 0 deletions app/controllers/oauth_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
class OauthController < ApplicationController
allow_unauthenticated_access only: [:callback]

def callback
auth_info = request.env["omniauth.auth"]

if signed_in?
Current.user.update!(twitter_uid: auth_info.uid)
redirect_to user_profile_path, notice: "X account connected successfully!"
else
user = User.find_by(twitter_uid: auth_info.uid) ||
User.find_by(email_address: auth_info.info.email)

if user
user.update!(twitter_uid: auth_info.uid) unless user.twitter_uid
start_new_session_for(user)
redirect_to post_authenticating_url
else
redirect_to new_session_path, alert: "No account found. Please sign up first or use email login."
end
end
rescue
redirect_to signed_in? ? user_profile_path : new_session_path, alert: "Authentication failed."
end

def disconnect
Current.user.update!(twitter_uid: nil)
redirect_to user_profile_path, notice: "X account disconnected."
end
end
2 changes: 2 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class User < ApplicationRecord
validates_presence_of :email_address, if: :person?
normalizes :email_address, with: ->(email_address) { email_address.downcase }

validates :twitter_uid, uniqueness: true, allow_nil: true

scope :without_default_names, -> { where.not(name: DEFAULT_NAME) }
scope :non_suspended, -> { where(suspended_at: nil) }
scope :unclaimed_gumroad_imports, -> { where.not(order_id: nil).where(last_authenticated_at: nil) }
Expand Down
7 changes: 7 additions & 0 deletions app/views/sessions/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@
<span class="for-screen-reader">Go</span>
<% end %>
<% end %>

<div class="txt-align-center margin-block-start">
<%= link_to "/auth/twitter2", class: "btn btn--primary center txt-medium" do %>
<%= image_tag "social/twitter-outline.svg", role: "presentation", size: 20, class: "colorize--white" %>
Sign in with X
<% end %>
</div>
</fieldset>
<% end %>
</div>
Expand Down
10 changes: 10 additions & 0 deletions app/views/users/profiles/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@
<%= form.text_field :twitter_url, class: "input txt-medium ", placeholder: "Your X profile", required: false %>
<%= image_tag "social/twitter-outline.svg", role: "presentation", size: 24, class: "colorize--black" %>
</label>

<% if @user.twitter_uid.present? %>
<%= button_to "/auth/twitter2/disconnect", method: :post, class: "btn btn--negative txt-small" do %>
Disconnect X
<% end %>
<% else %>
<%= link_to "/auth/twitter2", class: "btn btn--primary txt-small" do %>
Connect X
<% end %>
<% end %>
</div>

<div class="flex align-center gap">
Expand Down
5 changes: 5 additions & 0 deletions config/initializers/omniauth.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
unless Rails.env.test?
Rails.application.config.middleware.use OmniAuth::Builder do
provider :twitter2, ENV['TWITTER_CLIENT_ID'], ENV['TWITTER_CLIENT_SECRET']
end
end
8 changes: 8 additions & 0 deletions config/initializers/omniauth_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
OmniAuth.config.test_mode = false
OmniAuth.config.logger = Rails.logger

if Rails.env.test?
Rails.application.config.middleware.use OmniAuth::Builder do
provider :twitter2, 'test_client_id', 'test_client_secret'
end
end
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
resource :validations, only: %i[new create]
end
get "auth_tokens/validate/:token", to: "auth_tokens/validations#create", as: :sign_in_with_token
get "/auth/:provider/callback", to: "oauth#callback"
post "/auth/:provider/disconnect", to: "oauth#disconnect"

resource :account do
scope module: "accounts" do
Expand Down
6 changes: 6 additions & 0 deletions db/migrate/20251001000001_add_oauth_fields_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddOauthFieldsToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :twitter_uid, :string
add_index :users, :twitter_uid, unique: true
end
end
97 changes: 97 additions & 0 deletions test/controllers/oauth_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
require "test_helper"

class OauthControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:david)
@auth_hash = {
"uid" => "12345",
"info" => {
"email" => @user.email_address,
"nickname" => "david_twitter"
}
}
end

test "should connect X account when user is signed in" do
sign_in @user

OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:twitter2] = OmniAuth::AuthHash.new(@auth_hash)

get "/auth/twitter2/callback"

@user.reload
assert_equal "12345", @user.twitter_uid
assert_redirected_to user_profile_path
assert_equal "X account connected successfully!", flash[:notice]
end

test "should sign in existing user with X account" do
@user.update!(twitter_uid: "12345")

OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:twitter2] = OmniAuth::AuthHash.new(@auth_hash)

get "/auth/twitter2/callback"

assert_redirected_to root_path
assert @user.sessions.exists?
end

test "should link X account to existing user by email during sign in" do
assert_nil @user.twitter_uid

OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:twitter2] = OmniAuth::AuthHash.new(@auth_hash)

get "/auth/twitter2/callback"

@user.reload
assert_equal "12345", @user.twitter_uid
assert_redirected_to root_path
end

test "should redirect to signup when no user found" do
@auth_hash["info"]["email"] = "newuser@example.com"

OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:twitter2] = OmniAuth::AuthHash.new(@auth_hash)

get "/auth/twitter2/callback"

assert_redirected_to new_session_path
assert_equal "No account found. Please sign up first or use email login.", flash[:alert]
end

test "should handle authentication failure gracefully" do
OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:twitter2] = :invalid_credentials

get "/auth/twitter2/callback"

assert_redirected_to new_session_path
assert_equal "Authentication failed.", flash[:alert]
end

test "should disconnect X account" do
sign_in @user
@user.update!(twitter_uid: "12345")

post "/auth/twitter2/disconnect"

@user.reload
assert_nil @user.twitter_uid
assert_redirected_to user_profile_path
assert_equal "X account disconnected.", flash[:notice]
end

test "should require authentication for disconnect" do
post "/auth/twitter2/disconnect"
assert_redirected_to new_session_path
end

teardown do
OmniAuth.config.test_mode = false
OmniAuth.config.mock_auth[:twitter2] = nil
end
end
8 changes: 7 additions & 1 deletion test/fixtures/users.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<% password_digest = BCrypt::Password.create("secret123456") %>
# <% password_digest = BCrypt::Password.create("secret123456") %>

david:
name: David
Expand All @@ -24,6 +24,12 @@ kevin:
password_digest: <%= password_digest %>
bio: Programmer

twitter_user:
name: Twitter User
email_address: twitter@example.com
password_digest: <%= password_digest %>
twitter_uid: "existing_twitter_uid"

bender:
name: Bender Bot
bot_token: <%= User.generate_bot_token %>
Expand Down
17 changes: 17 additions & 0 deletions test/integration/oauth_routes_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
require "test_helper"

class OauthRoutesTest < ActionDispatch::IntegrationTest
test "oauth callback route exists" do
assert_routing "/auth/twitter2/callback", { controller: "oauth", action: "callback", provider: "twitter2" }
end

test "oauth disconnect route exists" do
assert_routing({ method: "post", path: "/auth/twitter2/disconnect" },
{ controller: "oauth", action: "disconnect", provider: "twitter2" })
end

test "twitter oauth redirect works" do
get "/auth/twitter2"
assert_response :redirect
end
end
53 changes: 53 additions & 0 deletions test/integration/oauth_user_flow_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
require "test_helper"

class OauthUserFlowTest < ActionDispatch::IntegrationTest
setup do
@user = users(:david)
@twitter_auth = {
"uid" => "twitter_12345",
"info" => {
"email" => @user.email_address,
"nickname" => "david_x"
}
}
end

test "complete user flow: connect X account then sign in with X" do
sign_in @user

OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:twitter2] = OmniAuth::AuthHash.new(@twitter_auth)

get "/auth/twitter2/callback"

@user.reload
assert_equal "twitter_12345", @user.twitter_uid
assert_equal "X account connected successfully!", flash[:notice]

delete session_path
assert_redirected_to unauthenticated_root_path

get "/auth/twitter2/callback"

assert_redirected_to root_path

assert @user.sessions.exists?
end

test "user without X account tries to sign in with X" do
@twitter_auth["info"]["email"] = "newuser@example.com"

OmniAuth.config.test_mode = true
OmniAuth.config.mock_auth[:twitter2] = OmniAuth::AuthHash.new(@twitter_auth)

get "/auth/twitter2/callback"

assert_redirected_to new_session_path
assert_equal "No account found. Please sign up first or use email login.", flash[:alert]
end

teardown do
OmniAuth.config.test_mode = false
OmniAuth.config.mock_auth[:twitter2] = nil
end
end
37 changes: 37 additions & 0 deletions test/models/user_oauth_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
require "test_helper"

class UserOauthTest < ActiveSupport::TestCase
setup do
@user = users(:david)
end

test "should validate twitter_uid uniqueness" do
@user.update!(twitter_uid: "12345")

duplicate_user = users(:jason)
duplicate_user.twitter_uid = "12345"

assert_not duplicate_user.valid?
assert_includes duplicate_user.errors[:twitter_uid], "has already been taken"
end

test "should allow nil twitter_uid" do
@user.twitter_uid = nil
assert @user.valid?
end

test "should allow multiple users with nil twitter_uid" do
users(:david).update!(twitter_uid: nil)
users(:jason).update!(twitter_uid: nil)

assert users(:david).valid?
assert users(:jason).valid?
end

test "should save twitter_uid successfully" do
@user.update!(twitter_uid: "twitter_12345")
@user.reload

assert_equal "twitter_12345", @user.twitter_uid
end
end
Loading