From: Andrew Gundersen Date: Fri, 27 Feb 2026 22:12:23 +0000 (-0500) Subject: Add passwordless email auth with pre-checkout signup X-Git-Url: https://andrewgundersen.net/repos?a=commitdiff_plain;h=a1175ab00d3218206e49ebe41e1073d22797494e;p=crimata-website Add passwordless email auth with pre-checkout signup 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 --- diff --git a/Gemfile b/Gemfile index e85b7ee..676b600 100644 --- a/Gemfile +++ b/Gemfile @@ -24,6 +24,7 @@ end gem "stripe", "~> 18.3" +gem "resend", "~> 0.12" gem "dotenv-rails", "~> 3.2" diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 09705d1..60f1c00 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/checkout_controller.rb b/app/controllers/checkout_controller.rb index 6a8decb..67ecf9b 100644 --- a/app/controllers/checkout_controller.rb +++ b/app/controllers/checkout_controller.rb @@ -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" } diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 1b86031..8686295 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -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 index 0000000..2f85d59 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -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 index 0000000..f3aee43 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -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 index 0000000..04d6b8f --- /dev/null +++ b/app/mailers/customer_mailer.rb @@ -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 diff --git a/app/models/customer.rb b/app/models/customer.rb index 0b52773..f974148 100644 --- a/app/models/customer.rb +++ b/app/models/customer.rb @@ -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 index 0000000..f21d8f8 --- /dev/null +++ b/app/views/customer_mailer/magic_link.html.erb @@ -0,0 +1,10 @@ +

Click the button below to sign in to Crimata. This link expires in <%= @expires %>.

+ +

+ Sign in to Crimata +

+ +

+ If you didn't request this, you can safely ignore this email.
+ This link can only be used once. +

diff --git a/app/views/customer_mailer/magic_link.text.erb b/app/views/customer_mailer/magic_link.text.erb new file mode 100644 index 0000000..466519f --- /dev/null +++ b/app/views/customer_mailer/magic_link.text.erb @@ -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. diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index d083e72..c26c2b2 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -23,6 +23,12 @@ <%= link_to "Crimata", root_path, class: "logo" %>
diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000..fe537c0 --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,20 @@ + + + + + + + + +
+ + <%= yield %> +
+ + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..37f0bdd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/pages/pricing.html.erb b/app/views/pages/pricing.html.erb index 5614fb3..20c4f02 100644 --- a/app/views/pages/pricing.html.erb +++ b/app/views/pages/pricing.html.erb @@ -26,24 +26,7 @@
  • Fully managed hosting
  • Cancel anytime
  • - + <%= link_to "Get Started", checkout_start_path, class: "btn", style: "width:100%; text-align:center; display:block;" %>

    No setup fees.

    - - diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..5fcb3b5 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,34 @@ +<% content_for :title, "Sign in — Crimata" %> + + + +
    +

    Create your account

    +

    Enter your email and we'll send you a sign-in link — no password needed.

    + + <% if flash[:alert] %> +

    <%= flash[:alert] %>

    + <% 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 %> +
    diff --git a/app/views/sessions/sent.html.erb b/app/views/sessions/sent.html.erb new file mode 100644 index 0000000..e131c31 --- /dev/null +++ b/app/views/sessions/sent.html.erb @@ -0,0 +1,16 @@ +<% content_for :title, "Check your email — Crimata" %> + + + +
    +
    +

    Check your email

    +

    We sent a sign-in link to your inbox. Click it to continue — the link expires in 15 minutes.

    +

    Wrong address? Go back and try again.

    +
    diff --git a/config/application.rb b/config/application.rb index d28b084..3061060 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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" diff --git a/config/environments/development.rb b/config/environments/development.rb index 11a6de1..e1d5748 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -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 diff --git a/config/environments/production.rb b/config/environments/production.rb index 1c031c9..4e529f5 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -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 index 0000000..1a49b15 --- /dev/null +++ b/config/initializers/resend.rb @@ -0,0 +1 @@ +Resend.api_key = ENV.fetch("RESEND_API_KEY", "") diff --git a/config/routes.rb b/config/routes.rb index 569633b..2ed4d67 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 index 0000000..855773f --- /dev/null +++ b/db/migrate/20260227100000_add_auth_fields_to_customers.rb @@ -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