Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Gemfile.saas.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
GIT
remote: https://github.com/basecamp/fizzy-saas
revision: 7f392bbbf9f5170d334b6ee2f6d240569bd157ed
revision: f80da3c2faf34b94d65a41a501f19e8dba379012
specs:
fizzy-saas (0.1.0)
prometheus-client-mmap
Expand Down Expand Up @@ -52,7 +52,7 @@ GIT

GIT
remote: https://github.com/rails/rails.git
revision: 4f7ab01bb5d6be78c7447dbb230c55027d08ae34
revision: 690ec8898318b8f50714e86676353ebe1551261e
branch: main
specs:
actioncable (8.2.0.alpha)
Expand Down Expand Up @@ -423,7 +423,7 @@ GEM
rake-compiler-dock (1.9.1)
rb_sys (0.9.117)
rake-compiler-dock (= 1.9.1)
rdoc (6.15.1)
rdoc (6.16.1)
erb
psych (>= 4.0.0)
tsort
Expand Down Expand Up @@ -479,10 +479,10 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (6.1.1)
sentry-rails (6.2.0)
railties (>= 5.2.0)
sentry-ruby (~> 6.1.1)
sentry-ruby (6.1.1)
sentry-ruby (~> 6.2.0)
sentry-ruby (6.2.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sniffer (0.5.0)
Expand Down
14 changes: 11 additions & 3 deletions app/controllers/sessions/magic_links_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ def show
end

def create
if identity = MagicLink.consume(code)
start_new_session_for identity
redirect_to after_authentication_url
if magic_link = MagicLink.consume(code)
start_new_session_for magic_link.identity
redirect_to after_sign_in_url(magic_link)
else
redirect_to session_magic_link_path, alert: "Try another code."
end
Expand All @@ -21,4 +21,12 @@ def create
def code
params.expect(:code)
end

def after_sign_in_url(magic_link)
if magic_link.for_sign_up?
new_signup_completion_path
else
after_authentication_url
end
end
end
28 changes: 4 additions & 24 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
class SessionsController < ApplicationController
# FIXME: Remove this before launch!
unless Rails.env.local?
http_basic_authenticate_with \
name: Rails.application.credentials.account_signup_http_basic_auth.name,
password: Rails.application.credentials.account_signup_http_basic_auth.password,
realm: "Fizzy Signup",
only: :create, unless: -> { Identity.exists?(email_address: email_address) }
end

disallow_account_scope
require_unauthenticated_access except: :destroy
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
Expand All @@ -19,10 +10,11 @@ def new

def create
if identity = Identity.find_by_email_address(email_address)
handle_existing_user(identity)
elsif
handle_new_signup
magic_link = identity.send_magic_link
flash[:magic_link_code] = magic_link&.code if Rails.env.development?
end

redirect_to session_magic_link_path
end

def destroy
Expand All @@ -34,16 +26,4 @@ def destroy
def email_address
params.expect(:email_address)
end

def handle_existing_user(identity)
magic_link = identity.send_magic_link
flash[:magic_link_code] = magic_link&.code if Rails.env.development?
redirect_to session_magic_link_path
end

def handle_new_signup
Signup.new(email_address: email_address).create_identity
session[:return_to_after_authenticating] = new_signup_completion_path
redirect_to session_magic_link_path
end
end
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class Signup::CompletionsController < ApplicationController
class Signups::CompletionsController < ApplicationController
layout "public"

disallow_account_scope
Expand Down
34 changes: 34 additions & 0 deletions app/controllers/signups_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class SignupsController < ApplicationController
# FIXME: Remove this before launch!
unless Rails.env.local?
http_basic_authenticate_with \
name: Rails.application.credentials.account_signup_http_basic_auth.name,
password: Rails.application.credentials.account_signup_http_basic_auth.password,
realm: "Fizzy Signup"
end

disallow_account_scope
allow_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." }
before_action :redirect_authenticated_user

layout "public"

def new
@signup = Signup.new
end

def create
Signup.new(signup_params).create_identity
redirect_to session_magic_link_path
end

private
def redirect_authenticated_user
redirect_to new_signup_completion_path if authenticated?
end

def signup_params
params.expect signup: :email_address
end
end
6 changes: 4 additions & 2 deletions app/models/identity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ class Identity < ApplicationRecord

normalizes :email_address, with: ->(value) { value.strip.downcase.presence }

def send_magic_link
magic_links.create!.tap do |magic_link|
def send_magic_link(**attributes)
attributes[:purpose] = attributes.delete(:for) if attributes.key?(:for)

magic_links.create!(attributes).tap do |magic_link|
MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
end
end
Expand Down
4 changes: 3 additions & 1 deletion app/models/magic_link.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ class MagicLink < ApplicationRecord

belongs_to :identity

enum :purpose, %w[ sign_in sign_up ], prefix: :for, default: :sign_in

scope :active, -> { where(expires_at: Time.current...) }
scope :stale, -> { where(expires_at: ..Time.current) }

Expand All @@ -24,7 +26,7 @@ def cleanup

def consume
destroy
identity
self
end

private
Expand Down
2 changes: 1 addition & 1 deletion app/models/signup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def initialize(...)

def create_identity
@identity = Identity.find_or_create_by!(email_address: email_address)
@identity.send_magic_link
@identity.send_magic_link for: :sign_up
end

def complete
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

<% if @identity.users.any? %>
<% if @magic_link.for_sign_in? %>
<h1 class="title">Fizzy verification code</h1>
<p class="subtitle">Please enter this 6-character verification code on the Fizzy sign-in page:</p>
<% else %>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<% if @identity.users.any? %>
<% if @magic_link.for_sign_in? %>
Please enter this 6-character verification code on the Fizzy sign-in page:
<% else %>
Please enter this 6-character verification code on the Fizzy sign-up page to create your new account:
Expand Down
9 changes: 0 additions & 9 deletions app/views/sessions/menus/show.html+menu_section.erb

This file was deleted.

2 changes: 1 addition & 1 deletion app/views/sessions/menus/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<% end %>

<div class="margin-block-start">
<%= link_to new_signup_completion_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %>
<%= link_to new_signup_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %>
<span>Sign up for a new Fizzy account</span>
<% end %>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/views/sessions/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</label>
</div>

<p><strong>New here?</strong> Enter your email to create an account. <strong>Already have an account?</strong> Enter your email and we’ll get you signed in.</p>
<p><strong>New here?</strong> <%= link_to "sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and we’ll get you signed in.</p>

<button type="submit" id="log_in" class="btn btn--link center txt-medium">
<span>Let’s go</span>
Expand Down
28 changes: 0 additions & 28 deletions app/views/signup/new.html.erb

This file was deleted.

24 changes: 24 additions & 0 deletions app/views/signups/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<% @page_title = "Signup for Fizzy" %>

<div class="panel panel--centered flex flex-column gap-half">
<h1 class="txt-x-large font-weight-black margin-block-end">Sign up</h1>

<%= form_with model: @signup, url: signup_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: "form" } do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "username", placeholder: "Enter your email address…" %>
</label>
</div>

<p>Enter your email to create an account.</p>

<button type="submit" id="log_in" class="btn btn--link center txt-medium">
<span>Let’s go</span>
<%= icon_tag "arrow-right" %>
</button>
<% end %>
</div>

<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
10 changes: 7 additions & 3 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,14 @@
end
end

get "/signup/new", to: redirect("/session/new")
get "/signup", to: redirect("/signup/new")

namespace :signup do
resource :completion, only: %i[ new create ]
resource :signup, only: %i[ new create ] do
collection do
scope module: :signups, as: :signup do
resource :completion, only: %i[ new create ]
end
end
end

resource :landing
Expand Down
11 changes: 11 additions & 0 deletions db/migrate/20251129110120_add_purpose_to_magic_links.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class AddPurposeToMagicLinks < ActiveRecord::Migration[8.2]
def change
add_column :magic_links, :purpose, :integer, null: true

execute <<-SQL
UPDATE magic_links SET purpose = 0
SQL

change_column_null :magic_links, :purpose, false
end
end
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion db/schema_sqlite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.2].define(version: 2025_11_27_000001) do
ActiveRecord::Schema[8.2].define(version: 2025_11_29_110120) do
create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
Expand Down Expand Up @@ -315,6 +315,7 @@
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.uuid "identity_id"
t.integer "purpose", null: false
t.datetime "updated_at", null: false
t.index ["code"], name: "index_magic_links_on_code", unique: true
t.index ["expires_at"], name: "index_magic_links_on_expires_at"
Expand Down
23 changes: 19 additions & 4 deletions test/controllers/sessions/magic_links_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,32 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
end
end

test "create" do
test "create with sign in code" do
identity = identities(:kevin)
magic_link = MagicLink.create!(identity: identity)

untenanted do
post session_magic_link_url, params: { code: magic_link.code }

assert_response :redirect
assert cookies[:session_token].present?
assert_redirected_to landing_path, "Should redirect to after authentication path"
assert_not MagicLink.exists?(magic_link.id), "The magic link should be consumed"
end
end

test "create with sign up code" do
identity = identities(:kevin)
magic_link = MagicLink.create!(identity: identity, purpose: :sign_up)

assert_response :redirect
assert cookies[:session_token].present?
assert_not MagicLink.exists?(magic_link.id), "The magic link should be consumed"
untenanted do
post session_magic_link_url, params: { code: magic_link.code }

assert_response :redirect
assert cookies[:session_token].present?
assert_redirected_to new_signup_completion_path, "Should redirect to signup completion"
assert_not MagicLink.exists?(magic_link.id), "The magic link should be consumed"
end
end

test "create with invalid code" do
Expand Down
Loading