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
7 changes: 6 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@
# web-push generate-vapid-keys
# ```
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_PRIVATE_KEY=

# Twitter OAuth credentials
# Get these from https://developer.twitter.com/en/portal/projects-and-apps
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=
5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ gem "image_processing", ">= 1.2"
gem "sentry-ruby"
gem "sentry-rails"

# OAuth
gem "omniauth"
gem "omniauth-twitter2"
gem "omniauth-rails_csrf_protection"

# Other
gem "bcrypt"
gem "web-push"
Expand Down
78 changes: 78 additions & 0 deletions TWITTER_OAUTH_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Twitter OAuth Setup Guide

This guide explains how to set up Twitter OAuth integration for Small Bets.

## Features

- **Sign in with X**: Users can sign in using their X (Twitter) account
- **Account Linking**: Existing users can link their X account to their profile
- **Automatic Profile Updates**: When linking, the user's Twitter username and profile URL are automatically updated

## Setup Instructions

### 1. Create Twitter Developer App

1. Go to [Twitter Developer Portal](https://developer.twitter.com/en/portal/projects-and-apps)
2. Create a new project and app
3. In your app settings, go to "User authentication settings"
4. Configure OAuth 2.0 settings:
- App permissions: Read
- Type of App: Web App
- Callback URLs: `https://your-domain.com/auth/twitter2/callback`
- Website URL: `https://your-domain.com`

### 2. Environment Variables

Add the following to your `.env` file:

```
TWITTER_CLIENT_ID=your_client_id_here
TWITTER_CLIENT_SECRET=your_client_secret_here
```

### 3. Install Dependencies

Run `bundle install` to install the required gems:
- `omniauth`
- `omniauth-twitter2`
- `omniauth-rails_csrf_protection`

### 4. Database Migration

Run the migration to add Twitter OAuth fields:

```bash
rails db:migrate
```

## Usage

### Sign In Page
- Users can click "Continue with X" on the sign-in page
- If they have an account with a matching email, it will be linked
- If not, a new account will be created (if email is provided)

### Profile Page
- Users can connect their X account from their profile page
- Once connected, their Twitter username will be displayed
- The Twitter URL field will be automatically populated

## Technical Details

### Database Changes
- Added `twitter_uid` (string, unique index)
- Added `twitter_username` (string)
- Existing `twitter_url` field is reused for profile links

### OAuth Flow
1. User clicks "Continue with X"
2. Redirected to Twitter OAuth
3. After authorization, redirected to `/auth/twitter2/callback`
4. `OauthCallbacksController` handles the response
5. User is either signed in or their account is linked

### Security
- Uses OAuth 2.0 with PKCE
- CSRF protection enabled
- Unique constraints on Twitter UID
- Proper error handling for failed authentications
76 changes: 76 additions & 0 deletions app/controllers/oauth_callbacks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
class OauthCallbacksController < ApplicationController
allow_unauthenticated_access only: %i[ create failure ]

def create
auth = request.env["omniauth.auth"]

case auth.provider
when "twitter2"
handle_twitter_auth(auth)
else
redirect_to new_session_path, alert: "Authentication provider not supported."
end
end

def failure
redirect_to new_session_path, alert: "Authentication failed. Please try again."
end

private

def handle_twitter_auth(auth)
email = auth.info.email
twitter_uid = auth.uid
twitter_username = auth.info.nickname
name = auth.info.name

if user_signed_in?
# User is already signed in, connect Twitter account
Current.user.update!(
twitter_uid: twitter_uid,
twitter_username: twitter_username,
twitter_url: "https://x.com/#{twitter_username}"
)
redirect_to user_profile_path, notice: "X account connected successfully!"
else
# Try to find user by Twitter UID first
user = User.find_by(twitter_uid: twitter_uid)

# If not found and email is present, try to find by email
user ||= User.find_by(email_address: email) if email.present?

if user
# Update Twitter info if user exists
user.update!(
twitter_uid: twitter_uid,
twitter_username: twitter_username,
twitter_url: "https://x.com/#{twitter_username}"
)
authenticate_user(user)
redirect_to root_path
else
# Create new user if email is present
if email.present?
user = User.create!(
email_address: email,
name: name.presence || User::DEFAULT_NAME,
twitter_uid: twitter_uid,
twitter_username: twitter_username,
twitter_url: "https://x.com/#{twitter_username}"
)
authenticate_user(user)
redirect_to root_path
else
redirect_to new_session_path, alert: "Email address is required for registration."
end
end
end
rescue ActiveRecord::RecordInvalid => e
redirect_to new_session_path, alert: "Registration failed: #{e.message}"
end

def authenticate_user(user)
session = user.sessions.create!
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
2 changes: 1 addition & 1 deletion app/controllers/users/profiles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def set_user
end

def user_params
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio, :twitter_url, :linkedin_url, :personal_url).compact
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio, :twitter_url, :linkedin_url, :personal_url, :twitter_uid, :twitter_username).compact
end

def after_update_url
Expand Down
14 changes: 12 additions & 2 deletions app/views/sessions/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
<%= form_with url: auth_tokens_url, class: "flex flex-column gap", data: { turbo: hotwire_native_app? }, html: { autocomplete: "off" } do |form| %>
<fieldset class="flex flex-column gap center-block upad">
<legend class="txt-large txt-align-center"><strong>Member Login</strong></legend>

<% if flash[:alert] %>
<p class="txt-medium txt-negative"><%= flash[:alert] %></p>
<% end %>

<div class="flex align-center gap">
<%= translation_button(:email_address) %>
<label class="flex align-center gap input input--actor txt-large">
Expand All @@ -37,6 +37,16 @@
<% end %>
</fieldset>
<% end %>

<fieldset class="flex flex-column gap center-block upad">
<legend class="txt-large txt-align-center"><strong>Or sign in with</strong></legend>

<%= link_to "/auth/twitter2", method: :post, class: "btn btn--reversed flex align-center gap center txt-large",
data: { confirm: "Sign in with X (Twitter)?" } do %>
<%= image_tag "social/twitter-outline.svg", role: "presentation", size: 24, class: "colorize--black" %>
<span>Continue with X</span>
<% end %>
</fieldset>
</div>
</div>

Expand Down
26 changes: 22 additions & 4 deletions app/views/users/profiles/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,30 @@
<div class="flex align-center gap">
<%= translation_button(:twitter) %>

<label class="flex align-center gap flex-item-grow input input--actor">
<%= 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? %>
<div class="flex align-center gap flex-item-grow input input--actor">
<span class="input txt-medium">Connected as @<%= @user.twitter_username %></span>
<%= image_tag "social/twitter-outline.svg", role: "presentation", size: 24, class: "colorize--black" %>
</div>
<% else %>
<label class="flex align-center gap flex-item-grow input input--actor">
<%= 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>
<% end %>
</div>

<% if @user.twitter_uid.blank? %>
<div class="margin-block">
<%= link_to "/auth/twitter2", method: :post,
class: "btn flex align-center gap center txt-medium",
data: { confirm: "Connect your X (Twitter) account?" } do %>
<%= image_tag "social/twitter-outline.svg", role: "presentation", size: 20, class: "colorize--black" %>
<span>Connect X Account</span>
<% end %>
</div>
<% end %>

<div class="flex align-center gap">
<%= translation_button(:linkedin) %>

Expand Down
6 changes: 6 additions & 0 deletions config/initializers/omniauth.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Rails.application.config.middleware.use OmniAuth::Builder do
provider :twitter2,
ENV['TWITTER_CLIENT_ID'],
ENV['TWITTER_CLIENT_SECRET'],
scope: 'users.read offline.access'
end
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
end
end

# OAuth routes
get "/auth/:provider/callback", to: "oauth_callbacks#create"
get "/auth/failure", to: "oauth_callbacks#failure"

resources :auth_tokens, only: %i[create]
namespace :auth_tokens do
resource :validations, only: %i[new create]
Expand Down
7 changes: 7 additions & 0 deletions db/migrate/20250924000204_add_twitter_oauth_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class AddTwitterOauthToUsers < ActiveRecord::Migration[7.2]
def change
add_column :users, :twitter_uid, :string
add_column :users, :twitter_username, :string
add_index :users, :twitter_uid, unique: true
end
end