From 7bc7ae5bc4dfb35ab41b8444e9d5f80724877b9a Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 17 Sep 2025 19:33:38 +0530 Subject: [PATCH 01/14] 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 From 44dc9ba18ea600443ce5cfe70c8cc9cbd7ab5fda Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 17 Sep 2025 22:27:50 +0530 Subject: [PATCH 02/14] feat: Allow detaching help center widget (#12459) ## Summary - allow help center portals to clear their associated web widget Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../api/v1/accounts/portals_controller.rb | 3 ++- .../PortalSettingsPage/PortalBaseSettings.vue | 12 ++++++++++-- .../dashboard/i18n/locale/en/helpCenter.json | 3 ++- .../v1/accounts/portals_controller_spec.rb | 19 +++++++++++++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) 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/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 From 9527ff62699d9b0868070810a4b1a612d150939b Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Thu, 18 Sep 2025 14:17:54 +0530 Subject: [PATCH 03/14] feat: Add support for labels in automations (#11658) - Add support for using labels as an action event for automation - Fix duplicated conversation_updated event dispatch for labels Fixes https://github.com/chatwoot/chatwoot/issues/8539 and multiple issues around duplication related to label change events. --------- Co-authored-by: Muhsin Keloth --- .../composables/useAutomationValues.js | 1 + .../dashboard/helper/automationHelper.js | 2 + .../dashboard/i18n/locale/en/automation.json | 3 +- .../settings/automation/constants.js | 24 ++ app/models/automation_rule.rb | 2 +- app/models/concerns/labelable.rb | 2 + app/models/conversation.rb | 2 - .../conditions_filter_service.rb | 39 ++- .../automation_rule_listener_labels_spec.rb | 244 ++++++++++++++++++ spec/models/automation_rule_spec.rb | 26 ++ spec/models/conversation_spec.rb | 2 +- .../automation_rules/action_service_spec.rb | 39 +++ .../conditions_filter_service_spec.rb | 81 ++++++ 13 files changed, 461 insertions(+), 6 deletions(-) create mode 100644 spec/listeners/automation_rule_listener_labels_spec.rb diff --git a/app/javascript/dashboard/composables/useAutomationValues.js b/app/javascript/dashboard/composables/useAutomationValues.js index abc44f66b..5279f15e4 100644 --- a/app/javascript/dashboard/composables/useAutomationValues.js +++ b/app/javascript/dashboard/composables/useAutomationValues.js @@ -104,6 +104,7 @@ export default function useAutomationValues() { contacts: contacts.value, customAttributes: getters['attributes/getAttributes'].value, inboxes: inboxes.value, + labels: labels.value, statusFilterOptions: statusFilterOptions.value, priorityOptions: priorityOptions.value, messageTypeOptions: messageTypeOptions.value, diff --git a/app/javascript/dashboard/helper/automationHelper.js b/app/javascript/dashboard/helper/automationHelper.js index 3723fd4d5..3e5f46f90 100644 --- a/app/javascript/dashboard/helper/automationHelper.js +++ b/app/javascript/dashboard/helper/automationHelper.js @@ -124,6 +124,7 @@ export const getConditionOptions = ({ customAttributes, inboxes, languages, + labels, statusFilterOptions, teams, type, @@ -150,6 +151,7 @@ export const getConditionOptions = ({ country_code: countries, message_type: messageTypeOptions, priority: priorityOptions, + labels: generateConditionOptions(labels, 'title'), }; return conditionFilterMaps[type]; diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index 80274f488..43245a1d5 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -177,7 +177,8 @@ "REFERER_LINK": "Referrer Link", "ASSIGNEE_NAME": "Assignee", "TEAM_NAME": "Team", - "PRIORITY": "Priority" + "PRIORITY": "Priority", + "LABELS": "Labels" } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js index 0a6905039..bc767040b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js @@ -68,6 +68,12 @@ export const AUTOMATIONS = { inputType: 'plain_text', filterOperators: OPERATOR_TYPES_6, }, + { + key: 'labels', + name: 'LABELS', + inputType: 'multi_select', + filterOperators: OPERATOR_TYPES_3, + }, ], actions: [ { @@ -186,6 +192,12 @@ export const AUTOMATIONS = { inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, + { + key: 'labels', + name: 'LABELS', + inputType: 'multi_select', + filterOperators: OPERATOR_TYPES_3, + }, ], actions: [ { @@ -308,6 +320,12 @@ export const AUTOMATIONS = { inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, + { + key: 'labels', + name: 'LABELS', + inputType: 'multi_select', + filterOperators: OPERATOR_TYPES_3, + }, ], actions: [ { @@ -424,6 +442,12 @@ export const AUTOMATIONS = { inputType: 'multi_select', filterOperators: OPERATOR_TYPES_1, }, + { + key: 'labels', + name: 'LABELS', + inputType: 'multi_select', + filterOperators: OPERATOR_TYPES_3, + }, ], actions: [ { diff --git a/app/models/automation_rule.rb b/app/models/automation_rule.rb index 6f3f47d9c..9dc4d97eb 100644 --- a/app/models/automation_rule.rb +++ b/app/models/automation_rule.rb @@ -36,7 +36,7 @@ class AutomationRule < ApplicationRecord def conditions_attributes %w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id - mail_subject phone_number priority conversation_language] + mail_subject phone_number priority conversation_language labels] end def actions_attributes diff --git a/app/models/concerns/labelable.rb b/app/models/concerns/labelable.rb index e710e97e9..bf8778921 100644 --- a/app/models/concerns/labelable.rb +++ b/app/models/concerns/labelable.rb @@ -10,6 +10,8 @@ module Labelable end def add_labels(new_labels = nil) + return if new_labels.blank? + new_labels = Array(new_labels) # Make sure new_labels is an array combined_labels = labels + new_labels update!(label_list: combined_labels) diff --git a/app/models/conversation.rb b/app/models/conversation.rb index d6c5d0e4a..4ec63acc2 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -297,8 +297,6 @@ class Conversation < ApplicationRecord previous_labels, current_labels = previous_changes[:label_list] return unless (previous_labels.is_a? Array) && (current_labels.is_a? Array) - dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes) - create_label_added(user_name, current_labels - previous_labels) create_label_removed(user_name, previous_labels - current_labels) end diff --git a/app/services/automation_rules/conditions_filter_service.rb b/app/services/automation_rules/conditions_filter_service.rb index 23873371d..993ed21c9 100644 --- a/app/services/automation_rules/conditions_filter_service.rb +++ b/app/services/automation_rules/conditions_filter_service.rb @@ -151,13 +151,36 @@ class AutomationRules::ConditionsFilterService < FilterService " #{table_name}.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} " when 'standard' if attribute_key == 'labels' - " tags.id #{filter_operator_value} #{query_operator} " + build_label_query_string(query_hash, current_index, query_operator) else " #{table_name}.#{attribute_key} #{filter_operator_value} #{query_operator} " end end end + def build_label_query_string(query_hash, current_index, query_operator) + case query_hash['filter_operator'] + when 'equal_to' + return " 1=0 #{query_operator} " if query_hash['values'].blank? + + value_placeholder = "value_#{current_index}" + @filter_values[value_placeholder] = query_hash['values'].first + " tags.name = :#{value_placeholder} #{query_operator} " + when 'not_equal_to' + return " 1=0 #{query_operator} " if query_hash['values'].blank? + + value_placeholder = "value_#{current_index}" + @filter_values[value_placeholder] = query_hash['values'].first + " tags.name != :#{value_placeholder} #{query_operator} " + when 'is_present' + " tags.id IS NOT NULL #{query_operator} " + when 'is_not_present' + " tags.id IS NULL #{query_operator} " + else + " tags.id #{filter_operation(query_hash, current_index)} #{query_operator} " + end + end + private def base_relation @@ -166,7 +189,21 @@ class AutomationRules::ConditionsFilterService < FilterService ).joins( 'LEFT OUTER JOIN messages on messages.conversation_id = conversations.id' ) + + # Only add label joins when label conditions exist + if label_conditions? + records = records.joins( + 'LEFT OUTER JOIN taggings ON taggings.taggable_id = conversations.id AND taggings.taggable_type = \'Conversation\'' + ).joins( + 'LEFT OUTER JOIN tags ON taggings.tag_id = tags.id' + ) + end + records = records.where(messages: { id: @options[:message].id }) if @options[:message].present? records end + + def label_conditions? + @rule.conditions.any? { |condition| condition['attribute_key'] == 'labels' } + end end diff --git a/spec/listeners/automation_rule_listener_labels_spec.rb b/spec/listeners/automation_rule_listener_labels_spec.rb new file mode 100644 index 000000000..36002f7f3 --- /dev/null +++ b/spec/listeners/automation_rule_listener_labels_spec.rb @@ -0,0 +1,244 @@ +require 'rails_helper' + +describe AutomationRuleListener do + let(:listener) { described_class.instance } + let!(:account) { create(:account) } + let!(:user) { create(:user, account: account) } + let!(:inbox) { create(:inbox, account: account) } + let!(:contact) { create(:contact, account: account) } + let!(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:label1) { create(:label, account: account, title: 'bug') } + let(:label2) { create(:label, account: account, title: 'feature') } + let(:label3) { create(:label, account: account, title: 'urgent') } + + before do + Current.user = user + end + + describe 'conversation_updated with label conditions and actions' do + context 'when label is added and automation rule has label condition' do + let(:automation_rule) do + create(:automation_rule, + event_name: 'conversation_updated', + account: account, + conditions: [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: ['bug'], + query_operator: nil + } + ], + actions: [ + { + action_name: 'add_label', + action_params: ['urgent'] + }, + { + action_name: 'send_message', + action_params: ['Bug report received. We will investigate this issue.'] + } + ]) + end + + it 'triggers automation when the specified label is added' do + automation_rule # Create the automation rule + expect(Messages::MessageBuilder).to receive(:new).and_call_original + + # Add the 'bug' label to trigger the automation + conversation.add_labels(['bug']) + + # Dispatch the event + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [[], ['bug']] } + }) + + listener.conversation_updated(event) + + # Verify the label was added by automation + expect(conversation.reload.label_list).to include('urgent') + + # Verify a message was sent + expect(conversation.messages.last.content).to eq('Bug report received. We will investigate this issue.') + end + + it 'does not trigger automation when a different label is added' do + automation_rule # Create the automation rule + expect(Messages::MessageBuilder).not_to receive(:new) + + # Add a different label + conversation.add_labels(['feature']) + + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [[], ['feature']] } + }) + + listener.conversation_updated(event) + + # Verify the automation did not run + expect(conversation.reload.label_list).not_to include('urgent') + end + end + + context 'when automation rule has is_present label condition' do + let(:automation_rule) do + create(:automation_rule, + event_name: 'conversation_updated', + account: account, + conditions: [ + { + attribute_key: 'labels', + filter_operator: 'is_present', + values: [], + query_operator: nil + } + ], + actions: [ + { + action_name: 'send_message', + action_params: ['Thank you for adding a label to categorize this conversation.'] + } + ]) + end + + it 'triggers automation when any label is added to an unlabeled conversation' do + automation_rule # Create the automation rule + expect(Messages::MessageBuilder).to receive(:new).and_call_original + + # Add any label to trigger the automation + conversation.add_labels(['feature']) + + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [[], ['feature']] } + }) + + listener.conversation_updated(event) + + # Verify a message was sent + expect(conversation.messages.last.content).to eq('Thank you for adding a label to categorize this conversation.') + end + + it 'still triggers when labels are removed but conversation still has labels' do + automation_rule # Create the automation rule + # Start with multiple labels + conversation.add_labels(%w[bug feature]) + conversation.reload + + expect(Messages::MessageBuilder).to receive(:new).and_call_original + + # Remove one label but conversation still has labels + conversation.update_labels(['bug']) + + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [%w[bug feature], ['bug']] } + }) + + listener.conversation_updated(event) + + # Should still trigger because conversation has labels (is_present condition) + expect(conversation.messages.last.content).to eq('Thank you for adding a label to categorize this conversation.') + end + + it 'does not trigger when all labels are removed' do + automation_rule # Create the automation rule + # Start with labels + conversation.add_labels(['bug']) + conversation.reload + + expect(Messages::MessageBuilder).not_to receive(:new) + + # Remove all labels + conversation.update_labels([]) + + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [['bug'], []] } + }) + + listener.conversation_updated(event) + end + end + + context 'when automation rule has remove_label action' do + let!(:automation_rule) do + create(:automation_rule, + event_name: 'conversation_updated', + account: account, + conditions: [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: ['urgent'], + query_operator: nil + } + ], + actions: [ + { + action_name: 'remove_label', + action_params: ['bug'] + } + ]) + end + + it 'removes specified labels when condition is met' do + automation_rule # Create the automation rule + # Start with both labels + conversation.add_labels(%w[bug urgent]) + + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [['bug'], %w[bug urgent]] } + }) + + listener.conversation_updated(event) + + # Verify the bug label was removed but urgent remains + expect(conversation.reload.label_list).to include('urgent') + expect(conversation.reload.label_list).not_to include('bug') + end + end + end + + describe 'preventing infinite loops' do + let!(:automation_rule) do + create(:automation_rule, + event_name: 'conversation_updated', + account: account, + conditions: [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: ['bug'], + query_operator: nil + } + ], + actions: [ + { + action_name: 'add_label', + action_params: ['processed'] + } + ]) + end + + it 'does not trigger automation when performed by automation rule' do + automation_rule # Create the automation rule + conversation.add_labels(['bug']) + + # Simulate event performed by automation rule + event = Events::Base.new('conversation_updated', Time.zone.now, { + conversation: conversation, + changed_attributes: { label_list: [[], ['bug']] }, + performed_by: automation_rule + }) + + # Should not process the event since it was performed by automation + expect(AutomationRules::ActionService).not_to receive(:new) + + listener.conversation_updated(event) + end + end +end diff --git a/spec/models/automation_rule_spec.rb b/spec/models/automation_rule_spec.rb index 53ebfa0c7..91452b8a4 100644 --- a/spec/models/automation_rule_spec.rb +++ b/spec/models/automation_rule_spec.rb @@ -60,6 +60,32 @@ RSpec.describe AutomationRule do expect(rule.valid?).to be false expect(rule.errors.messages[:conditions]).to eq(['Automation conditions should have query operator.']) end + + it 'allows labels as a valid condition attribute' do + params[:conditions] = [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: ['bug'], + query_operator: nil + } + ] + rule = FactoryBot.build(:automation_rule, params) + expect(rule.valid?).to be true + end + + it 'validates label condition operators' do + params[:conditions] = [ + { + attribute_key: 'labels', + filter_operator: 'is_present', + values: [], + query_operator: nil + } + ] + rule = FactoryBot.build(:automation_rule, params) + expect(rule.valid?).to be true + end end describe 'reauthorizable' do diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index a29359528..007ce987f 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -136,7 +136,7 @@ RSpec.describe Conversation do notifiable_assignee_change: false, changed_attributes: changed_attributes, performed_by: nil - ).exactly(2).times + ) end it 'runs after_update callbacks' do diff --git a/spec/services/automation_rules/action_service_spec.rb b/spec/services/automation_rules/action_service_spec.rb index e63fd7545..b4eaa5dd0 100644 --- a/spec/services/automation_rules/action_service_spec.rb +++ b/spec/services/automation_rules/action_service_spec.rb @@ -118,6 +118,45 @@ RSpec.describe AutomationRules::ActionService do end end + describe '#perform with add_label action' do + before do + rule.actions << { action_name: 'add_label', action_params: %w[bug feature] } + rule.save + end + + it 'will add labels to conversation' do + described_class.new(rule, account, conversation).perform + expect(conversation.reload.label_list).to include('bug', 'feature') + end + + it 'will not duplicate existing labels' do + conversation.add_labels(['bug']) + described_class.new(rule, account, conversation).perform + expect(conversation.reload.label_list.count('bug')).to eq(1) + expect(conversation.reload.label_list).to include('feature') + end + end + + describe '#perform with remove_label action' do + before do + conversation.add_labels(%w[bug feature support]) + rule.actions << { action_name: 'remove_label', action_params: %w[bug feature] } + rule.save + end + + it 'will remove specified labels from conversation' do + described_class.new(rule, account, conversation).perform + expect(conversation.reload.label_list).not_to include('bug', 'feature') + expect(conversation.reload.label_list).to include('support') + end + + it 'will not fail if labels do not exist on conversation' do + conversation.update_labels(['support']) # Remove bug and feature first + expect { described_class.new(rule, account, conversation).perform }.not_to raise_error + expect(conversation.reload.label_list).to include('support') + end + end + describe '#perform with add_private_note action' do let(:message_builder) { double } diff --git a/spec/services/automation_rules/conditions_filter_service_spec.rb b/spec/services/automation_rules/conditions_filter_service_spec.rb index 7082d31b1..5efd26341 100644 --- a/spec/services/automation_rules/conditions_filter_service_spec.rb +++ b/spec/services/automation_rules/conditions_filter_service_spec.rb @@ -134,5 +134,86 @@ RSpec.describe AutomationRules::ConditionsFilterService do end end end + + context 'when conditions based on labels' do + before do + conversation.add_labels(['bug']) + end + + context 'when filter_operator is equal_to' do + before do + rule.conditions = [ + { 'values': ['bug'], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'equal_to' } + ] + rule.save + end + + it 'will return true when conversation has the label' do + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true) + end + + it 'will return false when conversation does not have the label' do + rule.conditions = [ + { 'values': ['feature'], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'equal_to' } + ] + rule.save + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false) + end + end + + context 'when filter_operator is not_equal_to' do + before do + rule.conditions = [ + { 'values': ['feature'], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'not_equal_to' } + ] + rule.save + end + + it 'will return true when conversation does not have the label' do + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true) + end + + it 'will return false when conversation has the label' do + conversation.add_labels(['feature']) + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false) + end + end + + context 'when filter_operator is is_present' do + before do + rule.conditions = [ + { 'values': [], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'is_present' } + ] + rule.save + end + + it 'will return true when conversation has any labels' do + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true) + end + + it 'will return false when conversation has no labels' do + conversation.update_labels([]) + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false) + end + end + + context 'when filter_operator is is_not_present' do + before do + rule.conditions = [ + { 'values': [], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'is_not_present' } + ] + rule.save + end + + it 'will return false when conversation has any labels' do + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false) + end + + it 'will return true when conversation has no labels' do + conversation.update_labels([]) + expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true) + end + end + end end end From 8f4b252045ac8dbb8e2289b8517c6f47d0b86d8c Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 18 Sep 2025 14:44:56 +0530 Subject: [PATCH 04/14] feat: allow searching captain responses [CW-5631] (#12463) --- .../dashboard/api/captain/response.js | 4 +- .../dashboard/components-next/input/Input.vue | 19 +++++- .../i18n/locale/en/integrations.json | 1 + .../dashboard/captain/responses/Index.vue | 67 +++++++++++++------ .../captain/assistant_responses_controller.rb | 43 +++++++----- .../assistant_responses_controller_spec.rb | 47 +++++++++++++ 6 files changed, 140 insertions(+), 41 deletions(-) diff --git a/app/javascript/dashboard/api/captain/response.js b/app/javascript/dashboard/api/captain/response.js index e3c42757a..d48bd81c7 100644 --- a/app/javascript/dashboard/api/captain/response.js +++ b/app/javascript/dashboard/api/captain/response.js @@ -6,11 +6,11 @@ class CaptainResponses extends ApiClient { super('captain/assistant_responses', { accountScoped: true }); } - get({ page = 1, searchKey, assistantId, documentId, status } = {}) { + get({ page = 1, search, assistantId, documentId, status } = {}) { return axios.get(this.url, { params: { page, - searchKey, + search, assistant_id: assistantId, document_id: documentId, status, diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue index f4bf5a94f..71964b4f8 100644 --- a/app/javascript/dashboard/components-next/input/Input.vue +++ b/app/javascript/dashboard/components-next/input/Input.vue @@ -7,6 +7,11 @@ const props = defineProps({ placeholder: { type: String, default: '' }, label: { type: String, default: '' }, id: { type: String, default: '' }, + size: { + type: String, + default: 'md', + validator: value => ['sm', 'md'].includes(value), + }, message: { type: String, default: '' }, disabled: { type: Boolean, default: false }, messageType: { @@ -69,6 +74,17 @@ const handleFocus = event => { isFocused.value = true; }; +const sizeClass = computed(() => { + switch (props.size) { + case 'sm': + return 'h-8 !px-3 !py-2'; + case 'md': + return 'h-10 !px-3 !py-2.5'; + default: + return 'h-10 !px-3 !py-2.5'; + } +}); + const handleBlur = event => { emit('blur', event); isFocused.value = false; @@ -105,6 +121,7 @@ onMounted(() => { :class="[ customInputClass, inputOutlineClass, + sizeClass, { error: messageType === 'error', focus: isFocused, @@ -119,7 +136,7 @@ onMounted(() => { ? max : undefined " - class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + class="block w-full reset-base text-sm !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" @input="handleInput" @focus="handleFocus" @blur="handleBlur" diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index c4399b0e9..8a812dff3 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -759,6 +759,7 @@ "SELECTED": "{count} selected", "SELECT_ALL": "Select all ({count})", "UNSELECT_ALL": "Unselect all ({count})", + "SEARCH_PLACEHOLDER": "Search FAQs...", "BULK_APPROVE_BUTTON": "Approve", "BULK_DELETE_BUTTON": "Delete", "BULK_APPROVE": { diff --git a/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue index 85e7f1ed0..86d3fff69 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/responses/Index.vue @@ -6,10 +6,12 @@ import { useI18n } from 'vue-i18n'; import { OnClickOutside } from '@vueuse/components'; import { useRouter } from 'vue-router'; import { FEATURE_FLAGS } from 'dashboard/featureFlags'; +import { debounce } from '@chatwoot/utils'; import Button from 'dashboard/components-next/button/Button.vue'; import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; +import Input from 'dashboard/components-next/input/Input.vue'; import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue'; import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue'; import PageLayout from 'dashboard/components-next/captain/PageLayout.vue'; @@ -36,6 +38,7 @@ const bulkDeleteDialog = ref(null); const selectedStatus = ref('all'); const selectedAssistant = ref('all'); const dialogType = ref(''); +const searchQuery = ref(''); const { t } = useI18n(); const createDialog = ref(null); @@ -138,6 +141,9 @@ const fetchResponses = (page = 1) => { if (selectedAssistant.value !== 'all') { filterParams.assistantId = selectedAssistant.value; } + if (searchQuery.value) { + filterParams.search = searchQuery.value; + } store.dispatch('captainResponses/get', filterParams); }; @@ -250,6 +256,10 @@ const handleAssistantFilterChange = assistant => { fetchResponses(); }; +const debouncedSearch = debounce(async () => { + fetchResponses(); +}, 500); + onMounted(() => { store.dispatch('captainAssistants/get'); fetchResponses(); @@ -292,34 +302,47 @@ onMounted(() => { diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index bc4a8312a..e93dcd88e 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -36,6 +36,7 @@ import sla from './sla.json'; import teamsSettings from './teamsSettings.json'; import whatsappTemplates from './whatsappTemplates.json'; import contentTemplates from './contentTemplates.json'; +import mfa from './mfa.json'; export default { ...advancedFilters, @@ -76,4 +77,5 @@ export default { ...teamsSettings, ...whatsappTemplates, ...contentTemplates, + ...mfa, }; diff --git a/app/javascript/dashboard/i18n/locale/en/mfa.json b/app/javascript/dashboard/i18n/locale/en/mfa.json new file mode 100644 index 000000000..f7556fdcf --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/mfa.json @@ -0,0 +1,106 @@ +{ + "MFA_SETTINGS": { + "TITLE": "Two-Factor Authentication", + "SUBTITLE": "Secure your account with TOTP-based authentication", + "DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)", + "STATUS_TITLE": "Authentication Status", + "STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes", + "ENABLED": "Enabled", + "DISABLED": "Disabled", + "STATUS_ENABLED": "Two-factor authentication is active", + "STATUS_ENABLED_DESC": "Your account is protected with an additional layer of security", + "ENABLE_BUTTON": "Enable Two-Factor Authentication", + "ENHANCE_SECURITY": "Enhance Your Account Security", + "ENHANCE_SECURITY_DESC": "Two-factor authentication adds an extra layer of security by requiring a verification code from your authenticator app in addition to your password.", + "SETUP": { + "STEP_NUMBER_1": "1", + "STEP_NUMBER_2": "2", + "STEP1_TITLE": "Scan QR Code with Your Authenticator App", + "STEP1_DESCRIPTION": "Use Google Authenticator, Authy, or any TOTP-compatible app", + "LOADING_QR": "Loading...", + "MANUAL_ENTRY": "Can't scan? Enter code manually", + "SECRET_KEY": "Secret Key", + "COPY": "Copy", + "ENTER_CODE": "Enter the 6-digit code from your authenticator app", + "ENTER_CODE_PLACEHOLDER": "000000", + "VERIFY_BUTTON": "Verify & Continue", + "CANCEL": "Cancel", + "ERROR_STARTING": "MFA not enabled. Please contact administrator.", + "INVALID_CODE": "Invalid verification code", + "SECRET_COPIED": "Secret key copied to clipboard", + "SUCCESS": "Two-factor authentication has been enabled successfully" + }, + "BACKUP": { + "TITLE": "Save Your Backup Codes", + "DESCRIPTION": "Keep these codes safe. Each can be used once if you lose access to your authenticator", + "IMPORTANT": "Important:", + "IMPORTANT_NOTE": " Save these codes in a secure location. You won't be able to see them again.", + "DOWNLOAD": "Download", + "COPY_ALL": "Copy All", + "CONFIRM": "I have saved my backup codes in a secure location and understand that I won't be able to see them again", + "COMPLETE_SETUP": "Complete Setup", + "CODES_COPIED": "Backup codes copied to clipboard" + }, + "MANAGEMENT": { + "BACKUP_CODES": "Backup Codes", + "BACKUP_CODES_DESC": "Generate new codes if you've lost or used your existing ones", + "REGENERATE": "Regenerate Backup Codes", + "DISABLE_MFA": "Disable 2FA", + "DISABLE_MFA_DESC": "Remove two-factor authentication from your account", + "DISABLE_BUTTON": "Disable Two-Factor Authentication" + }, + "DISABLE": { + "TITLE": "Disable Two-Factor Authentication", + "DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.", + "PASSWORD": "Password", + "OTP_CODE": "Verification Code", + "OTP_CODE_PLACEHOLDER": "000000", + "CONFIRM": "Disable 2FA", + "CANCEL": "Cancel", + "SUCCESS": "Two-factor authentication has been disabled", + "ERROR": "Failed to disable MFA. Please check your credentials." + }, + "REGENERATE": { + "TITLE": "Regenerate Backup Codes", + "DESCRIPTION": "This will invalidate your existing backup codes and generate new ones. Enter your verification code to continue.", + "OTP_CODE": "Verification Code", + "OTP_CODE_PLACEHOLDER": "000000", + "CONFIRM": "Generate New Codes", + "CANCEL": "Cancel", + "NEW_CODES_TITLE": "New Backup Codes Generated", + "NEW_CODES_DESC": "Your old backup codes have been invalidated. Save these new codes in a secure location.", + "CODES_IMPORTANT": "Important:", + "CODES_IMPORTANT_NOTE": " Each code can only be used once. Save them before closing this window.", + "DOWNLOAD_CODES": "Download Codes", + "COPY_ALL_CODES": "Copy All Codes", + "CODES_SAVED": "I've Saved My Codes", + "SUCCESS": "New backup codes have been generated", + "ERROR": "Failed to regenerate backup codes" + } + }, + "MFA_VERIFICATION": { + "TITLE": "Two-Factor Authentication", + "DESCRIPTION": "Enter your verification code to continue", + "AUTHENTICATOR_APP": "Authenticator App", + "BACKUP_CODE": "Backup Code", + "ENTER_OTP_CODE": "Enter 6-digit code from your authenticator app", + "ENTER_BACKUP_CODE": "Enter one of your backup codes", + "BACKUP_CODE_PLACEHOLDER": "000000", + "VERIFY_BUTTON": "Verify", + "TRY_ANOTHER_METHOD": "Try another verification method", + "CANCEL_LOGIN": "Cancel and return to login", + "HELP_TEXT": "Having trouble signing in?", + "LEARN_MORE": "Learn more about 2FA", + "HELP_MODAL": { + "TITLE": "Two-Factor Authentication Help", + "AUTHENTICATOR_TITLE": "Using an Authenticator App", + "AUTHENTICATOR_DESC": "Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit code shown for your account.", + "BACKUP_TITLE": "Using a Backup Code", + "BACKUP_DESC": "If you don't have access to your authenticator app, you can use one of the backup codes you saved when setting up 2FA. Each code can only be used once.", + "CONTACT_TITLE": "Need More Help?", + "CONTACT_DESC_CLOUD": "If you've lost access to both your authenticator app and backup codes, please reach out to Chatwoot support for assistance.", + "CONTACT_DESC_SELF_HOSTED": "If you've lost access to both your authenticator app and backup codes, please contact your administrator for assistance." + }, + "VERIFICATION_FAILED": "Verification failed. Please try again." + } +} diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index b81a47f4e..9ddc3b805 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -80,6 +80,11 @@ "NOTE": "Updating your password would reset your logins in multiple devices.", "BTN_TEXT": "Change password" }, + "SECURITY_SECTION": { + "TITLE": "Security", + "NOTE": "Manage additional security features for your account.", + "MFA_BUTTON": "Manage Two-Factor Authentication" + }, "ACCESS_TOKEN": { "TITLE": "Access Token", "NOTE": "This token can be used if you are building an API based integration", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue index ce0a480bb..305a6d3ef 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue @@ -7,6 +7,7 @@ import { useBranding } from 'shared/composables/useBranding'; import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js'; import { copyTextToClipboard } from 'shared/helpers/clipboard'; import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; +import { parseBoolean } from '@chatwoot/utils'; import UserProfilePicture from './UserProfilePicture.vue'; import UserBasicDetails from './UserBasicDetails.vue'; import MessageSignature from './MessageSignature.vue'; @@ -18,6 +19,7 @@ import NotificationPreferences from './NotificationPreferences.vue'; import AudioNotifications from './AudioNotifications.vue'; import FormSection from 'dashboard/components/FormSection.vue'; import AccessToken from './AccessToken.vue'; +import MfaSettingsCard from './MfaSettingsCard.vue'; import Policy from 'dashboard/components/policy.vue'; import { ROLES, @@ -38,6 +40,7 @@ export default { NotificationPreferences, AudioNotifications, AccessToken, + MfaSettingsCard, }, setup() { const { isEditorHotKeyEnabled, updateUISettings } = useUISettings(); @@ -95,6 +98,9 @@ export default { currentUserId: 'getCurrentUserID', globalConfig: 'globalConfig/get', }), + isMfaEnabled() { + return parseBoolean(window.chatwootConfig?.isMfaEnabled); + }, }, mounted() { if (this.currentUserId) { @@ -283,6 +289,13 @@ export default { > + + + +import { ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { copyTextToClipboard } from 'shared/helpers/clipboard'; +import { useAlert } from 'dashboard/composables'; +import Button from 'dashboard/components-next/button/Button.vue'; +import Input from 'dashboard/components-next/input/Input.vue'; +import Icon from 'dashboard/components-next/icon/Icon.vue'; +import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; + +const props = defineProps({ + mfaEnabled: { + type: Boolean, + required: true, + }, + backupCodes: { + type: Array, + default: () => [], + }, +}); + +const emit = defineEmits(['disableMfa', 'regenerateBackupCodes']); + +const { t } = useI18n(); + +// Dialog refs +const disableDialogRef = ref(null); +const regenerateDialogRef = ref(null); +const backupCodesDialogRef = ref(null); + +// Form values +const disablePassword = ref(''); +const disableOtpCode = ref(''); +const regenerateOtpCode = ref(''); + +// Utility functions +const copyBackupCodes = async () => { + const codesText = props.backupCodes.join('\n'); + await copyTextToClipboard(codesText); + useAlert(t('MFA_SETTINGS.BACKUP.CODES_COPIED')); +}; + +const downloadBackupCodes = () => { + const codesText = `Chatwoot Two-Factor Authentication Backup Codes\n\n${props.backupCodes.join('\n')}\n\nKeep these codes in a safe place.`; + const blob = new Blob([codesText], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'chatwoot-backup-codes.txt'; + a.click(); + URL.revokeObjectURL(url); +}; + +const handleDisableMfa = async () => { + emit('disableMfa', { + password: disablePassword.value, + otpCode: disableOtpCode.value, + }); +}; + +const handleRegenerateBackupCodes = async () => { + emit('regenerateBackupCodes', { + otpCode: regenerateOtpCode.value, + }); +}; + +// Methods exposed for parent component +const resetDisableForm = () => { + disablePassword.value = ''; + disableOtpCode.value = ''; + disableDialogRef.value?.close(); +}; + +const resetRegenerateForm = () => { + regenerateOtpCode.value = ''; + regenerateDialogRef.value?.close(); +}; + +const showBackupCodesDialog = () => { + backupCodesDialogRef.value?.open(); +}; + +defineExpose({ + resetDisableForm, + resetRegenerateForm, + showBackupCodesDialog, +}); + + +