Add Join Codes
This commit is contained in:
@@ -29,6 +29,10 @@ module Authentication
|
||||
skip_before_action :require_authentication, **options
|
||||
before_action :redirect_tenanted_request, **options
|
||||
end
|
||||
|
||||
def require_identity(**options)
|
||||
before_action :require_identity, **options
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -38,8 +42,11 @@ module Authentication
|
||||
|
||||
def require_tenant
|
||||
unless ApplicationRecord.current_tenant.present?
|
||||
resume_identity
|
||||
redirect_to session_login_menu_url(script_name: nil)
|
||||
if resume_identity
|
||||
redirect_to session_login_menu_url(script_name: nil)
|
||||
else
|
||||
request_authentication
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
class Sessions::LoginMenusController < ApplicationController
|
||||
require_untenanted_access only: :show
|
||||
allow_unauthenticated_access only: :create
|
||||
skip_before_action :verify_authenticity_token, only: :create
|
||||
require_untenanted_access
|
||||
|
||||
def show
|
||||
@tenants = IdentityProvider.tenants_for(resume_identity)
|
||||
end
|
||||
|
||||
def create
|
||||
email_address = IdentityProvider.resolve_token(resume_identity)
|
||||
user = User.find_by(email_address: email_address)
|
||||
|
||||
if user
|
||||
start_new_session_for(user)
|
||||
redirect_to after_authentication_url
|
||||
else
|
||||
IdentityProvider.unlink(email_address: email_address, from: ApplicationRecord.current_tenant)
|
||||
redirect_to session_login_menu_path, alert: "You can't access this account"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class Sessions::StartsController < ApplicationController
|
||||
allow_unauthenticated_access
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
email_address = IdentityProvider.resolve_token(resume_identity)
|
||||
user = User.find_by(email_address: email_address)
|
||||
|
||||
if user
|
||||
start_new_session_for(user)
|
||||
redirect_to after_authentication_url
|
||||
else
|
||||
IdentityProvider.unlink(email_address: email_address, from: ApplicationRecord.current_tenant)
|
||||
redirect_to session_login_menu_path, alert: "You can't access this account"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class Users::EmailAddresses::ConfirmationsController < ApplicationController
|
||||
before_action :set_user
|
||||
rate_limit to: 3, within: 1.hour, only: :create
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
@user.change_email_address_using_token(token)
|
||||
redirect_to edit_user_path(@user)
|
||||
end
|
||||
|
||||
private
|
||||
def set_user
|
||||
@user = User.find(params[:user_id])
|
||||
end
|
||||
|
||||
def token
|
||||
params.expect :email_address_token
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class Users::EmailAddressesController < ApplicationController
|
||||
before_action :set_user
|
||||
rate_limit to: 3, within: 1.hour, only: :create
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
token = @user.generate_email_address_change_token(to: new_email_address, expires_in: 30.minutes)
|
||||
UserMailer.email_change_confirmation(user: @user, email_address: new_email_address, token: token).deliver_later
|
||||
end
|
||||
|
||||
private
|
||||
def set_user
|
||||
@user = User.find(params[:user_id])
|
||||
end
|
||||
|
||||
def new_email_address
|
||||
params.expect :email_address
|
||||
end
|
||||
end
|
||||
@@ -61,7 +61,7 @@ class UsersController < ApplicationController
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.expect(user: [ :name, :email_address, :avatar ])
|
||||
params.expect(user: [ :name, :avatar ])
|
||||
end
|
||||
|
||||
def invite_params
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module MagicLinkHelper
|
||||
def magic_link_code(magic_link)
|
||||
magic_link.code
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,4 @@
|
||||
class MagicLinkMailer < ApplicationMailer
|
||||
helper MagicLinkHelper
|
||||
|
||||
def sign_in_instructions(magic_link)
|
||||
@magic_link = magic_link
|
||||
@identity = @magic_link.identity
|
||||
@@ -0,0 +1,8 @@
|
||||
class UserMailer < ApplicationMailer
|
||||
def email_change_confirmation(user:, email_address:, token:)
|
||||
@user = user
|
||||
@token = token
|
||||
|
||||
mail to: email_address, subject: "Confirm your new email address"
|
||||
end
|
||||
end
|
||||
@@ -7,9 +7,10 @@ class Account < ApplicationRecord
|
||||
|
||||
class << self
|
||||
def create_with_admin_user(account:, owner:)
|
||||
User.system
|
||||
User.create!(**owner.reverse_merge(role: "admin", password: SecureRandom.hex(16)))
|
||||
create!(**account)
|
||||
create!(**account).tap do
|
||||
User.system
|
||||
User.create!(**owner.reverse_merge(role: "admin"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
module IdentityProvider
|
||||
Token = Data.define(:id, :updated_at) do
|
||||
delegate :dig, to: :to_h
|
||||
|
||||
def to_h
|
||||
{ "id" => id, "updated_at" => updated_at }
|
||||
end
|
||||
end
|
||||
|
||||
Tenant = Data.define(:id, :name)
|
||||
|
||||
extend self
|
||||
|
||||
def self.backend
|
||||
if Bootstrap.oss_config?
|
||||
raise NotImplementedError, "No identity provider configured in OSS version"
|
||||
else
|
||||
IdentityProvider::Saas
|
||||
end
|
||||
end
|
||||
mattr_accessor :backend, default: IdentityProvider::Simple
|
||||
|
||||
delegate :link, :unlink, :send_magic_link, :consume_magic_link, :tenants_for, :token_for, :resolve_token, :verify_token, to: :backend
|
||||
|
||||
def tenants_for(identity_token)
|
||||
backend.tenants_for(identity_token)
|
||||
end
|
||||
delegate :link, :unlink, :change_email_address, :send_magic_link, :consume_magic_link, :tenants_for, :token_for, :resolve_token, :verify_token, to: :backend
|
||||
end
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
module IdentityProvider::Simple
|
||||
extend self
|
||||
|
||||
def link(email_address:, to:)
|
||||
Identity.link(email_address: email_address, to: to)
|
||||
end
|
||||
|
||||
def unlink(email_address:, from:)
|
||||
Identity.unlink(email_address: email_address, from: from)
|
||||
end
|
||||
|
||||
def change_email_address(from:, to:, tenant:)
|
||||
Membership.change_email_address(from: from, to: to, tenant: tenant)
|
||||
end
|
||||
|
||||
def send_magic_link(email_address)
|
||||
magic_link = Identity.find_by(email_address: email_address)&.send_magic_link
|
||||
magic_link&.code
|
||||
end
|
||||
|
||||
def consume_magic_link(code)
|
||||
identity = MagicLink.consume(code)
|
||||
wrap_identity(identity)
|
||||
end
|
||||
|
||||
def token_for(email_address)
|
||||
identity = Identity.find_by(email_address: email_address)
|
||||
wrap_identity(identity)
|
||||
end
|
||||
|
||||
def resolve_token(token)
|
||||
Identity.find_signed(token&.dig("id"))&.email_address
|
||||
end
|
||||
|
||||
def verify_token(token)
|
||||
identity = Identity.find_signed(token&.dig("id"))
|
||||
wrap_identity(identity)
|
||||
end
|
||||
|
||||
def tenants_for(token)
|
||||
Identity.find_signed(token&.dig("id")).memberships.pluck(:tenant, :account_name).map do |id, name|
|
||||
IdentityProvider::Tenant.new(id: id, name: name)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def wrap_identity(identity)
|
||||
if identity
|
||||
IdentityProvider::Token.new(identity.signed_id, identity.updated_at)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -12,12 +12,4 @@ class Membership < UntenantedRecord
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def user
|
||||
User.with_tenant(tenant) { User.find_by(email_address: identity.email_address) }
|
||||
end
|
||||
|
||||
def account
|
||||
Account.with_tenant(tenant) { Account.sole }
|
||||
end
|
||||
end
|
||||
|
||||
+7
-4
@@ -1,13 +1,14 @@
|
||||
class User < ApplicationRecord
|
||||
include Accessor, Assignee, Attachable, Configurable, Identifiable,
|
||||
Invitable, Mentionable, Named, Notifiable, Role, Searcher, Staff, Transferable,
|
||||
Watcher
|
||||
include Accessor, Assignee, Attachable, Configurable, EmailAddressChangeable,
|
||||
Identifiable, Invitable, Mentionable, Named, Notifiable, Role, Searcher, Staff,
|
||||
Transferable, Watcher
|
||||
include Timelined # Depends on Accessor
|
||||
|
||||
self.ignored_columns = %i[ password_digest ]
|
||||
|
||||
has_one_attached :avatar
|
||||
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_secure_password validations: false
|
||||
|
||||
has_many :comments, inverse_of: :creator, dependent: :destroy
|
||||
|
||||
@@ -21,7 +22,9 @@ class User < ApplicationRecord
|
||||
def deactivate
|
||||
sessions.delete_all
|
||||
accesses.destroy_all
|
||||
old_email = email_address
|
||||
update! active: false, email_address: deactived_email_address
|
||||
unlink_identity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
module User::EmailAddressChangeable
|
||||
EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address"
|
||||
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def generate_email_address_change_token(from: email_address, to:, **options)
|
||||
options = options.reverse_merge(
|
||||
for: EMAIL_CHANGE_TOKEN_PURPOSE,
|
||||
old_email_address: from,
|
||||
new_email_address: to
|
||||
)
|
||||
|
||||
to_sgid(**options).to_s
|
||||
end
|
||||
|
||||
def change_email_address_using_token(token)
|
||||
parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE)
|
||||
|
||||
if parsed_token.nil?
|
||||
raise ArgumentError, "The token is invalid"
|
||||
elsif parsed_token.find != self
|
||||
raise ArgumentError, "The token is for another user"
|
||||
elsif email_address != parsed_token.params.fetch("old_email_address")
|
||||
raise ArgumentError, "The token was generated for a different email address"
|
||||
else
|
||||
update!(email_address: parsed_token.params.fetch("new_email_address"))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,29 +2,26 @@ module User::Identifiable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
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)) }
|
||||
after_create_commit :link_identity, unless: :system?
|
||||
after_update_commit :update_email_address_on_identity, if: -> { saved_change_to_email_address? && !system? }
|
||||
after_destroy_commit :unlink_identity, unless: :system?
|
||||
end
|
||||
|
||||
def set_identity(token_identity)
|
||||
if token_identity.present?
|
||||
if identity.nil?
|
||||
token_identity.memberships.create!(user_id: id, user_tenant: tenant, email_address: email_address, account_name: Account.sole.name)
|
||||
elsif identity != token_identity
|
||||
Identity.transaction do
|
||||
identity.memberships.update_all(identity_id: token_identity.id)
|
||||
identity.destroy
|
||||
end
|
||||
end
|
||||
token_identity
|
||||
elsif identity.present?
|
||||
identity
|
||||
else
|
||||
Identity.create!.tap do |identity|
|
||||
identity.memberships.create!(user_id: id, user_tenant: tenant, email_address: email_address, account_name: Account.sole.name)
|
||||
end
|
||||
def identity
|
||||
Identity.find_by(email_address: email_address)
|
||||
end
|
||||
|
||||
private
|
||||
def link_identity
|
||||
IdentityProvider.link(email_address: email_address, to: tenant)
|
||||
end
|
||||
|
||||
def unlink_identity
|
||||
IdentityProvider.unlink(email_address: email_address, from: tenant)
|
||||
end
|
||||
|
||||
def update_email_address_on_identity
|
||||
old_email, new_email = saved_change_to_email_address
|
||||
IdentityProvider.change_email_address(from: old_email, to: new_email, tenant: tenant)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,11 +4,10 @@ module User::Invitable
|
||||
class_methods do
|
||||
def invite(**attributes)
|
||||
create!(attributes).tap do |user|
|
||||
IdentityProvider.link(email_address: user.email_address, to: ApplicationRecord.current_tenant)
|
||||
IdentityProvider.send_magic_link(user.email_address)
|
||||
rescue
|
||||
rescue => e
|
||||
user.destroy!
|
||||
raise
|
||||
raise e
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<%= link_to "Sign in to Fizzy", session_magic_link_url(code: @magic_link.code), class: "btn" %>
|
||||
|
||||
<p class="margin-block-start-double">If you're signing in on another device, enter this special code:
|
||||
<br><strong class="txt-large"><%= magic_link_code(@magic_link) %></strong>
|
||||
<br><strong class="txt-large"><%= @magic_link.code %></strong>
|
||||
</p>
|
||||
|
||||
<p class="footer">Need help? <%= mail_to "support@37signals.com", "Email us"%>.
|
||||
|
||||
@@ -5,4 +5,4 @@ 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 this code:
|
||||
<%= magic_link_code(@magic_link) %>
|
||||
<%= @magic_link.code %>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<h1 class="title">Confirm your new email address</h1>
|
||||
<p class="subtitle">Hit the button below to confirm your email address change</p>
|
||||
|
||||
<%= link_to "Confirm email change", user_email_address_confirmation_url(@user, @token), class: "btn" %>
|
||||
|
||||
<p class="margin-block-start-double">If you didn't request this change, you can safely ignore this email. Your email address won't be changed unless you click the button above.</p>
|
||||
|
||||
<p class="footer">Need help? <%= mail_to "support@37signals.com", "Email us"%>.
|
||||
</p>
|
||||
@@ -0,0 +1,7 @@
|
||||
Confirm your new email address
|
||||
<%= "=" * 80 %>
|
||||
|
||||
Open this link in your browser to confirm your email address change:
|
||||
<%= user_email_address_confirmation_url(@user, @token) %>
|
||||
|
||||
If you didn't request this change, you can safely ignore this email. Your email address won't be changed unless you click the link above.
|
||||
@@ -1,5 +1,7 @@
|
||||
<%= collapsible_nav_section "Accounts" do %>
|
||||
<% Current.user.identity.memberships.each do |membership| %>
|
||||
<%= filter_place_menu_item root_url(script_name: "/#{membership.user_tenant}"), "#{membership.account_name}", "marker", new_window: true, current: membership.user_tenant == Current.user.tenant %>
|
||||
<% cache [ Current.user, Current.identity_token ] do %>
|
||||
<% Current.user.identity.memberships.where.not(tenant: Current.user.tenant).each do |membership| %>
|
||||
<%= filter_place_menu_item new_session_start_url(script_name: "/#{membership.tenant}"), "#{membership.account_name}", "marker", new_window: true %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
<%= filter_place_menu_item notifications_path, "Notifications", "bell" %>
|
||||
<%= filter_place_menu_item notifications_settings_path, "Notification Settings", "settings" %>
|
||||
|
||||
<% cache [ Current.user, Current.identity_token ] do %>
|
||||
<% IdentityProvider.tenants_for(Current.identity_token).each do |tenant| %>
|
||||
<%= filter_place_menu_item root_url(script_name: "/#{tenant.id}"), tenant.name, "logo-color", new_window: true %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %>
|
||||
<%= icon_tag "logout", class: "popup__icon" %>
|
||||
<%= button_to session_path, method: :delete, class: "popup__btn btn", data: { turbo: false } do %>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<% @tenants.each do |tenant| %>
|
||||
<li class="popup__item txt-medium">
|
||||
<%= icon_tag "logo-color", class: "popup__icon" %>
|
||||
<%= button_to session_login_menu_url(membership_id: 0, script_name: "/#{tenant.id}"), method: :post, class: "btn overflow-ellipsis fill-transparent popup__btn", form_class: "max-width flex-item-grow" do %>
|
||||
<%= link_to new_session_start_url(script_name: "/#{tenant.id}"), class: "btn overflow-ellipsis fill-transparent popup__btn" do %>
|
||||
<strong><%= tenant.name %></strong>
|
||||
<% end %>
|
||||
</li>
|
||||
@@ -33,12 +33,14 @@
|
||||
<p class="margin-none-block-start">You don’t have any Fizzy accounts.</p>
|
||||
<% end %>
|
||||
|
||||
<div class="margin-block-start">
|
||||
<%= link_to login_url, class: "btn center txt-small" do %>
|
||||
<%= icon_tag "add" %>
|
||||
<span>Create a Fizzy account</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if defined?(saas) %>
|
||||
<div class="margin-block-start">
|
||||
<%= link_to saas.new_signup_completion_path, class: "btn center txt-small" do %>
|
||||
<%= icon_tag "add" %>
|
||||
<span>Create a Fizzy account</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<% @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,28 @@
|
||||
<% @hide_footer_frames = true %>
|
||||
<% @page_title = "Signing into #{Account.sole.name}" %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<nav>
|
||||
<div class="fizzy-header-logo center flex-inline align-center txt-normal">
|
||||
<%= inline_svg "fizzy-logo" %>
|
||||
<strong class="txt-medium overflow-ellipsis margin-none">Fizzy</strong>
|
||||
</div>
|
||||
</nav>
|
||||
<% end %>
|
||||
|
||||
<div
|
||||
class="panel shadow center margin-block-double flex flex-column gap-half"
|
||||
style="--panel-size: 42ch; view-transition-name: sign-in-panel; --popup-icon-size: 24px; --popup-item-padding-inline: 0.5rem;"
|
||||
>
|
||||
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
|
||||
|
||||
<%= form_with url: session_start_path, method: :post, data: { controller: "auto-submit" } do |form| %>
|
||||
<%= form.button "Continue", type: "submit", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% content_for :footer do %>
|
||||
<div class="txt-align-center center margin-block-double txt-subtle">
|
||||
Fizzy™ Beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -29,15 +29,10 @@
|
||||
</div>
|
||||
<div class="flex align-center gap">
|
||||
<label class="flex align-center gap input input--actor">
|
||||
<%= form.email_field :email_address, class: "input full-width", autocomplete: "username", placeholder: "Email address", required: true %>
|
||||
<%= form.email_field :email_address, class: "input full-width", autocomplete: "username", placeholder: "Email address", required: true, readonly: true %>
|
||||
<%= icon_tag "email", class: "txt-large" %>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex align-center gap">
|
||||
<label class="flex align-center gap input input--actor">
|
||||
<%= form.password_field :password, class: "input full-width", autocomplete: "new-password", placeholder: "Password", required: false, maxlength: 72 %>
|
||||
<%= icon_tag "password", class: "txt-large" %>
|
||||
</label>
|
||||
<%= link_to "Change", new_user_email_address_path(@user), class: "btn btn--secondary" %>
|
||||
</div>
|
||||
<button type="submit" id="log_in" class="btn btn--reversed center">
|
||||
<%= icon_tag "check" %>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<% @page_title = "Confirm email change" %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<%= render "filters/menu" %>
|
||||
<% end %>
|
||||
|
||||
<div class="panel shadow center">
|
||||
<div class="flex flex-column gap txt-medium center">
|
||||
<%= icon_tag "email", class: "txt-xx-large" %>
|
||||
|
||||
<h1 class="txt-large">Confirming email change...</h1>
|
||||
|
||||
<%= form_with url: user_email_address_confirmation_path(@user, params[:email_address_token]), method: :post, data: { controller: "auto-submit" } do |form| %>
|
||||
<%= form.hidden_field :email_address_token, value: params[:email_address_token] %>
|
||||
|
||||
<button type="submit" class="btn btn--reversed center">
|
||||
<%= icon_tag "check" %>
|
||||
<span>Confirm email change</span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<% @page_title = "Check your email" %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<%= render "filters/menu" %>
|
||||
<% end %>
|
||||
|
||||
<div class="panel shadow center">
|
||||
<div class="flex flex-column gap txt-medium center">
|
||||
<%= icon_tag "email", class: "txt-xx-large" %>
|
||||
|
||||
<h1 class="txt-large">Check your email</h1>
|
||||
|
||||
<p>We've sent a confirmation link to <strong><%= params[:email_address] %></strong></p>
|
||||
|
||||
<p>Click the link in the email to confirm your new email address.</p>
|
||||
|
||||
<%= link_to "Back to profile", edit_user_path(@user), class: "btn btn--secondary center" %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<% @page_title = "Change email address" %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<%= render "filters/menu" %>
|
||||
<% end %>
|
||||
|
||||
<div class="panel shadow center">
|
||||
<div class="flex flex-column gap txt-medium">
|
||||
<h1 class="txt-large">Change email address</h1>
|
||||
|
||||
<%= form_with url: user_email_addresses_path(@user), method: :post, class: "flex flex-column gap", data: { turbo: false } do |form| %>
|
||||
<div class="flex align-center gap">
|
||||
<label class="flex align-center gap input input--actor">
|
||||
<%= form.email_field :email_address, class: "input full-width", autocomplete: "email", placeholder: "New email address", autofocus: true, required: true %>
|
||||
<%= icon_tag "email", class: "txt-large" %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn--reversed center">
|
||||
<%= icon_tag "check" %>
|
||||
<span>Continue</span>
|
||||
</button>
|
||||
|
||||
<%= link_to "Cancel", edit_user_path(@user), class: "btn btn--secondary center" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@ Rails.application.configure do
|
||||
config.action_mailer.delivery_method = :test
|
||||
|
||||
# Set host to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "%{tenant}.example.com" }
|
||||
config.action_mailer.default_url_options = { host: "example.com" }
|
||||
|
||||
# Print deprecation notices to the stderr.
|
||||
config.active_support.deprecation = :stderr
|
||||
|
||||
@@ -8,6 +8,9 @@ Rails.application.routes.draw do
|
||||
resources :users do
|
||||
resource :role, module: :users
|
||||
resources :push_subscriptions, module: :users
|
||||
resources :email_addresses, only: %i[ new create ], param: :token, module: :users do
|
||||
resource :confirmation, only: %i[ show create ], module: :email_addresses
|
||||
end
|
||||
end
|
||||
|
||||
resources :collections do
|
||||
@@ -131,6 +134,7 @@ Rails.application.routes.draw do
|
||||
resources :transfers, only: %i[ show update ]
|
||||
resource :magic_link, only: %i[ show create ]
|
||||
resource :login_menu, only: %i[ show create ]
|
||||
resource :start, only: %i[ new create ]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@ class CreateAccountJoinCodes < ActiveRecord::Migration[8.1]
|
||||
end
|
||||
|
||||
dir.down do
|
||||
execute <<-SQL
|
||||
UPDATE accounts
|
||||
SET join_code = (SELECT code FROM account_join_codes LIMIT 1);
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
remove_column :accounts, :join_code, :string
|
||||
end
|
||||
end
|
||||
|
||||
Generated
-1
@@ -27,7 +27,6 @@ ActiveRecord::Schema[8.2].define(version: 2025_10_29_161222) do
|
||||
create_table "accounts", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.integer "external_account_id"
|
||||
t.string "join_code"
|
||||
t.string "name", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
|
||||
|
||||
+4
-5
@@ -1,4 +1,5 @@
|
||||
raise "Seeding is just for development" unless Rails.env.development?
|
||||
IdentityProvider.backend = IdentityProvider::Simple
|
||||
|
||||
require "active_support/testing/time_helpers"
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
@@ -22,8 +23,7 @@ def create_tenant(signal_account_name)
|
||||
},
|
||||
owner: {
|
||||
name: "David Heinemeier Hansson",
|
||||
email_address: "david@37signals.com",
|
||||
password: "secret123456"
|
||||
email_address: "david@37signals.com"
|
||||
}
|
||||
)
|
||||
account.setup_basic_template
|
||||
@@ -31,7 +31,7 @@ def create_tenant(signal_account_name)
|
||||
|
||||
ApplicationRecord.current_tenant = tenant_id
|
||||
|
||||
identity = Identity.find_or_create_by!(email_address: User.first.email_address)
|
||||
identity = Identity.find_or_create_by!(email_address: User.find_by(role: :admin).email_address)
|
||||
identity.memberships.find_or_create_by!(tenant: tenant_id, account_name: Account.sole.name)
|
||||
end
|
||||
|
||||
@@ -41,8 +41,7 @@ def find_or_create_user(full_name, email_address)
|
||||
else
|
||||
user = User.create! \
|
||||
name: full_name,
|
||||
email_address: email_address,
|
||||
password: "secret123456"
|
||||
email_address: email_address
|
||||
|
||||
identity = Identity.find_or_create_by!(email_address: email_address)
|
||||
identity.memberships.find_or_create_by!(tenant: ApplicationRecord.current_tenant, account_name: Account.sole.name)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
module Restricted
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
http_basic_authenticate_with \
|
||||
name: Rails.env.test? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name,
|
||||
password: Rails.env.test? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password
|
||||
end
|
||||
end
|
||||
@@ -2,22 +2,22 @@ class IdentitiesController < ApplicationController
|
||||
include InternalApi
|
||||
|
||||
def link
|
||||
Identity.link(email_address: params[:email_address], to: params[:to])
|
||||
IdentityProvider::Simple.link(email_address: params[:email_address], to: params[:to])
|
||||
head :ok
|
||||
end
|
||||
|
||||
def unlink
|
||||
Identity.unlink(email_address: params[:email_address], from: params[:from])
|
||||
IdentityProvider::Simple.unlink(email_address: params[:email_address], from: params[:from])
|
||||
head :ok
|
||||
end
|
||||
|
||||
def change_email_address
|
||||
Membership.change_email_address(from: params[:from], to: params[:to], tenant: params[:tenant])
|
||||
IdentityProvider::Simple.change_email_address(from: params[:from], to: params[:to], tenant: params[:tenant])
|
||||
head :ok
|
||||
end
|
||||
|
||||
def send_magic_link
|
||||
magic_link = Identity.find_by(email_address: params[:email_address])&.send_magic_link
|
||||
render json: { code: magic_link&.code }
|
||||
code = IdentityProvider::Simple.send_magic_link(params[:email_address])
|
||||
render json: { code: code }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
class Signups::CompletionsController < ApplicationController
|
||||
include Restricted
|
||||
|
||||
require_untenanted_access
|
||||
before_action :require_identity
|
||||
|
||||
http_basic_authenticate_with \
|
||||
name: Rails.env.test? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name,
|
||||
password: Rails.env.test? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password
|
||||
|
||||
def new
|
||||
@signup = Signup.new
|
||||
end
|
||||
@@ -14,7 +12,7 @@ class Signups::CompletionsController < ApplicationController
|
||||
@signup = Signup.new(signup_params)
|
||||
|
||||
if @signup.complete
|
||||
redirect_to session_login_menu_path(go_to: @signup.tenant)
|
||||
redirect_to new_session_start_url(script_name: "/#{@signup.tenant}")
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class SignupsController < ApplicationController
|
||||
include Restricted
|
||||
|
||||
require_untenanted_access
|
||||
|
||||
rate_limit only: :create, name: "short-term", to: 5, within: 3.minutes,
|
||||
@@ -6,10 +8,6 @@ class SignupsController < ApplicationController
|
||||
rate_limit only: :create, name: "long-term", to: 10, within: 30.minutes,
|
||||
with: -> { redirect_to saas.new_signup_path, alert: "Try again later." }
|
||||
|
||||
http_basic_authenticate_with \
|
||||
name: Rails.env.test? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name,
|
||||
password: Rails.env.test? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password
|
||||
|
||||
def new
|
||||
@signup = Signup.new
|
||||
end
|
||||
|
||||
+7
-10
@@ -1,22 +1,19 @@
|
||||
module IdentityProvider::Saas
|
||||
# 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)
|
||||
class Error < StandardError; end
|
||||
|
||||
extend self
|
||||
extend Fizzy::Saas::Engine.routes.url_helpers
|
||||
include Fizzy::Saas::Engine.routes.url_helpers
|
||||
|
||||
def default_url_options
|
||||
Rails.application.config.action_mailer.default_url_options
|
||||
end
|
||||
|
||||
def url_options
|
||||
default_url_options
|
||||
default_url_options.merge(script_name: nil)
|
||||
end
|
||||
|
||||
def link(email_address:, to:)
|
||||
response = InternalApiClient.new(link_identity_url(script_name: nil)).post({ email_address: email_address, to: to })
|
||||
response = InternalApiClient.new(link_identity_url).post({ email_address: email_address, to: to })
|
||||
|
||||
unless response.success?
|
||||
raise Error, "Failed to link identity: #{response.error || response.code}"
|
||||
@@ -24,7 +21,7 @@ module IdentityProvider::Saas
|
||||
end
|
||||
|
||||
def unlink(email_address:, from:)
|
||||
response = InternalApiClient.new(unlink_identity_url(script_name: nil)).post({ email_address: email_address, from: from })
|
||||
response = InternalApiClient.new(unlink_identity_url).post({ email_address: email_address, from: from })
|
||||
|
||||
unless response.success?
|
||||
raise Error, "Failed to unlink identity: #{response.error || response.code}"
|
||||
@@ -32,7 +29,7 @@ module IdentityProvider::Saas
|
||||
end
|
||||
|
||||
def change_email_address(from:, to:, tenant:)
|
||||
response = InternalApiClient.new(change_identity_email_address_url(script_name: nil)).post({ from: from, to: to, tenant: tenant })
|
||||
response = InternalApiClient.new(change_identity_email_address_url).post({ from: from, to: to, tenant: tenant })
|
||||
|
||||
unless response.success?
|
||||
raise Error, "Failed to change email address: #{response.error || response.code}"
|
||||
@@ -40,7 +37,7 @@ module IdentityProvider::Saas
|
||||
end
|
||||
|
||||
def send_magic_link(email_address)
|
||||
response = InternalApiClient.new(send_magic_link_url(script_name: nil)).post({ email_address: email_address })
|
||||
response = InternalApiClient.new(send_magic_link_url).post({ email_address: email_address })
|
||||
|
||||
if response.success?
|
||||
response.parsed_body["code"]
|
||||
@@ -78,7 +75,7 @@ module IdentityProvider::Saas
|
||||
private
|
||||
def wrap_identity(identity)
|
||||
if identity
|
||||
Mock.new(identity.signed_id, identity.updated_at)
|
||||
IdentityProvider::Token.new(identity.signed_id, identity.updated_at)
|
||||
else
|
||||
nil
|
||||
end
|
||||
@@ -19,7 +19,6 @@ class Signup
|
||||
@company_name = nil
|
||||
@full_name = nil
|
||||
@email_address = nil
|
||||
@password = nil
|
||||
@tenant = nil
|
||||
@account = nil
|
||||
@user = nil
|
||||
@@ -48,9 +47,8 @@ class Signup
|
||||
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"))
|
||||
errors.add(:base, "Something went wrong, and we couldn't create your account. Please give it another try.")
|
||||
Rails.error.report(error, severity: :error)
|
||||
|
||||
false
|
||||
end
|
||||
@@ -89,8 +87,8 @@ class Signup
|
||||
ApplicationRecord.destroy_tenant(tenant)
|
||||
end
|
||||
|
||||
@account = nil
|
||||
@user = nil
|
||||
@account = nil
|
||||
@tenant = nil
|
||||
end
|
||||
|
||||
@@ -107,8 +105,8 @@ class Signup
|
||||
# attributes[:terms_of_service] = true
|
||||
|
||||
attributes[:product_name] = "fizzy"
|
||||
attributes[:name] = email_address
|
||||
attributes[:owner_name] = email_address
|
||||
attributes[:name] = company_name
|
||||
attributes[:owner_name] = full_name
|
||||
attributes[:owner_email] = email_address
|
||||
|
||||
attributes[:trial] = true
|
||||
|
||||
@@ -5,6 +5,8 @@ module Fizzy
|
||||
Queenbee.host_app = Fizzy
|
||||
|
||||
config.to_prepare do
|
||||
IdentityProvider.backend = IdentityProvider::Saas
|
||||
|
||||
Queenbee::Subscription.short_names = Subscription::SHORT_NAMES
|
||||
Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token)
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
require "test_helper"
|
||||
|
||||
class IdentitiesControllerTest < ActionDispatch::IntegrationTest
|
||||
include Fizzy::Saas::Engine.routes.url_helpers
|
||||
|
||||
setup do
|
||||
@token = InternalApiClient.token
|
||||
end
|
||||
|
||||
test "link" do
|
||||
new_email = "newuser@example.com"
|
||||
tenant = ApplicationRecord.current_tenant
|
||||
body = { email_address: new_email, to: tenant }.to_json
|
||||
|
||||
assert_difference -> { Identity.count }, 1 do
|
||||
assert_difference -> { Membership.count }, 1 do
|
||||
untenanted do
|
||||
post link_identity_url(script_name: nil), params: body, headers: authenticated_headers(body)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
assert_response :ok
|
||||
assert Identity.find_by(email_address: new_email).memberships.exists?(tenant: tenant)
|
||||
end
|
||||
|
||||
test "unlink" do
|
||||
identity = identities(:kevin)
|
||||
tenant = ApplicationRecord.current_tenant
|
||||
body = { email_address: identity.email_address, from: tenant }.to_json
|
||||
|
||||
assert_difference -> { Membership.count }, -1 do
|
||||
untenanted do
|
||||
post unlink_identity_url(script_name: nil), params: body, headers: authenticated_headers(body)
|
||||
end
|
||||
end
|
||||
|
||||
assert_response :ok
|
||||
end
|
||||
|
||||
test "change_email_address" do
|
||||
identity = identities(:kevin)
|
||||
tenant = ApplicationRecord.current_tenant
|
||||
new_email = "newemail@example.com"
|
||||
body = { from: identity.email_address, to: new_email, tenant: tenant }.to_json
|
||||
|
||||
untenanted do
|
||||
post change_identity_email_address_url(script_name: nil), params: body, headers: authenticated_headers(body)
|
||||
end
|
||||
|
||||
assert_response :ok
|
||||
assert Identity.find_by(email_address: new_email).memberships.exists?(tenant: tenant)
|
||||
end
|
||||
|
||||
test "send_magic_link" do
|
||||
identity = identities(:kevin)
|
||||
body = { email_address: identity.email_address }.to_json
|
||||
|
||||
assert_difference -> { MagicLink.count }, 1 do
|
||||
untenanted do
|
||||
post send_magic_link_url(script_name: nil), params: body, headers: authenticated_headers(body)
|
||||
end
|
||||
end
|
||||
|
||||
assert_response :ok
|
||||
json = JSON.parse(response.body)
|
||||
assert_equal MagicLink::CODE_LENGTH, json["code"].length
|
||||
|
||||
body = { email_address: "nonexistent@example.com" }.to_json
|
||||
untenanted do
|
||||
post send_magic_link_url(script_name: nil), params: body, headers: authenticated_headers(body)
|
||||
end
|
||||
|
||||
assert_response :ok
|
||||
json = JSON.parse(response.body)
|
||||
assert_nil json["code"]
|
||||
end
|
||||
|
||||
test "authentication" do
|
||||
body = { email_address: "test@example.com" }.to_json
|
||||
|
||||
untenanted do
|
||||
post send_magic_link_url(script_name: nil), params: body, headers: { "Content-Type" => "application/json" }
|
||||
end
|
||||
assert_response :unauthorized
|
||||
|
||||
untenanted do
|
||||
post send_magic_link_url(script_name: nil), params: body, headers: {
|
||||
"Content-Type" => "application/json",
|
||||
"Authorization" => "Bearer #{@token}",
|
||||
"X-Internal-Signature" => "invalid"
|
||||
}
|
||||
end
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
private
|
||||
def authenticated_headers(body)
|
||||
{
|
||||
"Content-Type" => "application/json",
|
||||
"Authorization" => "Bearer #{@token}",
|
||||
"X-Internal-Signature" => InternalApiClient.signature_for(body)
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -2,35 +2,52 @@ require "test_helper"
|
||||
|
||||
class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
set_identity_as :kevin
|
||||
@identity = Identity.create!(email_address: "newuser@example.com")
|
||||
magic_link = @identity.send_magic_link
|
||||
|
||||
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
|
||||
|
||||
test "new" do
|
||||
get saas.new_signup_completion_path
|
||||
untenanted do
|
||||
get saas.new_signup_completion_path, headers: http_basic_auth_headers
|
||||
|
||||
assert_response :success
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "create" do
|
||||
post saas.signup_completion_path, params: {
|
||||
signup: {
|
||||
full_name: "Kevin Systrom",
|
||||
company_name: "37signals"
|
||||
}
|
||||
}
|
||||
untenanted do
|
||||
assert_difference -> { Membership.count }, 1 do
|
||||
post saas.signup_completion_path, params: {
|
||||
signup: {
|
||||
full_name: "New User",
|
||||
company_name: "New Company"
|
||||
}
|
||||
}, headers: http_basic_auth_headers
|
||||
end
|
||||
|
||||
assert_redirected_to root_path, "Successful completion should redirect to root"
|
||||
assert cookies[:session_token].present?, "Successful completion should create a session"
|
||||
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_redirected_to session_login_menu_path(go_to: Membership.last.tenant), "Successful completion should redirect to login menu"
|
||||
|
||||
post saas.signup_completion_path, params: {
|
||||
signup: {
|
||||
full_name: "",
|
||||
company_name: ""
|
||||
}
|
||||
}
|
||||
post saas.signup_completion_path, params: {
|
||||
signup: {
|
||||
full_name: "",
|
||||
company_name: ""
|
||||
}
|
||||
}, headers: http_basic_auth_headers
|
||||
|
||||
assert_response :unprocessable_entity, "Invalid params should return unprocessable entity"
|
||||
assert_response :unprocessable_entity, "Invalid params should return unprocessable entity"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def http_basic_auth_headers
|
||||
{ "Authorization" => ActionController::HttpAuthentication::Basic.encode_credentials("testname", "testpassword") }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,7 +28,7 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "should create signup and redirect to magic link page" do
|
||||
assert_difference -> { ApplicationRecord.tenants.count }, 1 do
|
||||
assert_no_difference -> { ApplicationRecord.tenants.count } do
|
||||
post saas.signup_url, params: { signup: { email_address: @signup_params[:email_address] } }, headers: http_basic_auth_headers
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
require "test_helper"
|
||||
|
||||
class IdentityProvider::SaasTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/link})
|
||||
.to_return do |request|
|
||||
body = JSON.parse(request.body)
|
||||
IdentityProvider::Simple.link(email_address: body["email_address"], to: body["to"])
|
||||
{ status: 200, body: {}.to_json, headers: { "Content-Type" => "application/json" } }
|
||||
end
|
||||
|
||||
WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/unlink})
|
||||
.to_return do |request|
|
||||
body = JSON.parse(request.body)
|
||||
IdentityProvider::Simple.unlink(email_address: body["email_address"], from: body["from"])
|
||||
{ status: 200, body: {}.to_json, headers: { "Content-Type" => "application/json" } }
|
||||
end
|
||||
|
||||
WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/change_email_address})
|
||||
.to_return do |request|
|
||||
body = JSON.parse(request.body)
|
||||
IdentityProvider::Simple.change_email_address(from: body["from"], to: body["to"], tenant: body["tenant"])
|
||||
{ status: 200, body: {}.to_json, headers: { "Content-Type" => "application/json" } }
|
||||
end
|
||||
|
||||
WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/send_magic_link})
|
||||
.to_return do |request|
|
||||
body = JSON.parse(request.body)
|
||||
code = IdentityProvider::Simple.send_magic_link(body["email_address"])
|
||||
{ status: 200, body: { code: code }.to_json, headers: { "Content-Type" => "application/json" } }
|
||||
end
|
||||
end
|
||||
|
||||
test "link" do
|
||||
new_email = "newuser@example.com"
|
||||
tenant = ApplicationRecord.current_tenant
|
||||
|
||||
assert_difference -> { Identity.count }, 1 do
|
||||
assert_difference -> { Membership.count }, 1 do
|
||||
IdentityProvider::Saas.link(email_address: new_email, to: tenant)
|
||||
end
|
||||
end
|
||||
|
||||
identity = Identity.find_by(email_address: new_email)
|
||||
assert identity.memberships.exists?(tenant: tenant)
|
||||
end
|
||||
|
||||
test "unlink" do
|
||||
identity = identities(:kevin)
|
||||
tenant = ApplicationRecord.current_tenant
|
||||
|
||||
assert_difference -> { Membership.count }, -1 do
|
||||
IdentityProvider::Saas.unlink(email_address: identity.email_address, from: tenant)
|
||||
end
|
||||
end
|
||||
|
||||
test "change_email_address" do
|
||||
identity = identities(:kevin)
|
||||
tenant = ApplicationRecord.current_tenant
|
||||
new_email = "newemail@example.com"
|
||||
|
||||
IdentityProvider::Saas.change_email_address(from: identity.email_address, to: new_email, tenant: tenant)
|
||||
|
||||
new_identity = Identity.find_by(email_address: new_email)
|
||||
assert new_identity.memberships.exists?(tenant: tenant)
|
||||
end
|
||||
|
||||
test "send_magic_link" do
|
||||
identity = identities(:kevin)
|
||||
|
||||
assert_difference -> { MagicLink.count }, 1 do
|
||||
code = IdentityProvider::Saas.send_magic_link(identity.email_address)
|
||||
assert_equal MagicLink::CODE_LENGTH, code.length
|
||||
end
|
||||
|
||||
code = IdentityProvider::Saas.send_magic_link("nonexistent@example.com")
|
||||
assert_nil code
|
||||
end
|
||||
|
||||
test "consume_magic_link" do
|
||||
identity = identities(:kevin)
|
||||
magic_link = identity.send_magic_link
|
||||
|
||||
token = IdentityProvider::Saas.consume_magic_link(magic_link.code)
|
||||
assert_equal identity.signed_id, token.id
|
||||
assert_not MagicLink.exists?(magic_link.id)
|
||||
|
||||
token = IdentityProvider::Saas.consume_magic_link("invalid")
|
||||
assert_nil token
|
||||
end
|
||||
|
||||
test "token_for" do
|
||||
identity = identities(:kevin)
|
||||
|
||||
token = IdentityProvider::Saas.token_for(identity.email_address)
|
||||
assert_equal identity.signed_id, token.id
|
||||
|
||||
token = IdentityProvider::Saas.token_for("nonexistent@example.com")
|
||||
assert_nil token
|
||||
end
|
||||
|
||||
test "resolve_token" do
|
||||
identity = identities(:kevin)
|
||||
token = { "id" => identity.signed_id }
|
||||
|
||||
email = IdentityProvider::Saas.resolve_token(token)
|
||||
assert_equal identity.email_address, email
|
||||
|
||||
email = IdentityProvider::Saas.resolve_token({ "id" => "invalid" })
|
||||
assert_nil email
|
||||
end
|
||||
|
||||
test "verify_token" do
|
||||
identity = identities(:kevin)
|
||||
token = { "id" => identity.signed_id }
|
||||
|
||||
result = IdentityProvider::Saas.verify_token(token)
|
||||
assert_equal identity.signed_id, result.id
|
||||
|
||||
result = IdentityProvider::Saas.verify_token({ "id" => "invalid" })
|
||||
assert_nil result
|
||||
end
|
||||
|
||||
test "tenants_for" do
|
||||
identity = identities(:kevin)
|
||||
token = { "id" => identity.signed_id }
|
||||
|
||||
tenants = IdentityProvider::Saas.tenants_for(token)
|
||||
assert tenants.all? { |t| t.is_a?(IdentityProvider::Tenant) }
|
||||
assert_includes tenants.map(&:id), identity.memberships.first.tenant
|
||||
end
|
||||
end
|
||||
@@ -5,63 +5,57 @@ class SignupTest < ActiveSupport::TestCase
|
||||
@starting_tenants = ApplicationRecord.tenants
|
||||
end
|
||||
|
||||
test "#create_account" do
|
||||
Account.any_instance.expects(:setup_basic_template).once
|
||||
|
||||
test "#create_identity" do
|
||||
signup = Signup.new(email_address: "brian@example.com")
|
||||
|
||||
assert_difference -> { Membership.count }, 1 do
|
||||
assert_difference -> { Identity.count }, 1 do
|
||||
assert_difference -> { MagicLink.count }, 1 do
|
||||
assert signup.create_account, signup.errors.full_messages.to_sentence(words_connector: ". ")
|
||||
assert signup.create_identity
|
||||
end
|
||||
end
|
||||
|
||||
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.identity
|
||||
assert signup.identity.persisted?
|
||||
|
||||
signup_existing = Signup.new(email_address: "brian@example.com")
|
||||
|
||||
assert_no_difference -> { Membership.count } do
|
||||
assert_no_difference -> { Identity.count } do
|
||||
assert_difference -> { MagicLink.count }, 1 do
|
||||
assert signup_existing.create_account, "Should send magic link for existing membership"
|
||||
assert signup_existing.create_identity, "Should send magic link for existing identity"
|
||||
end
|
||||
end
|
||||
|
||||
signup_invalid = Signup.new(email_address: "")
|
||||
assert_not signup_invalid.create_account, "Should fail with invalid email"
|
||||
assert_not signup_invalid.create_identity, "Should fail with invalid email"
|
||||
assert_not_empty signup_invalid.errors[:email_address], "Should have validation error for email_address"
|
||||
|
||||
Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data")
|
||||
signup_error = Signup.new(email_address: "error@example.com")
|
||||
|
||||
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 "#complete" do
|
||||
Account.any_instance.expects(:setup_basic_template).once
|
||||
|
||||
signup = Signup.new(
|
||||
tenant: ApplicationRecord.current_tenant,
|
||||
user: users(:kevin),
|
||||
full_name: "Kevin Systrom",
|
||||
company_name: "37signals"
|
||||
full_name: "Kevin",
|
||||
company_name: "37signals",
|
||||
email_address: "kevin@example.com",
|
||||
identity: identities(:kevin)
|
||||
)
|
||||
|
||||
assert signup.complete, signup.errors.full_messages.to_sentence(words_connector: ". ")
|
||||
|
||||
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 signup.tenant
|
||||
assert signup.account
|
||||
assert signup.user
|
||||
assert_equal "Kevin", signup.user.name
|
||||
assert_equal "37signals", signup.account.name
|
||||
|
||||
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"
|
||||
signup_invalid = Signup.new(
|
||||
full_name: "",
|
||||
company_name: "37signals",
|
||||
email_address: "kevin@example.com",
|
||||
identity: identities(:kevin)
|
||||
)
|
||||
assert_not signup_invalid.complete, "Complete should fail with invalid params"
|
||||
assert_not_empty signup_invalid.errors[:full_name], "Should have validation error for full_name"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,7 +11,6 @@ if ARGV.size != 3
|
||||
end
|
||||
|
||||
company_name, owner_name, owner_email = ARGV
|
||||
password = SecureRandom.hex(16)
|
||||
|
||||
# Create a minimal Current context for the signup
|
||||
Current.set(
|
||||
@@ -66,8 +65,7 @@ Current.set(
|
||||
},
|
||||
owner: {
|
||||
name: owner_name,
|
||||
email_address: owner_email,
|
||||
password: password
|
||||
email_address: owner_email
|
||||
}
|
||||
)
|
||||
|
||||
@@ -99,7 +97,6 @@ Current.set(
|
||||
puts "Company: #{company_name}"
|
||||
puts "Owner: #{owner_name}"
|
||||
puts "Email: #{owner_email}"
|
||||
puts "Password: #{password}"
|
||||
puts "Join URL: #{Rails.application.routes.url_helpers.join_url(
|
||||
join_code: join_code,
|
||||
script_name: "/#{tenant_id}",
|
||||
|
||||
@@ -39,9 +39,11 @@ ApplicationRecord.with_tenant(tenant) do |tenant|
|
||||
|
||||
Account.create_with_admin_user \
|
||||
account: { name: "Company #{identifier}" },
|
||||
owner: { name: "Developer #{identifier}", email_address: "dev-#{identifier}@example.com", password: "secret123456" }
|
||||
owner: { name: "Developer #{identifier}", email_address: "dev-#{identifier}@example.com" }
|
||||
|
||||
user = User.last
|
||||
user = User.find_by(role: :admin)
|
||||
identity = Identity.find_or_create_by(email_address: user.email_address)
|
||||
identity.link_to(user.tenant)
|
||||
Collection.find_each do |collection|
|
||||
collection.accesses.grant_to(user)
|
||||
end
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
require_relative "../../config/environment"
|
||||
|
||||
ApplicationRecord.with_each_tenant do |tenant|
|
||||
puts "✅ #{tenant}"
|
||||
Account.sole.setup_complete!
|
||||
end
|
||||
@@ -2,6 +2,7 @@ require "test_helper"
|
||||
|
||||
class ControllerAuthenticationTest < ActionDispatch::IntegrationTest
|
||||
test "access without an account slug redirects to login menu" do
|
||||
identify_as :kevin
|
||||
integration_session.default_url_options[:script_name] = "" # no tenant
|
||||
|
||||
get cards_path
|
||||
|
||||
@@ -3,7 +3,7 @@ require "test_helper"
|
||||
class Sessions::LoginMenusControllerTest < ActionDispatch::IntegrationTest
|
||||
test "show" do
|
||||
untenanted do
|
||||
set_identity_as :kevin
|
||||
identify_as :kevin
|
||||
|
||||
get session_login_menu_url
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "create" do
|
||||
untenanted do
|
||||
membership = memberships(:kevin_in_37signals)
|
||||
magic_link = MagicLink.create!(membership: membership)
|
||||
identity = identities(:kevin)
|
||||
magic_link = MagicLink.create!(identity: identity)
|
||||
|
||||
post session_magic_link_url, params: { code: magic_link.code }
|
||||
|
||||
@@ -24,7 +24,7 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert_response :redirect, "Invalid code should redirect"
|
||||
|
||||
expired_link = MagicLink.create!(membership: membership)
|
||||
expired_link = MagicLink.create!(identity: identity)
|
||||
expired_link.update_column(:expires_at, 1.hour.ago)
|
||||
|
||||
post session_magic_link_url, params: { code: expired_link.code }
|
||||
|
||||
@@ -18,19 +18,17 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
test "create with existing membership" do
|
||||
test "create" do
|
||||
untenanted do
|
||||
membership = memberships(:kevin_in_37signals)
|
||||
identity = identities(:kevin)
|
||||
|
||||
assert_difference -> { MagicLink.count }, 1 do
|
||||
post session_path, params: { email_address: membership.email_address }
|
||||
post session_path, params: { email_address: identity.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" }
|
||||
|
||||
@@ -5,19 +5,18 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
|
||||
get new_user_path(params: { join_code: "bad" })
|
||||
assert_response :forbidden
|
||||
|
||||
get new_user_path(params: { join_code: accounts(:"37s").join_code })
|
||||
get new_user_path(params: { join_code: account_join_codes(:sole).code })
|
||||
assert_response :ok
|
||||
end
|
||||
|
||||
test "create" do
|
||||
assert_difference -> { User.active.count }, +1 do
|
||||
post users_path(params: { join_code: accounts(:"37s").join_code }),
|
||||
params: { user: { name: "Dash", email_address: "dash@example.com", password: "123" } }
|
||||
assert_redirected_to root_path
|
||||
assert_difference -> { User.count }, +1 do
|
||||
assert_difference -> { Identity.count }, +1 do
|
||||
post users_path(params: { join_code: account_join_codes(:sole).code }),
|
||||
params: { user: { name: "Dash", email_address: "dash@example.com" } }
|
||||
assert_redirected_to session_magic_link_path(script_name: nil)
|
||||
end
|
||||
end
|
||||
|
||||
follow_redirect!
|
||||
assert_response :ok
|
||||
end
|
||||
|
||||
test "show" do
|
||||
|
||||
Vendored
-2
@@ -1,7 +1,6 @@
|
||||
writebook_david:
|
||||
collection: writebook
|
||||
user: david
|
||||
involvement: watching
|
||||
|
||||
writebook_jz:
|
||||
collection: writebook
|
||||
@@ -10,7 +9,6 @@ writebook_jz:
|
||||
writebook_kevin:
|
||||
collection: writebook
|
||||
user: kevin
|
||||
involvement: watching
|
||||
|
||||
private_kevin:
|
||||
collection: private
|
||||
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
token: MyString
|
||||
usage_count: 1
|
||||
usage_limit: 1
|
||||
creator: one
|
||||
|
||||
two:
|
||||
token: MyString
|
||||
usage_count: 1
|
||||
usage_limit: 1
|
||||
creator: two
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
sole:
|
||||
code: 1234-5678-9XYZ
|
||||
usage_count: 0
|
||||
usage_limit: 10
|
||||
Vendored
-1
@@ -1,3 +1,2 @@
|
||||
37s:
|
||||
name: 37signals
|
||||
join_code: "ejpP-THlQ-Cc2f"
|
||||
|
||||
Vendored
+7
-2
@@ -1,3 +1,8 @@
|
||||
kevin_identity: {}
|
||||
kevin:
|
||||
email_address: kevin@37signals.com
|
||||
|
||||
jz_identity: {}
|
||||
jz:
|
||||
email_address: jz@37signals.com
|
||||
|
||||
david:
|
||||
email_address: david@37signals.com
|
||||
|
||||
Vendored
+9
-8
@@ -1,13 +1,14 @@
|
||||
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) %>
|
||||
identity: kevin
|
||||
tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %>
|
||||
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) %>
|
||||
identity: jz
|
||||
tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %>
|
||||
account_name: Fizzy
|
||||
|
||||
david_in_37signals:
|
||||
identity: david
|
||||
tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %>
|
||||
account_name: Fizzy
|
||||
|
||||
Vendored
-5
@@ -1,21 +1,16 @@
|
||||
<% digest = BCrypt::Password.create("secret123456") %>
|
||||
|
||||
david:
|
||||
name: David
|
||||
email_address: david@37signals.com
|
||||
password_digest: <%= digest %>
|
||||
role: member
|
||||
|
||||
jz:
|
||||
name: JZ
|
||||
email_address: jz@37signals.com
|
||||
password_digest: <%= digest %>
|
||||
role: member
|
||||
|
||||
kevin:
|
||||
name: Kevin
|
||||
email_address: kevin@37signals.com
|
||||
password_digest: <%= digest %>
|
||||
role: admin
|
||||
|
||||
system:
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class IdentityMembershipTest < ActionDispatch::IntegrationTest
|
||||
test "multiple signins on the same browser" do
|
||||
# Sign in as kevin to first account
|
||||
kevin = users(:kevin)
|
||||
sign_in_as(kevin)
|
||||
|
||||
# Then sign in as JZ
|
||||
jz = users(:jz)
|
||||
set_identity_as(jz)
|
||||
|
||||
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
|
||||
@@ -2,7 +2,7 @@ require "test_helper"
|
||||
|
||||
class MagicLinkMailerTest < ActionMailer::TestCase
|
||||
test "sign_in_instructions" do
|
||||
magic_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
|
||||
magic_link = MagicLink.create!(identity: identities(:kevin))
|
||||
email = MagicLinkMailer.sign_in_instructions(magic_link)
|
||||
|
||||
assert_emails 1 do
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
class MagicLinkPreview < ActionMailer::Preview
|
||||
def magic_link
|
||||
membership = Membership.new email_address: "test@example.com"
|
||||
magic_link = MagicLink.new(membership: membership)
|
||||
identity = Identity.new email_address: "test@example.com"
|
||||
magic_link = MagicLink.new(identity: identity)
|
||||
magic_link.valid?
|
||||
|
||||
MagicLinkMailer.sign_in_instructions(magic_link)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
require "test_helper"
|
||||
|
||||
class Account::JoinCodeTest < ActiveSupport::TestCase
|
||||
test "generate code" do
|
||||
join_code = Account::JoinCode.create!
|
||||
|
||||
assert join_code.code.present?
|
||||
|
||||
parts = join_code.code.split("-")
|
||||
assert_equal 3, parts.count
|
||||
end
|
||||
|
||||
test "redeem" do
|
||||
join_code = account_join_codes(:sole)
|
||||
|
||||
assert_difference -> { join_code.reload.usage_count }, 1 do
|
||||
Account::JoinCode.redeem(join_code.code)
|
||||
end
|
||||
end
|
||||
|
||||
test "reset" do
|
||||
join_code = account_join_codes(:sole)
|
||||
original_code = join_code.code
|
||||
|
||||
join_code.reset
|
||||
|
||||
assert_not_equal original_code, join_code.code
|
||||
assert_equal 0, join_code.usage_count
|
||||
end
|
||||
end
|
||||
@@ -1,7 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class Account::InvitationTokenTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
@@ -1,6 +1,12 @@
|
||||
require "test_helper"
|
||||
|
||||
class AccountTest < ActiveSupport::TestCase
|
||||
test "create" do
|
||||
assert_difference "Account::JoinCode.count", +1 do
|
||||
Account.create!(name: "ACME corp")
|
||||
end
|
||||
end
|
||||
|
||||
test "slug" do
|
||||
account = Account.sole
|
||||
assert_equal "/#{ApplicationRecord.current_tenant}", account.slug
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
require "test_helper"
|
||||
|
||||
class IdentityProvider::SimpleTest < ActiveSupport::TestCase
|
||||
test "link" do
|
||||
new_email = "newuser@example.com"
|
||||
tenant = ApplicationRecord.current_tenant
|
||||
|
||||
assert_difference -> { Identity.count }, 1 do
|
||||
assert_difference -> { Membership.count }, 1 do
|
||||
IdentityProvider::Simple.link(email_address: new_email, to: tenant)
|
||||
end
|
||||
end
|
||||
|
||||
identity = Identity.find_by(email_address: new_email)
|
||||
assert identity.memberships.exists?(tenant: tenant), "creates membership for tenant"
|
||||
end
|
||||
|
||||
test "unlink" do
|
||||
identity = identities(:kevin)
|
||||
tenant = ApplicationRecord.current_tenant
|
||||
|
||||
assert_difference -> { Membership.count }, -1 do
|
||||
IdentityProvider::Simple.unlink(email_address: identity.email_address, from: tenant)
|
||||
end
|
||||
|
||||
assert_not identity.reload.memberships.exists?(tenant: tenant), "removes membership from tenant"
|
||||
end
|
||||
|
||||
test "change_email_address" do
|
||||
identity = identities(:kevin)
|
||||
tenant = ApplicationRecord.current_tenant
|
||||
new_email = "newemail@example.com"
|
||||
|
||||
IdentityProvider::Simple.change_email_address(from: identity.email_address, to: new_email, tenant: tenant)
|
||||
|
||||
assert_not identity.reload.memberships.exists?(tenant: tenant), "removes old identity membership"
|
||||
new_identity = Identity.find_by(email_address: new_email)
|
||||
assert new_identity.memberships.exists?(tenant: tenant), "creates new identity membership"
|
||||
end
|
||||
|
||||
test "send_magic_link" do
|
||||
identity = identities(:kevin)
|
||||
|
||||
assert_difference -> { MagicLink.count }, 1 do
|
||||
code = IdentityProvider::Simple.send_magic_link(identity.email_address)
|
||||
assert_equal MagicLink::CODE_LENGTH, code.length, "returns code of correct length"
|
||||
end
|
||||
|
||||
code = IdentityProvider::Simple.send_magic_link("nonexistent@example.com")
|
||||
assert_nil code, "returns nil for non-existent email"
|
||||
end
|
||||
|
||||
test "consume_magic_link" do
|
||||
identity = identities(:kevin)
|
||||
magic_link = identity.send_magic_link
|
||||
|
||||
token = IdentityProvider::Simple.consume_magic_link(magic_link.code)
|
||||
assert_equal identity.signed_id, token.id, "returns token for valid code"
|
||||
assert_not MagicLink.exists?(magic_link.id), "deletes magic link after consumption"
|
||||
|
||||
token = IdentityProvider::Simple.consume_magic_link("invalid")
|
||||
assert_nil token, "returns nil for invalid code"
|
||||
end
|
||||
|
||||
test "token_for" do
|
||||
identity = identities(:kevin)
|
||||
|
||||
token = IdentityProvider::Simple.token_for(identity.email_address)
|
||||
assert_equal identity.signed_id, token.id, "returns token for existing email"
|
||||
|
||||
token = IdentityProvider::Simple.token_for("nonexistent@example.com")
|
||||
assert_nil token, "returns nil for non-existent email"
|
||||
end
|
||||
|
||||
test "resolve_token" do
|
||||
identity = identities(:kevin)
|
||||
token = { "id" => identity.signed_id }
|
||||
|
||||
email = IdentityProvider::Simple.resolve_token(token)
|
||||
assert_equal identity.email_address, email, "returns email address from valid token"
|
||||
|
||||
email = IdentityProvider::Simple.resolve_token({ "id" => "invalid" })
|
||||
assert_nil email, "returns nil for invalid token"
|
||||
end
|
||||
|
||||
test "verify_token" do
|
||||
identity = identities(:kevin)
|
||||
token = { "id" => identity.signed_id }
|
||||
|
||||
result = IdentityProvider::Simple.verify_token(token)
|
||||
assert_equal identity.signed_id, result.id, "returns token from valid token hash"
|
||||
|
||||
result = IdentityProvider::Simple.verify_token({ "id" => "invalid" })
|
||||
assert_nil result, "returns nil for invalid token"
|
||||
end
|
||||
|
||||
test "tenants_for" do
|
||||
identity = identities(:kevin)
|
||||
token = { "id" => identity.signed_id }
|
||||
|
||||
tenants = IdentityProvider::Simple.tenants_for(token)
|
||||
assert tenants.all? { |t| t.is_a?(IdentityProvider::Tenant) }, "returns Tenant objects"
|
||||
assert_includes tenants.map(&:id), identity.memberships.first.tenant, "includes identity's tenant"
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,13 @@
|
||||
require "test_helper"
|
||||
|
||||
class IdentityTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
test "send_magic_link" do
|
||||
identity = identities(:kevin)
|
||||
|
||||
assert_difference -> { identity.magic_links.count }, 1 do
|
||||
identity.send_magic_link
|
||||
end
|
||||
|
||||
assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ require "test_helper"
|
||||
|
||||
class MagicLinkTest < ActiveSupport::TestCase
|
||||
test "new" do
|
||||
magic_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
|
||||
magic_link = MagicLink.create!(identity: identities(:kevin))
|
||||
|
||||
assert magic_link.code.present?
|
||||
assert_equal MagicLink::CODE_LENGTH, magic_link.code.length
|
||||
@@ -11,8 +11,8 @@ class MagicLinkTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "active" do
|
||||
active_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
|
||||
expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
|
||||
active_link = MagicLink.create!(identity: identities(:kevin))
|
||||
expired_link = MagicLink.create!(identity: identities(:kevin))
|
||||
expired_link.update_column(:expires_at, 1.hour.ago)
|
||||
|
||||
assert_includes MagicLink.active, active_link
|
||||
@@ -20,8 +20,8 @@ class MagicLinkTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "stale" do
|
||||
active_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
|
||||
expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
|
||||
active_link = MagicLink.create!(identity: identities(:kevin))
|
||||
expired_link = MagicLink.create!(identity: identities(:kevin))
|
||||
expired_link.update_column(:expires_at, 1.hour.ago)
|
||||
|
||||
assert_includes MagicLink.stale, expired_link
|
||||
@@ -29,14 +29,14 @@ class MagicLinkTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "consume" do
|
||||
magic_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
|
||||
magic_link = MagicLink.create!(identity: identities(:kevin))
|
||||
code_with_spaces = magic_link.code.downcase.chars.join(" ")
|
||||
|
||||
membership = MagicLink.consume(code_with_spaces)
|
||||
assert_equal memberships(:kevin_in_37signals), membership
|
||||
identity = MagicLink.consume(code_with_spaces)
|
||||
assert_equal identities(:kevin), identity
|
||||
assert_not MagicLink.exists?(magic_link.id)
|
||||
|
||||
expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
|
||||
expired_link = MagicLink.create!(identity: identities(:kevin))
|
||||
expired_link.update_column(:expires_at, 1.hour.ago)
|
||||
assert_nil MagicLink.consume(expired_link.code)
|
||||
assert MagicLink.exists?(expired_link.id)
|
||||
@@ -46,8 +46,8 @@ class MagicLinkTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "cleanup" do
|
||||
active_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
|
||||
expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals))
|
||||
active_link = MagicLink.create!(identity: identities(:kevin))
|
||||
expired_link = MagicLink.create!(identity: identities(:kevin))
|
||||
expired_link.update_column(:expires_at, 1.hour.ago)
|
||||
|
||||
MagicLink.cleanup
|
||||
|
||||
@@ -1,31 +1,18 @@
|
||||
require "test_helper"
|
||||
|
||||
class MembershipTest < ActiveSupport::TestCase
|
||||
test "send_magic_link" do
|
||||
membership = memberships(:kevin_in_37signals)
|
||||
test "change_email_address" do
|
||||
tenant = ApplicationRecord.current_tenant
|
||||
old_identity = identities(:kevin)
|
||||
new_email = "kevin.new@37signals.com"
|
||||
|
||||
assert_difference -> { membership.magic_links.count }, 1 do
|
||||
membership.send_magic_link
|
||||
assert_difference -> { Identity.count }, 1 do
|
||||
Membership.change_email_address(from: old_identity.email_address, to: new_email, tenant: tenant)
|
||||
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
|
||||
new_identity = Identity.find_by(email_address: new_email)
|
||||
assert new_identity
|
||||
assert new_identity.memberships.exists?(tenant: tenant)
|
||||
assert_not old_identity.reload.memberships.exists?(tenant: tenant)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ require "test_helper"
|
||||
|
||||
class User::AccessorTest < ActiveSupport::TestCase
|
||||
test "new users get added to all_access collections on creation" do
|
||||
user = User.create!(name: "Jorge", email_address: "testregular@example.com", password: "secret123456")
|
||||
user = User.create!(name: "Jorge", email_address: "testregular@example.com")
|
||||
|
||||
assert_includes user.collections, collections(:writebook)
|
||||
assert_equal Collection.all_access.count, user.collections.count
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
require "test_helper"
|
||||
|
||||
class User::EmailAddressChangeableTest < ActiveSupport::TestCase
|
||||
test "generate_email_address_change_token" do
|
||||
user = users(:david)
|
||||
new_email_address = "new@example.com"
|
||||
|
||||
token = user.generate_email_address_change_token(to: new_email_address)
|
||||
|
||||
assert_kind_of String, token
|
||||
assert_not_equal new_email_address, user.reload.email_address
|
||||
end
|
||||
|
||||
test "change_email_address_using_token" do
|
||||
user = users(:david)
|
||||
old_email = user.email_address
|
||||
new_email = "david.new@37signals.com"
|
||||
|
||||
token = user.generate_email_address_change_token(from: old_email, to: new_email)
|
||||
|
||||
assert_equal old_email, user.reload.email_address
|
||||
|
||||
user.change_email_address_using_token(token)
|
||||
|
||||
assert_equal new_email, user.reload.email_address
|
||||
end
|
||||
end
|
||||
@@ -1,71 +1,38 @@
|
||||
require "test_helper"
|
||||
|
||||
class User::IdentifiableTest < ActiveSupport::TestCase
|
||||
test "set_identity when token is an identity and user has a matching identity" do
|
||||
user = users(:david)
|
||||
token_identity = Identity.create!
|
||||
token_identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: "asdf")
|
||||
assert_equal(token_identity, user.identity)
|
||||
test "create" do
|
||||
user = User.create!(
|
||||
role: "member",
|
||||
name: "New User",
|
||||
email_address: "newuser@example.com"
|
||||
)
|
||||
|
||||
result = user.set_identity(token_identity)
|
||||
user.reload
|
||||
|
||||
assert_equal(token_identity, result)
|
||||
assert_equal(token_identity, user.identity)
|
||||
assert user.identity.present?
|
||||
assert_equal "newuser@example.com", user.identity.email_address
|
||||
end
|
||||
|
||||
test "set_identity when token is an identity and user has no identity" do
|
||||
test "update email address" do
|
||||
user = users(:david)
|
||||
token_identity = Identity.create!
|
||||
assert_nil(user.membership)
|
||||
assert_nil(user.identity)
|
||||
old_email = user.email_address
|
||||
new_email = "david.updated@example.com"
|
||||
|
||||
result = user.set_identity(token_identity)
|
||||
user.reload
|
||||
assert_not Identity.find_by(email_address: new_email)
|
||||
|
||||
assert_equal(token_identity, result)
|
||||
assert_equal(token_identity, user.identity)
|
||||
assert_equal(user.email_address, user.membership.email_address)
|
||||
assert_equal(Account.sole.name, user.membership.account_name)
|
||||
user.update!(email_address: new_email)
|
||||
|
||||
new_identity = Identity.find_by(email_address: new_email)
|
||||
assert new_identity.present?
|
||||
assert new_identity.memberships.exists?(tenant: user.tenant)
|
||||
end
|
||||
|
||||
test "set_identity when token is an identity and user has a different identity" do
|
||||
kevin = users(:kevin)
|
||||
jz = users(:jz)
|
||||
test "destroy" do
|
||||
user = User.create!(name: "Bob")
|
||||
|
||||
assert_not_equal(kevin.identity, jz.identity, "Kevin and JZ should start with different identities")
|
||||
assert Identity.find_by(email_address: user.email_address)
|
||||
|
||||
kevin.set_identity(jz.identity)
|
||||
kevin.reload
|
||||
jz.reload
|
||||
user.destroy!
|
||||
|
||||
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
|
||||
user = users(:david)
|
||||
user_identity = Identity.create!
|
||||
user_identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: "asdf")
|
||||
assert_equal(user_identity, user.identity)
|
||||
|
||||
result = user.set_identity(nil)
|
||||
user.reload
|
||||
|
||||
assert_equal(user_identity, result)
|
||||
assert_equal(user_identity, user.identity)
|
||||
end
|
||||
|
||||
test "set_identity when token is nil and user has no identity" do
|
||||
user = users(:david)
|
||||
assert_nil(user.identity)
|
||||
|
||||
result = user.set_identity(nil)
|
||||
user.reload
|
||||
|
||||
assert_not_nil(result)
|
||||
assert_equal(result, user.identity)
|
||||
assert_equal(1, result.memberships.count)
|
||||
assert_equal(user.email_address, user.membership.email_address)
|
||||
assert_equal(Account.sole.name, user.membership.account_name)
|
||||
assert_not Identity.find_by(email_address: user.email_address).memberships.exists?(tenant: user.tenant)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,32 +5,36 @@ class UserTest < ActiveSupport::TestCase
|
||||
user = User.create! \
|
||||
role: "member",
|
||||
name: "Victor Cooper",
|
||||
email_address: "victor@hey.com",
|
||||
password: "secret123456"
|
||||
email_address: "victor@hey.com"
|
||||
|
||||
assert_equal user, User.authenticate_by(email_address: "victor@hey.com", password: "secret123456")
|
||||
assert_equal [ collections(:writebook) ], user.collections
|
||||
assert user.settings.present?
|
||||
end
|
||||
|
||||
test "creation gives access to all_access collections" do
|
||||
user = User.create! \
|
||||
role: "member",
|
||||
name: "Victor Cooper",
|
||||
email_address: "victor@hey.com",
|
||||
password: "secret123456"
|
||||
email_address: "victor@hey.com"
|
||||
|
||||
assert_equal [ collections(:writebook) ], user.collections
|
||||
end
|
||||
|
||||
test "deactivate" do
|
||||
users(:jz).sessions.create!
|
||||
tenant = ApplicationRecord.current_tenant
|
||||
|
||||
assert_changes -> { users(:jz).active? }, from: true, to: false do
|
||||
assert_changes -> { users(:jz).accesses.count }, from: 1, to: 0 do
|
||||
assert_changes -> { users(:jz).sessions.count }, from: 1, to: 0 do
|
||||
users(:jz).deactivate
|
||||
assert_difference -> { Membership.count }, -1 do
|
||||
users(:jz).deactivate
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
assert_not identities(:jz).reload.memberships.exists?(tenant: tenant)
|
||||
end
|
||||
|
||||
test "initials" do
|
||||
|
||||
@@ -65,3 +65,5 @@ end
|
||||
unless Rails.application.config.x.oss_config
|
||||
load File.expand_path("../gems/fizzy-saas/test/test_helper.rb", __dir__)
|
||||
end
|
||||
|
||||
IdentityProvider.backend = IdentityProvider::Simple
|
||||
|
||||
@@ -7,12 +7,10 @@ module SessionTestHelper
|
||||
cookies.delete :session_token
|
||||
user = users(user) unless user.is_a? User
|
||||
|
||||
set_identity_as user
|
||||
identify_as user
|
||||
|
||||
user.reload
|
||||
membership = user.membership
|
||||
tenanted do
|
||||
post session_login_menu_url, params: { membership_id: membership.id }
|
||||
post session_start_url
|
||||
assert_response :redirect, "Login should succeed"
|
||||
|
||||
cookie = cookies.get_cookie "session_token"
|
||||
@@ -21,17 +19,17 @@ module SessionTestHelper
|
||||
end
|
||||
end
|
||||
|
||||
def set_identity_as(user_or_identity)
|
||||
def identify_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
|
||||
identity = Identity.find_by(email_address: user.email_address)
|
||||
identity.send_magic_link
|
||||
|
||||
magic_link = membership.magic_links.order(id: :desc).first
|
||||
magic_link = identity.magic_links.order(id: :desc).first
|
||||
|
||||
untenanted do
|
||||
post session_magic_link_url, params: { code: magic_link.code }
|
||||
|
||||
Reference in New Issue
Block a user