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
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" %>
|
||||
<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>
|
||||
|
|
|
|||
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>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>
|
||||
|
|
|
|||
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>
|
||||
Loading…
Reference in a new issue