diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 0f936d05b..f1e997503 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -4,13 +4,14 @@ module ApplicationCable def connect super - ApplicationRecord.with_tenant(current_tenant) { set_current_user || reject_unauthorized_connection } + set_current_user || reject_unauthorized_connection end private def set_current_user if session = find_session_by_cookie - self.current_user = session.user + membership = session.identity.memberships.find_by!(tenant: current_tenant) + self.current_user = membership.user if membership.user.active? end end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 8d30d0cbd..f8ed9ec6d 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -21,24 +21,13 @@ module Authentication def allow_unauthenticated_access(**options) skip_before_action :require_authentication, **options - before_action :resume_identity, **options before_action :resume_session, **options end def require_untenanted_access(**options) skip_before_action :require_tenant, **options - skip_before_action :require_authentication, **options before_action :redirect_tenanted_request, **options end - - def require_identified_access(**options) - before_action :require_identity, **options - end - - def require_unidentified_access(**options) - before_action :resume_identity, **options - before_action :redirect_request_with_identification, **options - end end private @@ -48,34 +37,12 @@ module Authentication def require_tenant unless ApplicationRecord.current_tenant.present? - if resume_identity - redirect_to session_login_menu_url(script_name: nil) - else - request_authentication - end + redirect_to session_menu_url(script_name: nil) end end - def require_identity - resume_identity || request_authentication - end - def require_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_token = find_identity_by_cookie - set_current_identity(identity_token) - end + resume_session || request_authentication end def resume_session @@ -84,10 +51,6 @@ module Authentication end end - def find_identity_by_cookie - IdentityProvider.verify_token(cookies.signed[:identity_token]) - end - def find_session_by_cookie Session.find_signed(cookies.signed[:session_token]) end @@ -104,10 +67,6 @@ module Authentication session.delete(:return_to_after_authenticating) || root_url end - def after_identification_url - session.delete(:return_to_after_identification) || session_login_menu_path - end - def redirect_authenticated_user redirect_to root_url if authenticated? end @@ -116,34 +75,20 @@ module Authentication redirect_to root_url if ApplicationRecord.current_tenant end - def redirect_request_with_identification - redirect_to session_login_menu_path(script_name: nil) if Current.identity_token.present? - end - - def start_new_session_for(user) - user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + def start_new_session_for(identity) + identity.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| set_current_session session end end - def set_current_identity(identity_token) - Current.identity_token = if identity_token - cookies.signed.permanent[:identity_token] = { value: identity_token.to_h, httponly: true, same_site: :lax } - identity_token - else - nil - end - end - def set_current_session(session) - logger.struct " Authorized User##{session.user.id}", authentication: { user: { id: session.user.id } } + logger.struct " Authorized Identity##{session.identity.id}", authentication: { identity: { id: session.identity.id } } Current.session = session - cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax, path: Account.sole.slug } + cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax } end def terminate_session Current.session.destroy cookies.delete(:session_token) - cookies.delete(:identity_token) end end diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 9744a0465..6bb040c33 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -1,4 +1,21 @@ module Authorization + extend ActiveSupport::Concern + + included do + before_action :ensure_can_access_account, if: -> { ApplicationRecord.current_tenant && Current.session} + end + + class_methods do + def allow_unauthorized_access(**options) + skip_before_action :ensure_can_access_account, **options + end + + def require_access_without_a_user(**options) + skip_before_action :ensure_can_access_account, **options + before_action :redirect_existing_user, **options + end + end + private def ensure_admin head :forbidden unless Current.user.admin? @@ -7,4 +24,32 @@ module Authorization def ensure_staff head :forbidden unless Current.user.staff? end + + def ensure_can_access_account + if Current.membership.blank? + redirect_to session_menu_url(script_name: nil) + elsif Current.user.nil? && Current.membership.join_code.present? + redirect_to new_user_path + elsif !Current.user&.active? + redirect_to unlink_membership_url(script_name: nil, membership_id: Current.membership.signed_id(purpose: :unlinking)) + end + end + + def redirect_existing_user + redirect_to root_path if Current.user + end + + def account_entry_url(membership) + if !ApplicationRecord.tenant_exists?(membership.tenant) + elsif membership.user.blank? + # We are joining an account. This means the user doesn't yet exist and + # we have to create it + new_user_url(script_name: "/#{membership.tenant}", membership: membership.to_signed(for: :user_creation)) + # The user exists, but was deactivated, we want to remove the membership + unlink_membership_url(script_name: nil, membership: membership.to_signed(for: :unlinking)) + else + # Everything is fine, let the user enter the account + root_url(script_name: "/#{membership.tenant}") + end + end end diff --git a/app/controllers/identity/email_addresses/confirmations_controller.rb b/app/controllers/identity/email_addresses/confirmations_controller.rb new file mode 100644 index 000000000..e19983f82 --- /dev/null +++ b/app/controllers/identity/email_addresses/confirmations_controller.rb @@ -0,0 +1,34 @@ +class Identity::EmailAddresses::ConfirmationsController < ApplicationController + require_untenanted_access + + before_action :set_identity + rate_limit to: 3, within: 1.hour, only: :create + + def show + end + + def create + @identity.change_email_address_using_token(token) + + # Redirect to the tenant that initiated the change + tenant = SignedGlobalID.parse(token, for: Identity::EmailAddressChangeable::EMAIL_CHANGE_TOKEN_PURPOSE)&.params&.fetch("tenant") + + if tenant + redirect_to "#{tenant}/users/#{Current.user.id}/edit" + else + redirect_to session_menu_path + end + rescue ArgumentError => e + flash[:alert] = e.message + render :show, status: :unprocessable_entity + end + + private + def set_identity + @identity = Current.identity + end + + def token + params.expect :email_address_token + end +end diff --git a/app/controllers/identity/email_addresses_controller.rb b/app/controllers/identity/email_addresses_controller.rb new file mode 100644 index 000000000..147c3bb12 --- /dev/null +++ b/app/controllers/identity/email_addresses_controller.rb @@ -0,0 +1,36 @@ +class Identity::EmailAddressesController < ApplicationController + require_untenanted_access + + before_action :set_identity + rate_limit to: 3, within: 1.hour, only: :create + + def new + @tenant = params[:tenant] + @user = ApplicationRecord.with_tenant(@tenant) { Current.identity.user } + end + + def create + @tenant = tenant + @user = ApplicationRecord.with_tenant(@tenant) { Current.identity.user } + + if Identity.exists?(email_address: new_email_address) + flash.now[:alert] = "Someone else already uses that email" + render :new, status: :unprocessable_entity + else + @identity.send_email_address_change_confirmation(new_email_address, tenant: tenant) + end + end + + private + def set_identity + @identity = Current.identity + end + + def new_email_address + params.expect :email_address + end + + def tenant + params.expect :tenant + end +end diff --git a/app/controllers/join_codes_controller.rb b/app/controllers/join_codes_controller.rb new file mode 100644 index 000000000..972420439 --- /dev/null +++ b/app/controllers/join_codes_controller.rb @@ -0,0 +1,32 @@ +class JoinCodesController < ApplicationController + require_untenanted_access + allow_unauthenticated_access + before_action :set_join_code + + def new + @account_name = ApplicationRecord.with_tenant(tenant) { Account.sole.name } + end + + def create + Identity.transaction do + identity = Identity.find_or_create_by(email_address: params.expect(:email_address)) + identity.memberships.create!(tenant: tenant, join_code: code) + identity.send_magic_link + end + + redirect_to session_magic_link_path + end + + private + def set_join_code + @join_code ||= ApplicationRecord.with_tenant(tenant) { Account::JoinCode.active.find_by(code: code) } + end + + def tenant + params.expect(:tenant) + end + + def code + params.expect(:code) + end +end diff --git a/app/controllers/memberships/unlink_controller.rb b/app/controllers/memberships/unlink_controller.rb new file mode 100644 index 000000000..63664aca8 --- /dev/null +++ b/app/controllers/memberships/unlink_controller.rb @@ -0,0 +1,17 @@ +class Memberships::UnlinkController < ApplicationController + require_untenanted_access + before_action :set_membership + + def show + end + + def create + @membership.destroy + redirect_to session_menu_path + end + + private + def set_membership + @membership = Current.identity.memberships.find_signed!(params[:membership_id], purpose: :unlinking) + end +end diff --git a/app/controllers/my/menus_controller.rb b/app/controllers/my/menus_controller.rb index 2fac3d8d0..900cf7c0a 100644 --- a/app/controllers/my/menus_controller.rb +++ b/app/controllers/my/menus_controller.rb @@ -2,6 +2,6 @@ class My::MenusController < ApplicationController include FilterScoped def show - fresh_when etag: [ @user_filtering, Current.identity_token ] + fresh_when etag: [ @user_filtering, Current.session ] end end diff --git a/app/controllers/notifications/unsubscribes_controller.rb b/app/controllers/notifications/unsubscribes_controller.rb index f7486d91e..ca15e78d4 100644 --- a/app/controllers/notifications/unsubscribes_controller.rb +++ b/app/controllers/notifications/unsubscribes_controller.rb @@ -1,5 +1,6 @@ class Notifications::UnsubscribesController < ApplicationController allow_unauthenticated_access + allow_unauthorized_access skip_before_action :verify_authenticity_token before_action :set_user diff --git a/app/controllers/public/cards_controller.rb b/app/controllers/public/cards_controller.rb index 9434882e7..50d9b1ed0 100644 --- a/app/controllers/public/cards_controller.rb +++ b/app/controllers/public/cards_controller.rb @@ -2,6 +2,7 @@ class Public::CardsController < ApplicationController include CachedPublicly, PublicCardScoped allow_unauthenticated_access only: :show + allow_unauthorized_access only: :show layout "public" diff --git a/app/controllers/public/collections/columns/closeds_controller.rb b/app/controllers/public/collections/columns/closeds_controller.rb index f41e088f2..c049785cb 100644 --- a/app/controllers/public/collections/columns/closeds_controller.rb +++ b/app/controllers/public/collections/columns/closeds_controller.rb @@ -2,6 +2,7 @@ class Public::Collections::Columns::ClosedsController < ApplicationController include CachedPublicly, PublicCollectionScoped allow_unauthenticated_access only: :show + allow_unauthorized_access only: :show layout "public" diff --git a/app/controllers/public/collections/columns/not_nows_controller.rb b/app/controllers/public/collections/columns/not_nows_controller.rb index 64382869d..d3cd2ed02 100644 --- a/app/controllers/public/collections/columns/not_nows_controller.rb +++ b/app/controllers/public/collections/columns/not_nows_controller.rb @@ -2,6 +2,7 @@ class Public::Collections::Columns::NotNowsController < ApplicationController include CachedPublicly, PublicCollectionScoped allow_unauthenticated_access only: :show + allow_unauthorized_access only: :show layout "public" diff --git a/app/controllers/public/collections/columns/streams_controller.rb b/app/controllers/public/collections/columns/streams_controller.rb index a1fb07391..a8a2165e5 100644 --- a/app/controllers/public/collections/columns/streams_controller.rb +++ b/app/controllers/public/collections/columns/streams_controller.rb @@ -2,6 +2,7 @@ class Public::Collections::Columns::StreamsController < ApplicationController include CachedPublicly, PublicCollectionScoped allow_unauthenticated_access only: :show + allow_unauthorized_access only: :show layout "public" diff --git a/app/controllers/public/collections/columns_controller.rb b/app/controllers/public/collections/columns_controller.rb index 9f32afc1b..dcc2b46a3 100644 --- a/app/controllers/public/collections/columns_controller.rb +++ b/app/controllers/public/collections/columns_controller.rb @@ -2,6 +2,7 @@ class Public::Collections::ColumnsController < ApplicationController include ActionView::RecordIdentifier, CachedPublicly, PublicCollectionScoped allow_unauthenticated_access only: :show + allow_unauthorized_access only: :show layout "public" diff --git a/app/controllers/public/collections_controller.rb b/app/controllers/public/collections_controller.rb index 46fb5d750..18ae24022 100644 --- a/app/controllers/public/collections_controller.rb +++ b/app/controllers/public/collections_controller.rb @@ -2,6 +2,7 @@ class Public::CollectionsController < ApplicationController include CachedPublicly, PublicCollectionScoped allow_unauthenticated_access only: :show + allow_unauthorized_access only: :show layout "public" diff --git a/app/controllers/sessions/login_menus_controller.rb b/app/controllers/sessions/login_menus_controller.rb deleted file mode 100644 index 1b2d69bc9..000000000 --- a/app/controllers/sessions/login_menus_controller.rb +++ /dev/null @@ -1,10 +0,0 @@ -class Sessions::LoginMenusController < ApplicationController - require_untenanted_access - require_identified_access - - layout "public" - - def show - @tenants = IdentityProvider.tenants_for(resume_identity) - end -end diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 4f90b38d0..3b836e445 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -1,6 +1,6 @@ class Sessions::MagicLinksController < ApplicationController require_untenanted_access - require_unidentified_access + require_unauthenticated_access rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Try again in 15 minutes." } layout "public" @@ -9,13 +9,11 @@ class Sessions::MagicLinksController < ApplicationController end def create - identity_token = IdentityProvider.consume_magic_link(code) - - if identity_token.blank? - redirect_to session_magic_link_path, alert: "Try another code." + if identity = MagicLink.consume(code) + start_new_session_for identity + redirect_to after_authentication_url else - set_current_identity(identity_token) - redirect_to after_identification_url + redirect_to session_magic_link_path, alert: "Try another code." end end diff --git a/app/controllers/sessions/menus_controller.rb b/app/controllers/sessions/menus_controller.rb new file mode 100644 index 000000000..91f3d742b --- /dev/null +++ b/app/controllers/sessions/menus_controller.rb @@ -0,0 +1,17 @@ +class Sessions::MenusController < ApplicationController + require_untenanted_access + + layout "public" + + def show + if params[:menu_section] + request.variant = :menu_section + end + + @memberships = Current.identity.memberships + + if params[:without] + @memberships = @memberships.where.not(tenant: params[:without]) + end + end +end diff --git a/app/controllers/sessions/starts_controller.rb b/app/controllers/sessions/starts_controller.rb deleted file mode 100644 index b2a99aa88..000000000 --- a/app/controllers/sessions/starts_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -class Sessions::StartsController < ApplicationController - allow_unauthenticated_access - require_identified_access - - layout "public" - - 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/sessions/transfers_controller.rb b/app/controllers/sessions/transfers_controller.rb index d30c263b6..b3b7ae773 100644 --- a/app/controllers/sessions/transfers_controller.rb +++ b/app/controllers/sessions/transfers_controller.rb @@ -1,13 +1,14 @@ class Sessions::TransfersController < ApplicationController + require_untenanted_access require_unauthenticated_access def show end def update - if user = User.active.find_by_transfer_id(params[:id]) - start_new_session_for user - redirect_to root_path + if identity = Identity.find_by_transfer_id(params[:id]) + start_new_session_for identity + redirect_to session_menu_path(script_name: nil) else head :bad_request end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index a7a09c764..25cb98408 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,6 +1,6 @@ class SessionsController < ApplicationController - require_untenanted_access only: %i[ new create ] - require_unidentified_access only: %i[ new create ] + require_untenanted_access + require_unauthenticated_access except: :destroy rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } layout "public" @@ -9,11 +9,7 @@ class SessionsController < ApplicationController end def create - magic_link_code = IdentityProvider.send_magic_link(email_address) - - if magic_link_code && Rails.env.development? - flash[:notice] = "Magic Link Code: #{magic_link_code}" - end + Identity.find_by_email_address(email_address)&.send_magic_link redirect_to session_magic_link_path end diff --git a/app/controllers/users/email_addresses/confirmations_controller.rb b/app/controllers/users/email_addresses/confirmations_controller.rb deleted file mode 100644 index 594379f75..000000000 --- a/app/controllers/users/email_addresses/confirmations_controller.rb +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index c9c370315..000000000 --- a/app/controllers/users/email_addresses_controller.rb +++ /dev/null @@ -1,25 +0,0 @@ -class Users::EmailAddressesController < ApplicationController - before_action :set_user - rate_limit to: 3, within: 1.hour, only: :create - - def new - end - - def create - if User.exists?(email_address: new_email_address) - flash.now[:alert] = "Someone else already uses that email" - render :new, status: :unprocessable_entity - else - @user.send_email_address_change_confirmation(new_email_address) - end - 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 71b6e2462..811261c87 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,11 +1,12 @@ class UsersController < ApplicationController - require_unauthenticated_access only: %i[ new create ] - include FilterScoped - before_action :set_user, only: %i[ show edit update destroy ] + require_access_without_a_user only: %i[ new create ] + + before_action :set_join_code, only: %i[ new create] before_action :ensure_join_code_is_valid, only: %i[ new create ] - before_action :ensure_permission_to_change_user, only: %i[ update destroy ] + before_action :set_user, only: %i[ show edit update destroy ] + before_action :ensure_permission_to_change_user, only: %i[ update destroy ] before_action :set_filter, only: %i[ edit show ] before_action :set_user_filtering, only: %i[ edit show] @@ -13,12 +14,11 @@ class UsersController < ApplicationController end def create - if Account::JoinCode.redeem(params[:join_code]) - User.invite(**invite_params) - redirect_to session_magic_link_path(script_name: nil) - else - head :forbidden + @join_code.redeem do + User.create!(user_params.merge(membership: Current.membership)) end + + redirect_to root_path end def edit @@ -40,8 +40,14 @@ class UsersController < ApplicationController end private + def set_join_code + @join_code = Account::JoinCode.active.find_by(code: Current.membership.join_code) + end + def ensure_join_code_is_valid - head :forbidden unless Account::JoinCode.active?(params[:join_code]) + unless @join_code&.active? + redirect_to unlink_membership_url(script_name: nil, membership_id: Current.membership.signed_id(purpose: :unlinking)) + end end def set_user @@ -63,8 +69,4 @@ class UsersController < ApplicationController def user_params params.expect(user: [ :name, :avatar ]) end - - def invite_params - params.expect(user: [ :name, :email_address ]) - end end diff --git a/app/mailers/identity_mailer.rb b/app/mailers/identity_mailer.rb new file mode 100644 index 000000000..3814806d4 --- /dev/null +++ b/app/mailers/identity_mailer.rb @@ -0,0 +1,9 @@ +class IdentityMailer < ApplicationMailer + def email_change_confirmation(identity:, email_address:, token:, tenant:) + @identity = identity + @token = token + @tenant = tenant + + mail to: email_address, subject: "Confirm your new email address" + end +end diff --git a/app/mailers/notification/bundle_mailer.rb b/app/mailers/notification/bundle_mailer.rb index 4c7de3e68..32f883efd 100644 --- a/app/mailers/notification/bundle_mailer.rb +++ b/app/mailers/notification/bundle_mailer.rb @@ -10,7 +10,7 @@ class Notification::BundleMailer < ApplicationMailer @unsubscribe_token = @user.generate_token_for(:unsubscribe) mail \ - to: bundle.user.email_address, + to: bundle.user.identity.email_address, subject: "Latest Activity in BOXCAR" end end diff --git a/app/models/account/join_code.rb b/app/models/account/join_code.rb index 2ed42e5c1..ddf4cf822 100644 --- a/app/models/account/join_code.rb +++ b/app/models/account/join_code.rb @@ -9,20 +9,10 @@ class Account::JoinCode < ApplicationRecord validates :usage_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :usage_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 } - class << self - def redeem(code) - join_code = find_by(code: code) - - if join_code&.active? - join_code.increment!(:usage_count) - true - else - false - end - end - - def active?(code) - active.exists?(code: code) + def redeem + transaction do + increment!(:usage_count) + yield if block_given? end end diff --git a/app/models/current.rb b/app/models/current.rb index 9974e5db9..a3ba812a6 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,6 +1,15 @@ class Current < ActiveSupport::CurrentAttributes - attribute :session, :identity_token + attribute :session, :membership attribute :http_method, :request_id, :user_agent, :ip_address, :referrer - delegate :user, to: :session, allow_nil: true + delegate :identity, to: :session, allow_nil: true + delegate :user, to: :membership, allow_nil: true + + def session=(value) + super(value) + + unless value.nil? + self.membership = identity.memberships.find_by(tenant: ApplicationRecord.current_tenant) + end + end end diff --git a/app/models/identity.rb b/app/models/identity.rb index c2aec4061..5f024cfa7 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,18 +1,12 @@ class Identity < UntenantedRecord + include EmailAddressChangeable, Transferable + has_many :memberships, dependent: :destroy - has_many :magic_links, dependent: :delete_all + has_many :magic_links, dependent: :destroy + has_many :sessions, dependent: :destroy normalizes :email_address, with: ->(value) { value.strip.downcase } - - class << self - def link(email_address:, to:) - find_or_create_by!(email_address: email_address).tap { |identity| identity.link_to(to) } - end - - def unlink(email_address:, from:) - find_by(email_address: email_address)&.unlink_from(from) - end - end + validates :email_address, presence: true def send_magic_link magic_links.create!.tap do |magic_link| @@ -20,13 +14,17 @@ class Identity < UntenantedRecord end end - def link_to(tenant) + def link_to(tenant, context: nil) memberships.find_or_create_by!(tenant: tenant) do |membership| - membership.account_name = ApplicationRecord.with_tenant(membership.tenant) { Account.sole.name } + membership.context = context end end def unlink_from(tenant) memberships.find_by(tenant: tenant)&.destroy end + + def staff? + email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com") + end end diff --git a/app/models/identity/email_address_changeable.rb b/app/models/identity/email_address_changeable.rb new file mode 100644 index 000000000..90e4d94f5 --- /dev/null +++ b/app/models/identity/email_address_changeable.rb @@ -0,0 +1,58 @@ +module Identity::EmailAddressChangeable + EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address" + EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes + + extend ActiveSupport::Concern + + def send_email_address_change_confirmation(new_email_address, tenant:) + token = generate_email_address_change_token(to: new_email_address, tenant: tenant, expires_in: EMAIL_CHANGE_TOKEN_EXPIRATION) + IdentityMailer.email_change_confirmation(identity: self, email_address: new_email_address, token: token, tenant: tenant).deliver_later + end + + def generate_email_address_change_token(from: email_address, to:, tenant:, **options) + options = options.reverse_merge( + for: EMAIL_CHANGE_TOKEN_PURPOSE, + old_email_address: from, + new_email_address: to, + tenant: tenant + ) + + 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 identity" + elsif email_address != parsed_token.params.fetch("old_email_address") + raise ArgumentError, "The token was generated for a different email address" + else + tenant = parsed_token.params.fetch("tenant") + new_email_address = parsed_token.params.fetch("new_email_address") + change_email_address(new_email_address, tenant: tenant) + end + end + + private + def change_email_address(new_email_address, tenant:) + old_email_address = email_address + + # First update the identity email + update!(email_address: new_email_address) + + begin + # Then update the membership to point to the new identity + Membership.change_email_address(from: old_email_address, to: new_email_address, tenant: tenant) + + # Also update the user's email in the tenant database (read-only from untenanted context) + # This will be handled by the user updating their own record + rescue => e + # Rollback identity email if membership update fails + update!(email_address: old_email_address) + raise e + end + end +end diff --git a/app/models/identity/transferable.rb b/app/models/identity/transferable.rb new file mode 100644 index 000000000..7806c51ce --- /dev/null +++ b/app/models/identity/transferable.rb @@ -0,0 +1,15 @@ +module Identity::Transferable + extend ActiveSupport::Concern + + TRANSFER_LINK_EXPIRY_DURATION = 4.hours + + class_methods do + def find_by_transfer_id(id) + find_signed(id, purpose: :transfer) + end + end + + def transfer_id + signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION) + end +end diff --git a/app/models/identity_provider.rb b/app/models/identity_provider.rb deleted file mode 100644 index 2fb22bfd2..000000000 --- a/app/models/identity_provider.rb +++ /dev/null @@ -1,19 +0,0 @@ -module IdentityProvider - class Error < StandardError; end - - 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 - - mattr_accessor :backend, default: IdentityProvider::LocalBackend - - 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/local_backend.rb b/app/models/identity_provider/local_backend.rb deleted file mode 100644 index 99b709110..000000000 --- a/app/models/identity_provider/local_backend.rb +++ /dev/null @@ -1,54 +0,0 @@ -module IdentityProvider::LocalBackend - 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 e47a5c0c5..56af3e31e 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -1,6 +1,9 @@ class Membership < UntenantedRecord belongs_to :identity, touch: true + serialize :context, coder: JSON + store_accessor :context, :join_code + class << self def change_email_address(from:, to:, tenant:) identity = Identity.find_by(email_address: from) @@ -12,4 +15,16 @@ class Membership < UntenantedRecord end end end + + def account_name + ApplicationRecord.with_tenant(tenant) { Account.sole.name } + rescue ActiveRecord::Tenanted::TenantDoesNotExistError + nil + end + + def user + ApplicationRecord.with_tenant(tenant) { User.find_by(membership_id: id) } + rescue ActiveRecord::Tenanted::TenantDoesNotExistError + nil + end end diff --git a/app/models/session.rb b/app/models/session.rb index cf376fb28..92d0f20b2 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,3 +1,3 @@ -class Session < ApplicationRecord - belongs_to :user +class Session < UntenantedRecord + belongs_to :identity end diff --git a/app/models/user.rb b/app/models/user.rb index d18450332..2046b64d5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,14 +1,15 @@ class User < ApplicationRecord - include Accessor, Assignee, Attachable, Configurable, EmailAddressChangeable, - Identifiable, Invitable, Mentionable, Named, Notifiable, Role, Searcher, Staff, - Transferable, Watcher + include Accessor, Assignee, Attachable, Configurable, + Mentionable, Named, Notifiable, Role, Searcher, Watcher include Timelined # Depends on Accessor self.ignored_columns = %i[ password_digest ] has_one_attached :avatar - has_many :sessions, dependent: :destroy + belongs_to :membership, optional: true + + has_one :identity, through: :membership, disable_joins: true has_many :comments, inverse_of: :creator, dependent: :destroy @@ -19,21 +20,10 @@ class User < ApplicationRecord normalizes :email_address, with: ->(value) { value.strip.downcase } + delegate :staff?, to: :identity, allow_nil: true + def deactivate - old_email_address = email_address - - sessions.delete_all accesses.destroy_all - - update! active: false, email_address: deactivated_email_address - IdentityProvider.unlink(email_address: old_email_address, from: tenant) - rescue => e - update! active: true, email_address: old_email_address - raise e + update! active: false end - - private - def deactivated_email_address - email_address.sub(/@/, "-deactivated-#{SecureRandom.uuid}@") - end end diff --git a/app/models/user/email_address_changeable.rb b/app/models/user/email_address_changeable.rb deleted file mode 100644 index 784239079..000000000 --- a/app/models/user/email_address_changeable.rb +++ /dev/null @@ -1,48 +0,0 @@ -module User::EmailAddressChangeable - EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address" - EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes - - extend ActiveSupport::Concern - - def send_email_address_change_confirmation(new_email_address) - token = generate_email_address_change_token(to: new_email_address, expires_in: EMAIL_CHANGE_TOKEN_EXPIRATION) - UserMailer.email_change_confirmation(user: self, email_address: new_email_address, token: token).deliver_later - end - - 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 - change_email_address(parsed_token.params.fetch("new_email_address")) - end - end - - private - def change_email_address(new_email_address) - old_email_address = email_address - update!(email_address: new_email_address) - - begin - IdentityProvider.change_email_address(from: old_email_address, to: new_email_address, tenant: tenant) - rescue => e - update!(email_address: old_email_address) - raise e - end - end -end diff --git a/app/models/user/identifiable.rb b/app/models/user/identifiable.rb deleted file mode 100644 index abe7b412a..000000000 --- a/app/models/user/identifiable.rb +++ /dev/null @@ -1,21 +0,0 @@ -module User::Identifiable - extend ActiveSupport::Concern - - included do - after_create_commit :link_identity, unless: :system? - after_destroy_commit :unlink_identity, unless: :system? - 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 -end diff --git a/app/models/user/invitable.rb b/app/models/user/invitable.rb deleted file mode 100644 index 0aeff74a8..000000000 --- a/app/models/user/invitable.rb +++ /dev/null @@ -1,14 +0,0 @@ -module User::Invitable - extend ActiveSupport::Concern - - class_methods do - def invite(**attributes) - create!(attributes).tap do |user| - IdentityProvider.send_magic_link(user.email_address) - rescue => e - user.destroy! - raise e - end - end - end -end diff --git a/app/models/user/staff.rb b/app/models/user/staff.rb deleted file mode 100644 index c9b027088..000000000 --- a/app/models/user/staff.rb +++ /dev/null @@ -1,7 +0,0 @@ -module User::Staff - extend ActiveSupport::Concern - - def staff? - email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com") - end -end diff --git a/app/views/account/join_codes/show.html.erb b/app/views/account/join_codes/show.html.erb index 7a4b22129..c49aaf6b4 100644 --- a/app/views/account/join_codes/show.html.erb +++ b/app/views/account/join_codes/show.html.erb @@ -15,7 +15,7 @@
Share the link below to invite people to this account
- <% url = join_url(join_code: @join_code.code, script_name: "/#{@join_code.tenant}") %> + <% url = join_url(code: @join_code.code, tenant: @join_code.tenant, script_name: nil) %>Just a sec while we confirm your new email address.