From 1277cc065bf3c55190e0cb964cb8ce4acecea03a Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 9 Oct 2025 17:23:31 -0400 Subject: [PATCH 01/37] Introduce untenanted Identity and Membership models The new integration test shows the desired user-facing behavior, which is to make it easy to login without a tenanted URL and to jump between tenants. Note that we track two things in the identity_token cookie: a signed id, and the updated_at for the underlying Identity object. This allows us to effectively cache on the Identity without having to hit the database, by using an Identity::Mock object that is compatible with etag and cache methods. --- app/controllers/concerns/authentication.rb | 5 ++--- app/views/my/menus/_places.html.erb | 6 ++++++ app/views/sessions/login_menu.html.erb | 5 +++-- test/integration/identity_membership_test.rb | 8 ++++---- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 97eeeee9f..f98808b6e 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -86,13 +86,12 @@ module Authentication def link_identity(user) token_value = cookies.signed[:identity_token] - token_identity = Identity.find_signed(token_value["id"]) if token_value.present? - identity = user.set_identity(token_identity) + identity = Identity.find_signed(token_value["id"]) if token_value.present? + identity = user.set_identity(identity) cookies.signed.permanent[:identity_token] = { value: { "id" => identity.signed_id, "updated_at" => identity.updated_at }, httponly: true, same_site: :lax } end def set_current_identity_token - link_identity(Current.user) if cookies.signed[:identity_token].nil? && Current.user.present? Current.identity_token = Identity::Mock.new(**cookies.signed[:identity_token]) end diff --git a/app/views/my/menus/_places.html.erb b/app/views/my/menus/_places.html.erb index 8b7be5b73..428504dba 100644 --- a/app/views/my/menus/_places.html.erb +++ b/app/views/my/menus/_places.html.erb @@ -5,6 +5,12 @@ <%= 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 %> + <% Current.user.identity.memberships.where.not(user_tenant: Current.user.tenant).each do |membership| %> + <%= filter_place_menu_item root_url(script_name: "/#{membership.user_tenant}"), "#{membership.account_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_menu.html.erb b/app/views/sessions/login_menu.html.erb index 56f1e1373..88d6e0be6 100644 --- a/app/views/sessions/login_menu.html.erb +++ b/app/views/sessions/login_menu.html.erb @@ -3,6 +3,7 @@

BOXCAR

+<% cache [ Current.user, Current.identity_token ] do %> <% identity = Identity.find_signed(Current.identity_token.id) %> <% memberships = identity&.memberships %> <% if memberships.present? %> @@ -10,11 +11,11 @@ <% else %>

You don't have any existing BOXCAR accounts.

<% end %> -
+<% end %> diff --git a/test/integration/identity_membership_test.rb b/test/integration/identity_membership_test.rb index 21b8bd7dd..03d8a33f4 100644 --- a/test/integration/identity_membership_test.rb +++ b/test/integration/identity_membership_test.rb @@ -24,14 +24,14 @@ class IdentityMembershipTest < ActionDispatch::IntegrationTest # Render links for other Fizzies in the jump menu get my_menu_path(script_name: @tenant_paths[0]) - assert_select "#my_menu ul li a[href='#{@tenant_urls[1]}']", "Account for #{@tenants[1]}" + assert_select "#my_menu ul li a[href='#{@tenant_urls[1]}']", "Account for #{@tenants[1]}: user@example.com" get my_menu_path(script_name: @tenant_paths[1]) - assert_select "#my_menu ul li a[href='#{@tenant_urls[0]}']", "Account for #{@tenants[0]}" + assert_select "#my_menu ul li a[href='#{@tenant_urls[0]}']", "Account for #{@tenants[0]}: user@example.com" # Render links for all the identity's Fizzies get root_path(script_name: nil) - assert_select "ul li a[href='#{@tenant_urls[0]}']", "Account for #{@tenants[0]}" - assert_select "ul li a[href='#{@tenant_urls[1]}']", "Account for #{@tenants[1]}" + assert_select "ul li a[href='#{@tenant_urls[0]}']", "Account for #{@tenants[0]}: user@example.com" + assert_select "ul li a[href='#{@tenant_urls[1]}']", "Account for #{@tenants[1]}: user@example.com" end end From d0215e9fd2eecad325d91310c6ae5348dd7abf66 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 29 Sep 2025 14:44:54 -0400 Subject: [PATCH 02/37] Introduce untenanted Identity and Membership models and a script to populate these tables for existing 37id users --- app/models/membership.rb | 1 + test/fixtures/identities.yml | 1 + test/fixtures/memberships.yml | 1 + 3 files changed, 3 insertions(+) create mode 100644 test/fixtures/identities.yml create mode 100644 test/fixtures/memberships.yml diff --git a/app/models/membership.rb b/app/models/membership.rb index a8ac585ab..2289443f1 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -1,5 +1,6 @@ class Membership < UntenantedRecord belongs_to :identity, touch: true + has_many :magic_links, dependent: :delete # I want this to be `belongs_to :user`, but ActiveRecord::Tenanted doesn't yet support # associations from untenanted to untenanted models. diff --git a/test/fixtures/identities.yml b/test/fixtures/identities.yml new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/test/fixtures/identities.yml @@ -0,0 +1 @@ + diff --git a/test/fixtures/memberships.yml b/test/fixtures/memberships.yml new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/test/fixtures/memberships.yml @@ -0,0 +1 @@ + From 5cef4ffeb0c97a8eff1718f968ab8157221039e6 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Tue, 7 Oct 2025 19:33:51 +0200 Subject: [PATCH 03/37] Add sign in flow using magic links --- app/controllers/concerns/authentication.rb | 53 ++++++-- .../sessions/login_menus_controller.rb | 25 ++++ .../sessions/magic_links_controller.rb | 34 ++++++ app/controllers/sessions_controller.rb | 16 ++- app/helpers/login_helper.rb | 2 +- app/helpers/magic_link_helper.rb | 5 + app/jobs/magic_link/cleanup_job.rb | 5 + app/mailers/magic_link_mailer.rb | 15 +++ app/models/account.rb | 2 + app/models/identity.rb | 6 +- app/models/magic_link.rb | 41 +++++++ app/models/magic_link/code.rb | 31 +++++ app/models/membership.rb | 11 +- app/models/user/identifiable.rb | 2 + app/models/user/role.rb | 1 + .../sign_in_instructions.html.erb | 13 ++ .../sign_in_instructions.text.erb | 8 ++ app/views/sessions/login_menus/show.html.erb | 27 ++++ .../sessions/magic_links/create.html.erb | 22 ++++ app/views/sessions/magic_links/show.html.erb | 29 +++++ app/views/sessions/new.html.erb | 13 +- config/recurring.yml | 3 + config/routes.rb | 2 + ...1011080030_add_setup_status_to_accounts.rb | 5 + db/seeds.rb | 13 +- ...51007112917_create_identity_magic_links.rb | 11 ++ .../signups/completions_controller.rb | 46 +++++++ .../app/controllers/signups_controller.rb | 6 +- gems/fizzy-saas/app/models/signup.rb | 68 ++++++++--- .../views/signups/completions/new.html.erb | 42 +++++++ .../fizzy-saas/app/views/signups/new.html.erb | 42 ++----- gems/fizzy-saas/config/routes.rb | 8 +- .../signups/completions_controller_test.rb | 40 ++++++ .../controllers/signups_controller_test.rb | 45 +------ gems/fizzy-saas/test/models/signup_test.rb | 115 +++++++----------- .../20251013-mark-all-accounts-as-setup.rb | 8 ++ .../controller_authentication_test.rb | 7 +- .../sessions/login_menus_controller_test.rb | 21 ++++ .../sessions/magic_links_controller_test.rb | 36 ++++++ test/controllers/sessions_controller_test.rb | 38 +++--- test/fixtures/accesses.yml | 2 + test/fixtures/identities.yml | 2 + test/fixtures/memberships.yml | 12 ++ test/integration/identity_membership_test.rb | 37 ++---- test/mailers/magic_link_mailer_test.rb | 16 +++ test/mailers/previews/magic_link_preview.rb | 8 ++ test/models/magic_link/code_test.rb | 17 +++ test/models/magic_link_test.rb | 58 +++++++++ test/models/membership_test.rb | 30 ++++- test/models/user/identifiable_test.rb | 24 ++-- test/test_helpers/session_test_helper.rb | 53 +++++++- 51 files changed, 908 insertions(+), 268 deletions(-) create mode 100644 app/controllers/sessions/login_menus_controller.rb create mode 100644 app/controllers/sessions/magic_links_controller.rb create mode 100644 app/helpers/magic_link_helper.rb create mode 100644 app/jobs/magic_link/cleanup_job.rb create mode 100644 app/mailers/magic_link_mailer.rb create mode 100644 app/models/magic_link.rb create mode 100644 app/models/magic_link/code.rb create mode 100644 app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb create mode 100644 app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb create mode 100644 app/views/sessions/login_menus/show.html.erb create mode 100644 app/views/sessions/magic_links/create.html.erb create mode 100644 app/views/sessions/magic_links/show.html.erb create mode 100644 db/migrate/20251011080030_add_setup_status_to_accounts.rb create mode 100644 db/untenanted_migrate/20251007112917_create_identity_magic_links.rb create mode 100644 gems/fizzy-saas/app/controllers/signups/completions_controller.rb create mode 100644 gems/fizzy-saas/app/views/signups/completions/new.html.erb create mode 100644 gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb create mode 100755 script/migrations/20251013-mark-all-accounts-as-setup.rb create mode 100644 test/controllers/sessions/login_menus_controller_test.rb create mode 100644 test/controllers/sessions/magic_links_controller_test.rb create mode 100644 test/mailers/magic_link_mailer_test.rb create mode 100644 test/mailers/previews/magic_link_preview.rb create mode 100644 test/models/magic_link/code_test.rb create mode 100644 test/models/magic_link_test.rb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index f98808b6e..f9a462b7f 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -38,13 +38,31 @@ module Authentication def require_tenant unless ApplicationRecord.current_tenant.present? - set_current_identity_token - render "sessions/login_menu" + resume_identity + redirect_to session_login_menu_url(script_name: nil) end end + def require_identity + resume_identity || request_authentication + end + def require_authentication - resume_session || request_authentication + if resume_identity + resume_session || request_session_for_identity + else + request_authentication + end + end + + def request_session_for_identity + redirect_to session_login_menu_url(script_name: nil) + end + + def resume_identity + if identity = find_identity_by_cookie + set_current_identity(identity) + end end def resume_session @@ -53,11 +71,15 @@ module Authentication end end + def find_identity_by_cookie + Identity.find_signed(cookies.signed[:identity_token]&.dig("id")) + end + def find_session_by_cookie Session.find_signed(cookies.signed[:session_token]) end - def request_authentication(untenanted: false) + def request_authentication if ApplicationRecord.current_tenant.present? session[:return_to_after_authenticating] = request.url end @@ -84,26 +106,37 @@ module Authentication end end - def link_identity(user) + def link_identity(user_or_membership) token_value = cookies.signed[:identity_token] identity = Identity.find_signed(token_value["id"]) if token_value.present? - identity = user.set_identity(identity) - cookies.signed.permanent[:identity_token] = { value: { "id" => identity.signed_id, "updated_at" => identity.updated_at }, httponly: true, same_site: :lax } + + if user_or_membership.is_a?(User) + identity = user_or_membership.set_identity(identity) + elsif user_or_membership.is_a?(Membership) && identity + user_or_membership.update!(identity: identity) + end + + set_current_identity(identity) end - def set_current_identity_token - Current.identity_token = Identity::Mock.new(**cookies.signed[:identity_token]) + def set_current_identity(identity) + Current.identity_token = if identity + cookies.signed.permanent[:identity_token] = { value: { "id" => identity.signed_id, "updated_at" => identity.updated_at }, httponly: true, same_site: :lax } + Identity::Mock.new(**cookies.signed[:identity_token]) + else + nil + end end def set_current_session(session) logger.struct " Authorized User##{session.user.id}", authentication: { user: { id: session.user.id } } Current.session = session - set_current_identity_token cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax, path: Account.sole.slug } end def terminate_session Current.session.destroy cookies.delete(:session_token) + cookies.delete(:identity_token) end end diff --git a/app/controllers/sessions/login_menus_controller.rb b/app/controllers/sessions/login_menus_controller.rb new file mode 100644 index 000000000..f65ab3c5c --- /dev/null +++ b/app/controllers/sessions/login_menus_controller.rb @@ -0,0 +1,25 @@ +class Sessions::LoginMenusController < ApplicationController + require_untenanted_access only: :show + allow_unauthenticated_access only: :create + before_action :require_identity + before_action :set_identity + + def show + @memberships = @identity.memberships + end + + def create + membership = @identity.memberships.find(membership_id) + start_new_session_for(membership.user) + redirect_to after_authentication_url + end + + private + def set_identity + @identity = Current.identity_token.identity + end + + def membership_id + params.expect :membership_id + end +end diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb new file mode 100644 index 000000000..2039f527c --- /dev/null +++ b/app/controllers/sessions/magic_links_controller.rb @@ -0,0 +1,34 @@ +class Sessions::MagicLinksController < ApplicationController + require_untenanted_access + rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Try again in 15 minutes." } + + def show + end + + def create + membership = MagicLink.consume(code) + + if membership.blank? + redirect_to session_magic_link_path, alert: "Try another code." + else + resume_identity + + if Current.identity_token + link_identity(membership) + else + set_current_identity(membership.identity) + end + + if membership.account.setup_pending? + redirect_to saas.new_signup_completion_url(script_name: "/#{membership.user_tenant}") + else + redirect_to session_login_menu_path + end + end + end + + private + def code + params.expect(:code) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 16d368c59..5db073e97 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,21 +1,25 @@ class SessionsController < ApplicationController - require_unauthenticated_access only: %i[ new create ] + require_untenanted_access only: %i[ new create ] rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } def new end def create - if user = User.active.authenticate_by(params.permit(:email_address, :password)) - start_new_session_for user - redirect_to after_authentication_url - else - redirect_to new_session_path, alert: "Try another email address or password." + if membership = Membership.find_by(email_address: email_address) + membership.send_magic_link end + + redirect_to session_magic_link_path end def destroy terminate_session redirect_to_logout_url end + + private + def email_address + params.expect(:email_address) + end end diff --git a/app/helpers/login_helper.rb b/app/helpers/login_helper.rb index 0eed1781b..56627d968 100644 --- a/app/helpers/login_helper.rb +++ b/app/helpers/login_helper.rb @@ -1,6 +1,6 @@ module LoginHelper def login_url - new_session_path + new_session_path(script_name: nil) end def logout_url diff --git a/app/helpers/magic_link_helper.rb b/app/helpers/magic_link_helper.rb new file mode 100644 index 000000000..d34d71115 --- /dev/null +++ b/app/helpers/magic_link_helper.rb @@ -0,0 +1,5 @@ +module MagicLinkHelper + def magic_link_code(magic_link) + magic_link.code.scan(/.{3}/).join("-") + end +end diff --git a/app/jobs/magic_link/cleanup_job.rb b/app/jobs/magic_link/cleanup_job.rb new file mode 100644 index 000000000..eea054fd0 --- /dev/null +++ b/app/jobs/magic_link/cleanup_job.rb @@ -0,0 +1,5 @@ +class MagicLink::CleanupJob < ApplicationJob + def perform + MagicLink.cleanup + end +end diff --git a/app/mailers/magic_link_mailer.rb b/app/mailers/magic_link_mailer.rb new file mode 100644 index 000000000..11f0f5705 --- /dev/null +++ b/app/mailers/magic_link_mailer.rb @@ -0,0 +1,15 @@ +class MagicLinkMailer < ApplicationMailer + helper MagicLinkHelper + + def sign_in_instructions(magic_link) + @magic_link = magic_link + @membership = @magic_link.membership + + mail to: @membership.email_address, subject: "Sign in to Fizzy" + end + + private + def default_url_options + Rails.application.config.action_mailer.default_url_options + end +end diff --git a/app/models/account.rb b/app/models/account.rb index da2298c88..3e9b0fe19 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -3,6 +3,8 @@ class Account < ApplicationRecord has_many_attached :uploads + enum :setup_status, %w[ pending complete ].index_by(&:itself), prefix: :setup, default: :pending + class << self def create_with_admin_user(account:, owner:) User.system diff --git a/app/models/identity.rb b/app/models/identity.rb index bbf798dd1..74aa3830b 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,7 +1,11 @@ class Identity < UntenantedRecord # This is used to instantiate an Identity-like object from the `identity_token` without hitting # the untenanted database. It is intended to be used with caching/etagging methods. - Mock = Struct.new(:id, :updated_at) + Mock = Struct.new(:id, :updated_at) do + def identity + Identity.find_signed(id) + end + end has_many :memberships, dependent: :destroy end diff --git a/app/models/magic_link.rb b/app/models/magic_link.rb new file mode 100644 index 000000000..bb06313f7 --- /dev/null +++ b/app/models/magic_link.rb @@ -0,0 +1,41 @@ +class MagicLink < UntenantedRecord + CODE_LENGTH = 6 + EXPIRATION_TIME = 15.minutes + + belongs_to :membership + + scope :active, -> { where(expires_at: Time.current...) } + scope :stale, -> { where(expires_at: ..Time.current) } + + before_validation :generate_code, on: :create + before_validation :set_expiration, on: :create + + validates :code, uniqueness: true, presence: true + + class << self + def consume(code) + active.find_by(code: Code.sanitize(code))&.consume + end + + def cleanup + stale.delete_all + end + end + + def consume + destroy + membership + end + + private + def generate_code + self.code = loop do + candidate = Code.generate(CODE_LENGTH) + break candidate unless self.class.exists?(code: candidate) + end + end + + def set_expiration + self.expires_at = EXPIRATION_TIME.from_now + end +end diff --git a/app/models/magic_link/code.rb b/app/models/magic_link/code.rb new file mode 100644 index 000000000..6af1619d8 --- /dev/null +++ b/app/models/magic_link/code.rb @@ -0,0 +1,31 @@ +module MagicLink::Code + CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".chars.freeze + CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze + + class << self + def generate(length) + length.times.map { CODE_ALPHABET.sample }.join + end + + def sanitize(code) + return nil if code.blank? + + normalize_code(code) + .then { apply_substitutions(_1) } + .then { remove_invalid_characters(_1) } + end + + private + def normalize_code(code) + code.to_s.upcase + end + + def apply_substitutions(code) + CODE_SUBSTITUTIONS.reduce(code) { |result, (from, to)| result.gsub(from, to) } + end + + def remove_invalid_characters(code) + code.gsub(/[^#{CODE_ALPHABET.join}]/, "") + end + end +end diff --git a/app/models/membership.rb b/app/models/membership.rb index 2289443f1..9233de99c 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -1,6 +1,6 @@ class Membership < UntenantedRecord belongs_to :identity, touch: true - has_many :magic_links, dependent: :delete + has_many :magic_links, dependent: :delete_all # I want this to be `belongs_to :user`, but ActiveRecord::Tenanted doesn't yet support # associations from untenanted to untenanted models. @@ -11,4 +11,13 @@ class Membership < UntenantedRecord def user User.with_tenant(user_tenant) { User.find_by(id: user_id) } end + + def account + Account.with_tenant(user_tenant) { Account.sole } + end + + def send_magic_link + magic_link = magic_links.create! + MagicLinkMailer.sign_in_instructions(magic_link).deliver_later + end end diff --git a/app/models/user/identifiable.rb b/app/models/user/identifiable.rb index 0b3f0abbc..c90247b74 100644 --- a/app/models/user/identifiable.rb +++ b/app/models/user/identifiable.rb @@ -4,6 +4,8 @@ module User::Identifiable included do has_one :membership, ->(user) { where(user_tenant: user.tenant) } has_one :identity, through: :membership + + scope :with_identity, ->(identity) { where(id: identity.memberships.where(user_tenant: ApplicationRecord.current_tenant).pluck(:user_id)) } end def set_identity(token_identity) diff --git a/app/models/user/role.rb b/app/models/user/role.rb index aaa4bd5ff..5c89fdd25 100644 --- a/app/models/user/role.rb +++ b/app/models/user/role.rb @@ -4,6 +4,7 @@ module User::Role included do enum :role, %i[ admin member system ].index_by(&:itself), scopes: false + scope :admin, -> { where(role: :admin) } scope :member, -> { where(role: :member) } scope :active, -> { where(active: true, role: %i[ admin member ]) } end 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 new file mode 100644 index 000000000..69cb84cf8 --- /dev/null +++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb @@ -0,0 +1,13 @@ +

Sign in to Fizzy

+ +
+

Hit the button below to sign in to Fizzy on this device

+ +
<%= 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 the code below:

+ + <%= magic_link_code(@magic_link) %> +
diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb new file mode 100644 index 000000000..129bce961 --- /dev/null +++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb @@ -0,0 +1,8 @@ +Sign in to Fizzy +<%= "=" * 80 %> + +Open this link in your browser to login on this device: +<%= session_magic_link_url(code: @magic_link.code) %> + +If you're signing in on another device, enter the code below: +<%= magic_link_code(@magic_link) %> diff --git a/app/views/sessions/login_menus/show.html.erb b/app/views/sessions/login_menus/show.html.erb new file mode 100644 index 000000000..b66b2490d --- /dev/null +++ b/app/views/sessions/login_menus/show.html.erb @@ -0,0 +1,27 @@ +<% cache [ @identity, @memberships ] do %> +
+
+ <% if @memberships.present? %> +

Your Fizzy Accounts

+
+ <% @memberships.each do |membership| %> + <%= button_to session_login_menu_url(membership_id: membership, script_name: "/#{membership.user_tenant}"), method: :post, class: "btn center txt-medium" do %> + <%= membership.account_name %> + : + <%= membership.email_address %> + <% end %> + <% end %> +
+ <% else %> +

You don't have any existing Fizzy accounts.

+ <% end %> +
+ +
+ <%= link_to login_url, class: "btn center btn--reversed" do %> + <%= icon_tag "add" %> + Add another account + <% end %> +
+
+<% end %> diff --git a/app/views/sessions/magic_links/create.html.erb b/app/views/sessions/magic_links/create.html.erb new file mode 100644 index 000000000..c73b393eb --- /dev/null +++ b/app/views/sessions/magic_links/create.html.erb @@ -0,0 +1,22 @@ +<% @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/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb new file mode 100644 index 000000000..a47db86fb --- /dev/null +++ b/app/views/sessions/magic_links/show.html.erb @@ -0,0 +1,29 @@ +<% @page_title = "Magic link" %> + +
" + style="--panel-size: 55ch;" +> +

Check your email

+ +

We just emailed you a special code.

+ + <%= form_with url: session_magic_link_path, method: :post, html: { class: "flex flex-column gap", data: { controller: token_list("auto-submit" => params[:code].present?)} } do |form| %> +
+ +
+ + + <% end %> +
+ +<% content_for :footer do %> +
+ Fizzy™ version 0 +
+<% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 85cbddb81..9c0cf2464 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -11,16 +11,15 @@ -
- -
- <% end %> + +<% content_for :footer do %> +
+ Fizzy™ version 0 +
+<% end %> diff --git a/config/recurring.yml b/config/recurring.yml index 4f56c420f..8ed13f58d 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -25,6 +25,9 @@ production: &production cleanup_webhook_deliveries: class: Webhook::CleanupDeliveriesJob schedule: every 4 hours at minute 51 + cleanup_magic_links: + class: "MagicLink::CleanupJob" + schedule: every 4 hours sqlite_backups: class: SQLiteBackupsJob schedule: every day at 05:02 diff --git a/config/routes.rb b/config/routes.rb index 7593c0bcd..be32d7119 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -129,6 +129,8 @@ Rails.application.routes.draw do resource :session do scope module: "sessions" do resources :transfers, only: %i[ show update ] + resource :magic_link, only: %i[ show create ] + resource :login_menu, only: %i[ show create ] end end diff --git a/db/migrate/20251011080030_add_setup_status_to_accounts.rb b/db/migrate/20251011080030_add_setup_status_to_accounts.rb new file mode 100644 index 000000000..db5f5c537 --- /dev/null +++ b/db/migrate/20251011080030_add_setup_status_to_accounts.rb @@ -0,0 +1,5 @@ +class AddSetupStatusToAccounts < ActiveRecord::Migration[8.1] + def change + add_column :accounts, :setup_status, :string + end +end diff --git a/db/seeds.rb b/db/seeds.rb index b000de36f..76bcbd253 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -18,7 +18,8 @@ def create_tenant(signal_account_name) account = Account.create_with_admin_user( account: { external_account_id: tenant_id, - name: signal_account_name + name: signal_account_name, + setup_status: :complete }, owner: { name: "David Heinemeier Hansson", @@ -30,16 +31,24 @@ def create_tenant(signal_account_name) end ApplicationRecord.current_tenant = tenant_id + + identity = Membership.find_by(email_address: User.first.email_address)&.identity + User.first.set_identity(identity) end def find_or_create_user(full_name, email_address) if user = User.find_by(email_address: email_address) user else - User.create! \ + user = User.create! \ name: full_name, email_address: email_address, password: "secret123456" + + identity = Membership.find_by(email_address: email_address)&.identity || Identity.create! + user.set_identity(identity) + + user end end diff --git a/db/untenanted_migrate/20251007112917_create_identity_magic_links.rb b/db/untenanted_migrate/20251007112917_create_identity_magic_links.rb new file mode 100644 index 000000000..cdd99228d --- /dev/null +++ b/db/untenanted_migrate/20251007112917_create_identity_magic_links.rb @@ -0,0 +1,11 @@ +class CreateIdentityMagicLinks < ActiveRecord::Migration[8.1] + def change + create_table :magic_links do |t| + t.string :code, index: { unique: true }, null: false + t.belongs_to :membership, null: false, foreign_key: true + t.datetime :expires_at, index: true, null: false + + t.timestamps + end + end +end diff --git a/gems/fizzy-saas/app/controllers/signups/completions_controller.rb b/gems/fizzy-saas/app/controllers/signups/completions_controller.rb new file mode 100644 index 000000000..bf322354a --- /dev/null +++ b/gems/fizzy-saas/app/controllers/signups/completions_controller.rb @@ -0,0 +1,46 @@ +class Signups::CompletionsController < ApplicationController + allow_unauthenticated_access + + before_action :require_identity + before_action :ensure_setup_pending + before_action :ensure_identity_of_an_admin + + def new + @signup = Signup.new + end + + def create + @signup = Signup.new(signup_params) + + if @signup.complete + start_new_session_for(@signup.user) + redirect_to root_path + else + render :new, status: :unprocessable_entity + end + end + + private + def ensure_setup_pending + unless Account.sole.setup_pending? + redirect_to root_path + end + end + + def ensure_identity_of_an_admin + unless user&.admin? + redirect_to root_path + end + end + + def signup_params + params.expect(signup: %i[ full_name company_name ]).with_defaults( + tenant: ApplicationRecord.current_tenant, + user: user + ) + end + + def user + @user ||= User.all.admin.with_identity(Current.identity_token.identity).first + end +end diff --git a/gems/fizzy-saas/app/controllers/signups_controller.rb b/gems/fizzy-saas/app/controllers/signups_controller.rb index 73bcdeada..8421270e2 100644 --- a/gems/fizzy-saas/app/controllers/signups_controller.rb +++ b/gems/fizzy-saas/app/controllers/signups_controller.rb @@ -17,8 +17,8 @@ class SignupsController < ApplicationController def create @signup = Signup.new(signup_params) - if @signup.process - redirect_to root_url(script_name: @signup.account.slug) + if @signup.create_account + redirect_to session_magic_link_path else render :new, status: :unprocessable_entity end @@ -26,6 +26,6 @@ class SignupsController < ApplicationController private def signup_params - params.require(:signup).permit(*Signup::PERMITTED_KEYS) + params.expect(signup: %i[ email_address ]) end end diff --git a/gems/fizzy-saas/app/models/signup.rb b/gems/fizzy-saas/app/models/signup.rb index f9ead90be..e9d9dfbeb 100644 --- a/gems/fizzy-saas/app/models/signup.rb +++ b/gems/fizzy-saas/app/models/signup.rb @@ -3,14 +3,19 @@ class Signup include ActiveModel::Attributes include ActiveModel::Validations - PERMITTED_KEYS = %i[ full_name email_address password company_name ] + PERMITTED_KEYS = %i[ full_name email_address company_name ] - # Input attributes - attr_accessor :company_name, :full_name, :email_address, :password - validates_presence_of :company_name, :full_name, :email_address, :password + attr_accessor :company_name, :full_name, :email_address, :user, :tenant + attr_reader :queenbee_account, :account + + with_options on: :account_creation do + validates_presence_of :email_address + end + + with_options on: :completion do + validates_presence_of :company_name, :full_name + end - # Output attributes - attr_reader :tenant, :account, :user, :queenbee_account def initialize(...) @company_name = nil @@ -25,22 +30,43 @@ class Signup super end - def process - return false unless valid? + def create_account + return false unless valid?(:account_creation) - create_queenbee_account - create_tenant + if membership = Membership.find_by(email_address: email_address) + membership.send_magic_link + else + create_queenbee_account + create_tenant + user.membership.send_magic_link + end - true rescue => error destroy_tenant destroy_queenbee_account errors.add(:base, "An error occurred during signup: #{error.message}") + Rails.logger.error(error) + Rails.logger.error(error.backtrace.join("\n")) false end + def complete + return false unless valid?(:completion) + + ApplicationRecord.with_tenant(tenant) do + @account = Account.sole + + ApplicationRecord.transaction do + user.update!(name: full_name) + account.update!(name: company_name, setup_status: :complete) + user.membership.update!(account_name: company_name) + end + end + # TODO: Update company and user name in QB + end + private def create_queenbee_account @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) @@ -52,17 +78,18 @@ class Signup end def create_tenant - @tenant = queenbee_account.id.to_s + self.tenant = queenbee_account.id.to_s + ApplicationRecord.create_tenant(tenant) do @account = Account.create_with_admin_user( account: { external_account_id: tenant, - name: company_name + name: "New Account", + setup_status: :pending }, owner: { - name: full_name, - email_address: email_address, - password: password + name: email_address, + email_address: email_address } ) @user = User.find_by!(role: :admin) @@ -74,9 +101,10 @@ class Signup if tenant.present? && ApplicationRecord.tenant_exist?(tenant) ApplicationRecord.destroy_tenant(tenant) end - @user = nil + @account = nil - @tenant = nil + self.user = nil + self.tenant = nil end def queenbee_account_attributes @@ -92,8 +120,8 @@ class Signup # attributes[:terms_of_service] = true attributes[:product_name] = "fizzy" - attributes[:name] = company_name - attributes[:owner_name] = full_name + attributes[:name] = email_address + attributes[:owner_name] = email_address attributes[:owner_email] = email_address attributes[:trial] = true diff --git a/gems/fizzy-saas/app/views/signups/completions/new.html.erb b/gems/fizzy-saas/app/views/signups/completions/new.html.erb new file mode 100644 index 000000000..b78b84e76 --- /dev/null +++ b/gems/fizzy-saas/app/views/signups/completions/new.html.erb @@ -0,0 +1,42 @@ +<% @page_title = "Sign up for Fizzy" %> + +
" + style="--panel-size: 65ch;" +> +

Create your account

+ +

+ Great! We just need two more things... +

+ + <%= form_with model: @signup, url: saas.signup_completion_path, scope: "signup", class: "flex flex-column gap txt-large", data: { turbo: false } do |form| %> +
+ +
+ +
+ +
+ + <% if @signup.errors.any? %> +
+
    + <% @signup.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + + + <% end %> +
diff --git a/gems/fizzy-saas/app/views/signups/new.html.erb b/gems/fizzy-saas/app/views/signups/new.html.erb index c357d3c0f..11d4c926e 100644 --- a/gems/fizzy-saas/app/views/signups/new.html.erb +++ b/gems/fizzy-saas/app/views/signups/new.html.erb @@ -1,17 +1,16 @@ <% @page_title = "Sign up for Fizzy" %> -
" style="--panel-size: 65ch;"> +
" + style="--panel-size: 65ch;" +>

Fizzy

-

Create your account

+ +

+ Enter your email address to get started. +

<%= form_with model: @signup, url: saas.signup_path, scope: "signup", class: "flex flex-column gap txt-large", data: { turbo: false } do |form| %> -
- -
-
-
- -
- -
- -
- <% if @signup.errors.any? %>
    @@ -44,17 +29,8 @@ <% end %> <% end %> - -
diff --git a/gems/fizzy-saas/config/routes.rb b/gems/fizzy-saas/config/routes.rb index 698095350..172eb6e6f 100644 --- a/gems/fizzy-saas/config/routes.rb +++ b/gems/fizzy-saas/config/routes.rb @@ -1,4 +1,10 @@ Fizzy::Saas::Engine.routes.draw do - resource :signup, only: [ :new, :create ] + resource :signup, only: %i[ new create ] do + scope module: :signups do + collection do + resource :completion, only: %i[ new create ], as: :signup_completion + end + end + end Queenbee.routes(self) end diff --git a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb new file mode 100644 index 000000000..5b24277dc --- /dev/null +++ b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest + setup do + Account.sole.update!(setup_status: :pending) + set_identity_as :kevin + end + + test "new" do + get saas.new_signup_completion_path + + assert_response :success + end + + test "create" do + post saas.signup_completion_path, params: { + signup: { + full_name: "Kevin Systrom", + company_name: "37signals" + } + } + + assert_redirected_to root_path, "Successful completion should redirect to root" + assert cookies[:session_token].present?, "Successful completion should create a session" + assert_equal "complete", Account.sole.reload.setup_status, "Account setup status should be complete" + assert_equal "Kevin Systrom", users(:kevin).reload.name, "User name should be updated" + assert_equal "37signals", Account.sole.reload.name, "Account name should be updated" + + Account.sole.update!(setup_status: :pending) + + post saas.signup_completion_path, params: { + signup: { + full_name: "", + company_name: "" + } + } + + assert_response :unprocessable_entity, "Invalid params should return unprocessable entity" + end +end diff --git a/gems/fizzy-saas/test/controllers/signups_controller_test.rb b/gems/fizzy-saas/test/controllers/signups_controller_test.rb index 8cbe8ec9f..d23ad07e4 100644 --- a/gems/fizzy-saas/test/controllers/signups_controller_test.rb +++ b/gems/fizzy-saas/test/controllers/signups_controller_test.rb @@ -5,8 +5,7 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest @signup_params = { full_name: "Brian Wilson", email_address: "brian@example.com", - company_name: "Beach Boys", - password: SecureRandom.hex(16) + company_name: "Beach Boys" } @starting_tenants = ApplicationRecord.tenants @@ -24,39 +23,20 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest get saas.new_signup_url, headers: http_basic_auth_headers assert_response :success - assert_select "h2", "Create your account" - assert_select "input[name='signup[full_name]']" + assert_select "h2", "Enter your email address to get started." assert_select "input[name='signup[email_address]']" - assert_select "input[name='signup[company_name]']" - assert_select "input[name='signup[password]']" end - test "should create signup and redirect to tenant root on success" do - Account.any_instance.expects(:setup_basic_template).once - + test "should create signup and redirect to magic link page" do assert_difference -> { ApplicationRecord.tenants.count }, 1 do - post saas.signup_url, params: { signup: @signup_params }, headers: http_basic_auth_headers + post saas.signup_url, params: { signup: { email_address: @signup_params[:email_address] } }, headers: http_basic_auth_headers end - assert_response :redirect - - new_tenant = (ApplicationRecord.tenants - @starting_tenants).first - ApplicationRecord.with_tenant(new_tenant) do - account = Account.sole - assert account, "Account should have been created" - assert_equal @signup_params[:company_name], account.name - - user = User.find_by(email_address: @signup_params[:email_address]) - assert user, "User should have been created" - assert_equal @signup_params[:full_name], user.name - assert_equal @signup_params[:email_address], user.email_address - - assert_redirected_to root_url(script_name: account.slug) - end + assert_redirected_to session_magic_link_path end test "should render new with errors when signup fails validation" do - invalid_params = @signup_params.merge(password: "") + invalid_params = { email_address: "" } assert_no_difference -> { ApplicationRecord.tenants.count } do post saas.signup_url, params: { signup: invalid_params }, headers: http_basic_auth_headers @@ -64,19 +44,6 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity assert_select ".alert--error" - assert_select ".alert--error li", /Password can't be blank/ - end - - test "should render new with errors when signup processing fails" do - Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data") - - assert_no_difference -> { ApplicationRecord.tenants.count } do - post saas.signup_url, params: { signup: @signup_params }, headers: http_basic_auth_headers - end - - assert_response :unprocessable_entity - assert_select ".alert--error" - assert_select ".alert--error li", /An error occurred during signup/ end private diff --git a/gems/fizzy-saas/test/models/signup_test.rb b/gems/fizzy-saas/test/models/signup_test.rb index 9566fc0b4..48c6f499b 100644 --- a/gems/fizzy-saas/test/models/signup_test.rb +++ b/gems/fizzy-saas/test/models/signup_test.rb @@ -3,92 +3,67 @@ require "test_helper" class SignupTest < ActiveSupport::TestCase setup do @starting_tenants = ApplicationRecord.tenants - @signup = Signup.new( - email_address: "brian@example.com", - full_name: "Brian Wilson", - company_name: "Beach Boys", - password: SecureRandom.hex(16) - ) end - test "#process creates all the necessary objects for a new Fizzy account" do + test "#create_account" do Account.any_instance.expects(:setup_basic_template).once - assert @signup.process, @signup.errors.full_messages.to_sentence(words_connector: ". ") - assert_empty @signup.errors + signup = Signup.new(email_address: "brian@example.com") - assert @signup.tenant - assert_includes ApplicationRecord.tenants, @signup.tenant + assert_difference -> { Membership.count }, 1 do + assert_difference -> { MagicLink.count }, 1 do + assert signup.create_account, signup.errors.full_messages.to_sentence(words_connector: ". ") + end + end - assert @signup.account - assert @signup.account.persisted? - assert @signup.account.external_account_id - assert_equal @signup.company_name, @signup.account.name - assert_equal @signup.tenant, @signup.account.external_account_id.to_s - assert_equal @signup.tenant, @signup.account.tenant + assert_empty signup.errors + assert signup.tenant + assert_includes ApplicationRecord.tenants, signup.tenant + assert signup.account + assert signup.account.persisted? + assert signup.user + assert signup.user.persisted? - assert @signup.user - assert @signup.user.persisted? - assert_equal @signup.full_name, @signup.user.name - assert_equal @signup.email_address, @signup.user.email_address + signup_existing = Signup.new(email_address: "brian@example.com") - auth_params = { email_address: @signup.email_address, password: @signup.password } - user = ApplicationRecord.with_tenant(@signup.tenant) { User.authenticate_by(**auth_params) } + assert_no_difference -> { Membership.count } do + assert_difference -> { MagicLink.count }, 1 do + assert signup_existing.create_account, "Should send magic link for existing membership" + end + end - assert user, "User should be able to authenticate with #{auth_params.inspect}" - assert_equal @signup.user, user - assert_equal @signup.tenant, @signup.user.tenant - end + signup_invalid = Signup.new(email_address: "") + assert_not signup_invalid.create_account, "Should fail with invalid email" + assert_not_empty signup_invalid.errors[:email_address], "Should have validation error for email_address" - test "#process does nothing if a basic validation error occurs" do - @signup.password = "" - - assert_not @signup.process - assert_not_empty @signup.errors[:password] - - assert_nil @signup.tenant - assert_nil @signup.account - assert_nil @signup.user - assert_equal @starting_tenants, ApplicationRecord.tenants - end - - test "#process does nothing if an error occurs creating the queenbee record" do Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data") + signup_error = Signup.new(email_address: "error@example.com") - assert_not @signup.process - assert_not_empty @signup.errors[:base] - - assert_nil @signup.tenant - assert_nil @signup.account - assert_nil @signup.user - assert_equal @starting_tenants, ApplicationRecord.tenants + assert_not signup_error.create_account, "Should fail when error occurs" + assert_not_empty signup_error.errors[:base], "Should have base error" + assert_nil signup_error.tenant + assert_nil signup_error.account + assert_nil signup_error.user end - test "#process does nothing if an error occurs creating the tenant" do - ApplicationRecord.stubs(:create_tenant).raises(RuntimeError, "Tenant already exists") + test "#complete" do + Account.sole.update!(setup_status: :pending) + signup = Signup.new( + tenant: ApplicationRecord.current_tenant, + user: users(:kevin), + full_name: "Kevin Systrom", + company_name: "37signals" + ) - Queenbee::Remote::Account.any_instance.expects(:cancel).once + assert signup.complete, signup.errors.full_messages.to_sentence(words_connector: ". ") - assert_not @signup.process - assert_not_empty @signup.errors[:base] + assert_equal "complete", Account.sole.reload.setup_status, "Account setup status should be complete" + assert_equal "Kevin Systrom", users(:kevin).reload.name, "User name should be updated" + assert_equal "37signals", Account.sole.reload.name, "Account name should be updated" + assert_equal "37signals", users(:kevin).membership.reload.account_name, "Membership account name should be updated" - assert_nil @signup.tenant - assert_nil @signup.account - assert_nil @signup.user - assert_equal @starting_tenants, ApplicationRecord.tenants - end - - test "#process does nothing if an error occurs creating the tenanted records" do - Account.stubs(:create_with_admin_user).raises(ActiveRecord::RecordInvalid, "Validation failed: Name can't be blank") - - Queenbee::Remote::Account.any_instance.expects(:cancel).once - - assert_not @signup.process - assert_not_empty @signup.errors[:base] - - assert_nil @signup.tenant - assert_nil @signup.account - assert_nil @signup.user - assert_equal @starting_tenants, ApplicationRecord.tenants + signup.full_name = "" + assert_not signup.complete, "Complete should fail with invalid params" + assert_not_empty signup.errors[:full_name], "Should have validation error for full_name" end end diff --git a/script/migrations/20251013-mark-all-accounts-as-setup.rb b/script/migrations/20251013-mark-all-accounts-as-setup.rb new file mode 100755 index 000000000..792fcb288 --- /dev/null +++ b/script/migrations/20251013-mark-all-accounts-as-setup.rb @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby + +require_relative "../../config/environment" + +ApplicationRecord.with_each_tenant do |tenant| + puts "✅ #{tenant}" + Account.sole.setup_complete! +end diff --git a/test/controllers/controller_authentication_test.rb b/test/controllers/controller_authentication_test.rb index b3b2c75d3..509ce26c1 100644 --- a/test/controllers/controller_authentication_test.rb +++ b/test/controllers/controller_authentication_test.rb @@ -1,19 +1,18 @@ require "test_helper" class ControllerAuthenticationTest < ActionDispatch::IntegrationTest - test "access without an account slug redirects to new session" do + test "access without an account slug redirects to login menu" do integration_session.default_url_options[:script_name] = "" # no tenant get cards_path - assert_response :success - assert_dom "p", text: "You don't have any existing BOXCAR accounts." + assert_redirected_to session_login_menu_path end test "access with an account slug but no session redirects to new session" do get cards_path - assert_redirected_to new_session_path + assert_redirected_to new_session_path(script_name: nil) end test "access with an account slug and a session allows functional access" do diff --git a/test/controllers/sessions/login_menus_controller_test.rb b/test/controllers/sessions/login_menus_controller_test.rb new file mode 100644 index 000000000..8527b1fdd --- /dev/null +++ b/test/controllers/sessions/login_menus_controller_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class Sessions::LoginMenusControllerTest < ActionDispatch::IntegrationTest + test "show" do + untenanted do + set_identity_as :kevin + + get session_login_menu_url + + assert_response :success + end + end + + test "create" do + untenanted do + sign_in_as :kevin + + assert cookies[:session_token].present? + end + end +end diff --git a/test/controllers/sessions/magic_links_controller_test.rb b/test/controllers/sessions/magic_links_controller_test.rb new file mode 100644 index 000000000..837107309 --- /dev/null +++ b/test/controllers/sessions/magic_links_controller_test.rb @@ -0,0 +1,36 @@ +require "test_helper" + +class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest + test "show" do + untenanted do + get session_magic_link_url + + assert_response :success + end + end + + test "create" do + untenanted do + membership = memberships(:kevin_in_37signals) + magic_link = MagicLink.create!(membership: membership) + + post session_magic_link_url, params: { code: magic_link.code } + + assert_response :redirect, "Valid magic link should redirect" + assert cookies[:identity_token].present?, "Valid magic link should set identity token" + assert_not MagicLink.exists?(magic_link.id), "Valid magic link should be consumed" + + post session_magic_link_url, params: { code: "INVALID" } + + assert_response :redirect, "Invalid code should redirect" + + expired_link = MagicLink.create!(membership: membership) + expired_link.update_column(:expires_at, 1.hour.ago) + + post session_magic_link_url, params: { code: expired_link.code } + + assert_response :redirect, "Expired magic link should redirect" + assert MagicLink.exists?(expired_link.id), "Expired magic link should not be consumed" + end + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 09f1f13f0..b6a9b7b83 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -11,26 +11,32 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end test "new" do - get new_session_path + untenanted do + get new_session_path - assert_response :success - end - - test "create with valid credentials" do - assert_difference -> { Identity.count }, 1 do - assert_difference -> { Membership.count }, 1 do - post session_path, params: { email_address: "david@37signals.com", password: "secret123456" } - end + assert_response :success end - - assert_redirected_to root_path - assert cookies[:session_token].present? end - test "create with invalid credentials" do - post session_path, params: { email_address: "david@37signals.com", password: "wrong" } + test "create with existing membership" do + untenanted do + membership = memberships(:kevin_in_37signals) - assert_redirected_to new_session_path - assert_not cookies[:session_token].present? + assert_difference -> { MagicLink.count }, 1 do + post session_path, params: { email_address: membership.email_address } + end + + assert_redirected_to session_magic_link_path + end + end + + test "create with non-existent email" do + untenanted do + assert_no_difference -> { MagicLink.count } do + post session_path, params: { email_address: "nonexistent@example.com" } + end + + assert_redirected_to session_magic_link_path + end end end diff --git a/test/fixtures/accesses.yml b/test/fixtures/accesses.yml index 75e6fdf02..ad1a82b47 100644 --- a/test/fixtures/accesses.yml +++ b/test/fixtures/accesses.yml @@ -1,6 +1,7 @@ writebook_david: collection: writebook user: david + involvement: watching writebook_jz: collection: writebook @@ -9,6 +10,7 @@ writebook_jz: writebook_kevin: collection: writebook user: kevin + involvement: watching private_kevin: collection: private diff --git a/test/fixtures/identities.yml b/test/fixtures/identities.yml index 8b1378917..0da6dd9ae 100644 --- a/test/fixtures/identities.yml +++ b/test/fixtures/identities.yml @@ -1 +1,3 @@ +kevin_identity: {} +jz_identity: {} diff --git a/test/fixtures/memberships.yml b/test/fixtures/memberships.yml index 8b1378917..fd9924c21 100644 --- a/test/fixtures/memberships.yml +++ b/test/fixtures/memberships.yml @@ -1 +1,13 @@ +kevin_in_37signals: + identity: kevin_identity + email_address: kevin@37signals.com + user_tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %> + user_id: <%= ActiveRecord::FixtureSet.identify(:kevin) %> + account_name: Fizzy +jz_in_37signals: + identity: jz_identity + email_address: jz@37signals.com + user_tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %> + user_id: <%= ActiveRecord::FixtureSet.identify(:jz) %> + account_name: Fizzy diff --git a/test/integration/identity_membership_test.rb b/test/integration/identity_membership_test.rb index 03d8a33f4..11a28fba5 100644 --- a/test/integration/identity_membership_test.rb +++ b/test/integration/identity_membership_test.rb @@ -1,37 +1,16 @@ require "test_helper" class IdentityMembershipTest < ActionDispatch::IntegrationTest - setup do - @tenants = [ ActiveRecord::FixtureSet.identify("identity-tenant-1"), - ActiveRecord::FixtureSet.identify("identity-tenant-2") ] - @tenant_paths = @tenants.map { "/#{_1}" } - @tenant_urls = @tenant_paths.map { root_url(script_name: _1) } - @user_params = { email_address: "user@example.com", password: "password1234" } - @users = @tenants.map do |tenant| - ApplicationRecord.create_tenant(tenant) do - Account.create! name: "Account for #{tenant}" - User.create! @user_params.merge(name: "Harold Hancox") - end - end - end - test "multiple signins on the same browser" do - post session_path(script_name: @tenant_paths[0], params: @user_params) - assert_redirected_to root_path(script_name: @tenant_paths[0]) + # Sign in as kevin to first account + kevin = users(:kevin) + sign_in_as(kevin) - post session_path(script_name: @tenant_paths[1], params: @user_params) - assert_redirected_to root_path(script_name: @tenant_paths[1]) + # Then sign in as JZ + jz = users(:jz) + set_identity_as(jz) - # Render links for other Fizzies in the jump menu - get my_menu_path(script_name: @tenant_paths[0]) - assert_select "#my_menu ul li a[href='#{@tenant_urls[1]}']", "Account for #{@tenants[1]}: user@example.com" - - get my_menu_path(script_name: @tenant_paths[1]) - assert_select "#my_menu ul li a[href='#{@tenant_urls[0]}']", "Account for #{@tenants[0]}: user@example.com" - - # Render links for all the identity's Fizzies - get root_path(script_name: nil) - assert_select "ul li a[href='#{@tenant_urls[0]}']", "Account for #{@tenants[0]}: user@example.com" - assert_select "ul li a[href='#{@tenant_urls[1]}']", "Account for #{@tenants[1]}: user@example.com" + expected_membership_ids = [ memberships(:kevin_in_37signals), memberships(:jz_in_37signals) ].map(&:id) + assert_equal expected_membership_ids.sort, jz.reload.identity.memberships.pluck(:id).sort end end diff --git a/test/mailers/magic_link_mailer_test.rb b/test/mailers/magic_link_mailer_test.rb new file mode 100644 index 000000000..2c6785ec4 --- /dev/null +++ b/test/mailers/magic_link_mailer_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class MagicLinkMailerTest < ActionMailer::TestCase + test "sign_in_instructions" do + magic_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + email = MagicLinkMailer.sign_in_instructions(magic_link) + + assert_emails 1 do + email.deliver_now + end + + assert_equal [ "kevin@37signals.com" ], email.to + assert_equal "Sign in to Fizzy", email.subject + assert_match magic_link.code, email.body.encoded + end +end diff --git a/test/mailers/previews/magic_link_preview.rb b/test/mailers/previews/magic_link_preview.rb new file mode 100644 index 000000000..af638f373 --- /dev/null +++ b/test/mailers/previews/magic_link_preview.rb @@ -0,0 +1,8 @@ +class MagicLinkPreview < ActionMailer::Preview + def magic_link + membership = Membership.new email_address: "test@example.com" + magic_link = MagicLink.new(membership: membership) + magic_link.valid? + MagicLinkMailer.magic_link(magic_link) + end +end diff --git a/test/models/magic_link/code_test.rb b/test/models/magic_link/code_test.rb new file mode 100644 index 000000000..685f26d69 --- /dev/null +++ b/test/models/magic_link/code_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class MagicLink::CodeTest < ActiveSupport::TestCase + test "generate" do + code = MagicLink::Code.generate(6) + + assert_equal 6, code.length + assert_match(/\A[#{MagicLink::Code::CODE_ALPHABET.join}]+\z/, code) + end + + test "sanitize" do + assert_equal "011123", MagicLink::Code.sanitize("OIL123") + assert_equal "ABC123", MagicLink::Code.sanitize("ABC-123 !@#") + assert_nil MagicLink::Code.sanitize(nil) + assert_nil MagicLink::Code.sanitize("") + end +end diff --git a/test/models/magic_link_test.rb b/test/models/magic_link_test.rb new file mode 100644 index 000000000..0e921096e --- /dev/null +++ b/test/models/magic_link_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class MagicLinkTest < ActiveSupport::TestCase + test "new" do + magic_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + + assert magic_link.code.present? + assert_equal MagicLink::CODE_LENGTH, magic_link.code.length + assert magic_link.expires_at.present? + assert_in_delta MagicLink::EXPIRATION_TIME.from_now, magic_link.expires_at, 1.second + end + + test "active" do + active_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + expired_link.update_column(:expires_at, 1.hour.ago) + + assert_includes MagicLink.active, active_link + assert_not_includes MagicLink.active, expired_link + end + + test "stale" do + active_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + expired_link.update_column(:expires_at, 1.hour.ago) + + assert_includes MagicLink.stale, expired_link + assert_not_includes MagicLink.stale, active_link + end + + test "consume" do + magic_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + code_with_spaces = magic_link.code.downcase.chars.join(" ") + + membership = MagicLink.consume(code_with_spaces) + assert_equal memberships(:kevin_in_37signals), membership + assert_not MagicLink.exists?(magic_link.id) + + expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + expired_link.update_column(:expires_at, 1.hour.ago) + assert_nil MagicLink.consume(expired_link.code) + assert MagicLink.exists?(expired_link.id) + + assert_nil MagicLink.consume("INVALID") + assert_nil MagicLink.consume(nil) + end + + test "cleanup" do + active_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + expired_link = MagicLink.create!(membership: memberships(:kevin_in_37signals)) + expired_link.update_column(:expires_at, 1.hour.ago) + + MagicLink.cleanup + + assert MagicLink.exists?(active_link.id) + assert_not MagicLink.exists?(expired_link.id) + end +end diff --git a/test/models/membership_test.rb b/test/models/membership_test.rb index 8506331ed..f544b0c25 100644 --- a/test/models/membership_test.rb +++ b/test/models/membership_test.rb @@ -1,7 +1,31 @@ require "test_helper" class MembershipTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "send_magic_link" do + membership = memberships(:kevin_in_37signals) + + assert_difference -> { membership.magic_links.count }, 1 do + membership.send_magic_link + end + + assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob + end + + test "user" do + membership = memberships(:kevin_in_37signals) + + user = membership.user + + assert_equal users(:kevin).id, user.id + assert_equal users(:kevin).email_address, user.email_address + end + + test "account" do + membership = memberships(:kevin_in_37signals) + + account = membership.account + + assert_equal Account.sole.id, account.id + assert_equal Account.sole.name, account.name + end end diff --git a/test/models/user/identifiable_test.rb b/test/models/user/identifiable_test.rb index fb0529a06..4ff50783d 100644 --- a/test/models/user/identifiable_test.rb +++ b/test/models/user/identifiable_test.rb @@ -30,26 +30,16 @@ class User::IdentifiableTest < ActiveSupport::TestCase end test "set_identity when token is an identity and user has a different identity" do - user = users(:david) - user2 = users(:jz) - token_identity = Identity.create! - user_identity = Identity.create! - user_identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: "asdf") - user_identity.memberships.create!(user_id: user2.id, user_tenant: user2.tenant, email_address: user2.email_address, account_name: "qwer") - assert_equal(user_identity, user.identity) - assert_equal(user_identity, user2.identity) + kevin = users(:kevin) + jz = users(:jz) - result = user.set_identity(token_identity) - user.reload - user2.reload + assert_not_equal(kevin.identity, jz.identity, "Kevin and JZ should start with different identities") - assert_equal(token_identity, result) - assert_equal(token_identity, user.identity) - assert_equal(token_identity, user2.identity) + kevin.set_identity(jz.identity) + kevin.reload + jz.reload - assert_raises(ActiveRecord::RecordNotFound) do - user_identity.reload - end + assert_equal(kevin.identity, jz.identity, "Kevin and JZ should now share the same identity") end test "set_identity when token is nil and user has an identity" do diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb index ecc489b57..7e7e16feb 100644 --- a/test/test_helpers/session_test_helper.rb +++ b/test/test_helpers/session_test_helper.rb @@ -7,12 +7,39 @@ module SessionTestHelper cookies.delete :session_token user = users(user) unless user.is_a? User - post session_path, params: { email_address: user.email_address, password: "secret123456" } - assert_response :redirect + set_identity_as user - cookie = cookies.get_cookie "session_token" - assert_not_nil cookie, "Expected session_token cookie to be set after sign in" - assert_equal Account.sole.slug, cookie.path, "Expected session_token cookie to be scoped to account slug" + user.reload + membership = user.membership + tenanted do + post session_login_menu_url, params: { membership_id: membership.id } + assert_response :redirect, "Login should succeed" + + cookie = cookies.get_cookie "session_token" + assert_not_nil cookie, "Expected session_token cookie to be set after sign in" + assert_equal Account.sole.slug, cookie.path, "Expected session_token cookie to be scoped to account slug" + end + end + + def set_identity_as(user_or_identity) + user = if user_or_identity.is_a?(User) + user_or_identity + else + users(user_or_identity) + end + + membership = user.membership || user.set_identity(nil).memberships.find_by(user_id: user.id, user_tenant: user.tenant) + membership.send_magic_link + + magic_link = membership.magic_links.order(id: :desc).first + + untenanted do + post session_magic_link_url, params: { code: magic_link.code } + assert_response :redirect, "Magic link should succeed" + + cookie = cookies.get_cookie "identity_token" + assert_not_nil cookie, "Expected identity_token cookie to be set after magic link consumption" + end end def sign_out @@ -27,4 +54,20 @@ module SessionTestHelper ensure Current.clear_all end + + def untenanted(&block) + original_script_name = integration_session.default_url_options[:script_name] + integration_session.default_url_options[:script_name] = "" + yield + ensure + integration_session.default_url_options[:script_name] = original_script_name + end + + def tenanted(tenant = ApplicationRecord.current_tenant, &block) + original_script_name = integration_session.default_url_options[:script_name] + integration_session.default_url_options[:script_name] = "/#{tenant}" + yield + ensure + integration_session.default_url_options[:script_name] = original_script_name + end end From 193d8bd9b5031d42d21eca0e23db1aa1b6bed367 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 14 Oct 2025 14:45:32 -0500 Subject: [PATCH 04/37] Style email and magic link code screens --- app/assets/stylesheets/fizzy-menu.css | 12 ++++++++ app/views/sessions/magic_links/show.html.erb | 29 ++++++++++++-------- app/views/sessions/new.html.erb | 27 ++++++++++++------ 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/app/assets/stylesheets/fizzy-menu.css b/app/assets/stylesheets/fizzy-menu.css index cf55a3630..bb3cf24a3 100644 --- a/app/assets/stylesheets/fizzy-menu.css +++ b/app/assets/stylesheets/fizzy-menu.css @@ -1,4 +1,16 @@ @layer components { + .fizzy-header-logo { + inline-size: auto; + margin-block-start: 0.1em; + + svg { + block-size: auto; + inline-size: 1.1em; + margin-block-end: 0.2em; + margin-inline-end: 0.8ch; + } + } + .input:is(.fizzy-menu-trigger) { --input-background: var(--color-canvas); --input-border-color: transparent; diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index a47db86fb..e840ed5e8 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -1,29 +1,36 @@ -<% @page_title = "Magic link" %> +<% @page_title = "Enter your sign-in code" %> -
" - style="--panel-size: 55ch;" -> -

Check your email

+<% content_for :header do %> + +<% end %> -

We just emailed you a special code.

+
+
+

Check your email

+

We just emailed you a special code

+
<%= form_with url: session_magic_link_path, method: :post, html: { class: "flex flex-column gap", data: { controller: token_list("auto-submit" => params[:code].present?)} } do |form| %>
- <% end %>
<% content_for :footer do %>
- Fizzy™ version 0 + Fizzy™ Beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %>
<% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 9c0cf2464..f3fb2b3f2 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,25 +1,36 @@ <% @page_title = "Sign in" %> -
" style="--panel-size: 55ch;"> -

BOXCAR

+<% content_for :header do %> + +<% end %> - <%= form_with url: session_path, class: "flex flex-column gap txt-large" do |form| %> +
+
+

<%= @page_title %>

+

Enter your email address to continue

+
+ + <%= form_with url: session_path, class: "flex flex-column gap txt-medium" do |form| %>
- <% end %>
<% content_for :footer do %>
- Fizzy™ version 0 + BOXCAR™ Beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %>
<% end %> From 6ae20f9a9baf76c647033ffeb9c32f2482fad0a2 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 14 Oct 2025 16:35:11 -0500 Subject: [PATCH 05/37] Style account menu --- app/views/sessions/login_menus/show.html.erb | 60 +++++++++++++------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/app/views/sessions/login_menus/show.html.erb b/app/views/sessions/login_menus/show.html.erb index b66b2490d..0e806491d 100644 --- a/app/views/sessions/login_menus/show.html.erb +++ b/app/views/sessions/login_menus/show.html.erb @@ -1,27 +1,45 @@ -<% cache [ @identity, @memberships ] do %> -
-
- <% if @memberships.present? %> -

Your Fizzy Accounts

-
- <% @memberships.each do |membership| %> - <%= button_to session_login_menu_url(membership_id: membership, script_name: "/#{membership.user_tenant}"), method: :post, class: "btn center txt-medium" do %> - <%= membership.account_name %> - : - <%= membership.email_address %> - <% end %> - <% end %> -
- <% else %> -

You don't have any existing Fizzy accounts.

- <% end %> -
+<% @page_title = "Choose an account" %> -
- <%= link_to login_url, class: "btn center btn--reversed" do %> +<% content_for :header do %> + +<% end %> + +<% cache [ @identity, @memberships ] do %> +
+ <% if @memberships.present? %> +

Choose a Fizzy account

+ + <% @memberships.each do |membership| %> + + <% end %> + + <% else %> +

Hmm...

+

You don’t have any Fizzy accounts.

+ <% end %> + +
+ <%= link_to login_url, class: "btn center txt-small" do %> <%= icon_tag "add" %> - Add another account + Link a Fizzy account <% end %>
<% end %> + +<% content_for :footer do %> +
+ Fizzy™ Beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %> +
+<% end %> From 0e064fbcd51785b8126b0664332f0d4d25a213aa Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 14 Oct 2025 17:13:19 -0500 Subject: [PATCH 06/37] Style code input --- app/assets/stylesheets/inputs.css | 12 ++++++++++++ app/views/sessions/magic_links/show.html.erb | 4 +--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index e0ca57536..e9b453ad8 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -28,6 +28,18 @@ &[readonly] { --focus-ring-size: 0; } + + &[autocomplete='one-time-code'] { + --input-spacing: 0.5em; + + font-family: var(--font-mono); + font-size: var(--text-large); + font-weight: 900; + inline-size: 18ch; + letter-spacing: 1ch; + min-inline-size: 18ch; + text-align: center; + } } .input--file { diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index e840ed5e8..d8c323cf5 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -17,9 +17,7 @@ <%= form_with url: session_magic_link_path, method: :post, html: { class: "flex flex-column gap", data: { controller: token_list("auto-submit" => params[:code].present?)} } do |form| %>
- + <%= form.text_field :code, required: true, class: "input center txt-align-enter", autofocus: true, "data-1p-ignore": true, autocomplete: "one-time-code", maxlength: "6", value: params[:code] %>
- - -
- - <%= button_to_copy_to_clipboard(url) do %> - <%= icon_tag "copy-paste" %> - Copy join link - <% end %> - - <%= button_to account_join_code_path, method: :put, class: "btn btn--regenerate" do %> - <%= icon_tag "refresh" %> - Regenerate join link - <% end %> -
-
diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index e47304df4..6011b7e3a 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -5,8 +5,6 @@ navigable-list-actionable-items-value="true">

People on the account

- <%= render "account/settings/invite" %> -
diff --git a/app/views/my/menus/_places.html.erb b/app/views/my/menus/_places.html.erb index 428504dba..06b9fe02a 100644 --- a/app/views/my/menus/_places.html.erb +++ b/app/views/my/menus/_places.html.erb @@ -6,8 +6,8 @@ <%= filter_place_menu_item notifications_settings_path, "Notification Settings", "settings" %> <% cache [ Current.user, Current.identity_token ] do %> - <% Current.user.identity.memberships.where.not(user_tenant: Current.user.tenant).each do |membership| %> - <%= filter_place_menu_item root_url(script_name: "/#{membership.user_tenant}"), "#{membership.account_name}", "logo-color", new_window: true %> + <% 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 %> diff --git a/app/views/sessions/login_menu.html.erb b/app/views/sessions/login_menu.html.erb deleted file mode 100644 index 88d6e0be6..000000000 --- a/app/views/sessions/login_menu.html.erb +++ /dev/null @@ -1,21 +0,0 @@ -<% @page_title = "Choose Account" %> - -
-

BOXCAR

- -<% cache [ Current.user, Current.identity_token ] do %> - <% identity = Identity.find_signed(Current.identity_token.id) %> - <% memberships = identity&.memberships %> - <% if memberships.present? %> -

Your BOXCAR Accounts

-
    - <% memberships.each do |membership| %> -
  • - <%= link_to membership.account_name, root_url(script_name: "/#{membership.user_tenant}") %>: <%= membership.email_address %> -
  • - <% end %> -
- <% else %> -

You don't have any existing BOXCAR accounts.

- <% end %> -<% end %> diff --git a/app/views/sessions/login_menus/show.html.erb b/app/views/sessions/login_menus/show.html.erb index 0e806491d..7486ff2f5 100644 --- a/app/views/sessions/login_menus/show.html.erb +++ b/app/views/sessions/login_menus/show.html.erb @@ -9,17 +9,21 @@ <% end %> -<% cache [ @identity, @memberships ] do %> -
- <% if @memberships.present? %> -

Choose a Fizzy account

+<% cache [ Current.identity_token, @tenants ] do %> +
+ <% if @tenants.present? %> +

+ Choose a Fizzy account +

- <% @memberships.each do |membership| %> + <% @tenants.each do |tenant| %> <% end %> @@ -32,7 +36,7 @@
<%= link_to login_url, class: "btn center txt-small" do %> <%= icon_tag "add" %> - Link a Fizzy account + Create a Fizzy account <% end %>
diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb index f37665a64..302ffcd73 100644 --- a/app/views/users/new.html.erb +++ b/app/views/users/new.html.erb @@ -1,39 +1,48 @@ -<% @page_title = "Create your account" %> +<% @page_title = "Join #{Account.sole.name}" %> <% content_for :header do %> -
-
- <%= link_to login_url, class: "btn", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> - Sign in instead - <% end %> -
+ <% end %> -
-

BOXCAR

+
+
+

+ <%= @page_title %> +

+

Enter your email address to continue

+
- <%= form_with model: @user, url: join_path(params[:join_code]), class: "flex flex-column gap" do |form| %> + <%= form_with scope: "user", url: join_path(join_code: params[:join_code]), class: "flex flex-column gap txt-medium" do |form| %>
+
-
- -
- <% end %>
+ +<% content_for :footer do %> +
+ BOXCAR™ Beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %> +
+<% end %> diff --git a/config/recurring.yml b/config/recurring.yml index 8ed13f58d..89b43c567 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -26,7 +26,7 @@ production: &production class: Webhook::CleanupDeliveriesJob schedule: every 4 hours at minute 51 cleanup_magic_links: - class: "MagicLink::CleanupJob" + command: "MagicLink.cleanup" schedule: every 4 hours sqlite_backups: class: SQLiteBackupsJob diff --git a/config/routes.rb b/config/routes.rb index be32d7119..d8469db1d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,6 @@ Rails.application.routes.draw do namespace :account do - resource :join_code + resource :join_code, only: %i[ show edit update destroy ] resource :settings resource :entropy_configuration end diff --git a/db/migrate/20251011080030_add_setup_status_to_accounts.rb b/db/migrate/20251011080030_add_setup_status_to_accounts.rb deleted file mode 100644 index db5f5c537..000000000 --- a/db/migrate/20251011080030_add_setup_status_to_accounts.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddSetupStatusToAccounts < ActiveRecord::Migration[8.1] - def change - add_column :accounts, :setup_status, :string - end -end diff --git a/db/migrate/20251015081645_create_account_join_codes.rb b/db/migrate/20251015081645_create_account_join_codes.rb new file mode 100644 index 000000000..79b137475 --- /dev/null +++ b/db/migrate/20251015081645_create_account_join_codes.rb @@ -0,0 +1,25 @@ +class CreateAccountJoinCodes < ActiveRecord::Migration[8.1] + def change + create_table :account_join_codes do |t| + t.string :code, null: false, index: { unique: true } + t.integer :usage_count, default: 0, null: false + t.integer :usage_limit, default: 10, null: false + + t.timestamps + end + + reversible do |dir| + dir.up do + execute <<-SQL + INSERT INTO account_join_codes (code, usage_count, usage_limit, created_at, updated_at) + SELECT join_code, 0, 10, datetime('now'), datetime('now') + FROM accounts + WHERE join_code IS NOT NULL; + SQL + end + + dir.down do + end + end + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 76bcbd253..3a7dbf00e 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -18,8 +18,7 @@ def create_tenant(signal_account_name) account = Account.create_with_admin_user( account: { external_account_id: tenant_id, - name: signal_account_name, - setup_status: :complete + name: signal_account_name }, owner: { name: "David Heinemeier Hansson", @@ -32,8 +31,8 @@ def create_tenant(signal_account_name) ApplicationRecord.current_tenant = tenant_id - identity = Membership.find_by(email_address: User.first.email_address)&.identity - User.first.set_identity(identity) + identity = Identity.find_or_create_by!(email_address: User.first.email_address) + identity.memberships.find_or_create_by!(tenant: tenant_id, account_name: Account.sole.name) end def find_or_create_user(full_name, email_address) @@ -45,8 +44,8 @@ def find_or_create_user(full_name, email_address) email_address: email_address, password: "secret123456" - identity = Membership.find_by(email_address: email_address)&.identity || Identity.create! - user.set_identity(identity) + 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) user end diff --git a/db/untenanted_migrate/20251015170934_move_email_to_identity.rb b/db/untenanted_migrate/20251015170934_move_email_to_identity.rb new file mode 100644 index 000000000..711fd5268 --- /dev/null +++ b/db/untenanted_migrate/20251015170934_move_email_to_identity.rb @@ -0,0 +1,47 @@ +class MoveEmailToIdentity < ActiveRecord::Migration[8.1] + def change + add_column :identities, :email_address, :string + + # Create identities for each unique email + execute <<-SQL + INSERT INTO identities (email_address, created_at, updated_at) + SELECT DISTINCT email_address, datetime('now'), datetime('now') + FROM memberships + WHERE email_address IS NOT NULL + AND email_address NOT IN (SELECT email_address FROM identities WHERE email_address IS NOT NULL); + SQL + + # And link memberships to them + execute <<-SQL + UPDATE memberships + SET identity_id = ( + SELECT id + FROM identities + WHERE identities.email_address = memberships.email_address + ) + WHERE email_address IS NOT NULL; + SQL + + # Delete memberships associated with identities without an email address + execute <<-SQL + DELETE FROM memberships + WHERE identity_id IN ( + SELECT id + FROM identities + WHERE email_address IS NULL + ); + SQL + + # Delete identities without an email address + execute <<-SQL + DELETE FROM identities + WHERE email_address IS NULL; + SQL + + change_column_null :memberships, :identity_id, false + add_index :identities, :email_address, unique: true + remove_column :memberships, :email_address + remove_column :memberships, :user_id, :bigint + rename_column :memberships, :user_tenant, :tenant + end +end diff --git a/db/untenanted_migrate/20251015173452_associate_magic_links_to_identities.rb b/db/untenanted_migrate/20251015173452_associate_magic_links_to_identities.rb new file mode 100644 index 000000000..67574ea12 --- /dev/null +++ b/db/untenanted_migrate/20251015173452_associate_magic_links_to_identities.rb @@ -0,0 +1,18 @@ +class AssociateMagicLinksToIdentities < ActiveRecord::Migration[8.1] + def change + add_reference :magic_links, :identity, foreign_key: true + + # Re-associate magic links from memberships to their identity + execute <<-SQL + UPDATE magic_links + SET identity_id = ( + SELECT identity_id + FROM memberships + WHERE memberships.id = magic_links.membership_id + ) + WHERE membership_id IS NOT NULL; + SQL + + remove_column :magic_links, :membership_id + end +end diff --git a/gems/fizzy-saas/app/controllers/concerns/internal_api.rb b/gems/fizzy-saas/app/controllers/concerns/internal_api.rb new file mode 100644 index 000000000..4c88a6d46 --- /dev/null +++ b/gems/fizzy-saas/app/controllers/concerns/internal_api.rb @@ -0,0 +1,28 @@ +module InternalApi + extend ActiveSupport::Concern + + included do + require_untenanted_access + skip_before_action :verify_authenticity_token + before_action :verify_request_authentication + before_action :verify_request_signature + end + + private + def verify_request_authentication + authenticated = authenticate_with_http_token do |token, options| + ActiveSupport::SecurityUtils.secure_compare(token, InternalApiClient.token) + end + + head :unauthorized unless authenticated + end + + def verify_request_signature + signature = request.headers[InternalApiClient::SIGNATURE_HEADER].to_s + computed_signature = InternalApiClient.signature_for(request.raw_post) + + unless ActiveSupport::SecurityUtils.secure_compare(signature, computed_signature) + head :unauthorized + end + end +end diff --git a/gems/fizzy-saas/app/controllers/identities_controller.rb b/gems/fizzy-saas/app/controllers/identities_controller.rb new file mode 100644 index 000000000..0592bed79 --- /dev/null +++ b/gems/fizzy-saas/app/controllers/identities_controller.rb @@ -0,0 +1,23 @@ +class IdentitiesController < ApplicationController + include InternalApi + + def link + Identity.link(email_address: params[:email_address], to: params[:to]) + head :ok + end + + def unlink + Identity.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]) + 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 } + 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 bf322354a..ecdf5b38d 100644 --- a/gems/fizzy-saas/app/controllers/signups/completions_controller.rb +++ b/gems/fizzy-saas/app/controllers/signups/completions_controller.rb @@ -1,9 +1,10 @@ class Signups::CompletionsController < ApplicationController - allow_unauthenticated_access - + require_untenanted_access before_action :require_identity - before_action :ensure_setup_pending - before_action :ensure_identity_of_an_admin + + 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 @@ -13,34 +14,21 @@ class Signups::CompletionsController < ApplicationController @signup = Signup.new(signup_params) if @signup.complete - start_new_session_for(@signup.user) - redirect_to root_path + redirect_to session_login_menu_path(go_to: @signup.tenant) else render :new, status: :unprocessable_entity end end private - def ensure_setup_pending - unless Account.sole.setup_pending? - redirect_to root_path - end - end - - def ensure_identity_of_an_admin - unless user&.admin? - redirect_to root_path - end - end - def signup_params params.expect(signup: %i[ full_name company_name ]).with_defaults( - tenant: ApplicationRecord.current_tenant, - user: user + identity: identity, + email_address: identity.email_address ) end - def user - @user ||= User.all.admin.with_identity(Current.identity_token.identity).first + def identity + @identity ||= Identity.find_signed(Current.identity_token.id) end end diff --git a/gems/fizzy-saas/app/controllers/signups_controller.rb b/gems/fizzy-saas/app/controllers/signups_controller.rb index 8421270e2..ce82f23bb 100644 --- a/gems/fizzy-saas/app/controllers/signups_controller.rb +++ b/gems/fizzy-saas/app/controllers/signups_controller.rb @@ -17,7 +17,8 @@ class SignupsController < ApplicationController def create @signup = Signup.new(signup_params) - if @signup.create_account + if @signup.create_identity + session[:return_to_after_identification] = saas.new_signup_completion_path redirect_to session_magic_link_path else render :new, status: :unprocessable_entity diff --git a/app/mailers/magic_link_mailer.rb b/gems/fizzy-saas/app/mailers/magic_link_mailer.rb similarity index 70% rename from app/mailers/magic_link_mailer.rb rename to gems/fizzy-saas/app/mailers/magic_link_mailer.rb index 11f0f5705..9b04d2ff5 100644 --- a/app/mailers/magic_link_mailer.rb +++ b/gems/fizzy-saas/app/mailers/magic_link_mailer.rb @@ -3,9 +3,9 @@ class MagicLinkMailer < ApplicationMailer def sign_in_instructions(magic_link) @magic_link = magic_link - @membership = @magic_link.membership + @identity = @magic_link.identity - mail to: @membership.email_address, subject: "Sign in to Fizzy" + mail to: @identity.email_address, subject: "Sign in to Fizzy" end private diff --git a/gems/fizzy-saas/app/models/internal_api_client.rb b/gems/fizzy-saas/app/models/internal_api_client.rb new file mode 100644 index 000000000..093fa75db --- /dev/null +++ b/gems/fizzy-saas/app/models/internal_api_client.rb @@ -0,0 +1,99 @@ +class InternalApiClient + SECRET_KEY = "internal_api_client_signing_secret" + TOKEN_KEY = "internal_api_client_token" + USER_AGENT = "fizzy/1.0.0 InternalApiClient" + SIGNATURE_HEADER = "X-Internal-Signature" + DEFAULT_TIMEOUT = 5.seconds + + class Error < StandardError; end + class TimeoutError < Error; end + class ConnectionError < Error; end + + Response = Struct.new(:code, :body, :error) do + def parsed_body + @parsed_body ||= JSON.parse(body) if body.present? + end + + def success? + error.nil? && code.between?(200, 299) + end + end + + attr_reader :url, :response + + class << self + def token + Rails.application.key_generator.generate_key(TOKEN_KEY, 32).unpack1("H*") + end + + def signature_for(body) + OpenSSL::HMAC.hexdigest("SHA256", signing_secret, body) + end + + private + def signing_secret + Rails.application.key_generator.generate_key(SECRET_KEY, 32).unpack1("H*") + end + end + + def initialize(url, timeout: DEFAULT_TIMEOUT) + @url = url + @timeout = timeout + end + + def post(body = nil, params: {}) + uri = build_uri(@url, params) + payload = prepare_payload(body) + request = Net::HTTP::Post.new(uri, headers(payload)) + request.body = payload + perform_request(uri, request) + end + + private + def build_uri(url, params) + uri = URI(url) + + if params.any? + existing_params = URI.decode_www_form(uri.query || "").to_h + uri.query = URI.encode_www_form(existing_params.merge(params)) + end + + uri + end + + def prepare_payload(body) + case body + when nil + "" + when String + body + else + body.to_json + end + end + + def headers(payload) + { + "User-Agent" => USER_AGENT, + "Content-Type" => "application/json", + "Authorization" => "Bearer #{self.class.token}", + SIGNATURE_HEADER => self.class.signature_for(payload) + } + end + + def perform_request(uri, request) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") + http.open_timeout = @timeout + http.read_timeout = @timeout + + response = http.request(request) + Response.new(code: response.code.to_i, body: response.body) + rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT + Response.new(error: :connection_timeout) + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET, SocketError + Response.new(error: :destination_unreachable) + rescue OpenSSL::SSL::SSLError + Response.new(error: :failed_tls) + end +end diff --git a/gems/fizzy-saas/app/models/signup.rb b/gems/fizzy-saas/app/models/signup.rb index e9d9dfbeb..b52da43a0 100644 --- a/gems/fizzy-saas/app/models/signup.rb +++ b/gems/fizzy-saas/app/models/signup.rb @@ -3,17 +3,15 @@ class Signup include ActiveModel::Attributes include ActiveModel::Validations - PERMITTED_KEYS = %i[ full_name email_address company_name ] + attr_accessor :company_name, :full_name, :email_address, :identity + attr_reader :queenbee_account, :account, :user, :tenant - attr_accessor :company_name, :full_name, :email_address, :user, :tenant - attr_reader :queenbee_account, :account - - with_options on: :account_creation do + with_options on: :identity_creation do validates_presence_of :email_address end with_options on: :completion do - validates_presence_of :company_name, :full_name + validates_presence_of :company_name, :full_name, :identity end @@ -30,17 +28,22 @@ class Signup super end - def create_account - return false unless valid?(:account_creation) + def create_identity + return false unless valid?(:identity_creation) - if membership = Membership.find_by(email_address: email_address) - membership.send_magic_link - else - create_queenbee_account - create_tenant - user.membership.send_magic_link - end + @identity = Identity.find_or_create_by!(email_address: email_address) + @identity.send_magic_link + end + def complete + return false unless valid?(:completion) + + create_queenbee_account + create_tenant + + identity.link_to(tenant) + + true rescue => error destroy_tenant destroy_queenbee_account @@ -52,21 +55,6 @@ class Signup false end - def complete - return false unless valid?(:completion) - - ApplicationRecord.with_tenant(tenant) do - @account = Account.sole - - ApplicationRecord.transaction do - user.update!(name: full_name) - account.update!(name: company_name, setup_status: :complete) - user.membership.update!(account_name: company_name) - end - end - # TODO: Update company and user name in QB - end - private def create_queenbee_account @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) @@ -78,17 +66,16 @@ class Signup end def create_tenant - self.tenant = queenbee_account.id.to_s + @tenant = queenbee_account.id.to_s ApplicationRecord.create_tenant(tenant) do @account = Account.create_with_admin_user( account: { external_account_id: tenant, - name: "New Account", - setup_status: :pending + name: company_name }, owner: { - name: email_address, + name: full_name, email_address: email_address } ) @@ -103,8 +90,8 @@ class Signup end @account = nil - self.user = nil - self.tenant = nil + @user = nil + @tenant = nil end def queenbee_account_attributes diff --git a/gems/fizzy-saas/config/routes.rb b/gems/fizzy-saas/config/routes.rb index 172eb6e6f..f6519a5ae 100644 --- a/gems/fizzy-saas/config/routes.rb +++ b/gems/fizzy-saas/config/routes.rb @@ -6,5 +6,11 @@ Fizzy::Saas::Engine.routes.draw do end end end + Queenbee.routes(self) + + post "identities/link", to: "identities#link", as: :link_identity + post "identities/unlink", to: "identities#unlink", as: :unlink_identity + post "identities/change_email_address", to: "identities#change_email_address", as: :change_identity_email_address + post "identities/send_magic_link", to: "identities#send_magic_link", as: :send_magic_link end diff --git a/gems/fizzy-saas/lib/fizzy/saas.rb b/gems/fizzy-saas/lib/fizzy/saas.rb index bfdd34dfa..dd0d35492 100644 --- a/gems/fizzy-saas/lib/fizzy/saas.rb +++ b/gems/fizzy-saas/lib/fizzy/saas.rb @@ -3,6 +3,5 @@ require "fizzy/saas/engine" module Fizzy module Saas - # Your code goes here... 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 5b24277dc..19c542ff0 100644 --- a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb +++ b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb @@ -2,7 +2,6 @@ require "test_helper" class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest setup do - Account.sole.update!(setup_status: :pending) set_identity_as :kevin end @@ -22,12 +21,9 @@ class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to root_path, "Successful completion should redirect to root" assert cookies[:session_token].present?, "Successful completion should create a session" - assert_equal "complete", Account.sole.reload.setup_status, "Account setup status should be complete" assert_equal "Kevin Systrom", users(:kevin).reload.name, "User name should be updated" assert_equal "37signals", Account.sole.reload.name, "Account name should be updated" - Account.sole.update!(setup_status: :pending) - post saas.signup_completion_path, params: { signup: { full_name: "", diff --git a/gems/fizzy-saas/test/models/signup_test.rb b/gems/fizzy-saas/test/models/signup_test.rb index 48c6f499b..e4552be8a 100644 --- a/gems/fizzy-saas/test/models/signup_test.rb +++ b/gems/fizzy-saas/test/models/signup_test.rb @@ -47,7 +47,6 @@ class SignupTest < ActiveSupport::TestCase end test "#complete" do - Account.sole.update!(setup_status: :pending) signup = Signup.new( tenant: ApplicationRecord.current_tenant, user: users(:kevin), @@ -57,7 +56,6 @@ class SignupTest < ActiveSupport::TestCase assert signup.complete, signup.errors.full_messages.to_sentence(words_connector: ". ") - assert_equal "complete", Account.sole.reload.setup_status, "Account setup status should be complete" assert_equal "Kevin Systrom", users(:kevin).reload.name, "User name should be updated" assert_equal "37signals", Account.sole.reload.name, "Account name should be updated" assert_equal "37signals", users(:kevin).membership.reload.account_name, "Membership account name should be updated" diff --git a/test/fixtures/account/invitation_tokens.yml b/test/fixtures/account/invitation_tokens.yml new file mode 100644 index 000000000..bdd7db922 --- /dev/null +++ b/test/fixtures/account/invitation_tokens.yml @@ -0,0 +1,13 @@ +# 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/models/account/join_codes_test.rb b/test/models/account/join_codes_test.rb new file mode 100644 index 000000000..7afcab749 --- /dev/null +++ b/test/models/account/join_codes_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Account::InvitationTokenTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end From ecc023829d0cf40d755be813a2cc59d47652dd83 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 17 Oct 2025 10:27:30 -0500 Subject: [PATCH 11/37] Link to invite from the menu --- app/views/my/menus/_users.html.erb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/views/my/menus/_users.html.erb b/app/views/my/menus/_users.html.erb index 1d3f634cf..8fea7f8a3 100644 --- a/app/views/my/menus/_users.html.erb +++ b/app/views/my/menus/_users.html.erb @@ -1,3 +1,9 @@ <%= collapsible_nav_section "People" do %> + <%= render partial: "my/menus/users/user", collection: user_filtering.users, as: :user %> <% end %> From e3f0c2da04f799eb3e8fe8d17775a20ae5488faa Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 17 Oct 2025 10:40:33 -0500 Subject: [PATCH 12/37] Link to invite from the account screen --- app/views/account/settings/_users.html.erb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index 6011b7e3a..62cdc2de4 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -5,6 +5,11 @@ navigable-list-actionable-items-value="true">

People on the account

+ <%= link_to account_join_code_path, class: "btn btn--link center" do %> + <%= icon_tag "add" %> + Add people + <% end %> +
From 5a0ddbad3ddea801929cd4ff87bf94c0cf5467c3 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 17 Oct 2025 12:07:53 -0500 Subject: [PATCH 13/37] Style invite link flow --- app/assets/stylesheets/utilities.css | 1 + app/views/account/join_codes/edit.html.erb | 47 +++----- app/views/account/join_codes/show.html.erb | 131 +++++++++------------ app/views/account/settings/_users.html.erb | 2 +- 4 files changed, 76 insertions(+), 105 deletions(-) diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 3eb820bfb..cea383315 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -19,6 +19,7 @@ .txt-subtle { color: var(--color-ink-medium); } .txt-alert { color: var(--color-marker); } .txt-undecorated { text-decoration: none; } + .txt-underline { text-decoration: underline; } .txt-tight-lines { line-height: 1.2; } .txt-nowrap { white-space: nowrap; } .txt-break { word-break: break-word; } diff --git a/app/views/account/join_codes/edit.html.erb b/app/views/account/join_codes/edit.html.erb index e93191e10..febe6ab67 100644 --- a/app/views/account/join_codes/edit.html.erb +++ b/app/views/account/join_codes/edit.html.erb @@ -1,44 +1,33 @@ -<% @page_title = "Edit Join Code" %> +<% @page_title = "Change usage limit" %> <% content_for :header do %> <%= render "filters/menu" %> -
+
<%= link_to account_join_code_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> - Join Code + Invite link <% end %>
- -

<%= @page_title %>

<% end %> -
+
+
+

<%= @page_title %>

+

How many times can this link be used to join the account?

+
<%= form_with model: @join_code, url: account_join_code_path, method: :patch, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %> -
- <%= form.label :usage_limit do %> - Usage limit -
+ <%= form.number_field :usage_limit, + required: true, + autofocus: true, + min: @join_code.usage_count, + class: "input center txt-large fit-content font-weight-black", + data: { action: "keydown.esc@document->form#cancel focus->form#select" } %> -

- How many times can this code be used to join the account? -

- <% end %> - - <%= form.number_field :usage_limit, - required: true, - autofocus: true, - min: @join_code.usage_count, - class: "input", - data: { action: "keydown.esc@document->form#cancel" } %> -

- Current usage: <%= @join_code.usage_count %>. You can only increase the limit to allow more uses. -

-
+

+ This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit %> times. +

<%= form.button type: :submit, class: "btn btn--link center txt-medium" do %> - Update join code + Save changes <% end %> <%= link_to "Go back", account_join_code_path, data: { form_target: "cancel" }, hidden: true %> diff --git a/app/views/account/join_codes/show.html.erb b/app/views/account/join_codes/show.html.erb index b33d283be..7a4b22129 100644 --- a/app/views/account/join_codes/show.html.erb +++ b/app/views/account/join_codes/show.html.erb @@ -1,87 +1,68 @@ -<% @page_title = "Join Code" %> +<% @page_title = "Add people" %> <% content_for :header do %> <%= render "filters/menu" %> -

<%= @page_title %>

+
+ <%= link_to account_settings_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + Account Settings + <% end %> +
<% end %> -
-
-
-

- <%= @join_code.code %> -

- <% if @join_code.active? %> - Active - <% else %> - Used up +
+
+

<%= @page_title %>

+

Share the link below to invite people to this account

+
+ + <% url = join_url(join_code: @join_code.code, script_name: "/#{@join_code.tenant}") %> +
+ + <%= button_to account_join_code_path, method: :delete, class: "btn btn--circle txt-small", + data: { turbo_confirm: "Are you sure you want to generate a new link? The previous code will stop working." } do %> + <%= icon_tag "refresh" %> + Generate a new code + <% end %> +
+ +
+ <%= tag.button class: "btn btn--link", data: { + controller: "copy-to-clipboard", action: "copy-to-clipboard#copy", + copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: url } do %> + + <%= icon_tag "copy-paste" %> + Copy invite link + <% end %> + +
+ <%= tag.button class: "btn", data: { action: "dialog#open" } do %> + <%= icon_tag "qr-code" %> + Get QR code <% end %> -
-
-
- Usage -

- <%= @join_code.usage_count %> - of - <%= @join_code.usage_limit %> - uses - <% if @join_code.active? %> - ( - <%= @join_code.usage_limit - @join_code.usage_count %> - remaining) - <% end %> -

-
-
+ +

Scan this code with the camera on your mobile device

-
- -
- Join URL - - <% url = join_url(join_code: @join_code.code, script_name: "/#{@join_code.tenant}") %> -
- - <%= button_to_copy_to_clipboard(url) do %> - <%= icon_tag "copy-paste" %> - Copy join link - <% end %> -
- -

- Share this URL with people you want to invite to your account. -

- -
<%= qr_code_image(url) %> -
-
-
- -
- <%= link_to edit_account_join_code_path, class: "btn btn--secondary txt-small" do %> - <%= icon_tag "edit" %> - Edit limit - <% end %> - - <%= button_to account_join_code_path, method: :delete, class: "btn btn--negative txt-small", - data: { turbo_confirm: "Are you sure you want to regenerate this join code? The old code will no longer work." } do %> - <%= icon_tag "refresh" %> - Regenerate - <% end %> +
+ +
+
-
+ +
+
+ +

"> + This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit %> times + (<%= @join_code.active? ? @join_code.usage_limit - @join_code.usage_count : "none" %> remaining) + <%= link_to edit_account_join_code_path, class: @join_code.active? ? "txt-link" : "txt-negative txt-underline" do %> + Change limit + <% end %> +

+
+
\ No newline at end of file diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index 62cdc2de4..631cfddf0 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -5,7 +5,7 @@ navigable-list-actionable-items-value="true">

People on the account

- <%= link_to account_join_code_path, class: "btn btn--link center" do %> + <%= link_to account_join_code_path, class: "btn btn--link center txt-small" do %> <%= icon_tag "add" %> Add people <% end %> From 79b012f319f5577433d1462fa0c0f1b4536648b3 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 17 Oct 2025 16:28:19 +0200 Subject: [PATCH 14/37] Add Join Codes --- app/controllers/concerns/authentication.rb | 11 +- .../sessions/login_menus_controller.rb | 17 +-- app/controllers/sessions/starts_controller.rb | 19 +++ .../confirmations_controller.rb | 21 +++ .../users/email_addresses_controller.rb | 21 +++ app/controllers/users_controller.rb | 2 +- app/helpers/magic_link_helper.rb | 5 - .../app => app}/mailers/magic_link_mailer.rb | 2 - app/mailers/user_mailer.rb | 8 ++ app/models/account.rb | 7 +- app/models/identity_provider.rb | 22 ++- app/models/identity_provider/simple.rb | 54 +++++++ app/models/membership.rb | 8 -- app/models/user.rb | 11 +- app/models/user/email_address_changeable.rb | 29 ++++ app/models/user/identifiable.rb | 41 +++--- app/models/user/invitable.rb | 5 +- app/models/user/role.rb | 1 - .../sign_in_instructions.html.erb | 2 +- .../sign_in_instructions.text.erb | 2 +- .../email_change_confirmation.html.erb | 9 ++ .../email_change_confirmation.text.erb | 7 + app/views/my/menus/_accounts.html.erb | 6 +- app/views/my/menus/_places.html.erb | 6 - app/views/sessions/login_menus/show.html.erb | 16 ++- .../sessions/magic_links/create.html.erb | 22 --- app/views/sessions/starts/new.html.erb | 28 ++++ app/views/users/edit.html.erb | 9 +- .../confirmations/show.html.erb | 22 +++ .../users/email_addresses/create.html.erb | 19 +++ app/views/users/email_addresses/new.html.erb | 27 ++++ config/environments/test.rb | 2 +- config/routes.rb | 4 + ...0251015081645_create_account_join_codes.rb | 6 + db/schema.rb | 1 - db/seeds.rb | 9 +- .../app/controllers/concerns/restricted.rb | 9 ++ .../app/controllers/identities_controller.rb | 10 +- .../signups/completions_controller.rb | 8 +- .../app/controllers/signups_controller.rb | 6 +- .../app}/models/identity_provider/saas.rb | 17 +-- gems/fizzy-saas/app/models/signup.rb | 12 +- gems/fizzy-saas/lib/fizzy/saas/engine.rb | 2 + .../controllers/identities_controller_test.rb | 105 ++++++++++++++ .../signups/completions_controller_test.rb | 57 +++++--- .../controllers/signups_controller_test.rb | 2 +- .../models/identity_provider/saas_test.rb | 132 ++++++++++++++++++ gems/fizzy-saas/test/models/signup_test.rb | 60 ++++---- script/create-account.rb | 5 +- script/load-prod-db-in-dev.rb | 6 +- .../20251013-mark-all-accounts-as-setup.rb | 8 -- .../controller_authentication_test.rb | 1 + .../sessions/login_menus_controller_test.rb | 2 +- .../sessions/magic_links_controller_test.rb | 6 +- test/controllers/sessions_controller_test.rb | 8 +- test/controllers/users_controller_test.rb | 15 +- test/fixtures/accesses.yml | 2 - test/fixtures/account/invitation_tokens.yml | 13 -- test/fixtures/account/join_codes.yml | 6 + test/fixtures/accounts.yml | 1 - test/fixtures/identities.yml | 9 +- test/fixtures/memberships.yml | 17 +-- test/fixtures/users.yml | 5 - test/integration/identity_membership_test.rb | 16 --- test/mailers/magic_link_mailer_test.rb | 2 +- test/mailers/previews/magic_link_preview.rb | 5 +- test/models/account/join_code_test.rb | 30 ++++ test/models/account/join_codes_test.rb | 7 - test/models/account_test.rb | 6 + test/models/identity_provider/simple_test.rb | 105 ++++++++++++++ test/models/identity_test.rb | 12 +- test/models/magic_link_test.rb | 22 +-- test/models/membership_test.rb | 33 ++--- test/models/user/accessor_test.rb | 2 +- .../user/email_address_changeable_test.rb | 27 ++++ test/models/user/identifiable_test.rb | 77 +++------- test/models/user_test.rb | 16 ++- test/test_helper.rb | 2 + test/test_helpers/session_test_helper.rb | 14 +- 79 files changed, 969 insertions(+), 412 deletions(-) create mode 100644 app/controllers/sessions/starts_controller.rb create mode 100644 app/controllers/users/email_addresses/confirmations_controller.rb create mode 100644 app/controllers/users/email_addresses_controller.rb delete mode 100644 app/helpers/magic_link_helper.rb rename {gems/fizzy-saas/app => app}/mailers/magic_link_mailer.rb (92%) create mode 100644 app/mailers/user_mailer.rb create mode 100644 app/models/identity_provider/simple.rb create mode 100644 app/models/user/email_address_changeable.rb create mode 100644 app/views/mailers/user_mailer/email_change_confirmation.html.erb create mode 100644 app/views/mailers/user_mailer/email_change_confirmation.text.erb delete mode 100644 app/views/sessions/magic_links/create.html.erb create mode 100644 app/views/sessions/starts/new.html.erb create mode 100644 app/views/users/email_addresses/confirmations/show.html.erb create mode 100644 app/views/users/email_addresses/create.html.erb create mode 100644 app/views/users/email_addresses/new.html.erb create mode 100644 gems/fizzy-saas/app/controllers/concerns/restricted.rb rename {app => gems/fizzy-saas/app}/models/identity_provider/saas.rb (68%) create mode 100644 gems/fizzy-saas/test/controllers/identities_controller_test.rb create mode 100644 gems/fizzy-saas/test/models/identity_provider/saas_test.rb delete mode 100755 script/migrations/20251013-mark-all-accounts-as-setup.rb delete mode 100644 test/fixtures/account/invitation_tokens.yml create mode 100644 test/fixtures/account/join_codes.yml delete mode 100644 test/integration/identity_membership_test.rb create mode 100644 test/models/account/join_code_test.rb delete mode 100644 test/models/account/join_codes_test.rb create mode 100644 test/models/identity_provider/simple_test.rb create mode 100644 test/models/user/email_address_changeable_test.rb 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 @@
-
-
- + <%= link_to "Change", new_user_email_address_path(@user), class: "btn btn--secondary" %>
+ <% 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| %> +
+ +
+ + + + <%= 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 } From c0b4205b3c34b7022c689ee2e4e110e794a99dbf Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Tue, 21 Oct 2025 15:11:31 -0700 Subject: [PATCH 15/37] Fix missing images --- app/views/sessions/login_menus/show.html.erb | 4 ++-- app/views/sessions/magic_links/show.html.erb | 2 +- app/views/sessions/new.html.erb | 2 +- app/views/sessions/starts/new.html.erb | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/sessions/login_menus/show.html.erb b/app/views/sessions/login_menus/show.html.erb index 7085f5c6c..26bdbea43 100644 --- a/app/views/sessions/login_menus/show.html.erb +++ b/app/views/sessions/login_menus/show.html.erb @@ -3,7 +3,7 @@ <% content_for :header do %> @@ -21,7 +21,7 @@ <% @tenants.each do |tenant| %>