Add sign in flow using magic links

This commit is contained in:
Stanko K.R.
2025-10-07 19:33:51 +02:00
parent d0215e9fd2
commit 5cef4ffeb0
51 changed files with 908 additions and 268 deletions
+43 -10
View File
@@ -38,13 +38,31 @@ module Authentication
def require_tenant
unless ApplicationRecord.current_tenant.present?
set_current_identity_token
render "sessions/login_menu"
resume_identity
redirect_to session_login_menu_url(script_name: nil)
end
end
def require_identity
resume_identity || request_authentication
end
def require_authentication
resume_session || request_authentication
if resume_identity
resume_session || request_session_for_identity
else
request_authentication
end
end
def request_session_for_identity
redirect_to session_login_menu_url(script_name: nil)
end
def resume_identity
if identity = find_identity_by_cookie
set_current_identity(identity)
end
end
def resume_session
@@ -53,11 +71,15 @@ module Authentication
end
end
def find_identity_by_cookie
Identity.find_signed(cookies.signed[:identity_token]&.dig("id"))
end
def find_session_by_cookie
Session.find_signed(cookies.signed[:session_token])
end
def request_authentication(untenanted: false)
def request_authentication
if ApplicationRecord.current_tenant.present?
session[:return_to_after_authenticating] = request.url
end
@@ -84,26 +106,37 @@ module Authentication
end
end
def link_identity(user)
def link_identity(user_or_membership)
token_value = cookies.signed[:identity_token]
identity = Identity.find_signed(token_value["id"]) if token_value.present?
identity = user.set_identity(identity)
cookies.signed.permanent[:identity_token] = { value: { "id" => identity.signed_id, "updated_at" => identity.updated_at }, httponly: true, same_site: :lax }
if user_or_membership.is_a?(User)
identity = user_or_membership.set_identity(identity)
elsif user_or_membership.is_a?(Membership) && identity
user_or_membership.update!(identity: identity)
end
set_current_identity(identity)
end
def set_current_identity_token
Current.identity_token = Identity::Mock.new(**cookies.signed[:identity_token])
def set_current_identity(identity)
Current.identity_token = if identity
cookies.signed.permanent[:identity_token] = { value: { "id" => identity.signed_id, "updated_at" => identity.updated_at }, httponly: true, same_site: :lax }
Identity::Mock.new(**cookies.signed[:identity_token])
else
nil
end
end
def set_current_session(session)
logger.struct " Authorized User##{session.user.id}", authentication: { user: { id: session.user.id } }
Current.session = session
set_current_identity_token
cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax, path: Account.sole.slug }
end
def terminate_session
Current.session.destroy
cookies.delete(:session_token)
cookies.delete(:identity_token)
end
end
@@ -0,0 +1,25 @@
class Sessions::LoginMenusController < ApplicationController
require_untenanted_access only: :show
allow_unauthenticated_access only: :create
before_action :require_identity
before_action :set_identity
def show
@memberships = @identity.memberships
end
def create
membership = @identity.memberships.find(membership_id)
start_new_session_for(membership.user)
redirect_to after_authentication_url
end
private
def set_identity
@identity = Current.identity_token.identity
end
def membership_id
params.expect :membership_id
end
end
@@ -0,0 +1,34 @@
class Sessions::MagicLinksController < ApplicationController
require_untenanted_access
rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Try again in 15 minutes." }
def show
end
def create
membership = MagicLink.consume(code)
if membership.blank?
redirect_to session_magic_link_path, alert: "Try another code."
else
resume_identity
if Current.identity_token
link_identity(membership)
else
set_current_identity(membership.identity)
end
if membership.account.setup_pending?
redirect_to saas.new_signup_completion_url(script_name: "/#{membership.user_tenant}")
else
redirect_to session_login_menu_path
end
end
end
private
def code
params.expect(:code)
end
end
+10 -6
View File
@@ -1,21 +1,25 @@
class SessionsController < ApplicationController
require_unauthenticated_access only: %i[ new create ]
require_untenanted_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.active.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
if membership = Membership.find_by(email_address: email_address)
membership.send_magic_link
end
redirect_to session_magic_link_path
end
def destroy
terminate_session
redirect_to_logout_url
end
private
def email_address
params.expect(:email_address)
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
module LoginHelper
def login_url
new_session_path
new_session_path(script_name: nil)
end
def logout_url
+5
View File
@@ -0,0 +1,5 @@
module MagicLinkHelper
def magic_link_code(magic_link)
magic_link.code.scan(/.{3}/).join("-")
end
end
+5
View File
@@ -0,0 +1,5 @@
class MagicLink::CleanupJob < ApplicationJob
def perform
MagicLink.cleanup
end
end
+15
View File
@@ -0,0 +1,15 @@
class MagicLinkMailer < ApplicationMailer
helper MagicLinkHelper
def sign_in_instructions(magic_link)
@magic_link = magic_link
@membership = @magic_link.membership
mail to: @membership.email_address, subject: "Sign in to Fizzy"
end
private
def default_url_options
Rails.application.config.action_mailer.default_url_options
end
end
+2
View File
@@ -3,6 +3,8 @@ class Account < ApplicationRecord
has_many_attached :uploads
enum :setup_status, %w[ pending complete ].index_by(&:itself), prefix: :setup, default: :pending
class << self
def create_with_admin_user(account:, owner:)
User.system
+5 -1
View File
@@ -1,7 +1,11 @@
class Identity < UntenantedRecord
# This is used to instantiate an Identity-like object from the `identity_token` without hitting
# the untenanted database. It is intended to be used with caching/etagging methods.
Mock = Struct.new(:id, :updated_at)
Mock = Struct.new(:id, :updated_at) do
def identity
Identity.find_signed(id)
end
end
has_many :memberships, dependent: :destroy
end
+41
View File
@@ -0,0 +1,41 @@
class MagicLink < UntenantedRecord
CODE_LENGTH = 6
EXPIRATION_TIME = 15.minutes
belongs_to :membership
scope :active, -> { where(expires_at: Time.current...) }
scope :stale, -> { where(expires_at: ..Time.current) }
before_validation :generate_code, on: :create
before_validation :set_expiration, on: :create
validates :code, uniqueness: true, presence: true
class << self
def consume(code)
active.find_by(code: Code.sanitize(code))&.consume
end
def cleanup
stale.delete_all
end
end
def consume
destroy
membership
end
private
def generate_code
self.code = loop do
candidate = Code.generate(CODE_LENGTH)
break candidate unless self.class.exists?(code: candidate)
end
end
def set_expiration
self.expires_at = EXPIRATION_TIME.from_now
end
end
+31
View File
@@ -0,0 +1,31 @@
module MagicLink::Code
CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".chars.freeze
CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze
class << self
def generate(length)
length.times.map { CODE_ALPHABET.sample }.join
end
def sanitize(code)
return nil if code.blank?
normalize_code(code)
.then { apply_substitutions(_1) }
.then { remove_invalid_characters(_1) }
end
private
def normalize_code(code)
code.to_s.upcase
end
def apply_substitutions(code)
CODE_SUBSTITUTIONS.reduce(code) { |result, (from, to)| result.gsub(from, to) }
end
def remove_invalid_characters(code)
code.gsub(/[^#{CODE_ALPHABET.join}]/, "")
end
end
end
+10 -1
View File
@@ -1,6 +1,6 @@
class Membership < UntenantedRecord
belongs_to :identity, touch: true
has_many :magic_links, dependent: :delete
has_many :magic_links, dependent: :delete_all
# I want this to be `belongs_to :user`, but ActiveRecord::Tenanted doesn't yet support
# associations from untenanted to untenanted models.
@@ -11,4 +11,13 @@ class Membership < UntenantedRecord
def user
User.with_tenant(user_tenant) { User.find_by(id: user_id) }
end
def account
Account.with_tenant(user_tenant) { Account.sole }
end
def send_magic_link
magic_link = magic_links.create!
MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
end
end
+2
View File
@@ -4,6 +4,8 @@ module User::Identifiable
included do
has_one :membership, ->(user) { where(user_tenant: user.tenant) }
has_one :identity, through: :membership
scope :with_identity, ->(identity) { where(id: identity.memberships.where(user_tenant: ApplicationRecord.current_tenant).pluck(:user_id)) }
end
def set_identity(token_identity)
+1
View File
@@ -4,6 +4,7 @@ module User::Role
included do
enum :role, %i[ admin member system ].index_by(&:itself), scopes: false
scope :admin, -> { where(role: :admin) }
scope :member, -> { where(role: :member) }
scope :active, -> { where(active: true, role: %i[ admin member ]) }
end
@@ -0,0 +1,13 @@
<h1>Sign in to Fizzy</h1>
<div>
<p>Hit the button below to sign in to Fizzy on this device</p>
<div><%= link_to "Sign in to Fizzy", session_magic_link_url(code: @magic_link.code), class: "btn" %></div>
</div>
<div>
<p>If you're signing in on another device, enter the code below:</p>
<b><%= magic_link_code(@magic_link) %></b>
</div>
@@ -0,0 +1,8 @@
Sign in to Fizzy
<%= "=" * 80 %>
Open this link in your browser to login on this device:
<%= session_magic_link_url(code: @magic_link.code) %>
If you're signing in on another device, enter the code below:
<%= magic_link_code(@magic_link) %>
@@ -0,0 +1,27 @@
<% cache [ @identity, @memberships ] do %>
<div class="panel shadow center flex flex-column gap">
<div class="flex flex-column gap">
<% if @memberships.present? %>
<h2>Your Fizzy Accounts</h2>
<div class="flex flex-column gap-half margin-none unpad">
<% @memberships.each do |membership| %>
<%= button_to session_login_menu_url(membership_id: membership, script_name: "/#{membership.user_tenant}"), method: :post, class: "btn center txt-medium" do %>
<%= membership.account_name %>
:
<%= membership.email_address %>
<% end %>
<% end %>
</div>
<% else %>
<p>You don't have any existing Fizzy accounts.</p>
<% end %>
</div>
<div class="flex flex-column gap">
<%= link_to login_url, class: "btn center btn--reversed" do %>
<%= icon_tag "add" %>
Add another account
<% end %>
</div>
</div>
<% end %>
@@ -0,0 +1,22 @@
<% @page_title = "Log in" %>
<div
class="panel shadow center margin-block-double"
style="--panel-size: 55ch;"
>
<h1 class="txt-xx-large margin-block-end-double">Log in</h1>
<p>Pick where you want to log in.</p>
<div>
<% @memberships.each do |membership| %>
<%= link_to membership.email_address, session_transfer_path(membership.user.transfer_id) %>
<% end %>
</div>
</div>
<% content_for :footer do %>
<div class="txt-align-center center margin-block-double txt-subtle">
Fizzy&trade; version 0
</div>
<% end %>
@@ -0,0 +1,29 @@
<% @page_title = "Magic link" %>
<div
class="panel shadow center margin-block-double <%= "shake" if flash[:alert] %>"
style="--panel-size: 55ch;"
>
<h1 class="txt-xx-large margin-block-end-double">Check your email</h1>
<p>We just emailed you a special code.</p>
<%= form_with url: session_magic_link_path, method: :post, html: { class: "flex flex-column gap", data: { controller: token_list("auto-submit" => params[:code].present?)} } do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.text_field :code, required: true, class: "input full-width text-align-center", autofocus: true, autocomplete: "code", placeholder: "Enter the code", value: params[:code] %>
</label>
</div>
<button type="submit" class="btn btn--reversed center">
<%= icon_tag "arrow-right" %>
<span class="for-screen-reader">Next</span>
</button>
<% end %>
</div>
<% content_for :footer do %>
<div class="txt-align-center center margin-block-double txt-subtle">
Fizzy&trade; version 0
</div>
<% end %>
+6 -7
View File
@@ -11,16 +11,15 @@
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.password_field :password, required: true, class: "input full-width", autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %>
<%= icon_tag "password", class: "txt-large" %>
</label>
</div>
<button type="submit" id="log_in" class="btn btn--reversed center">
<%= icon_tag "arrow-right" %>
<span class="for-screen-reader">Sign in</span>
</button>
<% end %>
</div>
<% content_for :footer do %>
<div class="txt-align-center center margin-block-double txt-subtle">
Fizzy&trade; version 0
</div>
<% end %>
+3
View File
@@ -25,6 +25,9 @@ production: &production
cleanup_webhook_deliveries:
class: Webhook::CleanupDeliveriesJob
schedule: every 4 hours at minute 51
cleanup_magic_links:
class: "MagicLink::CleanupJob"
schedule: every 4 hours
sqlite_backups:
class: SQLiteBackupsJob
schedule: every day at 05:02
+2
View File
@@ -129,6 +129,8 @@ Rails.application.routes.draw do
resource :session do
scope module: "sessions" do
resources :transfers, only: %i[ show update ]
resource :magic_link, only: %i[ show create ]
resource :login_menu, only: %i[ show create ]
end
end
@@ -0,0 +1,5 @@
class AddSetupStatusToAccounts < ActiveRecord::Migration[8.1]
def change
add_column :accounts, :setup_status, :string
end
end
+11 -2
View File
@@ -18,7 +18,8 @@ def create_tenant(signal_account_name)
account = Account.create_with_admin_user(
account: {
external_account_id: tenant_id,
name: signal_account_name
name: signal_account_name,
setup_status: :complete
},
owner: {
name: "David Heinemeier Hansson",
@@ -30,16 +31,24 @@ def create_tenant(signal_account_name)
end
ApplicationRecord.current_tenant = tenant_id
identity = Membership.find_by(email_address: User.first.email_address)&.identity
User.first.set_identity(identity)
end
def find_or_create_user(full_name, email_address)
if user = User.find_by(email_address: email_address)
user
else
User.create! \
user = User.create! \
name: full_name,
email_address: email_address,
password: "secret123456"
identity = Membership.find_by(email_address: email_address)&.identity || Identity.create!
user.set_identity(identity)
user
end
end
@@ -0,0 +1,11 @@
class CreateIdentityMagicLinks < ActiveRecord::Migration[8.1]
def change
create_table :magic_links do |t|
t.string :code, index: { unique: true }, null: false
t.belongs_to :membership, null: false, foreign_key: true
t.datetime :expires_at, index: true, null: false
t.timestamps
end
end
end
@@ -0,0 +1,46 @@
class Signups::CompletionsController < ApplicationController
allow_unauthenticated_access
before_action :require_identity
before_action :ensure_setup_pending
before_action :ensure_identity_of_an_admin
def new
@signup = Signup.new
end
def create
@signup = Signup.new(signup_params)
if @signup.complete
start_new_session_for(@signup.user)
redirect_to root_path
else
render :new, status: :unprocessable_entity
end
end
private
def ensure_setup_pending
unless Account.sole.setup_pending?
redirect_to root_path
end
end
def ensure_identity_of_an_admin
unless user&.admin?
redirect_to root_path
end
end
def signup_params
params.expect(signup: %i[ full_name company_name ]).with_defaults(
tenant: ApplicationRecord.current_tenant,
user: user
)
end
def user
@user ||= User.all.admin.with_identity(Current.identity_token.identity).first
end
end
@@ -17,8 +17,8 @@ class SignupsController < ApplicationController
def create
@signup = Signup.new(signup_params)
if @signup.process
redirect_to root_url(script_name: @signup.account.slug)
if @signup.create_account
redirect_to session_magic_link_path
else
render :new, status: :unprocessable_entity
end
@@ -26,6 +26,6 @@ class SignupsController < ApplicationController
private
def signup_params
params.require(:signup).permit(*Signup::PERMITTED_KEYS)
params.expect(signup: %i[ email_address ])
end
end
+48 -20
View File
@@ -3,14 +3,19 @@ class Signup
include ActiveModel::Attributes
include ActiveModel::Validations
PERMITTED_KEYS = %i[ full_name email_address password company_name ]
PERMITTED_KEYS = %i[ full_name email_address company_name ]
# Input attributes
attr_accessor :company_name, :full_name, :email_address, :password
validates_presence_of :company_name, :full_name, :email_address, :password
attr_accessor :company_name, :full_name, :email_address, :user, :tenant
attr_reader :queenbee_account, :account
with_options on: :account_creation do
validates_presence_of :email_address
end
with_options on: :completion do
validates_presence_of :company_name, :full_name
end
# Output attributes
attr_reader :tenant, :account, :user, :queenbee_account
def initialize(...)
@company_name = nil
@@ -25,22 +30,43 @@ class Signup
super
end
def process
return false unless valid?
def create_account
return false unless valid?(:account_creation)
create_queenbee_account
create_tenant
if membership = Membership.find_by(email_address: email_address)
membership.send_magic_link
else
create_queenbee_account
create_tenant
user.membership.send_magic_link
end
true
rescue => error
destroy_tenant
destroy_queenbee_account
errors.add(:base, "An error occurred during signup: #{error.message}")
Rails.logger.error(error)
Rails.logger.error(error.backtrace.join("\n"))
false
end
def complete
return false unless valid?(:completion)
ApplicationRecord.with_tenant(tenant) do
@account = Account.sole
ApplicationRecord.transaction do
user.update!(name: full_name)
account.update!(name: company_name, setup_status: :complete)
user.membership.update!(account_name: company_name)
end
end
# TODO: Update company and user name in QB
end
private
def create_queenbee_account
@queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes)
@@ -52,17 +78,18 @@ class Signup
end
def create_tenant
@tenant = queenbee_account.id.to_s
self.tenant = queenbee_account.id.to_s
ApplicationRecord.create_tenant(tenant) do
@account = Account.create_with_admin_user(
account: {
external_account_id: tenant,
name: company_name
name: "New Account",
setup_status: :pending
},
owner: {
name: full_name,
email_address: email_address,
password: password
name: email_address,
email_address: email_address
}
)
@user = User.find_by!(role: :admin)
@@ -74,9 +101,10 @@ class Signup
if tenant.present? && ApplicationRecord.tenant_exist?(tenant)
ApplicationRecord.destroy_tenant(tenant)
end
@user = nil
@account = nil
@tenant = nil
self.user = nil
self.tenant = nil
end
def queenbee_account_attributes
@@ -92,8 +120,8 @@ class Signup
# attributes[:terms_of_service] = true
attributes[:product_name] = "fizzy"
attributes[:name] = company_name
attributes[:owner_name] = full_name
attributes[:name] = email_address
attributes[:owner_name] = email_address
attributes[:owner_email] = email_address
attributes[:trial] = true
@@ -0,0 +1,42 @@
<% @page_title = "Sign up for Fizzy" %>
<div
class="panel shadow center margin-block-double <%= "shake" if flash[:alert] %>"
style="--panel-size: 65ch;"
>
<h1 class="txt-xx-large margin-block-end-double">Create your account</h1>
<h2 class="margin-block-end-double">
Great! We just need two more things...
</h2>
<%= form_with model: @signup, url: saas.signup_completion_path, scope: "signup", class: "flex flex-column gap txt-large", data: { turbo: false } do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.text_field :full_name, class: "input", autocomplete: "name", placeholder: "Your name", autofocus: true, required: true %>
<%= icon_tag "person", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.text_field :company_name, class: "input", autocomplete: "organization", placeholder: "Your organization", required: true %>
<%= icon_tag "building", class: "txt-large" %>
</label>
</div>
<% if @signup.errors.any? %>
<div class="alert alert--error">
<ul class="margin-block-none">
<% @signup.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<button type="submit" class="btn btn--reversed center">
<span>Finish</span>
</button>
<% end %>
</div>
+9 -33
View File
@@ -1,17 +1,16 @@
<% @page_title = "Sign up for Fizzy" %>
<div class="panel shadow center margin-block-double <%= "shake" if flash[:alert] %>" style="--panel-size: 65ch;">
<div
class="panel shadow center margin-block-double <%= "shake" if flash[:alert] %>"
style="--panel-size: 65ch;"
>
<h1 class="txt-xx-large margin-block-end-double">Fizzy</h1>
<h2 class="txt-large margin-block-end-double">Create your account</h2>
<h2 class="margin-block-end-double">
Enter your email address to get started.
</h2>
<%= form_with model: @signup, url: saas.signup_path, scope: "signup", class: "flex flex-column gap txt-large", data: { turbo: false } do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.text_field :full_name, class: "input", autocomplete: "name", placeholder: "Your name", autofocus: true, required: true %>
<%= icon_tag "person", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.email_field :email_address, class: "input", autocomplete: "username", placeholder: "Your email address", required: true %>
@@ -19,20 +18,6 @@
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.text_field :company_name, class: "input", autocomplete: "organization", placeholder: "Your organization's name", required: true %>
<%= icon_tag "building", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.password_field :password, class: "input", autocomplete: "new-password", placeholder: "Password (at least 12 characters)", required: true, maxlength: 72, minlength: 12 %>
<%= icon_tag "password", class: "txt-large" %>
</label>
</div>
<% if @signup.errors.any? %>
<div class="alert alert--error">
<ul class="margin-block-none">
@@ -44,17 +29,8 @@
<% end %>
<button type="submit" class="btn btn--reversed center">
<span>Go</span>
<%= icon_tag "arrow-right" %>
<span>Create your account</span>
</button>
<% end %>
<!-- TODO: Add this back when we have Identities and magic link login working
<footer class="margin-block-start-double txt-center">
<p class="txt-small txt-muted">
Already have an account?
<%= link_to "Sign in", login_url, class: "decorated" %>
</p>
</footer>
-->
</div>
+7 -1
View File
@@ -1,4 +1,10 @@
Fizzy::Saas::Engine.routes.draw do
resource :signup, only: [ :new, :create ]
resource :signup, only: %i[ new create ] do
scope module: :signups do
collection do
resource :completion, only: %i[ new create ], as: :signup_completion
end
end
end
Queenbee.routes(self)
end
@@ -0,0 +1,40 @@
require "test_helper"
class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest
setup do
Account.sole.update!(setup_status: :pending)
set_identity_as :kevin
end
test "new" do
get saas.new_signup_completion_path
assert_response :success
end
test "create" do
post saas.signup_completion_path, params: {
signup: {
full_name: "Kevin Systrom",
company_name: "37signals"
}
}
assert_redirected_to root_path, "Successful completion should redirect to root"
assert cookies[:session_token].present?, "Successful completion should create a session"
assert_equal "complete", Account.sole.reload.setup_status, "Account setup status should be complete"
assert_equal "Kevin Systrom", users(:kevin).reload.name, "User name should be updated"
assert_equal "37signals", Account.sole.reload.name, "Account name should be updated"
Account.sole.update!(setup_status: :pending)
post saas.signup_completion_path, params: {
signup: {
full_name: "",
company_name: ""
}
}
assert_response :unprocessable_entity, "Invalid params should return unprocessable entity"
end
end
@@ -5,8 +5,7 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest
@signup_params = {
full_name: "Brian Wilson",
email_address: "brian@example.com",
company_name: "Beach Boys",
password: SecureRandom.hex(16)
company_name: "Beach Boys"
}
@starting_tenants = ApplicationRecord.tenants
@@ -24,39 +23,20 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest
get saas.new_signup_url, headers: http_basic_auth_headers
assert_response :success
assert_select "h2", "Create your account"
assert_select "input[name='signup[full_name]']"
assert_select "h2", "Enter your email address to get started."
assert_select "input[name='signup[email_address]']"
assert_select "input[name='signup[company_name]']"
assert_select "input[name='signup[password]']"
end
test "should create signup and redirect to tenant root on success" do
Account.any_instance.expects(:setup_basic_template).once
test "should create signup and redirect to magic link page" do
assert_difference -> { ApplicationRecord.tenants.count }, 1 do
post saas.signup_url, params: { signup: @signup_params }, headers: http_basic_auth_headers
post saas.signup_url, params: { signup: { email_address: @signup_params[:email_address] } }, headers: http_basic_auth_headers
end
assert_response :redirect
new_tenant = (ApplicationRecord.tenants - @starting_tenants).first
ApplicationRecord.with_tenant(new_tenant) do
account = Account.sole
assert account, "Account should have been created"
assert_equal @signup_params[:company_name], account.name
user = User.find_by(email_address: @signup_params[:email_address])
assert user, "User should have been created"
assert_equal @signup_params[:full_name], user.name
assert_equal @signup_params[:email_address], user.email_address
assert_redirected_to root_url(script_name: account.slug)
end
assert_redirected_to session_magic_link_path
end
test "should render new with errors when signup fails validation" do
invalid_params = @signup_params.merge(password: "")
invalid_params = { email_address: "" }
assert_no_difference -> { ApplicationRecord.tenants.count } do
post saas.signup_url, params: { signup: invalid_params }, headers: http_basic_auth_headers
@@ -64,19 +44,6 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
assert_select ".alert--error"
assert_select ".alert--error li", /Password can't be blank/
end
test "should render new with errors when signup processing fails" do
Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data")
assert_no_difference -> { ApplicationRecord.tenants.count } do
post saas.signup_url, params: { signup: @signup_params }, headers: http_basic_auth_headers
end
assert_response :unprocessable_entity
assert_select ".alert--error"
assert_select ".alert--error li", /An error occurred during signup/
end
private
+45 -70
View File
@@ -3,92 +3,67 @@ require "test_helper"
class SignupTest < ActiveSupport::TestCase
setup do
@starting_tenants = ApplicationRecord.tenants
@signup = Signup.new(
email_address: "brian@example.com",
full_name: "Brian Wilson",
company_name: "Beach Boys",
password: SecureRandom.hex(16)
)
end
test "#process creates all the necessary objects for a new Fizzy account" do
test "#create_account" do
Account.any_instance.expects(:setup_basic_template).once
assert @signup.process, @signup.errors.full_messages.to_sentence(words_connector: ". ")
assert_empty @signup.errors
signup = Signup.new(email_address: "brian@example.com")
assert @signup.tenant
assert_includes ApplicationRecord.tenants, @signup.tenant
assert_difference -> { Membership.count }, 1 do
assert_difference -> { MagicLink.count }, 1 do
assert signup.create_account, signup.errors.full_messages.to_sentence(words_connector: ". ")
end
end
assert @signup.account
assert @signup.account.persisted?
assert @signup.account.external_account_id
assert_equal @signup.company_name, @signup.account.name
assert_equal @signup.tenant, @signup.account.external_account_id.to_s
assert_equal @signup.tenant, @signup.account.tenant
assert_empty signup.errors
assert signup.tenant
assert_includes ApplicationRecord.tenants, signup.tenant
assert signup.account
assert signup.account.persisted?
assert signup.user
assert signup.user.persisted?
assert @signup.user
assert @signup.user.persisted?
assert_equal @signup.full_name, @signup.user.name
assert_equal @signup.email_address, @signup.user.email_address
signup_existing = Signup.new(email_address: "brian@example.com")
auth_params = { email_address: @signup.email_address, password: @signup.password }
user = ApplicationRecord.with_tenant(@signup.tenant) { User.authenticate_by(**auth_params) }
assert_no_difference -> { Membership.count } do
assert_difference -> { MagicLink.count }, 1 do
assert signup_existing.create_account, "Should send magic link for existing membership"
end
end
assert user, "User should be able to authenticate with #{auth_params.inspect}"
assert_equal @signup.user, user
assert_equal @signup.tenant, @signup.user.tenant
end
signup_invalid = Signup.new(email_address: "")
assert_not signup_invalid.create_account, "Should fail with invalid email"
assert_not_empty signup_invalid.errors[:email_address], "Should have validation error for email_address"
test "#process does nothing if a basic validation error occurs" do
@signup.password = ""
assert_not @signup.process
assert_not_empty @signup.errors[:password]
assert_nil @signup.tenant
assert_nil @signup.account
assert_nil @signup.user
assert_equal @starting_tenants, ApplicationRecord.tenants
end
test "#process does nothing if an error occurs creating the queenbee record" do
Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data")
signup_error = Signup.new(email_address: "error@example.com")
assert_not @signup.process
assert_not_empty @signup.errors[:base]
assert_nil @signup.tenant
assert_nil @signup.account
assert_nil @signup.user
assert_equal @starting_tenants, ApplicationRecord.tenants
assert_not signup_error.create_account, "Should fail when error occurs"
assert_not_empty signup_error.errors[:base], "Should have base error"
assert_nil signup_error.tenant
assert_nil signup_error.account
assert_nil signup_error.user
end
test "#process does nothing if an error occurs creating the tenant" do
ApplicationRecord.stubs(:create_tenant).raises(RuntimeError, "Tenant already exists")
test "#complete" do
Account.sole.update!(setup_status: :pending)
signup = Signup.new(
tenant: ApplicationRecord.current_tenant,
user: users(:kevin),
full_name: "Kevin Systrom",
company_name: "37signals"
)
Queenbee::Remote::Account.any_instance.expects(:cancel).once
assert signup.complete, signup.errors.full_messages.to_sentence(words_connector: ". ")
assert_not @signup.process
assert_not_empty @signup.errors[:base]
assert_equal "complete", Account.sole.reload.setup_status, "Account setup status should be complete"
assert_equal "Kevin Systrom", users(:kevin).reload.name, "User name should be updated"
assert_equal "37signals", Account.sole.reload.name, "Account name should be updated"
assert_equal "37signals", users(:kevin).membership.reload.account_name, "Membership account name should be updated"
assert_nil @signup.tenant
assert_nil @signup.account
assert_nil @signup.user
assert_equal @starting_tenants, ApplicationRecord.tenants
end
test "#process does nothing if an error occurs creating the tenanted records" do
Account.stubs(:create_with_admin_user).raises(ActiveRecord::RecordInvalid, "Validation failed: Name can't be blank")
Queenbee::Remote::Account.any_instance.expects(:cancel).once
assert_not @signup.process
assert_not_empty @signup.errors[:base]
assert_nil @signup.tenant
assert_nil @signup.account
assert_nil @signup.user
assert_equal @starting_tenants, ApplicationRecord.tenants
signup.full_name = ""
assert_not signup.complete, "Complete should fail with invalid params"
assert_not_empty signup.errors[:full_name], "Should have validation error for full_name"
end
end
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env ruby
require_relative "../../config/environment"
ApplicationRecord.with_each_tenant do |tenant|
puts "#{tenant}"
Account.sole.setup_complete!
end
@@ -1,19 +1,18 @@
require "test_helper"
class ControllerAuthenticationTest < ActionDispatch::IntegrationTest
test "access without an account slug redirects to new session" do
test "access without an account slug redirects to login menu" do
integration_session.default_url_options[:script_name] = "" # no tenant
get cards_path
assert_response :success
assert_dom "p", text: "You don't have any existing BOXCAR accounts."
assert_redirected_to session_login_menu_path
end
test "access with an account slug but no session redirects to new session" do
get cards_path
assert_redirected_to new_session_path
assert_redirected_to new_session_path(script_name: nil)
end
test "access with an account slug and a session allows functional access" do
@@ -0,0 +1,21 @@
require "test_helper"
class Sessions::LoginMenusControllerTest < ActionDispatch::IntegrationTest
test "show" do
untenanted do
set_identity_as :kevin
get session_login_menu_url
assert_response :success
end
end
test "create" do
untenanted do
sign_in_as :kevin
assert cookies[:session_token].present?
end
end
end
@@ -0,0 +1,36 @@
require "test_helper"
class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
test "show" do
untenanted do
get session_magic_link_url
assert_response :success
end
end
test "create" do
untenanted do
membership = memberships(:kevin_in_37signals)
magic_link = MagicLink.create!(membership: membership)
post session_magic_link_url, params: { code: magic_link.code }
assert_response :redirect, "Valid magic link should redirect"
assert cookies[:identity_token].present?, "Valid magic link should set identity token"
assert_not MagicLink.exists?(magic_link.id), "Valid magic link should be consumed"
post session_magic_link_url, params: { code: "INVALID" }
assert_response :redirect, "Invalid code should redirect"
expired_link = MagicLink.create!(membership: membership)
expired_link.update_column(:expires_at, 1.hour.ago)
post session_magic_link_url, params: { code: expired_link.code }
assert_response :redirect, "Expired magic link should redirect"
assert MagicLink.exists?(expired_link.id), "Expired magic link should not be consumed"
end
end
end
+22 -16
View File
@@ -11,26 +11,32 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
end
test "new" do
get new_session_path
untenanted do
get new_session_path
assert_response :success
end
test "create with valid credentials" do
assert_difference -> { Identity.count }, 1 do
assert_difference -> { Membership.count }, 1 do
post session_path, params: { email_address: "david@37signals.com", password: "secret123456" }
end
assert_response :success
end
assert_redirected_to root_path
assert cookies[:session_token].present?
end
test "create with invalid credentials" do
post session_path, params: { email_address: "david@37signals.com", password: "wrong" }
test "create with existing membership" do
untenanted do
membership = memberships(:kevin_in_37signals)
assert_redirected_to new_session_path
assert_not cookies[:session_token].present?
assert_difference -> { MagicLink.count }, 1 do
post session_path, params: { email_address: membership.email_address }
end
assert_redirected_to session_magic_link_path
end
end
test "create with non-existent email" do
untenanted do
assert_no_difference -> { MagicLink.count } do
post session_path, params: { email_address: "nonexistent@example.com" }
end
assert_redirected_to session_magic_link_path
end
end
end
+2
View File
@@ -1,6 +1,7 @@
writebook_david:
collection: writebook
user: david
involvement: watching
writebook_jz:
collection: writebook
@@ -9,6 +10,7 @@ writebook_jz:
writebook_kevin:
collection: writebook
user: kevin
involvement: watching
private_kevin:
collection: private
+2
View File
@@ -1 +1,3 @@
kevin_identity: {}
jz_identity: {}
+12
View File
@@ -1 +1,13 @@
kevin_in_37signals:
identity: kevin_identity
email_address: kevin@37signals.com
user_tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %>
user_id: <%= ActiveRecord::FixtureSet.identify(:kevin) %>
account_name: Fizzy
jz_in_37signals:
identity: jz_identity
email_address: jz@37signals.com
user_tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %>
user_id: <%= ActiveRecord::FixtureSet.identify(:jz) %>
account_name: Fizzy
+8 -29
View File
@@ -1,37 +1,16 @@
require "test_helper"
class IdentityMembershipTest < ActionDispatch::IntegrationTest
setup do
@tenants = [ ActiveRecord::FixtureSet.identify("identity-tenant-1"),
ActiveRecord::FixtureSet.identify("identity-tenant-2") ]
@tenant_paths = @tenants.map { "/#{_1}" }
@tenant_urls = @tenant_paths.map { root_url(script_name: _1) }
@user_params = { email_address: "user@example.com", password: "password1234" }
@users = @tenants.map do |tenant|
ApplicationRecord.create_tenant(tenant) do
Account.create! name: "Account for #{tenant}"
User.create! @user_params.merge(name: "Harold Hancox")
end
end
end
test "multiple signins on the same browser" do
post session_path(script_name: @tenant_paths[0], params: @user_params)
assert_redirected_to root_path(script_name: @tenant_paths[0])
# Sign in as kevin to first account
kevin = users(:kevin)
sign_in_as(kevin)
post session_path(script_name: @tenant_paths[1], params: @user_params)
assert_redirected_to root_path(script_name: @tenant_paths[1])
# Then sign in as JZ
jz = users(:jz)
set_identity_as(jz)
# Render links for other Fizzies in the jump menu
get my_menu_path(script_name: @tenant_paths[0])
assert_select "#my_menu ul li a[href='#{@tenant_urls[1]}']", "Account for #{@tenants[1]}: user@example.com"
get my_menu_path(script_name: @tenant_paths[1])
assert_select "#my_menu ul li a[href='#{@tenant_urls[0]}']", "Account for #{@tenants[0]}: user@example.com"
# Render links for all the identity's Fizzies
get root_path(script_name: nil)
assert_select "ul li a[href='#{@tenant_urls[0]}']", "Account for #{@tenants[0]}: user@example.com"
assert_select "ul li a[href='#{@tenant_urls[1]}']", "Account for #{@tenants[1]}: user@example.com"
expected_membership_ids = [ memberships(:kevin_in_37signals), memberships(:jz_in_37signals) ].map(&:id)
assert_equal expected_membership_ids.sort, jz.reload.identity.memberships.pluck(:id).sort
end
end
+16
View File
@@ -0,0 +1,16 @@
require "test_helper"
class MagicLinkMailerTest < ActionMailer::TestCase
test "sign_in_instructions" do
magic_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
email = MagicLinkMailer.sign_in_instructions(magic_link)
assert_emails 1 do
email.deliver_now
end
assert_equal [ "kevin@37signals.com" ], email.to
assert_equal "Sign in to Fizzy", email.subject
assert_match magic_link.code, email.body.encoded
end
end
@@ -0,0 +1,8 @@
class MagicLinkPreview < ActionMailer::Preview
def magic_link
membership = Membership.new email_address: "test@example.com"
magic_link = MagicLink.new(membership: membership)
magic_link.valid?
MagicLinkMailer.magic_link(magic_link)
end
end
+17
View File
@@ -0,0 +1,17 @@
require "test_helper"
class MagicLink::CodeTest < ActiveSupport::TestCase
test "generate" do
code = MagicLink::Code.generate(6)
assert_equal 6, code.length
assert_match(/\A[#{MagicLink::Code::CODE_ALPHABET.join}]+\z/, code)
end
test "sanitize" do
assert_equal "011123", MagicLink::Code.sanitize("OIL123")
assert_equal "ABC123", MagicLink::Code.sanitize("ABC-123 !@#")
assert_nil MagicLink::Code.sanitize(nil)
assert_nil MagicLink::Code.sanitize("")
end
end
+58
View File
@@ -0,0 +1,58 @@
require "test_helper"
class MagicLinkTest < ActiveSupport::TestCase
test "new" do
magic_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
assert magic_link.code.present?
assert_equal MagicLink::CODE_LENGTH, magic_link.code.length
assert magic_link.expires_at.present?
assert_in_delta MagicLink::EXPIRATION_TIME.from_now, magic_link.expires_at, 1.second
end
test "active" do
active_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
expired_link.update_column(:expires_at, 1.hour.ago)
assert_includes MagicLink.active, active_link
assert_not_includes MagicLink.active, expired_link
end
test "stale" do
active_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
expired_link.update_column(:expires_at, 1.hour.ago)
assert_includes MagicLink.stale, expired_link
assert_not_includes MagicLink.stale, active_link
end
test "consume" do
magic_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
code_with_spaces = magic_link.code.downcase.chars.join(" ")
membership = MagicLink.consume(code_with_spaces)
assert_equal memberships(:kevin_in_37signals), membership
assert_not MagicLink.exists?(magic_link.id)
expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
expired_link.update_column(:expires_at, 1.hour.ago)
assert_nil MagicLink.consume(expired_link.code)
assert MagicLink.exists?(expired_link.id)
assert_nil MagicLink.consume("INVALID")
assert_nil MagicLink.consume(nil)
end
test "cleanup" do
active_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
expired_link.update_column(:expires_at, 1.hour.ago)
MagicLink.cleanup
assert MagicLink.exists?(active_link.id)
assert_not MagicLink.exists?(expired_link.id)
end
end
+27 -3
View File
@@ -1,7 +1,31 @@
require "test_helper"
class MembershipTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
test "send_magic_link" do
membership = memberships(:kevin_in_37signals)
assert_difference -> { membership.magic_links.count }, 1 do
membership.send_magic_link
end
assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob
end
test "user" do
membership = memberships(:kevin_in_37signals)
user = membership.user
assert_equal users(:kevin).id, user.id
assert_equal users(:kevin).email_address, user.email_address
end
test "account" do
membership = memberships(:kevin_in_37signals)
account = membership.account
assert_equal Account.sole.id, account.id
assert_equal Account.sole.name, account.name
end
end
+7 -17
View File
@@ -30,26 +30,16 @@ class User::IdentifiableTest < ActiveSupport::TestCase
end
test "set_identity when token is an identity and user has a different identity" do
user = users(:david)
user2 = users(:jz)
token_identity = Identity.create!
user_identity = Identity.create!
user_identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: "asdf")
user_identity.memberships.create!(user_id: user2.id, user_tenant: user2.tenant, email_address: user2.email_address, account_name: "qwer")
assert_equal(user_identity, user.identity)
assert_equal(user_identity, user2.identity)
kevin = users(:kevin)
jz = users(:jz)
result = user.set_identity(token_identity)
user.reload
user2.reload
assert_not_equal(kevin.identity, jz.identity, "Kevin and JZ should start with different identities")
assert_equal(token_identity, result)
assert_equal(token_identity, user.identity)
assert_equal(token_identity, user2.identity)
kevin.set_identity(jz.identity)
kevin.reload
jz.reload
assert_raises(ActiveRecord::RecordNotFound) do
user_identity.reload
end
assert_equal(kevin.identity, jz.identity, "Kevin and JZ should now share the same identity")
end
test "set_identity when token is nil and user has an identity" do
+48 -5
View File
@@ -7,12 +7,39 @@ module SessionTestHelper
cookies.delete :session_token
user = users(user) unless user.is_a? User
post session_path, params: { email_address: user.email_address, password: "secret123456" }
assert_response :redirect
set_identity_as user
cookie = cookies.get_cookie "session_token"
assert_not_nil cookie, "Expected session_token cookie to be set after sign in"
assert_equal Account.sole.slug, cookie.path, "Expected session_token cookie to be scoped to account slug"
user.reload
membership = user.membership
tenanted do
post session_login_menu_url, params: { membership_id: membership.id }
assert_response :redirect, "Login should succeed"
cookie = cookies.get_cookie "session_token"
assert_not_nil cookie, "Expected session_token cookie to be set after sign in"
assert_equal Account.sole.slug, cookie.path, "Expected session_token cookie to be scoped to account slug"
end
end
def set_identity_as(user_or_identity)
user = if user_or_identity.is_a?(User)
user_or_identity
else
users(user_or_identity)
end
membership = user.membership || user.set_identity(nil).memberships.find_by(user_id: user.id, user_tenant: user.tenant)
membership.send_magic_link
magic_link = membership.magic_links.order(id: :desc).first
untenanted do
post session_magic_link_url, params: { code: magic_link.code }
assert_response :redirect, "Magic link should succeed"
cookie = cookies.get_cookie "identity_token"
assert_not_nil cookie, "Expected identity_token cookie to be set after magic link consumption"
end
end
def sign_out
@@ -27,4 +54,20 @@ module SessionTestHelper
ensure
Current.clear_all
end
def untenanted(&block)
original_script_name = integration_session.default_url_options[:script_name]
integration_session.default_url_options[:script_name] = ""
yield
ensure
integration_session.default_url_options[:script_name] = original_script_name
end
def tenanted(tenant = ApplicationRecord.current_tenant, &block)
original_script_name = integration_session.default_url_options[:script_name]
integration_session.default_url_options[:script_name] = "/#{tenant}"
yield
ensure
integration_session.default_url_options[:script_name] = original_script_name
end
end