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 <noreply@anthropic.com>
This commit is contained in:
parent
fd2d498141
commit
a1175ab00d
22 changed files with 284 additions and 51 deletions
1
Gemfile
1
Gemfile
|
|
@ -24,6 +24,7 @@ end
|
||||||
|
|
||||||
|
|
||||||
gem "stripe", "~> 18.3"
|
gem "stripe", "~> 18.3"
|
||||||
|
gem "resend", "~> 0.12"
|
||||||
|
|
||||||
gem "dotenv-rails", "~> 3.2"
|
gem "dotenv-rails", "~> 3.2"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,21 @@
|
||||||
class ApplicationController < ActionController::Base
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,33 @@
|
||||||
|
require "net/http"
|
||||||
|
require "uri"
|
||||||
|
require "json"
|
||||||
|
|
||||||
class CheckoutController < ApplicationController
|
class CheckoutController < ApplicationController
|
||||||
skip_before_action :verify_authenticity_token, only: [:webhook]
|
skip_before_action :verify_authenticity_token, only: [:webhook]
|
||||||
|
before_action :require_auth, only: [:start]
|
||||||
|
|
||||||
def create_session
|
# GET /checkout/start — create Stripe session and redirect
|
||||||
session = Stripe::Checkout::Session.create(
|
def start
|
||||||
mode: "subscription",
|
# Already subscribed — send to dashboard
|
||||||
line_items: [{ price: ENV.fetch("STRIPE_PRICE_ID"), quantity: 1 }],
|
if current_customer.subscribed?
|
||||||
success_url: "#{ENV.fetch("BASE_URL")}/dashboard?session_id={CHECKOUT_SESSION_ID}",
|
return redirect_to dashboard_path
|
||||||
cancel_url: "#{ENV.fetch("BASE_URL")}/",
|
end
|
||||||
|
|
||||||
|
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
|
rescue Stripe::StripeError => e
|
||||||
render json: { error: e.message }, status: :bad_request
|
redirect_to pricing_path, alert: e.message
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /checkout/webhook
|
||||||
def webhook
|
def webhook
|
||||||
payload = request.body.read
|
payload = request.body.read
|
||||||
sig_header = request.headers["HTTP_STRIPE_SIGNATURE"]
|
sig_header = request.headers["HTTP_STRIPE_SIGNATURE"]
|
||||||
|
|
@ -24,6 +39,12 @@ class CheckoutController < ApplicationController
|
||||||
if event["type"] == "checkout.session.completed"
|
if event["type"] == "checkout.session.completed"
|
||||||
sess = event["data"]["object"]
|
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")
|
uri = URI("#{ENV.fetch("INFRA_SERVICE_URL")}/instances")
|
||||||
http = Net::HTTP.new(uri.host, uri.port)
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
http.use_ssl = uri.scheme == "https"
|
http.use_ssl = uri.scheme == "https"
|
||||||
|
|
@ -31,17 +52,17 @@ class CheckoutController < ApplicationController
|
||||||
req.body = {
|
req.body = {
|
||||||
stripe_customer_id: sess["customer"],
|
stripe_customer_id: sess["customer"],
|
||||||
stripe_subscription_id: sess["subscription"],
|
stripe_subscription_id: sess["subscription"],
|
||||||
email: sess.dig("customer_details", "email")
|
email: customer.email
|
||||||
}.to_json
|
}.to_json
|
||||||
res = http.request(req)
|
res = http.request(req)
|
||||||
|
|
||||||
body = JSON.parse(res.body)
|
body = JSON.parse(res.body)
|
||||||
Customer.find_or_create_by(stripe_customer_id: sess["customer"]) do |c|
|
|
||||||
c.stripe_subscription_id = sess["subscription"]
|
customer.update!(
|
||||||
c.email = sess.dig("customer_details", "email")
|
stripe_customer_id: sess["customer"],
|
||||||
c.slug = body["slug"]
|
stripe_subscription_id: sess["subscription"],
|
||||||
c.status = body["status"] || "provisioning"
|
slug: body["slug"],
|
||||||
end
|
status: body["status"] || "provisioning"
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: { status: "ok" }
|
render json: { status: "ok" }
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,7 @@
|
||||||
class DashboardController < ApplicationController
|
class DashboardController < ApplicationController
|
||||||
|
before_action :require_auth
|
||||||
|
|
||||||
def show
|
def show
|
||||||
if params[:session_id].present?
|
@customer = current_customer
|
||||||
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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
48
app/controllers/sessions_controller.rb
Normal file
48
app/controllers/sessions_controller.rb
Normal file
|
|
@ -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
|
||||||
4
app/mailers/application_mailer.rb
Normal file
4
app/mailers/application_mailer.rb
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
class ApplicationMailer < ActionMailer::Base
|
||||||
|
default from: ENV.fetch("MAILER_FROM", "hello@crimata.com")
|
||||||
|
layout "mailer"
|
||||||
|
end
|
||||||
9
app/mailers/customer_mailer.rb
Normal file
9
app/mailers/customer_mailer.rb
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,2 +1,30 @@
|
||||||
class Customer < ApplicationRecord
|
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
|
end
|
||||||
|
|
|
||||||
10
app/views/customer_mailer/magic_link.html.erb
Normal file
10
app/views/customer_mailer/magic_link.html.erb
Normal file
|
|
@ -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>
|
||||||
8
app/views/customer_mailer/magic_link.text.erb
Normal file
8
app/views/customer_mailer/magic_link.text.erb
Normal file
|
|
@ -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.
|
||||||
|
|
@ -23,6 +23,12 @@
|
||||||
<%= link_to "Crimata", root_path, class: "logo" %>
|
<%= link_to "Crimata", root_path, class: "logo" %>
|
||||||
<nav>
|
<nav>
|
||||||
<%= link_to "Pricing", pricing_path %>
|
<%= 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>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
|
|
||||||
20
app/views/layouts/mailer.html.erb
Normal file
20
app/views/layouts/mailer.html.erb
Normal file
|
|
@ -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>
|
||||||
1
app/views/layouts/mailer.text.erb
Normal file
1
app/views/layouts/mailer.text.erb
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<%= yield %>
|
||||||
|
|
@ -26,24 +26,7 @@
|
||||||
<li>Fully managed hosting</li>
|
<li>Fully managed hosting</li>
|
||||||
<li>Cancel anytime</li>
|
<li>Cancel anytime</li>
|
||||||
</ul>
|
</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>
|
<p class="sub">No setup fees.</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
|
|
|
||||||
34
app/views/sessions/new.html.erb
Normal file
34
app/views/sessions/new.html.erb
Normal file
|
|
@ -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>
|
||||||
16
app/views/sessions/sent.html.erb
Normal file
16
app/views/sessions/sent.html.erb
Normal file
|
|
@ -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">✉</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>
|
||||||
|
|
@ -7,7 +7,7 @@ require "active_model/railtie"
|
||||||
require "active_record/railtie"
|
require "active_record/railtie"
|
||||||
# require "active_storage/engine"
|
# require "active_storage/engine"
|
||||||
require "action_controller/railtie"
|
require "action_controller/railtie"
|
||||||
# require "action_mailer/railtie"
|
require "action_mailer/railtie"
|
||||||
# require "action_mailbox/engine"
|
# require "action_mailbox/engine"
|
||||||
# require "action_text/engine"
|
# require "action_text/engine"
|
||||||
require "action_view/railtie"
|
require "action_view/railtie"
|
||||||
|
|
|
||||||
|
|
@ -57,4 +57,8 @@ Rails.application.configure do
|
||||||
|
|
||||||
# Raise error when a before_action's only/except options reference missing actions
|
# Raise error when a before_action's only/except options reference missing actions
|
||||||
config.action_controller.raise_on_missing_callback_actions = true
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
require "active_support/core_ext/integer/time"
|
require "active_support/core_ext/integer/time"
|
||||||
|
require "uri"
|
||||||
|
|
||||||
Rails.application.configure do
|
Rails.application.configure do
|
||||||
# Settings specified here will take precedence over those in config/application.rb.
|
# 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.
|
# Do not dump schema after migrations.
|
||||||
config.active_record.dump_schema_after_migration = false
|
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.
|
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||||
# config.hosts = [
|
# config.hosts = [
|
||||||
# "example.com", # Allow requests from example.com
|
# "example.com", # Allow requests from example.com
|
||||||
|
|
|
||||||
1
config/initializers/resend.rb
Normal file
1
config/initializers/resend.rb
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Resend.api_key = ENV.fetch("RESEND_API_KEY", "")
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
root "pages#home"
|
root "pages#home"
|
||||||
get "/pricing", to: "pages#pricing"
|
get "/pricing", to: "pages#pricing"
|
||||||
get "/success", to: "pages#success"
|
|
||||||
get "/dashboard", to: "dashboard#show"
|
|
||||||
|
|
||||||
post "/checkout/create_session", to: "checkout#create_session"
|
# Auth
|
||||||
post "/checkout/webhook", to: "checkout#webhook"
|
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
|
get "up" => "rails/health#show", as: :rails_health_check
|
||||||
end
|
end
|
||||||
|
|
|
||||||
10
db/migrate/20260227100000_add_auth_fields_to_customers.rb
Normal file
10
db/migrate/20260227100000_add_auth_fields_to_customers.rb
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue