← insidertrades.directory / built with rails

first cut signin

Jason Kotchoff committed on 2025-09-29 13:44:01 +1000 · commit 87a1c80ecbbf1891df8facb10b725f45488a57b1

@@ -57,4 +57,7 @@ end
5757 group :development do
5858 # Use console on exceptions pages [https://github.com/rails/web-console]
5959 gem "web-console"
60+
61+ # Open emails in browser during development
62+ gem "letter_opener"
6063 end
@@ -72,6 +72,8 @@ GEM
7272 securerandom (>= 0.3)
7373 tzinfo (~> 2.0, >= 2.0.5)
7474 uri (>= 0.13.1)
75+ addressable (2.8.7)
76+ public_suffix (>= 2.0.2, < 7.0)
7577 ast (2.4.3)
7678 base64 (0.3.0)
7779 bcrypt_pbkdf (1.1.1)
@@ -83,6 +85,8 @@ GEM
8385 brakeman (7.1.0)
8486 racc
8587 builder (3.3.0)
88+ childprocess (5.1.0)
89+ logger (~> 1.5)
8690 concurrent-ruby (1.3.5)
8791 connection_pool (2.5.4)
8892 crass (1.0.6)
@@ -130,6 +134,12 @@ GEM
130134 thor (~> 1.3)
131135 zeitwerk (>= 2.6.18, < 3.0)
132136 language_server-protocol (3.17.0.5)
137+ launchy (3.1.1)
138+ addressable (~> 2.8)
139+ childprocess (~> 5.0)
140+ logger (~> 1.6)
141+ letter_opener (1.10.0)
142+ launchy (>= 2.2, < 4)
133143 lint_roller (1.1.0)
134144 logger (1.7.0)
135145 loofah (2.24.1)
@@ -189,6 +199,7 @@ GEM
189199 psych (5.2.6)
190200 date
191201 stringio
202+ public_suffix (6.0.2)
192203 puma (7.0.2)
193204 nio4r (~> 2.0)
194205 raabro (1.4.0)
@@ -363,6 +374,7 @@ DEPENDENCIES
363374 importmap-rails
364375 jbuilder
365376 kamal
377+ letter_opener
366378 propshaft
367379 puma (>= 5.0)
368380 rails (~> 8.0.2, >= 8.0.2.1)
@@ -0,0 +1,16 @@
1+module ApplicationCable
2+ class Connection < ActionCable::Connection::Base
3+ identified_by :current_user
4+
5+ def connect
6+ set_current_user || reject_unauthorized_connection
7+ end
8+
9+ private
10+ def set_current_user
11+ if session = Session.find_by(id: cookies.signed[:session_id])
12+ self.current_user = session.user
13+ end
14+ end
15+ end
16+end
@@ -1,4 +1,5 @@
11 class ApplicationController < ActionController::Base
2+ include Authentication
23 # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
34 allow_browser versions: :modern
45 end
@@ -0,0 +1,51 @@
1+module Authentication
2+ extend ActiveSupport::Concern
3+
4+ included do
5+ helper_method :authenticated?
6+ end
7+
8+ class_methods do
9+ def require_authentication(**options)
10+ before_action :require_authentication_check, **options
11+ end
12+ end
13+
14+ private
15+ def authenticated?
16+ resume_session
17+ end
18+
19+ def require_authentication_check
20+ resume_session || request_authentication
21+ end
22+
23+ def resume_session
24+ Current.session ||= find_session_by_cookie
25+ end
26+
27+ def find_session_by_cookie
28+ Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
29+ end
30+
31+ def request_authentication
32+ session[:return_to_after_authenticating] = request.url
33+ redirect_to new_session_path
34+ end
35+
36+ def after_authentication_url
37+ session.delete(:return_to_after_authenticating) || root_url
38+ end
39+
40+ def start_new_session_for(user)
41+ user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
42+ Current.session = session
43+ cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
44+ end
45+ end
46+
47+ def terminate_session
48+ Current.session.destroy
49+ cookies.delete(:session_id)
50+ end
51+end
@@ -0,0 +1,83 @@
1+class SessionsController < ApplicationController
2+ require_authentication only: :destroy
3+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }
4+
5+ def new
6+ # Show signin form with email subscription
7+ end
8+
9+ def create
10+ email = params[:email_address]&.strip&.downcase
11+
12+ if email.blank?
13+ flash[:alert] = "Please enter a valid email address"
14+ redirect_to signin_path and return
15+ end
16+
17+ user = User.find_or_initialize_by(email_address: email)
18+ user.generate_verification_code!
19+
20+ # Send verification email
21+ UsersMailer.verification_code(user).deliver_now
22+
23+ # Store user ID in session for verification
24+ session[:pending_user_id] = user.id
25+
26+ flash[:notice] = "We've sent a verification code to #{email}. Please check your email."
27+ redirect_to verify_path
28+ rescue => e
29+ Rails.logger.error "Error creating session: #{e.message}"
30+ flash[:alert] = "There was an error processing your request. Please try again."
31+ redirect_to signin_path
32+ end
33+
34+ def verify
35+ redirect_to signin_path unless session[:pending_user_id]
36+ end
37+
38+ def verify_code
39+ user_id = session[:pending_user_id]
40+ code = params[:verification_code]
41+
42+ if user_id.blank? || code.blank?
43+ flash[:alert] = "Invalid verification attempt"
44+ redirect_to signin_path and return
45+ end
46+
47+ user = User.find_by(id: user_id)
48+
49+ unless user
50+ flash[:alert] = "User not found"
51+ redirect_to signin_path and return
52+ end
53+
54+ if user.verification_code_expired?
55+ flash[:alert] = "Verification code has expired. Please request a new one."
56+ redirect_to signin_path and return
57+ end
58+
59+ if user.verification_code_valid?(code)
60+ # Clear verification code
61+ user.update!(verification_code: nil, verification_code_generated_at: nil)
62+
63+ # Sign them in using the proper authentication method
64+ start_new_session_for(user)
65+ session[:pending_user_id] = nil
66+
67+ flash[:notice] = "Welcome! You've been successfully signed in."
68+ redirect_to root_path
69+ else
70+ flash[:alert] = "Invalid verification code. Please try again."
71+ redirect_to verify_path
72+ end
73+ rescue => e
74+ Rails.logger.error "Error verifying code: #{e.message}"
75+ flash[:alert] = "There was an error verifying your code. Please try again."
76+ redirect_to verify_path
77+ end
78+
79+ def destroy
80+ terminate_session
81+ redirect_to root_path, notice: "You have been signed out."
82+ end
83+end
@@ -0,0 +1,11 @@
1+class UsersMailer < ApplicationMailer
2+ def verification_code(user)
3+ @user = user
4+ @verification_code = user.verification_code
5+
6+ mail(
7+ to: user.email_address,
8+ subject: "Your verification code"
9+ )
10+ end
11+end
\ No newline at end of file
@@ -0,0 +1,4 @@
1+class Current < ActiveSupport::CurrentAttributes
2+ attribute :session
3+ delegate :user, to: :session, allow_nil: true
4+end
@@ -0,0 +1,3 @@
1+class Session < ApplicationRecord
2+ belongs_to :user
3+end
@@ -0,0 +1,24 @@
1+class User < ApplicationRecord
2+ has_many :sessions, dependent: :destroy
3+
4+ normalizes :email_address, with: ->(e) { e.strip.downcase }
5+
6+ def generate_verification_code!
7+ self.verification_code = rand(100000..999999).to_s
8+ self.verification_code_generated_at = Time.current
9+ save!
10+ end
11+
12+ def verification_code_valid?(code)
13+ return false unless verification_code.present? && verification_code_generated_at.present?
14+ return false if verification_code_expired?
15+
16+ verification_code == code
17+ end
18+
19+ def verification_code_expired?
20+ return true unless verification_code_generated_at.present?
21+
22+ verification_code_generated_at < 15.minutes.ago
23+ end
24+end
@@ -38,12 +38,33 @@
3838 <li class="nav-item">
3939 <%= link_to "Built with Rails", built_with_rails_path, class: "nav-link" %>
4040 </li>
41+ <li class="nav-item">
42+ <% if authenticated? %>
43+ <%= button_to "Sign Out", session_path, method: :delete,
44+ class: "btn btn-outline-light btn-sm ms-2",
45+ style: "border-color: #fff;" %>
46+ <% else %>
47+ <%= link_to "Join mailing list", signin_path,
48+ class: "btn btn-primary btn-sm ms-2" %>
49+ <% end %>
50+ </li>
4151 </ul>
4252 </div>
4353 </div>
4454 </nav>
4555
4656 <main>
57+ <% if flash.any? %>
58+ <div class="container mt-3">
59+ <% flash.each do |type, message| %>
60+ <div class="alert alert-<%= type == 'notice' ? 'success' : 'danger' %> alert-dismissible fade show" role="alert">
61+ <%= message %>
62+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
63+ </div>
64+ <% end %>
65+ </div>
66+ <% end %>
67+
4768 <%= yield %>
4869 </main>
4970
@@ -0,0 +1,60 @@
1+<div class="signin-container" style="display: flex; min-height: 70vh; gap: 2rem;">
2+ <!-- Left Panel - Email Subscription Form -->
3+ <div class="signin-form" style="flex: 1; padding: 2rem; background: #f8f9fa; border-radius: 8px;">
4+ <h2>Join our mailing list</h2>
5+
6+ <%= form_with url: signin_path, local: true, class: "subscription-form" do |form| %>
7+ <div style="margin-bottom: 1.5rem;">
8+ <%= form.email_field :email_address,
9+ placeholder: "Enter your email address",
10+ required: true,
11+ style: "width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 16px;" %>
12+ </div>
13+
14+ <div>
15+ <%= form.submit "Subscribe",
16+ style: "background: #007bff; color: white; padding: 12px 24px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; width: 100%;" %>
17+ </div>
18+ <% end %>
19+
20+ <p style="margin-top: 1rem; font-size: 14px; color: #666;">
21+ We'll send you a verification code to confirm your email address.
22+ </p>
23+ </div>
24+
25+ <!-- Right Panel - Benefits -->
26+ <div class="benefits-panel" style="flex: 1; padding: 2rem;">
27+ <h3>Why join our mailing list?</h3>
28+
29+ <ul style="list-style: none; padding: 0;">
30+ <li style="margin-bottom: 1rem; padding-left: 1.5rem; position: relative;">
31+ <span style="position: absolute; left: 0; color: #28a745;">✓</span>
32+ <strong>Exclusive insights</strong> - Get insider trading analysis delivered to your inbox
33+ </li>
34+
35+ <li style="margin-bottom: 1rem; padding-left: 1.5rem; position: relative;">
36+ <span style="position: absolute; left: 0; color: #28a745;">✓</span>
37+ <strong>Early access</strong> - Be the first to know about new features and updates
38+ </li>
39+
40+ <li style="margin-bottom: 1rem; padding-left: 1.5rem; position: relative;">
41+ <span style="position: absolute; left: 0; color: #28a745;">✓</span>
42+ <strong>Market alerts</strong> - Receive notifications about significant insider trading activity
43+ </li>
44+
45+ <li style="margin-bottom: 1rem; padding-left: 1.5rem; position: relative;">
46+ <span style="position: absolute; left: 0; color: #28a745;">✓</span>
47+ <strong>Weekly summaries</strong> - Get curated highlights of the most important trades
48+ </li>
49+
50+ <li style="margin-bottom: 1rem; padding-left: 1.5rem; position: relative;">
51+ <span style="position: absolute; left: 0; color: #28a745;">✓</span>
52+ <strong>No spam</strong> - We respect your inbox and only send valuable content
53+ </li>
54+ </ul>
55+
56+ <div style="margin-top: 2rem; padding: 1rem; background: #e9ecef; border-radius: 4px; font-size: 14px;">
57+ <strong>Privacy note:</strong> Your email will only be used for our mailing list and will never be shared with third parties.
58+ </div>
59+ </div>
60+</div>
\ No newline at end of file
@@ -0,0 +1,55 @@
1+<div class="verify-container" style="max-width: 500px; margin: 2rem auto; padding: 2rem; background: #f8f9fa; border-radius: 8px; text-align: center;">
2+ <h2>Enter verification code</h2>
3+
4+ <p style="margin-bottom: 2rem; color: #666;">
5+ We've sent a 6-digit verification code to your email address.
6+ Please enter it below to complete your subscription.
7+ </p>
8+
9+ <%= form_with url: verify_path, local: true, class: "verification-form" do |form| %>
10+ <div style="margin-bottom: 1.5rem;">
11+ <%= form.text_field :verification_code,
12+ placeholder: "000000",
13+ maxlength: 6,
14+ pattern: "[0-9]{6}",
15+ required: true,
16+ style: "width: 200px; padding: 15px; border: 2px solid #ddd; border-radius: 4px; font-size: 24px; text-align: center; letter-spacing: 0.2em;" %>
17+ </div>
18+
19+ <div style="margin-bottom: 1rem;">
20+ <%= form.submit "Verify Code",
21+ style: "background: #28a745; color: white; padding: 12px 24px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer;" %>
22+ </div>
23+ <% end %>
24+
25+ <div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd;">
26+ <p style="font-size: 14px; color: #666; margin-bottom: 0.5rem;">
27+ Code expires in 15 minutes
28+ </p>
29+
30+ <p style="font-size: 14px;">
31+ Didn't receive the code?
32+ <%= link_to "Try again", signin_path, style: "color: #007bff; text-decoration: none;" %>
33+ </p>
34+ </div>
35+</div>
36+
37+<script>
38+ // Auto-focus the verification code input
39+ document.addEventListener('DOMContentLoaded', function() {
40+ const input = document.querySelector('input[name="verification_code"]');
41+ if (input) {
42+ input.focus();
43+
44+ // Auto-submit when 6 digits are entered
45+ input.addEventListener('input', function(e) {
46+ if (e.target.value.length === 6) {
47+ // Small delay to let user see they've completed it
48+ setTimeout(() => {
49+ e.target.closest('form').submit();
50+ }, 300);
51+ }
52+ });
53+ }
54+ });
55+</script>
\ No newline at end of file
@@ -0,0 +1,15 @@
1+<h2>Your verification code</h2>
2+
3+<p>Hi there!</p>
4+
5+<p>You've requested to join our mailing list. Please use the verification code below to complete your subscription:</p>
6+
7+<div style="font-size: 24px; font-weight: bold; background: #f5f5f5; padding: 20px; text-align: center; margin: 20px 0; border-radius: 5px;">
8+ <%= @verification_code %>
9+</div>
10+
11+<p>This code will expire in 15 minutes for security purposes.</p>
12+
13+<p>If you didn't request this, you can safely ignore this email.</p>
14+
15+<p>Thanks!</p>
\ No newline at end of file
@@ -0,0 +1,13 @@
1+Your verification code
2+
3+Hi there!
4+
5+You've requested to join our mailing list. Please use the verification code below to complete your subscription:
6+
7+<%= @verification_code %>
8+
9+This code will expire in 15 minutes for security purposes.
10+
11+If you didn't request this, you can safely ignore this email.
12+
13+Thanks!
\ No newline at end of file
@@ -39,6 +39,10 @@ Rails.application.configure do
3939
4040 # Set localhost to be used by links generated in mailer templates.
4141 config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
42+
43+ # Use letter_opener to open emails in browser during development
44+ config.action_mailer.delivery_method = :letter_opener
45+ config.action_mailer.perform_deliveries = true
4246
4347 # Print deprecation notices to the Rails logger.
4448 config.active_support.deprecation = :log
@@ -1,4 +1,15 @@
11 Rails.application.routes.draw do
2+ resource :session do
3+ member do
4+ get :verify
5+ post :verify_code
6+ end
7+ end
8+
9+ get "signin", to: "sessions#new"
10+ post "signin", to: "sessions#create"
11+ get "verify", to: "sessions#verify"
12+ post "verify", to: "sessions#verify_code"
213 # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
314
415 # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
@@ -0,0 +1,11 @@
1+class CreateUsers < ActiveRecord::Migration[8.0]
2+ def change
3+ create_table :users do |t|
4+ t.string :email_address, null: false
5+ t.string :password_digest, null: false
6+
7+ t.timestamps
8+ end
9+ add_index :users, :email_address, unique: true
10+ end
11+end
@@ -0,0 +1,11 @@
1+class CreateSessions < ActiveRecord::Migration[8.0]
2+ def change
3+ create_table :sessions do |t|
4+ t.references :user, null: false, foreign_key: true
5+ t.string :ip_address
6+ t.string :user_agent
7+
8+ t.timestamps
9+ end
10+ end
11+end
@@ -0,0 +1,6 @@
1+class AddVerificationFieldsToUsers < ActiveRecord::Migration[8.0]
2+ def change
3+ add_column :users, :verification_code, :string
4+ add_column :users, :verification_code_generated_at, :datetime
5+ end
6+end
@@ -0,0 +1,5 @@
1+class RemovePasswordDigestFromUsers < ActiveRecord::Migration[8.0]
2+ def change
3+ remove_column :users, :password_digest, :string
4+ end
5+end
@@ -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.0].define(version: 2025_09_28_234550) do
13+ActiveRecord::Schema[8.0].define(version: 2025_09_29_031843) do
1414 create_table "posts", force: :cascade do |t|
1515 t.string "title", null: false
1616 t.string "slug", null: false
@@ -26,4 +26,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_28_234550) do
2626 t.index ["slug"], name: "index_posts_on_slug", unique: true
2727 t.index ["status"], name: "index_posts_on_status"
2828 end
29+
30+ create_table "sessions", force: :cascade do |t|
31+ t.integer "user_id", null: false
32+ t.string "ip_address"
33+ t.string "user_agent"
34+ t.datetime "created_at", null: false
35+ t.datetime "updated_at", null: false
36+ t.index ["user_id"], name: "index_sessions_on_user_id"
37+ end
38+
39+ create_table "users", force: :cascade do |t|
40+ t.string "email_address", null: false
41+ t.datetime "created_at", null: false
42+ t.datetime "updated_at", null: false
43+ t.string "verification_code"
44+ t.datetime "verification_code_generated_at"
45+ t.index ["email_address"], name: "index_users_on_email_address", unique: true
46+ end
47+
48+ add_foreign_key "sessions", "users"
2949 end