diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
index b0b2ceeb4..5ffd86551 100644
--- a/app/controllers/concerns/authentication.rb
+++ b/app/controllers/concerns/authentication.rb
@@ -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
diff --git a/app/controllers/sessions/login_menus_controller.rb b/app/controllers/sessions/login_menus_controller.rb
index 8db72726b..03c5a4083 100644
--- a/app/controllers/sessions/login_menus_controller.rb
+++ b/app/controllers/sessions/login_menus_controller.rb
@@ -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
diff --git a/app/controllers/sessions/starts_controller.rb b/app/controllers/sessions/starts_controller.rb
new file mode 100644
index 000000000..8661634f3
--- /dev/null
+++ b/app/controllers/sessions/starts_controller.rb
@@ -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
diff --git a/app/controllers/users/email_addresses/confirmations_controller.rb b/app/controllers/users/email_addresses/confirmations_controller.rb
new file mode 100644
index 000000000..594379f75
--- /dev/null
+++ b/app/controllers/users/email_addresses/confirmations_controller.rb
@@ -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
diff --git a/app/controllers/users/email_addresses_controller.rb b/app/controllers/users/email_addresses_controller.rb
new file mode 100644
index 000000000..0595e3470
--- /dev/null
+++ b/app/controllers/users/email_addresses_controller.rb
@@ -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
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 400a4ba94..71b6e2462 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -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
diff --git a/app/helpers/magic_link_helper.rb b/app/helpers/magic_link_helper.rb
deleted file mode 100644
index e17a78fc1..000000000
--- a/app/helpers/magic_link_helper.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-module MagicLinkHelper
- def magic_link_code(magic_link)
- magic_link.code
- end
-end
diff --git a/gems/fizzy-saas/app/mailers/magic_link_mailer.rb b/app/mailers/magic_link_mailer.rb
similarity index 92%
rename from gems/fizzy-saas/app/mailers/magic_link_mailer.rb
rename to app/mailers/magic_link_mailer.rb
index 9b04d2ff5..391e64f6d 100644
--- a/gems/fizzy-saas/app/mailers/magic_link_mailer.rb
+++ b/app/mailers/magic_link_mailer.rb
@@ -1,6 +1,4 @@
class MagicLinkMailer < ApplicationMailer
- helper MagicLinkHelper
-
def sign_in_instructions(magic_link)
@magic_link = magic_link
@identity = @magic_link.identity
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
new file mode 100644
index 000000000..09926c09b
--- /dev/null
+++ b/app/mailers/user_mailer.rb
@@ -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
diff --git a/app/models/account.rb b/app/models/account.rb
index f6fbd98b2..38f67d0da 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -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
diff --git a/app/models/identity_provider.rb b/app/models/identity_provider.rb
index d42325f48..c76872487 100644
--- a/app/models/identity_provider.rb
+++ b/app/models/identity_provider.rb
@@ -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
diff --git a/app/models/identity_provider/simple.rb b/app/models/identity_provider/simple.rb
new file mode 100644
index 000000000..122a0b431
--- /dev/null
+++ b/app/models/identity_provider/simple.rb
@@ -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
diff --git a/app/models/membership.rb b/app/models/membership.rb
index ae7f94c44..e47a5c0c5 100644
--- a/app/models/membership.rb
+++ b/app/models/membership.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
index e569ac2ef..cc4c3965c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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
diff --git a/app/models/user/email_address_changeable.rb b/app/models/user/email_address_changeable.rb
new file mode 100644
index 000000000..da2137b39
--- /dev/null
+++ b/app/models/user/email_address_changeable.rb
@@ -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
diff --git a/app/models/user/identifiable.rb b/app/models/user/identifiable.rb
index c90247b74..58cac51ea 100644
--- a/app/models/user/identifiable.rb
+++ b/app/models/user/identifiable.rb
@@ -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
diff --git a/app/models/user/invitable.rb b/app/models/user/invitable.rb
index aaa52af10..0aeff74a8 100644
--- a/app/models/user/invitable.rb
+++ b/app/models/user/invitable.rb
@@ -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
diff --git a/app/models/user/role.rb b/app/models/user/role.rb
index 5c89fdd25..aaa4bd5ff 100644
--- a/app/models/user/role.rb
+++ b/app/models/user/role.rb
@@ -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
diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb
index f7e78e8dc..b8ae68967 100644
--- a/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb
+++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb
@@ -4,7 +4,7 @@
<%= link_to "Sign in to Fizzy", session_magic_link_url(code: @magic_link.code), class: "btn" %>
If you're signing in on another device, enter this special code:
- <%= magic_link_code(@magic_link) %>
+ <%= @magic_link.code %>
Confirm your new email address
+Hit the button below to confirm your email address change
+
+<%= link_to "Confirm email change", user_email_address_confirmation_url(@user, @token), class: "btn" %>
+
+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.
+
+
diff --git a/app/views/mailers/user_mailer/email_change_confirmation.text.erb b/app/views/mailers/user_mailer/email_change_confirmation.text.erb
new file mode 100644
index 000000000..90d6b11d5
--- /dev/null
+++ b/app/views/mailers/user_mailer/email_change_confirmation.text.erb
@@ -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.
diff --git a/app/views/my/menus/_accounts.html.erb b/app/views/my/menus/_accounts.html.erb
index a7532cea8..45817ddae 100644
--- a/app/views/my/menus/_accounts.html.erb
+++ b/app/views/my/menus/_accounts.html.erb
@@ -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 %>
diff --git a/app/views/my/menus/_places.html.erb b/app/views/my/menus/_places.html.erb
index 06b9fe02a..8b7be5b73 100644
--- a/app/views/my/menus/_places.html.erb
+++ b/app/views/my/menus/_places.html.erb
@@ -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 %>
diff --git a/app/views/sessions/login_menus/show.html.erb b/app/views/sessions/login_menus/show.html.erb
index 7486ff2f5..7085f5c6c 100644
--- a/app/views/sessions/login_menus/show.html.erb
+++ b/app/views/sessions/login_menus/show.html.erb
@@ -22,7 +22,7 @@
<% @tenants.each do |tenant| %>
@@ -33,12 +33,14 @@
You don’t have any Fizzy accounts.
<% end %>
-
- <%= link_to login_url, class: "btn center txt-small" do %>
- <%= icon_tag "add" %>
- Create a Fizzy account
- <% end %>
-
+ <% if defined?(saas) %>
+
+ <%= link_to saas.new_signup_completion_path, class: "btn center txt-small" do %>
+ <%= icon_tag "add" %>
+ Create a Fizzy account
+ <% end %>
+
+ <% end %>
<% end %>
diff --git a/app/views/sessions/magic_links/create.html.erb b/app/views/sessions/magic_links/create.html.erb
deleted file mode 100644
index c73b393eb..000000000
--- a/app/views/sessions/magic_links/create.html.erb
+++ /dev/null
@@ -1,22 +0,0 @@
-<% @page_title = "Log in" %>
-
-
-
Log in
-
-
Pick where you want to log in.
-
-
- <% @memberships.each do |membership| %>
- <%= link_to membership.email_address, session_transfer_path(membership.user.transfer_id) %>
- <% end %>
-
-
-
-<% content_for :footer do %>
-
- Fizzy™ version 0
-
-<% end %>
diff --git a/app/views/sessions/starts/new.html.erb b/app/views/sessions/starts/new.html.erb
new file mode 100644
index 000000000..614e00957
--- /dev/null
+++ b/app/views/sessions/starts/new.html.erb
@@ -0,0 +1,28 @@
+<% @hide_footer_frames = true %>
+<% @page_title = "Signing into #{Account.sole.name}" %>
+
+<% content_for :header do %>
+
+
+
+<% end %>
+
+
+
<%= @page_title %>
+
+ <%= form_with url: session_start_path, method: :post, data: { controller: "auto-submit" } do |form| %>
+ <%= form.button "Continue", type: "submit", class: "btn btn-primary" %>
+ <% end %>
+
+
+<% content_for :footer do %>
+
+ Fizzy™ Beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %>
+
+<% end %>
diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb
index caf6abd82..c616e2c21 100644
--- a/app/views/users/edit.html.erb
+++ b/app/views/users/edit.html.erb
@@ -29,15 +29,10 @@
- <%= 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" %>
-
-
-
- <%= form.password_field :password, class: "input full-width", autocomplete: "new-password", placeholder: "Password", required: false, maxlength: 72 %>
- <%= icon_tag "password", class: "txt-large" %>
-
+ <%= link_to "Change", new_user_email_address_path(@user), class: "btn btn--secondary" %>
<%= icon_tag "check" %>
diff --git a/app/views/users/email_addresses/confirmations/show.html.erb b/app/views/users/email_addresses/confirmations/show.html.erb
new file mode 100644
index 000000000..83a03b485
--- /dev/null
+++ b/app/views/users/email_addresses/confirmations/show.html.erb
@@ -0,0 +1,22 @@
+<% @page_title = "Confirm email change" %>
+
+<% content_for :header do %>
+ <%= render "filters/menu" %>
+<% end %>
+
+
+
+ <%= icon_tag "email", class: "txt-xx-large" %>
+
+
Confirming email change...
+
+ <%= 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] %>
+
+
+ <%= icon_tag "check" %>
+ Confirm email change
+
+ <% end %>
+
+
diff --git a/app/views/users/email_addresses/create.html.erb b/app/views/users/email_addresses/create.html.erb
new file mode 100644
index 000000000..55b615e48
--- /dev/null
+++ b/app/views/users/email_addresses/create.html.erb
@@ -0,0 +1,19 @@
+<% @page_title = "Check your email" %>
+
+<% content_for :header do %>
+ <%= render "filters/menu" %>
+<% end %>
+
+
+
+ <%= icon_tag "email", class: "txt-xx-large" %>
+
+
Check your email
+
+
We've sent a confirmation link to <%= params[:email_address] %>
+
+
Click the link in the email to confirm your new email address.
+
+ <%= link_to "Back to profile", edit_user_path(@user), class: "btn btn--secondary center" %>
+
+
diff --git a/app/views/users/email_addresses/new.html.erb b/app/views/users/email_addresses/new.html.erb
new file mode 100644
index 000000000..a7b0c4f89
--- /dev/null
+++ b/app/views/users/email_addresses/new.html.erb
@@ -0,0 +1,27 @@
+<% @page_title = "Change email address" %>
+
+<% content_for :header do %>
+ <%= render "filters/menu" %>
+<% end %>
+
+
+
+
Change email address
+
+ <%= form_with url: user_email_addresses_path(@user), method: :post, class: "flex flex-column gap", data: { turbo: false } do |form| %>
+
+
+ <%= 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" %>
+
+
+
+
+ <%= icon_tag "check" %>
+ Continue
+
+
+ <%= link_to "Cancel", edit_user_path(@user), class: "btn btn--secondary center" %>
+ <% end %>
+
+
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 45dc01625..4be27d242 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index d8469db1d..8739b0778 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/db/migrate/20251015081645_create_account_join_codes.rb b/db/migrate/20251015081645_create_account_join_codes.rb
index 79b137475..e603d5805 100644
--- a/db/migrate/20251015081645_create_account_join_codes.rb
+++ b/db/migrate/20251015081645_create_account_join_codes.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index ede583448..86470ce07 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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
diff --git a/db/seeds.rb b/db/seeds.rb
index 3a7dbf00e..6311e6969 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -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)
diff --git a/gems/fizzy-saas/app/controllers/concerns/restricted.rb b/gems/fizzy-saas/app/controllers/concerns/restricted.rb
new file mode 100644
index 000000000..668d21f35
--- /dev/null
+++ b/gems/fizzy-saas/app/controllers/concerns/restricted.rb
@@ -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
diff --git a/gems/fizzy-saas/app/controllers/identities_controller.rb b/gems/fizzy-saas/app/controllers/identities_controller.rb
index 0592bed79..2520ccbce 100644
--- a/gems/fizzy-saas/app/controllers/identities_controller.rb
+++ b/gems/fizzy-saas/app/controllers/identities_controller.rb
@@ -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
diff --git a/gems/fizzy-saas/app/controllers/signups/completions_controller.rb b/gems/fizzy-saas/app/controllers/signups/completions_controller.rb
index ecdf5b38d..c7bf66a3e 100644
--- a/gems/fizzy-saas/app/controllers/signups/completions_controller.rb
+++ b/gems/fizzy-saas/app/controllers/signups/completions_controller.rb
@@ -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
diff --git a/gems/fizzy-saas/app/controllers/signups_controller.rb b/gems/fizzy-saas/app/controllers/signups_controller.rb
index ce82f23bb..f837345fb 100644
--- a/gems/fizzy-saas/app/controllers/signups_controller.rb
+++ b/gems/fizzy-saas/app/controllers/signups_controller.rb
@@ -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
diff --git a/app/models/identity_provider/saas.rb b/gems/fizzy-saas/app/models/identity_provider/saas.rb
similarity index 68%
rename from app/models/identity_provider/saas.rb
rename to gems/fizzy-saas/app/models/identity_provider/saas.rb
index 9e956bca9..d3b74fb4c 100644
--- a/app/models/identity_provider/saas.rb
+++ b/gems/fizzy-saas/app/models/identity_provider/saas.rb
@@ -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
diff --git a/gems/fizzy-saas/app/models/signup.rb b/gems/fizzy-saas/app/models/signup.rb
index b52da43a0..4bdfe87a7 100644
--- a/gems/fizzy-saas/app/models/signup.rb
+++ b/gems/fizzy-saas/app/models/signup.rb
@@ -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
diff --git a/gems/fizzy-saas/lib/fizzy/saas/engine.rb b/gems/fizzy-saas/lib/fizzy/saas/engine.rb
index db24c8557..db71a4b0b 100644
--- a/gems/fizzy-saas/lib/fizzy/saas/engine.rb
+++ b/gems/fizzy-saas/lib/fizzy/saas/engine.rb
@@ -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)
diff --git a/gems/fizzy-saas/test/controllers/identities_controller_test.rb b/gems/fizzy-saas/test/controllers/identities_controller_test.rb
new file mode 100644
index 000000000..c6811e352
--- /dev/null
+++ b/gems/fizzy-saas/test/controllers/identities_controller_test.rb
@@ -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
diff --git a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb
index 19c542ff0..f750e389c 100644
--- a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb
+++ b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb
@@ -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
diff --git a/gems/fizzy-saas/test/controllers/signups_controller_test.rb b/gems/fizzy-saas/test/controllers/signups_controller_test.rb
index d23ad07e4..7c44c54d0 100644
--- a/gems/fizzy-saas/test/controllers/signups_controller_test.rb
+++ b/gems/fizzy-saas/test/controllers/signups_controller_test.rb
@@ -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
diff --git a/gems/fizzy-saas/test/models/identity_provider/saas_test.rb b/gems/fizzy-saas/test/models/identity_provider/saas_test.rb
new file mode 100644
index 000000000..d4cd6f22f
--- /dev/null
+++ b/gems/fizzy-saas/test/models/identity_provider/saas_test.rb
@@ -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
diff --git a/gems/fizzy-saas/test/models/signup_test.rb b/gems/fizzy-saas/test/models/signup_test.rb
index e4552be8a..9e2631a65 100644
--- a/gems/fizzy-saas/test/models/signup_test.rb
+++ b/gems/fizzy-saas/test/models/signup_test.rb
@@ -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
diff --git a/script/create-account.rb b/script/create-account.rb
index cb7c74416..f0cf2b16a 100755
--- a/script/create-account.rb
+++ b/script/create-account.rb
@@ -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}",
diff --git a/script/load-prod-db-in-dev.rb b/script/load-prod-db-in-dev.rb
index 7166db354..167b60c46 100755
--- a/script/load-prod-db-in-dev.rb
+++ b/script/load-prod-db-in-dev.rb
@@ -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
diff --git a/script/migrations/20251013-mark-all-accounts-as-setup.rb b/script/migrations/20251013-mark-all-accounts-as-setup.rb
deleted file mode 100755
index 792fcb288..000000000
--- a/script/migrations/20251013-mark-all-accounts-as-setup.rb
+++ /dev/null
@@ -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
diff --git a/test/controllers/controller_authentication_test.rb b/test/controllers/controller_authentication_test.rb
index 509ce26c1..6b7337067 100644
--- a/test/controllers/controller_authentication_test.rb
+++ b/test/controllers/controller_authentication_test.rb
@@ -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
diff --git a/test/controllers/sessions/login_menus_controller_test.rb b/test/controllers/sessions/login_menus_controller_test.rb
index 8527b1fdd..4a4da92d2 100644
--- a/test/controllers/sessions/login_menus_controller_test.rb
+++ b/test/controllers/sessions/login_menus_controller_test.rb
@@ -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
diff --git a/test/controllers/sessions/magic_links_controller_test.rb b/test/controllers/sessions/magic_links_controller_test.rb
index 837107309..210e59d86 100644
--- a/test/controllers/sessions/magic_links_controller_test.rb
+++ b/test/controllers/sessions/magic_links_controller_test.rb
@@ -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 }
diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb
index b6a9b7b83..ea4936f25 100644
--- a/test/controllers/sessions_controller_test.rb
+++ b/test/controllers/sessions_controller_test.rb
@@ -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" }
diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb
index 33a1fc096..4e69cef50 100644
--- a/test/controllers/users_controller_test.rb
+++ b/test/controllers/users_controller_test.rb
@@ -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
diff --git a/test/fixtures/accesses.yml b/test/fixtures/accesses.yml
index ad1a82b47..75e6fdf02 100644
--- a/test/fixtures/accesses.yml
+++ b/test/fixtures/accesses.yml
@@ -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
diff --git a/test/fixtures/account/invitation_tokens.yml b/test/fixtures/account/invitation_tokens.yml
deleted file mode 100644
index bdd7db922..000000000
--- a/test/fixtures/account/invitation_tokens.yml
+++ /dev/null
@@ -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
diff --git a/test/fixtures/account/join_codes.yml b/test/fixtures/account/join_codes.yml
new file mode 100644
index 000000000..d346d7e65
--- /dev/null
+++ b/test/fixtures/account/join_codes.yml
@@ -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
diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml
index 587f05a44..ced04edf6 100644
--- a/test/fixtures/accounts.yml
+++ b/test/fixtures/accounts.yml
@@ -1,3 +1,2 @@
37s:
name: 37signals
- join_code: "ejpP-THlQ-Cc2f"
diff --git a/test/fixtures/identities.yml b/test/fixtures/identities.yml
index 0da6dd9ae..f424411ec 100644
--- a/test/fixtures/identities.yml
+++ b/test/fixtures/identities.yml
@@ -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
diff --git a/test/fixtures/memberships.yml b/test/fixtures/memberships.yml
index fd9924c21..6ad0d2483 100644
--- a/test/fixtures/memberships.yml
+++ b/test/fixtures/memberships.yml
@@ -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
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
index d12c1a4b3..ace9359ed 100644
--- a/test/fixtures/users.yml
+++ b/test/fixtures/users.yml
@@ -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:
diff --git a/test/integration/identity_membership_test.rb b/test/integration/identity_membership_test.rb
deleted file mode 100644
index 11a28fba5..000000000
--- a/test/integration/identity_membership_test.rb
+++ /dev/null
@@ -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
diff --git a/test/mailers/magic_link_mailer_test.rb b/test/mailers/magic_link_mailer_test.rb
index 2c6785ec4..ee3d56abc 100644
--- a/test/mailers/magic_link_mailer_test.rb
+++ b/test/mailers/magic_link_mailer_test.rb
@@ -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
diff --git a/test/mailers/previews/magic_link_preview.rb b/test/mailers/previews/magic_link_preview.rb
index 0f7b99c2a..88e087e5e 100644
--- a/test/mailers/previews/magic_link_preview.rb
+++ b/test/mailers/previews/magic_link_preview.rb
@@ -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
diff --git a/test/models/account/join_code_test.rb b/test/models/account/join_code_test.rb
new file mode 100644
index 000000000..a5a1540ba
--- /dev/null
+++ b/test/models/account/join_code_test.rb
@@ -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
diff --git a/test/models/account/join_codes_test.rb b/test/models/account/join_codes_test.rb
deleted file mode 100644
index 7afcab749..000000000
--- a/test/models/account/join_codes_test.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-require "test_helper"
-
-class Account::InvitationTokenTest < ActiveSupport::TestCase
- # test "the truth" do
- # assert true
- # end
-end
diff --git a/test/models/account_test.rb b/test/models/account_test.rb
index a1ac811d1..99711439d 100644
--- a/test/models/account_test.rb
+++ b/test/models/account_test.rb
@@ -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
diff --git a/test/models/identity_provider/simple_test.rb b/test/models/identity_provider/simple_test.rb
new file mode 100644
index 000000000..4a246a207
--- /dev/null
+++ b/test/models/identity_provider/simple_test.rb
@@ -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
diff --git a/test/models/identity_test.rb b/test/models/identity_test.rb
index da7146fbd..f98c27461 100644
--- a/test/models/identity_test.rb
+++ b/test/models/identity_test.rb
@@ -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
diff --git a/test/models/magic_link_test.rb b/test/models/magic_link_test.rb
index 0e921096e..4dec89737 100644
--- a/test/models/magic_link_test.rb
+++ b/test/models/magic_link_test.rb
@@ -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
diff --git a/test/models/membership_test.rb b/test/models/membership_test.rb
index f544b0c25..d455b0e70 100644
--- a/test/models/membership_test.rb
+++ b/test/models/membership_test.rb
@@ -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
diff --git a/test/models/user/accessor_test.rb b/test/models/user/accessor_test.rb
index 96a743ce0..5ca1f834f 100644
--- a/test/models/user/accessor_test.rb
+++ b/test/models/user/accessor_test.rb
@@ -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
diff --git a/test/models/user/email_address_changeable_test.rb b/test/models/user/email_address_changeable_test.rb
new file mode 100644
index 000000000..3b372c336
--- /dev/null
+++ b/test/models/user/email_address_changeable_test.rb
@@ -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
diff --git a/test/models/user/identifiable_test.rb b/test/models/user/identifiable_test.rb
index 4ff50783d..b842632fd 100644
--- a/test/models/user/identifiable_test.rb
+++ b/test/models/user/identifiable_test.rb
@@ -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
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index 3104da483..5ad4f4d3e 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -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
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 94949d567..63e6262fa 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -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
diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb
index 7e7e16feb..9c9315bf0 100644
--- a/test/test_helpers/session_test_helper.rb
+++ b/test/test_helpers/session_test_helper.rb
@@ -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 }