]> Repos - crimata-website/commitdiff
Add passwordless email auth with pre-checkout signup
authorAndrew Gundersen <adgundersen@gmail.com>
Fri, 27 Feb 2026 22:12:23 +0000 (17:12 -0500)
committerAndrew Gundersen <adgundersen@gmail.com>
Fri, 27 Feb 2026 22:12:23 +0000 (17:12 -0500)
Flow: enter email → magic link → verified → Stripe checkout → dashboard

- SessionsController: new/create (send magic link), sent, verify, destroy
- Customer model: generate_magic_token!, magic_token_valid?, verify_email!
- Migration: magic_token, magic_token_expires_at, email_verified_at + unique indexes
- CustomerMailer + mailer layout with magic link email (html + text)
- CheckoutController: GET /checkout/start requires auth, passes customer_id
  as Stripe metadata; webhook finds customer by metadata and updates record
- DashboardController: requires auth, uses current_customer from session
- ApplicationController: current_customer, require_auth, redirect_after_auth
  (stores return_to so verify sends user back to where they were headed)
- Resend gem + initializer; production uses :resend delivery method
- Dev logs magic link URL to Rails logger instead of sending email
- Pricing page: simple link to /checkout/start (no more JS fetch)
- Layout: Sign in / Dashboard / Sign out nav links

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
22 files changed:
Gemfile
app/controllers/application_controller.rb
app/controllers/checkout_controller.rb
app/controllers/dashboard_controller.rb
app/controllers/sessions_controller.rb [new file with mode: 0644]
app/mailers/application_mailer.rb [new file with mode: 0644]
app/mailers/customer_mailer.rb [new file with mode: 0644]
app/models/customer.rb
app/views/customer_mailer/magic_link.html.erb [new file with mode: 0644]
app/views/customer_mailer/magic_link.text.erb [new file with mode: 0644]
app/views/layouts/application.html.erb
app/views/layouts/mailer.html.erb [new file with mode: 0644]
app/views/layouts/mailer.text.erb [new file with mode: 0644]
app/views/pages/pricing.html.erb
app/views/sessions/new.html.erb [new file with mode: 0644]
app/views/sessions/sent.html.erb [new file with mode: 0644]
config/application.rb
config/environments/development.rb
config/environments/production.rb
config/initializers/resend.rb [new file with mode: 0644]
config/routes.rb
db/migrate/20260227100000_add_auth_fields_to_customers.rb [new file with mode: 0644]

diff --git a/Gemfile b/Gemfile
index e85b7eeb5cf08d9d27cb3659e8c65cadab272f80..676b6000f961b4fdcb18453be0cb0ae39f5a25ae 100644 (file)
--- a/Gemfile
+++ b/Gemfile
@@ -24,6 +24,7 @@ end
 
 
 gem "stripe", "~> 18.3"
+gem "resend", "~> 0.12"
 
 gem "dotenv-rails", "~> 3.2"
 
index 09705d12ab4dfe301535a973e2607fad4efc9d0d..60f1c00029196fb0096d3154d3a2d853056b7ee5 100644 (file)
@@ -1,2 +1,21 @@
 class ApplicationController < ActionController::Base
+  helper_method :current_customer
+
+  private
+
+  def current_customer
+    @current_customer ||= Customer.find_by(id: session[:customer_id])
+  end
+
+  def require_auth
+    unless current_customer
+      session[:return_to] = request.fullpath
+      redirect_to login_path
+    end
+  end
+
+  def redirect_after_auth
+    path = session.delete(:return_to) || dashboard_path
+    redirect_to path
+  end
 end
index 6a8decb4708b01d0c9790826202ff866e9b6eeca..67ecf9b987d068c6df9a37a325975b1b24254a33 100644 (file)
@@ -1,18 +1,33 @@
+require "net/http"
+require "uri"
+require "json"
+
 class CheckoutController < ApplicationController
   skip_before_action :verify_authenticity_token, only: [:webhook]
+  before_action :require_auth, only: [:start]
+
+  # GET /checkout/start — create Stripe session and redirect
+  def start
+    # Already subscribed — send to dashboard
+    if current_customer.subscribed?
+      return redirect_to dashboard_path
+    end
 
-  def create_session
-    session = Stripe::Checkout::Session.create(
-      mode: "subscription",
-      line_items: [{ price: ENV.fetch("STRIPE_PRICE_ID"), quantity: 1 }],
-      success_url: "#{ENV.fetch("BASE_URL")}/dashboard?session_id={CHECKOUT_SESSION_ID}",
-      cancel_url:  "#{ENV.fetch("BASE_URL")}/",
+    stripe_session = Stripe::Checkout::Session.create(
+      customer_email: current_customer.email,
+      mode:           "subscription",
+      line_items:     [{ price: ENV.fetch("STRIPE_PRICE_ID"), quantity: 1 }],
+      success_url:    "#{ENV.fetch("BASE_URL")}/dashboard",
+      cancel_url:     "#{ENV.fetch("BASE_URL")}/pricing",
+      metadata:       { customer_id: current_customer.id }
     )
-    render json: { url: session.url }
+
+    redirect_to stripe_session.url, allow_other_host: true
   rescue Stripe::StripeError => e
-    render json: { error: e.message }, status: :bad_request
+    redirect_to pricing_path, alert: e.message
   end
 
+  # POST /checkout/webhook
   def webhook
     payload    = request.body.read
     sig_header = request.headers["HTTP_STRIPE_SIGNATURE"]
@@ -24,6 +39,12 @@ class CheckoutController < ApplicationController
     if event["type"] == "checkout.session.completed"
       sess = event["data"]["object"]
 
+      # Find the customer we created at signup via metadata
+      customer  = Customer.find_by(id: sess.dig("metadata", "customer_id"))
+      customer ||= Customer.find_by(email: sess.dig("customer_details", "email"))
+
+      return render json: { status: "ok" } unless customer
+
       uri  = URI("#{ENV.fetch("INFRA_SERVICE_URL")}/instances")
       http = Net::HTTP.new(uri.host, uri.port)
       http.use_ssl = uri.scheme == "https"
@@ -31,17 +52,17 @@ class CheckoutController < ApplicationController
       req.body = {
         stripe_customer_id:     sess["customer"],
         stripe_subscription_id: sess["subscription"],
-        email:                  sess.dig("customer_details", "email")
+        email:                  customer.email
       }.to_json
-      res = http.request(req)
-
+      res  = http.request(req)
       body = JSON.parse(res.body)
-      Customer.find_or_create_by(stripe_customer_id: sess["customer"]) do |c|
-        c.stripe_subscription_id = sess["subscription"]
-        c.email                  = sess.dig("customer_details", "email")
-        c.slug                   = body["slug"]
-        c.status                 = body["status"] || "provisioning"
-      end
+
+      customer.update!(
+        stripe_customer_id:     sess["customer"],
+        stripe_subscription_id: sess["subscription"],
+        slug:                   body["slug"],
+        status:                 body["status"] || "provisioning"
+      )
     end
 
     render json: { status: "ok" }
index 1b86031688b04d6a45875cb34a21d8fb212f1c99..8686295c0e9e7c2499dea91ba1e6ca51a1d8851a 100644 (file)
@@ -1,14 +1,7 @@
 class DashboardController < ApplicationController
+  before_action :require_auth
+
   def show
-    if params[:session_id].present?
-      stripe_session = Stripe::Checkout::Session.retrieve(params[:session_id])
-      @customer = Customer.find_by(stripe_customer_id: stripe_session.customer)
-    elsif params[:email].present?
-      @customer = Customer.find_by(email: params[:email])
-    else
-      redirect_to root_path
-    end
-  rescue Stripe::StripeError
-    redirect_to root_path
+    @customer = current_customer
   end
 end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
new file mode 100644 (file)
index 0000000..2f85d59
--- /dev/null
@@ -0,0 +1,48 @@
+class SessionsController < ApplicationController
+  def new
+    redirect_to dashboard_path if current_customer
+  end
+
+  def create
+    email = params[:email].to_s.strip.downcase
+
+    unless email.match?(URI::MailTo::EMAIL_REGEXP)
+      flash.now[:alert] = "Please enter a valid email address."
+      return render :new, status: :unprocessable_entity
+    end
+
+    customer = Customer.find_or_initialize_by(email: email)
+    customer.generate_magic_token!
+
+    CustomerMailer.magic_link(customer).deliver_now
+
+    # In development, log the link so you don't need a real email service
+    if Rails.env.development?
+      Rails.logger.info "MAGIC LINK: #{verify_url(token: customer.magic_token)}"
+    end
+
+    redirect_to login_sent_path
+  end
+
+  def sent
+  end
+
+  def verify
+    customer = Customer.find_by(magic_token: params[:token])
+
+    if customer.nil? || !customer.magic_token_valid?
+      flash[:alert] = "That link has expired or is invalid. Request a new one."
+      return redirect_to login_path
+    end
+
+    customer.verify_email!
+    session[:customer_id] = customer.id
+
+    redirect_after_auth
+  end
+
+  def destroy
+    session.delete(:customer_id)
+    redirect_to root_path
+  end
+end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
new file mode 100644 (file)
index 0000000..f3aee43
--- /dev/null
@@ -0,0 +1,4 @@
+class ApplicationMailer < ActionMailer::Base
+  default from: ENV.fetch("MAILER_FROM", "hello@crimata.com")
+  layout "mailer"
+end
diff --git a/app/mailers/customer_mailer.rb b/app/mailers/customer_mailer.rb
new file mode 100644 (file)
index 0000000..04d6b8f
--- /dev/null
@@ -0,0 +1,9 @@
+class CustomerMailer < ApplicationMailer
+  def magic_link(customer)
+    @customer = customer
+    @url      = verify_url(token: customer.magic_token)
+    @expires  = "15 minutes"
+
+    mail(to: @customer.email, subject: "Your Crimata sign-in link")
+  end
+end
index 0b5277335c7d1116e35b581e0611796771a6aec7..f974148e2864a049a489167c60ac4b5927c0a027 100644 (file)
@@ -1,2 +1,30 @@
 class Customer < ApplicationRecord
+  TOKEN_TTL = 15.minutes
+
+  def generate_magic_token!
+    self.magic_token            = SecureRandom.urlsafe_base64(32)
+    self.magic_token_expires_at = TOKEN_TTL.from_now
+    save!
+    magic_token
+  end
+
+  def magic_token_valid?
+    magic_token.present? && magic_token_expires_at&.future?
+  end
+
+  def verify_email!
+    update!(
+      email_verified_at:      Time.current,
+      magic_token:            nil,
+      magic_token_expires_at: nil
+    )
+  end
+
+  def email_verified?
+    email_verified_at.present?
+  end
+
+  def subscribed?
+    stripe_customer_id.present?
+  end
 end
diff --git a/app/views/customer_mailer/magic_link.html.erb b/app/views/customer_mailer/magic_link.html.erb
new file mode 100644 (file)
index 0000000..f21d8f8
--- /dev/null
@@ -0,0 +1,10 @@
+<p>Click the button below to sign in to Crimata. This link expires in <%= @expires %>.</p>
+
+<p style="margin: 32px 0;">
+  <a href="<%= @url %>" class="btn">Sign in to Crimata</a>
+</p>
+
+<p class="muted">
+  If you didn't request this, you can safely ignore this email.<br />
+  This link can only be used once.
+</p>
diff --git a/app/views/customer_mailer/magic_link.text.erb b/app/views/customer_mailer/magic_link.text.erb
new file mode 100644 (file)
index 0000000..466519f
--- /dev/null
@@ -0,0 +1,8 @@
+Sign in to Crimata
+
+Click the link below to sign in. This link expires in <%= @expires %>.
+
+<%= @url %>
+
+If you didn't request this, you can safely ignore this email.
+This link can only be used once.
index d083e72654b5f254566edab54a8eb41dfd7e4267..c26c2b2cb8582582cda069e24db4dac8a9d1d512 100644 (file)
     <%= link_to "Crimata", root_path, class: "logo" %>
     <nav>
       <%= link_to "Pricing", pricing_path %>
+      <% if current_customer %>
+        <%= link_to "Dashboard", dashboard_path %>
+        <%= link_to "Sign out", logout_path %>
+      <% else %>
+        <%= link_to "Sign in", login_path %>
+      <% end %>
     </nav>
   </header>
   <main>
diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb
new file mode 100644 (file)
index 0000000..fe537c0
--- /dev/null
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <style>
+    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f9f9f9; color: #111; margin: 0; padding: 40px 16px; }
+    .card { background: #fff; border: 1px solid #eee; border-radius: 12px; max-width: 480px; margin: 0 auto; padding: 40px; }
+    .logo { font-size: 1.2rem; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 32px; }
+    .btn { display: inline-block; background: #111; color: #fff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 0.95rem; }
+    .muted { color: #888; font-size: 0.85rem; margin-top: 24px; }
+  </style>
+</head>
+<body>
+  <div class="card">
+    <div class="logo">Crimata</div>
+    <%= yield %>
+  </div>
+</body>
+</html>
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb
new file mode 100644 (file)
index 0000000..37f0bdd
--- /dev/null
@@ -0,0 +1 @@
+<%= yield %>
index 5614fb3faff1bd5247304d0ed1b767be2e6b10d0..20c4f029fa48d3fe60803991e85df69b9cd2ecfd 100644 (file)
       <li>Fully managed hosting</li>
       <li>Cancel anytime</li>
     </ul>
-    <button class="btn" id="checkout-btn" style="width:100%">Get Started</button>
+    <%= link_to "Get Started", checkout_start_path, class: "btn", style: "width:100%; text-align:center; display:block;" %>
     <p class="sub">No setup fees.</p>
   </div>
 </div>
-
-<script>
-  document.getElementById('checkout-btn').addEventListener('click', async () => {
-    const btn = document.getElementById('checkout-btn')
-    btn.disabled = true
-    btn.textContent = 'Redirecting...'
-    try {
-      const res = await fetch('/checkout/create_session', { method: 'POST', headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content } })
-      const data = await res.json()
-      window.location.href = data.url
-    } catch {
-      btn.disabled = false
-      btn.textContent = 'Get Started'
-      alert('Something went wrong. Please try again.')
-    }
-  })
-</script>
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
new file mode 100644 (file)
index 0000000..5fcb3b5
--- /dev/null
@@ -0,0 +1,34 @@
+<% content_for :title, "Sign in — Crimata" %>
+
+<style>
+  .auth-wrap { max-width: 400px; margin: 0 auto; padding-top: 2rem; }
+  .auth-wrap h1 { font-size: 1.75rem; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 0.5rem; }
+  .auth-wrap p { color: #555; margin-bottom: 2rem; font-size: 0.95rem; }
+  .auth-wrap input[type=email] {
+    width: 100%; padding: 0.875rem 1rem;
+    border: 1px solid #ddd; border-radius: 8px;
+    font-size: 1rem; margin-bottom: 1rem; outline: none;
+    transition: border-color 0.15s;
+  }
+  .auth-wrap input[type=email]:focus { border-color: #111; }
+  .auth-wrap .btn { width: 100%; text-align: center; }
+  .alert { color: #c0392b; font-size: 0.875rem; margin-bottom: 1rem; }
+</style>
+
+<div class="auth-wrap">
+  <h1>Create your account</h1>
+  <p>Enter your email and we'll send you a sign-in link — no password needed.</p>
+
+  <% if flash[:alert] %>
+    <p class="alert"><%= flash[:alert] %></p>
+  <% end %>
+
+  <%= form_with url: login_path, method: :post do |f| %>
+    <%= f.email_field :email,
+          placeholder: "you@example.com",
+          autofocus: true,
+          autocomplete: "email",
+          required: true %>
+    <%= f.submit "Send sign-in link", class: "btn" %>
+  <% end %>
+</div>
diff --git a/app/views/sessions/sent.html.erb b/app/views/sessions/sent.html.erb
new file mode 100644 (file)
index 0000000..e131c31
--- /dev/null
@@ -0,0 +1,16 @@
+<% content_for :title, "Check your email — Crimata" %>
+
+<style>
+  .sent-wrap { max-width: 400px; margin: 0 auto; padding-top: 2rem; text-align: center; }
+  .sent-wrap .icon { font-size: 3rem; margin-bottom: 1.5rem; }
+  .sent-wrap h1 { font-size: 1.75rem; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 0.75rem; }
+  .sent-wrap p { color: #555; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; }
+  .sent-wrap a { color: #111; font-weight: 500; }
+</style>
+
+<div class="sent-wrap">
+  <div class="icon">&#9993;</div>
+  <h1>Check your email</h1>
+  <p>We sent a sign-in link to your inbox. Click it to continue — the link expires in 15 minutes.</p>
+  <p>Wrong address? <a href="<%= login_path %>">Go back</a> and try again.</p>
+</div>
index d28b084895cce9d4f8ca85c79e680d861fc88f6f..3061060d262a6ef7df21d2ab52452cc5fcf4ca26 100644 (file)
@@ -7,7 +7,7 @@ require "active_model/railtie"
 require "active_record/railtie"
 # require "active_storage/engine"
 require "action_controller/railtie"
-require "action_mailer/railtie"
+require "action_mailer/railtie"
 # require "action_mailbox/engine"
 # require "action_text/engine"
 require "action_view/railtie"
index 11a6de173c51861da4f1870bcd0478811c6df718..e1d57482efde35a0b4e4b5785cb4a13f9a5575f5 100644 (file)
@@ -57,4 +57,8 @@ Rails.application.configure do
 
   # Raise error when a before_action's only/except options reference missing actions
   config.action_controller.raise_on_missing_callback_actions = true
+
+  # Mailer — don't actually send in dev; magic link is logged to Rails logger
+  config.action_mailer.delivery_method    = :test
+  config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
 end
index 1c031c95675a03f6a69c5467e19ebe3cbfb9aeac..4e529f58351058ebcf32b69964930a3a2beee42a 100644 (file)
@@ -1,4 +1,5 @@
 require "active_support/core_ext/integer/time"
+require "uri"
 
 Rails.application.configure do
   # Settings specified here will take precedence over those in config/application.rb.
@@ -63,6 +64,13 @@ Rails.application.configure do
   # Do not dump schema after migrations.
   config.active_record.dump_schema_after_migration = false
 
+  # Mailer
+  config.action_mailer.delivery_method = :resend
+  config.action_mailer.default_url_options = {
+    host:     URI.parse(ENV.fetch("BASE_URL", "https://crimata.com")).host,
+    protocol: "https"
+  }
+
   # Enable DNS rebinding protection and other `Host` header attacks.
   # config.hosts = [
   #   "example.com",     # Allow requests from example.com
diff --git a/config/initializers/resend.rb b/config/initializers/resend.rb
new file mode 100644 (file)
index 0000000..1a49b15
--- /dev/null
@@ -0,0 +1 @@
+Resend.api_key = ENV.fetch("RESEND_API_KEY", "")
index 569633b327fe3700709619db129cadc9c2387234..2ed4d672c5c793480622e96fbcfc59e37456ca9e 100644 (file)
@@ -1,11 +1,20 @@
 Rails.application.routes.draw do
   root "pages#home"
-  get  "/pricing",  to: "pages#pricing"
-  get  "/success",  to: "pages#success"
-  get  "/dashboard", to: "dashboard#show"
+  get  "/pricing", to: "pages#pricing"
 
-  post "/checkout/create_session", to: "checkout#create_session"
-  post "/checkout/webhook",        to: "checkout#webhook"
+  # Auth
+  get    "/login",      to: "sessions#new",     as: :login
+  post   "/login",      to: "sessions#create"
+  get    "/login/sent", to: "sessions#sent",    as: :login_sent
+  get    "/verify",     to: "sessions#verify",  as: :verify
+  get    "/logout",     to: "sessions#destroy", as: :logout
+
+  # Dashboard (requires auth)
+  get "/dashboard", to: "dashboard#show"
+
+  # Checkout
+  get  "/checkout/start",   to: "checkout#start",   as: :checkout_start
+  post "/checkout/webhook", to: "checkout#webhook"
 
   get "up" => "rails/health#show", as: :rails_health_check
 end
diff --git a/db/migrate/20260227100000_add_auth_fields_to_customers.rb b/db/migrate/20260227100000_add_auth_fields_to_customers.rb
new file mode 100644 (file)
index 0000000..855773f
--- /dev/null
@@ -0,0 +1,10 @@
+class AddAuthFieldsToCustomers < ActiveRecord::Migration[7.1]
+  def change
+    add_column :customers, :magic_token,            :string
+    add_column :customers, :magic_token_expires_at, :datetime
+    add_column :customers, :email_verified_at,      :datetime
+
+    add_index :customers, :magic_token, unique: true
+    add_index :customers, :email,       unique: true
+  end
+end