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]
- 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")}/",
+ # 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
+
+ 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" %>
<%= 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 %>
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 @@
+
+
+
+
+
+
+
+
+
+
Crimata
+ <%= 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
- Get Started
+ <%= 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