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/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index af96441f8..57344cc1e 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -85,7 +85,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController def live_chat_widget_params permitted_params = params.permit(:inbox_id) - return {} if permitted_params[:inbox_id].blank? + return {} unless permitted_params.key?(:inbox_id) + return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank? inbox = Inbox.find(permitted_params[:inbox_id]) return {} unless inbox.web_widget? diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue index f74bf95e2..b99f08a29 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue @@ -51,12 +51,20 @@ const originalState = reactive({ ...state }); const liveChatWidgets = computed(() => { const inboxes = store.getters['inboxes/getInboxes']; - return inboxes + const widgetOptions = inboxes .filter(inbox => inbox.channel_type === 'Channel::WebWidget') .map(inbox => ({ value: inbox.id, label: inbox.name, })); + + return [ + { + value: '', + label: t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.NONE_OPTION'), + }, + ...widgetOptions, + ]; }); const rules = { @@ -108,7 +116,7 @@ watch( widgetColor: newVal.color, homePageLink: newVal.homepage_link, slug: newVal.slug, - liveChatWidgetInboxId: newVal.inbox?.id, + liveChatWidgetInboxId: newVal.inbox?.id || '', }); if (newVal.logo) { const { diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index 16f108c0e..b47af9181 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -741,7 +741,8 @@ "LIVE_CHAT_WIDGET": { "LABEL": "Live chat widget", "PLACEHOLDER": "Select live chat widget", - "HELP_TEXT": "Select a live chat widget that will appear on your help center" + "HELP_TEXT": "Select a live chat widget that will appear on your help center", + "NONE_OPTION": "No widget" }, "BRAND_COLOR": { "LABEL": "Brand color" 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/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb index d0ea13e2b..f38660706 100644 --- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb @@ -154,6 +154,25 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do portal.reload expect(portal.archived).to be_truthy end + + it 'clears associated web widget when inbox selection is blank' do + web_widget_inbox = create(:inbox, account: account) + portal.update!(channel_web_widget: web_widget_inbox.channel) + + expect(portal.channel_web_widget_id).to eq(web_widget_inbox.channel.id) + + put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}", + params: { + portal: { name: portal.name }, + inbox_id: '' + }, + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:success) + portal.reload + expect(portal.channel_web_widget_id).to be_nil + expect(response.parsed_body['inbox']).to be_nil + end end 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