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:
Andrew Gundersen 2026-02-27 17:12:23 -05:00
commit a1175ab00d
22 changed files with 284 additions and 51 deletions

View file

@ -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>