Add sign in flow using magic links
This commit is contained in:
@@ -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
|
||||
@@ -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,6 +1,6 @@
|
||||
module LoginHelper
|
||||
def login_url
|
||||
new_session_path
|
||||
new_session_path(script_name: nil)
|
||||
end
|
||||
|
||||
def logout_url
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module MagicLinkHelper
|
||||
def magic_link_code(magic_link)
|
||||
magic_link.code.scan(/.{3}/).join("-")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class MagicLink::CleanupJob < ApplicationJob
|
||||
def perform
|
||||
MagicLink.cleanup
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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™ 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™ version 0
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -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™ version 0
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Vendored
+2
@@ -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
|
||||
|
||||
Vendored
+2
@@ -1 +1,3 @@
|
||||
kevin_identity: {}
|
||||
|
||||
jz_identity: {}
|
||||
|
||||
Vendored
+12
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user