Make sign up Magic Links work across devices

Previously if someone started signing in on their phone, but finished it on their laptop, they'd end up on the menu screen with no account. Bu tracking the purpose of a Magic Link we can always direct the user to sign up if they requested a magic link through sign up. This also makes the logic for changing the copy in the email more robust.
This commit is contained in:
Stanko K.R.
2025-11-29 12:36:00 +01:00
parent e302e79b55
commit 2311efd03e
10 changed files with 56 additions and 17 deletions
@@ -9,9 +9,9 @@ class Sessions::MagicLinksController < ApplicationController
end end
def create def create
if identity = MagicLink.consume(code) if magic_link = MagicLink.consume(code)
start_new_session_for identity start_new_session_for magic_link.identity
redirect_to after_authentication_url redirect_to after_sign_in_url(magic_link)
else else
redirect_to session_magic_link_path, alert: "Try another code." redirect_to session_magic_link_path, alert: "Try another code."
end end
@@ -21,4 +21,12 @@ class Sessions::MagicLinksController < ApplicationController
def code def code
params.expect(:code) params.expect(:code)
end 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 end
+4 -2
View File
@@ -12,8 +12,10 @@ class Identity < ApplicationRecord
normalizes :email_address, with: ->(value) { value.strip.downcase.presence } normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
def send_magic_link def send_magic_link(**attributes)
magic_links.create!.tap do |magic_link| 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 MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
end end
end end
+3 -1
View File
@@ -4,6 +4,8 @@ class MagicLink < ApplicationRecord
belongs_to :identity belongs_to :identity
enum :purpose, %w[ sign_in sign_up ], prefix: :for, default: :sign_in
scope :active, -> { where(expires_at: Time.current...) } scope :active, -> { where(expires_at: Time.current...) }
scope :stale, -> { where(expires_at: ..Time.current) } scope :stale, -> { where(expires_at: ..Time.current) }
@@ -24,7 +26,7 @@ class MagicLink < ApplicationRecord
def consume def consume
destroy destroy
identity self
end end
private private
+1 -1
View File
@@ -18,7 +18,7 @@ class Signup
def create_identity def create_identity
@identity = Identity.find_or_create_by!(email_address: email_address) @identity = Identity.find_or_create_by!(email_address: email_address)
@identity.send_magic_link @identity.send_magic_link for: :sign_up
end end
def complete def complete
@@ -1,5 +1,5 @@
<% if @identity.users.any? %> <% if @magic_link.for_sign_in? %>
<h1 class="title">Fizzy verification code</h1> <h1 class="title">Fizzy verification code</h1>
<p class="subtitle">Please enter this 6-character verification code on the Fizzy sign-in page:</p> <p class="subtitle">Please enter this 6-character verification code on the Fizzy sign-in page:</p>
<% else %> <% else %>
@@ -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: Please enter this 6-character verification code on the Fizzy sign-in page:
<% else %> <% else %>
Please enter this 6-character verification code on the Fizzy sign-up page to create your new account: Please enter this 6-character verification code on the Fizzy sign-up page to create your new account:
@@ -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
Generated
+2 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at" t.datetime "accessed_at"
t.uuid "account_id", null: false t.uuid "account_id", null: false
@@ -315,6 +315,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_27_000001) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.uuid "identity_id" t.uuid "identity_id"
t.integer "purpose", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["code"], name: "index_magic_links_on_code", unique: true t.index ["code"], name: "index_magic_links_on_code", unique: true
t.index ["expires_at"], name: "index_magic_links_on_expires_at" t.index ["expires_at"], name: "index_magic_links_on_expires_at"
@@ -9,17 +9,32 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
end end
end end
test "create" do test "create with sign in code" do
identity = identities(:kevin) identity = identities(:kevin)
magic_link = MagicLink.create!(identity: identity) magic_link = MagicLink.create!(identity: identity)
untenanted do untenanted do
post session_magic_link_url, params: { code: magic_link.code } post session_magic_link_url, params: { code: magic_link.code }
end
assert_response :redirect assert_response :redirect
assert cookies[:session_token].present? assert cookies[:session_token].present?
assert_not MagicLink.exists?(magic_link.id), "The magic link should be consumed" 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)
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 end
test "create with invalid code" do test "create with invalid code" do
+2 -2
View File
@@ -32,8 +32,8 @@ class MagicLinkTest < ActiveSupport::TestCase
magic_link = MagicLink.create!(identity: identities(:kevin)) magic_link = MagicLink.create!(identity: identities(:kevin))
code_with_spaces = magic_link.code.downcase.chars.join(" ") code_with_spaces = magic_link.code.downcase.chars.join(" ")
identity = MagicLink.consume(code_with_spaces) consumed_magic_link = MagicLink.consume(code_with_spaces)
assert_equal identities(:kevin), identity assert_equal magic_link, consumed_magic_link
assert_not MagicLink.exists?(magic_link.id) assert_not MagicLink.exists?(magic_link.id)
expired_link = MagicLink.create!(identity: identities(:kevin)) expired_link = MagicLink.create!(identity: identities(:kevin))