From 7bc7ae5bc4dfb35ab41b8444e9d5f80724877b9a Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 17 Sep 2025 19:33:38 +0530 Subject: [PATCH] feat: setup invite to handle SAML enabled account [CW-5613] (#12439) --- app/builders/agent_builder.rb | 2 + .../app/builders/enterprise/agent_builder.rb | 13 ++ .../mailer/confirmation_instructions.html.erb | 45 ++++++ .../enterprise/builders/agent_builder_spec.rb | 139 ++++++++++++++++ spec/enterprise/mailers/devise_mailer_spec.rb | 150 ++++++++++++++++++ 5 files changed, 349 insertions(+) create mode 100644 enterprise/app/builders/enterprise/agent_builder.rb create mode 100644 enterprise/app/views/devise/mailer/confirmation_instructions.html.erb create mode 100644 spec/enterprise/builders/agent_builder_spec.rb create mode 100644 spec/enterprise/mailers/devise_mailer_spec.rb diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb index 54f478920..2fe11cae0 100644 --- a/app/builders/agent_builder.rb +++ b/app/builders/agent_builder.rb @@ -52,3 +52,5 @@ class AgentBuilder }.compact)) end end + +AgentBuilder.prepend_mod_with('AgentBuilder') diff --git a/enterprise/app/builders/enterprise/agent_builder.rb b/enterprise/app/builders/enterprise/agent_builder.rb new file mode 100644 index 000000000..3007dbb61 --- /dev/null +++ b/enterprise/app/builders/enterprise/agent_builder.rb @@ -0,0 +1,13 @@ +module Enterprise::AgentBuilder + def perform + super.tap do |user| + convert_to_saml_provider(user) if user.persisted? && account.saml_enabled? + end + end + + private + + def convert_to_saml_provider(user) + user.update!(provider: 'saml') unless user.provider == 'saml' + end +end diff --git a/enterprise/app/views/devise/mailer/confirmation_instructions.html.erb b/enterprise/app/views/devise/mailer/confirmation_instructions.html.erb new file mode 100644 index 000000000..91837f980 --- /dev/null +++ b/enterprise/app/views/devise/mailer/confirmation_instructions.html.erb @@ -0,0 +1,45 @@ +

Hi <%= @resource.name %>,

+ +<% account_user = @resource&.account_users&.first %> +<% is_saml_account = account_user&.account&.saml_enabled? %> + +<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %> + <% if is_saml_account %> +

<%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to access <%= global_config['BRAND_NAME'] || 'Chatwoot' %> via Single Sign-On (SSO).

+

Your organization uses SSO for secure authentication. You will not need a password to access your account.

+ <% else %> +

<%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.

+ <% end %> +<% end %> + +<% if @resource.confirmed? %> +

You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:

+<% else %> + <% if account_user&.inviter.blank? %> +

+ Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you. +

+ <% end %> + <% unless is_saml_account %> +

Please take a moment and click the link below and activate your account.

+ <% end %> +<% end %> + + +<% if @resource.unconfirmed_email.present? %> +

<%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>

+<% elsif @resource.confirmed? %> + <% if is_saml_account %> +

You can now access your account by logging in through your organization's SSO portal.

+ <% else %> +

<%= link_to 'Login to my account', frontend_url('auth/sign_in') %>

+ <% end %> +<% elsif account_user&.inviter.present? %> + <% if is_saml_account %> +

You can access your account by logging in through your organization's SSO portal.

+ <% else %> +

<%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %>

+ <% end %> +<% else %> +

<%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %>

+<% end %> diff --git a/spec/enterprise/builders/agent_builder_spec.rb b/spec/enterprise/builders/agent_builder_spec.rb new file mode 100644 index 000000000..8f0ae4c17 --- /dev/null +++ b/spec/enterprise/builders/agent_builder_spec.rb @@ -0,0 +1,139 @@ +require 'rails_helper' + +RSpec.describe AgentBuilder do + let(:email) { 'agent@example.com' } + let(:name) { 'Test Agent' } + let(:account) { create(:account) } + let!(:inviter) { create(:user, account: account, role: 'administrator') } + let(:builder) do + described_class.new( + email: email, + name: name, + account: account, + inviter: inviter + ) + end + + describe '#perform with SAML enabled' do + let(:saml_settings) do + create(:account_saml_settings, account: account) + end + + before { saml_settings } + + context 'when user does not exist' do + it 'creates a new user with SAML provider' do + expect { builder.perform }.to change(User, :count).by(1) + + user = User.from_email(email) + expect(user.provider).to eq('saml') + end + + it 'creates user with correct attributes' do + user = builder.perform + + expect(user.email).to eq(email) + expect(user.name).to eq(name) + expect(user.provider).to eq('saml') + expect(user.encrypted_password).to be_present + end + + it 'adds user to the account with correct role' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + + expect(account_user).to be_present + expect(account_user.role).to eq('agent') + expect(account_user.inviter).to eq(inviter) + end + end + + context 'when user already exists with email provider' do + let!(:existing_user) { create(:user, email: email, provider: 'email') } + + it 'does not create a new user' do + expect { builder.perform }.not_to change(User, :count) + end + + it 'converts existing user to SAML provider' do + expect(existing_user.provider).to eq('email') + + builder.perform + + expect(existing_user.reload.provider).to eq('saml') + end + + it 'adds existing user to the account' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + + expect(account_user).to be_present + expect(account_user.inviter).to eq(inviter) + end + end + + context 'when user already exists with SAML provider' do + let!(:existing_user) { create(:user, email: email, provider: 'saml') } + + it 'does not change the provider' do + expect { builder.perform }.not_to(change { existing_user.reload.provider }) + end + + it 'still adds user to the account' do + user = builder.perform + account_user = AccountUser.find_by(user: user, account: account) + + expect(account_user).to be_present + end + end + end + + describe '#perform without SAML' do + context 'when user does not exist' do + it 'creates a new user with email provider (default behavior)' do + expect { builder.perform }.to change(User, :count).by(1) + + user = User.from_email(email) + expect(user.provider).to eq('email') + end + end + + context 'when user already exists' do + let!(:existing_user) { create(:user, email: email, provider: 'email') } + + it 'does not change the existing user provider' do + expect { builder.perform }.not_to(change { existing_user.reload.provider }) + end + end + end + + describe '#perform with different account configurations' do + context 'when account has no SAML settings' do + # No saml_settings created for this account + + it 'treats account as non-SAML enabled' do + user = builder.perform + expect(user.provider).to eq('email') + end + end + + context 'when SAML settings are deleted after user creation' do + let(:saml_settings) do + create(:account_saml_settings, account: account) + end + let(:existing_user) { create(:user, email: email, provider: 'saml') } + + before do + saml_settings + existing_user + end + + it 'does not affect existing SAML users when adding to account' do + saml_settings.destroy! + + user = builder.perform + expect(user.provider).to eq('saml') # Unchanged + end + end + end +end diff --git a/spec/enterprise/mailers/devise_mailer_spec.rb b/spec/enterprise/mailers/devise_mailer_spec.rb new file mode 100644 index 000000000..286e863f7 --- /dev/null +++ b/spec/enterprise/mailers/devise_mailer_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Devise::Mailer' do + describe 'confirmation_instructions with Enterprise features' do + let(:account) { create(:account) } + let!(:confirmable_user) { create(:user, inviter: inviter_val, account: account) } + let(:inviter_val) { nil } + let(:mail) { Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {}) } + + before do + confirmable_user.update!(confirmed_at: nil) + confirmable_user.send(:generate_confirmation_token) + end + + context 'with SAML enabled account' do + let(:saml_settings) { create(:account_saml_settings, account: account) } + + before { saml_settings } + + context 'when user has no inviter' do + it 'shows standard welcome message without SSO references' do + expect(mail.body).to match('We have a suite of powerful tools ready for you to explore.') + expect(mail.body).not_to match('via Single Sign-On') + end + + it 'does not show activation instructions for SAML accounts' do + expect(mail.body).not_to match('Please take a moment and click the link below and activate your account') + end + + it 'shows confirmation link' do + expect(mail.body).to include("app/auth/confirmation?confirmation_token=#{confirmable_user.confirmation_token}") + end + end + + context 'when user has inviter and SAML is enabled' do + let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) } + + it 'mentions SSO invitation' do + expect(mail.body).to match( + "#{CGI.escapeHTML(inviter_val.name)}, with #{CGI.escapeHTML(account.name)}, has invited you to access.*via Single Sign-On \\(SSO\\)" + ) + end + + it 'explains SSO authentication' do + expect(mail.body).to match('Your organization uses SSO for secure authentication') + expect(mail.body).to match('You will not need a password to access your account') + end + + it 'does not show standard invitation message' do + expect(mail.body).not_to match('has invited you to try out') + end + + it 'directs to SSO portal instead of password reset' do + expect(mail.body).to match('You can access your account by logging in through your organization\'s SSO portal') + expect(mail.body).not_to include('app/auth/password/edit') + end + end + + context 'when user is already confirmed and has inviter' do + let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) } + + before do + confirmable_user.confirm + end + + it 'shows SSO login instructions' do + expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal') + expect(mail.body).not_to include('/auth/sign_in') + end + end + + context 'when user updates email on SAML account' do + let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) } + + before do + confirmable_user.update!(email: 'updated@example.com') + end + + it 'still shows confirmation link for email verification' do + expect(mail.body).to include('app/auth/confirmation?confirmation_token') + expect(confirmable_user.unconfirmed_email.blank?).to be false + end + end + + context 'when user is already confirmed with no inviter' do + before do + confirmable_user.confirm + end + + it 'shows SSO login instructions instead of regular login' do + expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal') + expect(mail.body).not_to include('/auth/sign_in') + end + end + end + + context 'when account does not have SAML enabled' do + context 'when user has inviter' do + let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) } + + it 'shows standard invitation without SSO references' do + expect(mail.body).to match('has invited you to try out Chatwoot') + expect(mail.body).not_to match('via Single Sign-On') + expect(mail.body).not_to match('SSO portal') + end + + it 'shows password reset link' do + expect(mail.body).to include('app/auth/password/edit') + end + end + + context 'when user has no inviter' do + it 'shows standard welcome message and activation instructions' do + expect(mail.body).to match('We have a suite of powerful tools ready for you to explore') + expect(mail.body).to match('Please take a moment and click the link below and activate your account') + end + + it 'shows confirmation link' do + expect(mail.body).to include("app/auth/confirmation?confirmation_token=#{confirmable_user.confirmation_token}") + end + end + + context 'when user is already confirmed' do + let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) } + + before do + confirmable_user.confirm + end + + it 'shows regular login link' do + expect(mail.body).to include('/auth/sign_in') + expect(mail.body).not_to match('SSO portal') + end + end + + context 'when user updates email' do + before do + confirmable_user.update!(email: 'updated@example.com') + end + + it 'shows confirmation link for email verification' do + expect(mail.body).to include('app/auth/confirmation?confirmation_token') + expect(confirmable_user.unconfirmed_email.blank?).to be false + end + end + end + end +end