<%= 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/enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb
index c7b0366dd..151cf279c 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistant_responses_controller.rb
@@ -10,21 +10,9 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
RESULTS_PER_PAGE = 25
def index
- base_query = @responses
- base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
-
- if permitted_params[:document_id].present?
- base_query = base_query.where(
- documentable_id: permitted_params[:document_id],
- documentable_type: 'Captain::Document'
- )
- end
-
- base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
-
- @responses_count = base_query.count
-
- @responses = base_query.page(@current_page).per(RESULTS_PER_PAGE)
+ filtered_query = apply_filters(@responses)
+ @responses_count = filtered_query.count
+ @responses = filtered_query.page(@current_page).per(RESULTS_PER_PAGE)
end
def show; end
@@ -46,6 +34,29 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
private
+ def apply_filters(base_query)
+ base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
+
+ if permitted_params[:document_id].present?
+ base_query = base_query.where(
+ documentable_id: permitted_params[:document_id],
+ documentable_type: 'Captain::Document'
+ )
+ end
+
+ base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
+
+ if permitted_params[:search].present?
+ search_term = "%#{permitted_params[:search]}%"
+ base_query = base_query.where(
+ 'question ILIKE :search OR answer ILIKE :search',
+ search: search_term
+ )
+ end
+
+ base_query
+ end
+
def set_assistant
@assistant = Current.account.captain_assistants.find_by(id: params[:assistant_id])
end
@@ -63,7 +74,7 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
end
def permitted_params
- params.permit(:id, :assistant_id, :page, :document_id, :account_id, :status)
+ params.permit(:id, :assistant_id, :page, :document_id, :account_id, :status, :search)
end
def response_params
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistant_responses_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistant_responses_controller_spec.rb
index 58c23f897..038ee5e8d 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/assistant_responses_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistant_responses_controller_spec.rb
@@ -90,6 +90,53 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(json_response[:payload][0][:documentable][:id]).to eq(document.id)
end
end
+
+ context 'when searching' do
+ before do
+ create(:captain_assistant_response,
+ account: account,
+ assistant: assistant,
+ question: 'How to reset password?',
+ answer: 'Click forgot password')
+ create(:captain_assistant_response,
+ account: account,
+ assistant: assistant,
+ question: 'How to change email?',
+ answer: 'Go to settings')
+ end
+
+ it 'finds responses by question text' do
+ get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
+ params: { search: 'password' },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(json_response[:payload].length).to eq(1)
+ expect(json_response[:payload][0][:question]).to include('password')
+ end
+
+ it 'finds responses by answer text' do
+ get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
+ params: { search: 'settings' },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(json_response[:payload].length).to eq(1)
+ expect(json_response[:payload][0][:answer]).to include('settings')
+ end
+
+ it 'returns empty when no matches' do
+ get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
+ params: { search: 'nonexistent' },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(json_response[:payload].length).to eq(0)
+ end
+ end
end
describe 'GET /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
From f03a52bd77b407cc04e1222a75b3455607079fec Mon Sep 17 00:00:00 2001
From: Aguinaldo Tupy <44652991+aguinaldotupy@users.noreply.github.com>
Date: Thu, 18 Sep 2025 06:55:31 -0300
Subject: [PATCH 05/14] feat: Add `media_name` support for WhatsApp templates
document files (#12462)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description
This implementation adds support for the `media_name` parameter for
WhatsApp document templates, resolving the issue where documents appear
as "untitled" when sent via templates.
**Problem solved:** Documents sent via WhatsApp templates always
appeared as "untitled" because Chatwoot didn't process the `filename`
field required by the WhatsApp API.
**Solution:** Added support for the `media_name` parameter that maps to
the WhatsApp API's `filename` field.
## Type of change
- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update
## How Has This Been Tested?
Created and executed **7 comprehensive test scenarios**:
1. ✅ Document without `media_name` (backward compatibility)
2. ✅ Document with valid `media_name`
3. ✅ Document with blank `media_name`
4. ✅ Document with null `media_name`
5. ✅ Image with `media_name` (ignored as expected)
6. ✅ Video with `media_name` (ignored as expected)
7. ✅ Blank URL (returns nil appropriately)
**All tests passed** and confirmed **100% backward compatibility**.
## Technical Implementation
**Backend Changes:**
- `PopulateTemplateParametersService`: Added `media_name` parameter
support
- `TemplateProcessorService`: Pass `media_name` to parameter builder
- `WhatsappCloudService`: Updated documentation with `media_name`
example
**Frontend Changes:**
- `WhatsAppTemplateParser.vue`: Added UI field for document filename
input
- `templateHelper.js`: Include `media_name` for document templates
- `whatsappTemplates.json`: Added translation key for document name
placeholder
**Key Features:**
- 🔄 **100% Backward Compatible** - Existing templates continue working
- 📝 **Document Filename Support** - Users can specify custom filenames
- 🎯 **Document-Only Feature** - Only affects document media types
- ✅ **Comprehensive Testing** - All edge cases covered
## Expected Behavior
**Before:**
```ruby
# All documents appear as "untitled"
{
type: 'document',
document: { link: 'https://example.com/document.pdf' }
}
```
**After:**
```ruby
# With media_name - displays custom filename
{
type: 'document',
document: {
link: 'https://example.com/document.pdf',
filename: 'Invoice_2025.pdf'
}
}
# Without media_name - works as before
{
type: 'document',
document: { link: 'https://example.com/document.pdf' }
}
```
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
Co-authored-by: Muhsin Keloth
---
.../whatsapp/WhatsAppTemplateParser.vue | 22 +++++++++++++++++++
.../helper/specs/templateHelper.spec.js | 1 +
.../dashboard/helper/templateHelper.js | 5 +++++
.../i18n/locale/en/whatsappTemplates.json | 1 +
.../populate_template_parameters_service.rb | 15 ++++++++-----
.../providers/whatsapp_cloud_service.rb | 6 ++++-
.../whatsapp/template_processor_service.rb | 5 +++--
7 files changed, 46 insertions(+), 9 deletions(-)
diff --git a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue
index cf62a6d5d..6df06642c 100644
--- a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue
+++ b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue
@@ -72,6 +72,10 @@ const formatType = computed(() => {
return format ? format.charAt(0) + format.slice(1).toLowerCase() : '';
});
+const isDocumentTemplate = computed(() => {
+ return headerComponent.value?.format?.toLowerCase() === 'document';
+});
+
const hasVariables = computed(() => {
return bodyText.value?.match(/{{([^}]+)}}/g) !== null;
});
@@ -126,6 +130,11 @@ const updateMediaUrl = value => {
processedParams.value.header.media_url = value;
};
+const updateMediaName = value => {
+ processedParams.value.header ??= {};
+ processedParams.value.header.media_name = value;
+};
+
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid) return;
@@ -168,10 +177,12 @@ defineExpose({
processedParams,
hasVariables,
hasMediaHeader,
+ isDocumentTemplate,
headerComponent,
renderedTemplate,
v$,
updateMediaUrl,
+ updateMediaName,
sendMessage,
resetTemplate,
goBack,
@@ -225,6 +236,17 @@ defineExpose({
@update:model-value="updateMediaUrl"
/>
+
+
+
diff --git a/app/javascript/dashboard/helper/specs/templateHelper.spec.js b/app/javascript/dashboard/helper/specs/templateHelper.spec.js
index 6e0661152..375e38a2d 100644
--- a/app/javascript/dashboard/helper/specs/templateHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/templateHelper.spec.js
@@ -218,6 +218,7 @@ describe('templateHelper', () => {
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
+ media_name: '',
});
expect(result.body).toEqual({
1: '',
diff --git a/app/javascript/dashboard/helper/templateHelper.js b/app/javascript/dashboard/helper/templateHelper.js
index 5c9bbff05..1fb61d760 100644
--- a/app/javascript/dashboard/helper/templateHelper.js
+++ b/app/javascript/dashboard/helper/templateHelper.js
@@ -51,6 +51,11 @@ export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
if (!allVariables.header) allVariables.header = {};
allVariables.header.media_url = '';
allVariables.header.media_type = headerComponent.format.toLowerCase();
+
+ // For document templates, include media_name field for filename support
+ if (headerComponent.format.toLowerCase() === 'document') {
+ allVariables.header.media_name = '';
+ }
}
// Process button variables
diff --git a/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json b/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json
index 5f53faaa8..cf28312dc 100644
--- a/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json
+++ b/app/javascript/dashboard/i18n/locale/en/whatsappTemplates.json
@@ -40,6 +40,7 @@
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
+ "DOCUMENT_NAME_PLACEHOLDER": "Enter document filename (e.g., Invoice_2025.pdf)",
"BUTTON_PARAMETER": "Enter button parameter"
}
}
diff --git a/app/services/whatsapp/populate_template_parameters_service.rb b/app/services/whatsapp/populate_template_parameters_service.rb
index 278e52f64..3f9f64b91 100644
--- a/app/services/whatsapp/populate_template_parameters_service.rb
+++ b/app/services/whatsapp/populate_template_parameters_service.rb
@@ -30,12 +30,12 @@ class Whatsapp::PopulateTemplateParametersService
end
end
- def build_media_parameter(url, media_type)
+ def build_media_parameter(url, media_type, media_name = nil)
return nil if url.blank?
sanitized_url = sanitize_parameter(url)
validate_url(sanitized_url)
- build_media_type_parameter(sanitized_url, media_type.downcase)
+ build_media_type_parameter(sanitized_url, media_type.downcase, media_name)
end
def build_named_parameter(parameter_name, value)
@@ -89,14 +89,14 @@ class Whatsapp::PopulateTemplateParametersService
}
end
- def build_media_type_parameter(sanitized_url, media_type)
+ def build_media_type_parameter(sanitized_url, media_type, media_name = nil)
case media_type
when 'image'
build_image_parameter(sanitized_url)
when 'video'
build_video_parameter(sanitized_url)
when 'document'
- build_document_parameter(sanitized_url)
+ build_document_parameter(sanitized_url, media_name)
else
raise ArgumentError, "Unsupported media type: #{media_type}"
end
@@ -110,8 +110,11 @@ class Whatsapp::PopulateTemplateParametersService
{ type: 'video', video: { link: url } }
end
- def build_document_parameter(url)
- { type: 'document', document: { link: url } }
+ def build_document_parameter(url, media_name = nil)
+ document_params = { link: url }
+ document_params[:filename] = media_name if media_name.present?
+
+ { type: 'document', document: document_params }
end
def rich_formatting?(text)
diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
index 68e965595..124e1e5d3 100644
--- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb
+++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
@@ -141,7 +141,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
# {
# processed_params: {
# body: { '1': 'John', '2': '123 Main St' },
- # header: { media_url: 'https://...', media_type: 'image' },
+ # header: {
+ # media_url: 'https://...',
+ # media_type: 'image',
+ # media_name: 'filename.pdf' # Optional, for document templates only
+ # },
# buttons: [{ type: 'url', parameter: 'otp123456' }]
# }
# }
diff --git a/app/services/whatsapp/template_processor_service.rb b/app/services/whatsapp/template_processor_service.rb
index 3b12bf58b..4a89d684a 100644
--- a/app/services/whatsapp/template_processor_service.rb
+++ b/app/services/whatsapp/template_processor_service.rb
@@ -60,9 +60,10 @@ class Whatsapp::TemplateProcessorService
next if value.blank?
if media_url_with_type?(key, header_data)
- media_param = parameter_builder.build_media_parameter(value, header_data['media_type'])
+ media_name = header_data['media_name']
+ media_param = parameter_builder.build_media_parameter(value, header_data['media_type'], media_name)
header_params << media_param if media_param
- elsif key != 'media_type'
+ elsif key != 'media_type' && key != 'media_name'
header_params << parameter_builder.build_parameter(value)
end
end
From 239c4dcb915adbca3213488e958e6998e16d022a Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Thu, 18 Sep 2025 16:49:24 +0200
Subject: [PATCH 06/14] feat: MFA (#12290)
## Linear:
- https://github.com/chatwoot/chatwoot/issues/486
## Description
This PR implements Multi-Factor Authentication (MFA) support for user
accounts, enhancing security by requiring a second form of verification
during login. The feature adds TOTP (Time-based One-Time Password)
authentication with QR code generation and backup codes for account
recovery.
## Type of change
- [ ] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
- Added comprehensive RSpec tests for MFA controller functionality
- Tested MFA setup flow with QR code generation
- Verified OTP validation and backup code generation
- Tested login flow with MFA enabled/disabled
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Pranav
Co-authored-by: Sojan Jose
Co-authored-by: Muhsin Keloth
---
.env.example | 7 +
.github/workflows/run_mfa_spec.yml | 99 +++++++
Gemfile | 2 +
Gemfile.lock | 7 +
.../api/v1/profile/mfa_controller.rb | 68 +++++
.../devise_overrides/sessions_controller.rb | 61 +++-
app/models/super_admin.rb | 14 +-
app/models/user.rb | 42 ++-
app/services/base_token_service.rb | 27 ++
app/services/mfa/authentication_service.rb | 23 ++
app/services/mfa/management_service.rb | 88 ++++++
app/services/mfa/token_service.rb | 28 ++
app/services/widget/token_service.rb | 22 +-
.../v1/profile/mfa/backup_codes.json.jbuilder | 1 +
.../api/v1/profile/mfa/create.json.jbuilder | 2 +
.../api/v1/profile/mfa/destroy.json.jbuilder | 1 +
.../api/v1/profile/mfa/show.json.jbuilder | 3 +
.../api/v1/profile/mfa/verify.json.jbuilder | 2 +
config/application.rb | 22 ++
.../initializers/filter_parameter_logging.rb | 3 +-
config/initializers/rack_attack.rb | 31 +-
config/locales/en.yml | 12 +
config/routes.rb | 8 +
.../20250820130619_add_two_factor_to_users.rb | 11 +
db/schema.rb | 6 +
.../sessions_controller_spec.rb | 146 ++++++++++
spec/enterprise/models/inbox_spec.rb | 2 +-
spec/models/user_spec.rb | 107 +++++++
.../api/v1/profile/mfa_controller_spec.rb | 274 ++++++++++++++++++
spec/services/base_token_service_spec.rb | 42 +++
.../mfa/authentication_service_spec.rb | 106 +++++++
spec/services/mfa/token_service_spec.rb | 72 +++++
spec/services/widget/token_service_spec.rb | 43 +++
33 files changed, 1345 insertions(+), 37 deletions(-)
create mode 100644 .github/workflows/run_mfa_spec.yml
create mode 100644 app/controllers/api/v1/profile/mfa_controller.rb
create mode 100644 app/services/base_token_service.rb
create mode 100644 app/services/mfa/authentication_service.rb
create mode 100644 app/services/mfa/management_service.rb
create mode 100644 app/services/mfa/token_service.rb
create mode 100644 app/views/api/v1/profile/mfa/backup_codes.json.jbuilder
create mode 100644 app/views/api/v1/profile/mfa/create.json.jbuilder
create mode 100644 app/views/api/v1/profile/mfa/destroy.json.jbuilder
create mode 100644 app/views/api/v1/profile/mfa/show.json.jbuilder
create mode 100644 app/views/api/v1/profile/mfa/verify.json.jbuilder
create mode 100644 db/migrate/20250820130619_add_two_factor_to_users.rb
create mode 100644 spec/controllers/devise_overrides/sessions_controller_spec.rb
create mode 100644 spec/requests/api/v1/profile/mfa_controller_spec.rb
create mode 100644 spec/services/base_token_service_spec.rb
create mode 100644 spec/services/mfa/authentication_service_spec.rb
create mode 100644 spec/services/mfa/token_service_spec.rb
create mode 100644 spec/services/widget/token_service_spec.rb
diff --git a/.env.example b/.env.example
index 2ab2933dc..de671599c 100644
--- a/.env.example
+++ b/.env.example
@@ -6,6 +6,13 @@
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
+# Active Record Encryption keys (required for MFA/2FA functionality)
+# Generate these keys by running: rails db:encryption:init
+# IMPORTANT: Use different keys for each environment (development, staging, production)
+# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
+# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
+# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
+
# Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000
# To use a dedicated URL for help center pages
diff --git a/.github/workflows/run_mfa_spec.yml b/.github/workflows/run_mfa_spec.yml
new file mode 100644
index 000000000..61b406f8a
--- /dev/null
+++ b/.github/workflows/run_mfa_spec.yml
@@ -0,0 +1,99 @@
+name: Run MFA Tests
+permissions:
+ contents: read
+
+on:
+ pull_request:
+
+# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
+concurrency:
+ group: pr-${{ github.workflow }}-${{ github.head_ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-22.04
+ # Only run if MFA test keys are available
+ if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]')
+
+ services:
+ postgres:
+ image: pgvector/pgvector:pg15
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: ''
+ POSTGRES_DB: postgres
+ POSTGRES_HOST_AUTH_METHOD: trust
+ ports:
+ - 5432:5432
+ options: >-
+ --mount type=tmpfs,destination=/var/lib/postgresql/data
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ redis:
+ image: redis
+ ports:
+ - 6379:6379
+ options: --entrypoint redis-server
+
+ env:
+ RAILS_ENV: test
+ POSTGRES_HOST: localhost
+ # Active Record encryption keys required for MFA - test keys only, not for production use
+ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7'
+ ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8'
+ ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9'
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+
+ - name: Create database
+ run: bundle exec rake db:create
+
+ - name: Install pgvector extension
+ run: |
+ PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;"
+
+ - name: Seed database
+ run: bundle exec rake db:schema:load
+
+ - name: Run MFA-related backend tests
+ run: |
+ bundle exec rspec \
+ spec/services/mfa/token_service_spec.rb \
+ spec/services/mfa/authentication_service_spec.rb \
+ spec/requests/api/v1/profile/mfa_controller_spec.rb \
+ spec/controllers/devise_overrides/sessions_controller_spec.rb \
+ --profile=10 \
+ --format documentation
+ env:
+ NODE_OPTIONS: --openssl-legacy-provider
+
+ - name: Run MFA-related tests in user_spec
+ run: |
+ # Run specific MFA-related tests from user_spec
+ bundle exec rspec spec/models/user_spec.rb \
+ -e "two factor" \
+ -e "2FA" \
+ -e "MFA" \
+ -e "otp" \
+ -e "backup code" \
+ --profile=10 \
+ --format documentation
+ env:
+ NODE_OPTIONS: --openssl-legacy-provider
+
+ - name: Upload test logs
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: mfa-test-logs
+ path: |
+ log/test.log
+ tmp/screenshots/
diff --git a/Gemfile b/Gemfile
index 927a853a0..265c609c1 100644
--- a/Gemfile
+++ b/Gemfile
@@ -78,6 +78,8 @@ gem 'barnes'
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3'
+# two-factor authentication
+gem 'devise-two-factor', '>= 5.0.0'
# authorization
gem 'jwt'
gem 'pundit'
diff --git a/Gemfile.lock b/Gemfile.lock
index 2cce9f322..f9e253e6c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -212,6 +212,11 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
+ devise-two-factor (6.1.0)
+ activesupport (>= 7.0, < 8.1)
+ devise (~> 4.0)
+ railties (>= 7.0, < 8.1)
+ rotp (~> 6.0)
devise_token_auth (1.2.5)
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
@@ -723,6 +728,7 @@ GEM
reverse_markdown (2.1.1)
nokogiri
rexml (3.4.1)
+ rotp (6.3.0)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.2)
@@ -1005,6 +1011,7 @@ DEPENDENCIES
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password!
+ devise-two-factor (>= 5.0.0)
devise_token_auth (>= 1.2.3)
dotenv-rails (>= 3.0.0)
down
diff --git a/app/controllers/api/v1/profile/mfa_controller.rb b/app/controllers/api/v1/profile/mfa_controller.rb
new file mode 100644
index 000000000..dd874f222
--- /dev/null
+++ b/app/controllers/api/v1/profile/mfa_controller.rb
@@ -0,0 +1,68 @@
+class Api::V1::Profile::MfaController < Api::BaseController
+ before_action :check_mfa_feature_available
+ before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
+ before_action :check_mfa_disabled, only: [:create, :verify]
+ before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
+ before_action :validate_password, only: [:destroy]
+
+ def show; end
+
+ def create
+ mfa_service.enable_two_factor!
+ end
+
+ def verify
+ @backup_codes = mfa_service.verify_and_activate!
+ end
+
+ def destroy
+ mfa_service.disable_two_factor!
+ end
+
+ def backup_codes
+ @backup_codes = mfa_service.generate_backup_codes!
+ end
+
+ private
+
+ def mfa_service
+ @mfa_service ||= Mfa::ManagementService.new(user: current_user)
+ end
+
+ def check_mfa_enabled
+ render_could_not_create_error(I18n.t('errors.mfa.not_enabled')) unless current_user.mfa_enabled?
+ end
+
+ def check_mfa_feature_available
+ return if Chatwoot.mfa_enabled?
+
+ render json: {
+ error: I18n.t('errors.mfa.feature_unavailable')
+ }, status: :forbidden
+ end
+
+ def check_mfa_disabled
+ render_could_not_create_error(I18n.t('errors.mfa.already_enabled')) if current_user.mfa_enabled?
+ end
+
+ def validate_otp
+ authenticated = Mfa::AuthenticationService.new(
+ user: current_user,
+ otp_code: mfa_params[:otp_code]
+ ).authenticate
+
+ return if authenticated
+
+ render_could_not_create_error(I18n.t('errors.mfa.invalid_code'))
+ end
+
+ def validate_password
+ return if current_user.valid_password?(mfa_params[:password])
+
+ render_could_not_create_error(I18n.t('errors.mfa.invalid_credentials'))
+ end
+
+ def mfa_params
+ params.permit(:otp_code, :password)
+ end
+end
diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb
index fc7b12767..bf3a7f221 100644
--- a/app/controllers/devise_overrides/sessions_controller.rb
+++ b/app/controllers/devise_overrides/sessions_controller.rb
@@ -9,13 +9,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def create
- # Authenticate user via the temporary sso auth token
- if params[:sso_auth_token].present? && @resource.present?
- authenticate_resource_with_sso_token
- yield @resource if block_given?
- render_create_success
- else
- super
+ return handle_mfa_verification if mfa_verification_request?
+ return handle_sso_authentication if sso_authentication_request?
+
+ super do |resource|
+ return handle_mfa_required(resource) if resource&.mfa_enabled?
end
end
@@ -25,6 +23,20 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private
+ def mfa_verification_request?
+ params[:mfa_token].present?
+ end
+
+ def sso_authentication_request?
+ params[:sso_auth_token].present? && @resource.present?
+ end
+
+ def handle_sso_authentication
+ authenticate_resource_with_sso_token
+ yield @resource if block_given?
+ render_create_success
+ end
+
def login_page_url(error: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
@@ -46,6 +58,41 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = User.from_email(params[:email])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
+
+ def handle_mfa_required(resource)
+ render json: {
+ mfa_required: true,
+ mfa_token: Mfa::TokenService.new(user: resource).generate_token
+ }, status: :partial_content
+ end
+
+ def handle_mfa_verification
+ user = Mfa::TokenService.new(token: params[:mfa_token]).verify_token
+ return render_mfa_error('errors.mfa.invalid_token', :unauthorized) unless user
+
+ authenticated = Mfa::AuthenticationService.new(
+ user: user,
+ otp_code: params[:otp_code],
+ backup_code: params[:backup_code]
+ ).authenticate
+
+ return render_mfa_error('errors.mfa.invalid_code') unless authenticated
+
+ sign_in_mfa_user(user)
+ end
+
+ def sign_in_mfa_user(user)
+ @resource = user
+ @token = @resource.create_token
+ @resource.save!
+
+ sign_in(:user, @resource, store: false, bypass: false)
+ render_create_success
+ end
+
+ def render_mfa_error(message_key, status = :bad_request)
+ render json: { error: I18n.t(message_key) }, status: status
+ end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
diff --git a/app/models/super_admin.rb b/app/models/super_admin.rb
index f41610ee4..316d60c7b 100644
--- a/app/models/super_admin.rb
+++ b/app/models/super_admin.rb
@@ -7,6 +7,7 @@
# confirmation_sent_at :datetime
# confirmation_token :string
# confirmed_at :datetime
+# consumed_timestep :integer
# current_sign_in_at :datetime
# current_sign_in_ip :string
# custom_attributes :jsonb
@@ -17,6 +18,9 @@
# last_sign_in_ip :string
# message_signature :text
# name :string not null
+# otp_backup_codes :text
+# otp_required_for_login :boolean default(FALSE), not null
+# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
# remember_created_at :datetime
@@ -33,10 +37,12 @@
#
# Indexes
#
-# index_users_on_email (email)
-# index_users_on_pubsub_token (pubsub_token) UNIQUE
-# index_users_on_reset_password_token (reset_password_token) UNIQUE
-# index_users_on_uid_and_provider (uid,provider) UNIQUE
+# index_users_on_email (email)
+# index_users_on_otp_required_for_login (otp_required_for_login)
+# index_users_on_otp_secret (otp_secret) UNIQUE
+# index_users_on_pubsub_token (pubsub_token) UNIQUE
+# index_users_on_reset_password_token (reset_password_token) UNIQUE
+# index_users_on_uid_and_provider (uid,provider) UNIQUE
#
class SuperAdmin < User
end
diff --git a/app/models/user.rb b/app/models/user.rb
index e0440a67c..4923d0a35 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -7,6 +7,7 @@
# confirmation_sent_at :datetime
# confirmation_token :string
# confirmed_at :datetime
+# consumed_timestep :integer
# current_sign_in_at :datetime
# current_sign_in_ip :string
# custom_attributes :jsonb
@@ -17,6 +18,9 @@
# last_sign_in_ip :string
# message_signature :text
# name :string not null
+# otp_backup_codes :text
+# otp_required_for_login :boolean default(FALSE), not null
+# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
# remember_created_at :datetime
@@ -33,10 +37,12 @@
#
# Indexes
#
-# index_users_on_email (email)
-# index_users_on_pubsub_token (pubsub_token) UNIQUE
-# index_users_on_reset_password_token (reset_password_token) UNIQUE
-# index_users_on_uid_and_provider (uid,provider) UNIQUE
+# index_users_on_email (email)
+# index_users_on_otp_required_for_login (otp_required_for_login)
+# index_users_on_otp_secret (otp_secret) UNIQUE
+# index_users_on_pubsub_token (pubsub_token) UNIQUE
+# index_users_on_reset_password_token (reset_password_token) UNIQUE
+# index_users_on_uid_and_provider (uid,provider) UNIQUE
#
class User < ApplicationRecord
@@ -58,6 +64,7 @@ class User < ApplicationRecord
:validatable,
:confirmable,
:password_has_required_content,
+ :two_factor_authenticatable,
:omniauthable, omniauth_providers: [:google_oauth2, :saml]
# TODO: remove in a future version once online status is moved to account users
@@ -70,6 +77,12 @@ class User < ApplicationRecord
validates :email, presence: true
+ serialize :otp_backup_codes, type: Array
+
+ # Encrypt sensitive MFA fields
+ encrypts :otp_secret, deterministic: true
+ encrypts :otp_backup_codes
+
has_many :account_users, dependent: :destroy_async
has_many :accounts, through: :account_users
accepts_nested_attributes_for :account_users
@@ -156,6 +169,27 @@ class User < ApplicationRecord
find_by(email: email&.downcase)
end
+ # 2FA/MFA Methods
+ # Delegated to Mfa::ManagementService for better separation of concerns
+ def mfa_service
+ @mfa_service ||= Mfa::ManagementService.new(user: self)
+ end
+
+ delegate :two_factor_provisioning_uri, to: :mfa_service
+ delegate :backup_codes_generated?, to: :mfa_service
+ delegate :enable_two_factor!, to: :mfa_service
+ delegate :disable_two_factor!, to: :mfa_service
+ delegate :generate_backup_codes!, to: :mfa_service
+ delegate :validate_backup_code!, to: :mfa_service
+
+ def mfa_enabled?
+ otp_required_for_login?
+ end
+
+ def mfa_feature_available?
+ Chatwoot.mfa_enabled?
+ end
+
private
def remove_macros
diff --git a/app/services/base_token_service.rb b/app/services/base_token_service.rb
new file mode 100644
index 000000000..966404108
--- /dev/null
+++ b/app/services/base_token_service.rb
@@ -0,0 +1,27 @@
+class BaseTokenService
+ pattr_initialize [:payload, :token]
+
+ def generate_token
+ JWT.encode(token_payload, secret_key, algorithm)
+ end
+
+ def decode_token
+ JWT.decode(token, secret_key, true, algorithm: algorithm).first.symbolize_keys
+ rescue JWT::ExpiredSignature, JWT::DecodeError
+ {}
+ end
+
+ private
+
+ def token_payload
+ payload || {}
+ end
+
+ def secret_key
+ Rails.application.secret_key_base
+ end
+
+ def algorithm
+ 'HS256'
+ end
+end
diff --git a/app/services/mfa/authentication_service.rb b/app/services/mfa/authentication_service.rb
new file mode 100644
index 000000000..caad66cf7
--- /dev/null
+++ b/app/services/mfa/authentication_service.rb
@@ -0,0 +1,23 @@
+class Mfa::AuthenticationService
+ pattr_initialize [:user!, :otp_code, :backup_code]
+
+ def authenticate
+ return false unless user
+
+ return authenticate_with_otp if otp_code.present?
+ return authenticate_with_backup_code if backup_code.present?
+
+ false
+ end
+
+ private
+
+ def authenticate_with_otp
+ user.validate_and_consume_otp!(otp_code)
+ end
+
+ def authenticate_with_backup_code
+ mfa_service = Mfa::ManagementService.new(user: user)
+ mfa_service.validate_backup_code!(backup_code)
+ end
+end
diff --git a/app/services/mfa/management_service.rb b/app/services/mfa/management_service.rb
new file mode 100644
index 000000000..d4c01ec1f
--- /dev/null
+++ b/app/services/mfa/management_service.rb
@@ -0,0 +1,88 @@
+class Mfa::ManagementService
+ pattr_initialize [:user!]
+
+ def enable_two_factor!
+ user.otp_secret = User.generate_otp_secret
+ user.save!
+ end
+
+ def disable_two_factor!
+ user.otp_secret = nil
+ user.otp_required_for_login = false
+ user.otp_backup_codes = nil
+ user.save!
+ end
+
+ def verify_and_activate!
+ ActiveRecord::Base.transaction do
+ user.update!(otp_required_for_login: true)
+ backup_codes_generated? ? nil : generate_backup_codes!
+ end
+ end
+
+ def two_factor_provisioning_uri
+ return nil if user.otp_secret.blank?
+
+ issuer = 'Chatwoot'
+ label = user.email
+ user.otp_provisioning_uri(label, issuer: issuer)
+ end
+
+ def generate_backup_codes!
+ codes = Array.new(10) { SecureRandom.hex(4).upcase }
+ user.otp_backup_codes = codes
+ user.save!
+ codes
+ end
+
+ def validate_backup_code!(code)
+ return false unless valid_backup_code_input?(code)
+
+ codes = user.otp_backup_codes
+ found_index = find_matching_code_index(codes, code)
+
+ return false if found_index.nil?
+
+ mark_code_as_used(codes, found_index)
+ end
+
+ private
+
+ def valid_backup_code_input?(code)
+ user.otp_backup_codes.present? && code.present?
+ end
+
+ def find_matching_code_index(codes, code)
+ found_index = nil
+
+ # Constant-time comparison to prevent timing attacks
+ codes.each_with_index do |stored_code, idx|
+ is_match = ActiveSupport::SecurityUtils.secure_compare(stored_code, code)
+ is_unused = stored_code != 'XXXXXXXX'
+ found_index = idx if is_match && is_unused
+ end
+
+ found_index
+ end
+
+ def mark_code_as_used(codes, index)
+ codes[index] = 'XXXXXXXX'
+ user.otp_backup_codes = codes
+ user.save!
+ true
+ end
+
+ public
+
+ def backup_codes_generated?
+ user.otp_backup_codes.present?
+ end
+
+ def mfa_enabled?
+ user.otp_required_for_login?
+ end
+
+ def two_factor_setup_pending?
+ user.otp_secret.present? && !user.otp_required_for_login?
+ end
+end
diff --git a/app/services/mfa/token_service.rb b/app/services/mfa/token_service.rb
new file mode 100644
index 000000000..a7994b60c
--- /dev/null
+++ b/app/services/mfa/token_service.rb
@@ -0,0 +1,28 @@
+class Mfa::TokenService < BaseTokenService
+ pattr_initialize [:user, :token]
+
+ MFA_TOKEN_EXPIRY = 5.minutes
+
+ def generate_token
+ @payload = build_payload
+ super
+ end
+
+ def verify_token
+ decoded = decode_token
+ return nil if decoded.blank?
+
+ User.find(decoded[:user_id])
+ rescue ActiveRecord::RecordNotFound
+ nil
+ end
+
+ private
+
+ def build_payload
+ {
+ user_id: user.id,
+ exp: MFA_TOKEN_EXPIRY.from_now.to_i
+ }
+ end
+end
diff --git a/app/services/widget/token_service.rb b/app/services/widget/token_service.rb
index 4eebaab9e..b50119eb1 100644
--- a/app/services/widget/token_service.rb
+++ b/app/services/widget/token_service.rb
@@ -1,24 +1,14 @@
-class Widget::TokenService
+class Widget::TokenService < BaseTokenService
DEFAULT_EXPIRY_DAYS = 180
- pattr_initialize [:payload, :token]
-
def generate_token
- JWT.encode payload_with_expiry, secret_key, 'HS256'
- end
-
- def decode_token
- JWT.decode(
- token, secret_key, true, algorithm: 'HS256'
- ).first.symbolize_keys
- rescue StandardError
- {}
+ JWT.encode(token_payload, secret_key, algorithm)
end
private
- def payload_with_expiry
- payload.merge(exp: exp, iat: iat)
+ def token_payload
+ (payload || {}).merge(exp: exp, iat: iat)
end
def iat
@@ -34,8 +24,4 @@ class Widget::TokenService
token_expiry_value = InstallationConfig.find_by(name: 'WIDGET_TOKEN_EXPIRY')&.value
(token_expiry_value.presence || DEFAULT_EXPIRY_DAYS).to_i
end
-
- def secret_key
- Rails.application.secret_key_base
- end
end
diff --git a/app/views/api/v1/profile/mfa/backup_codes.json.jbuilder b/app/views/api/v1/profile/mfa/backup_codes.json.jbuilder
new file mode 100644
index 000000000..2aafdd3cd
--- /dev/null
+++ b/app/views/api/v1/profile/mfa/backup_codes.json.jbuilder
@@ -0,0 +1 @@
+json.backup_codes @backup_codes
diff --git a/app/views/api/v1/profile/mfa/create.json.jbuilder b/app/views/api/v1/profile/mfa/create.json.jbuilder
new file mode 100644
index 000000000..52072ccfb
--- /dev/null
+++ b/app/views/api/v1/profile/mfa/create.json.jbuilder
@@ -0,0 +1,2 @@
+json.provisioning_url @user.mfa_service.two_factor_provisioning_uri
+json.secret @user.otp_secret
diff --git a/app/views/api/v1/profile/mfa/destroy.json.jbuilder b/app/views/api/v1/profile/mfa/destroy.json.jbuilder
new file mode 100644
index 000000000..a5bf2ea84
--- /dev/null
+++ b/app/views/api/v1/profile/mfa/destroy.json.jbuilder
@@ -0,0 +1 @@
+json.enabled @user.mfa_enabled?
diff --git a/app/views/api/v1/profile/mfa/show.json.jbuilder b/app/views/api/v1/profile/mfa/show.json.jbuilder
new file mode 100644
index 000000000..4568f48f9
--- /dev/null
+++ b/app/views/api/v1/profile/mfa/show.json.jbuilder
@@ -0,0 +1,3 @@
+json.feature_available Chatwoot.mfa_enabled?
+json.enabled @user.mfa_enabled?
+json.backup_codes_generated @user.mfa_service.backup_codes_generated? if Chatwoot.mfa_enabled?
diff --git a/app/views/api/v1/profile/mfa/verify.json.jbuilder b/app/views/api/v1/profile/mfa/verify.json.jbuilder
new file mode 100644
index 000000000..54be3fc35
--- /dev/null
+++ b/app/views/api/v1/profile/mfa/verify.json.jbuilder
@@ -0,0 +1,2 @@
+json.enabled @user.mfa_enabled?
+json.backup_codes @backup_codes if @backup_codes.present?
diff --git a/config/application.rb b/config/application.rb
index 7fd1b94ba..d644dd28f 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -68,6 +68,16 @@ module Chatwoot
# Disable PDF/video preview generation as we don't use them
config.active_storage.previewers = []
+
+ # Active Record Encryption configuration
+ # Required for MFA/2FA features - skip if not using encryption
+ if ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present?
+ config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
+ config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil)
+ config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil)
+ config.active_record.encryption.support_unencrypted_data = true
+ config.active_record.encryption.store_key_references = true
+ end
end
def self.config
@@ -82,4 +92,16 @@ module Chatwoot
# ref: https://www.rubydoc.info/stdlib/openssl/OpenSSL/SSL/SSLContext#DEFAULT_PARAMS-constant
ENV['REDIS_OPENSSL_VERIFY_MODE'] == 'none' ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
end
+
+ def self.encryption_configured?
+ # Check if proper encryption keys are configured
+ # MFA/2FA features should only be enabled when proper keys are set
+ ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? &&
+ ENV['ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY'].present? &&
+ ENV['ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT'].present?
+ end
+
+ def self.mfa_enabled?
+ encryption_configured?
+ end
end
diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb
index 4c9ecc032..18855c95f 100644
--- a/config/initializers/filter_parameter_logging.rb
+++ b/config/initializers/filter_parameter_logging.rb
@@ -2,7 +2,8 @@
# Configure sensitive parameters which will be filtered from the log file.
Rails.application.config.filter_parameters += [
- :password, :secret, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn
+ :password, :secret, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn,
+ :otp_secret, :otp_code, :backup_code, :mfa_token, :otp_backup_codes
]
# Regex to filter all occurrences of 'token' in keys except for 'website_token'
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index fe3f6c554..fe40974f2 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -83,12 +83,17 @@ class Rack::Attack
end
# ### Prevent Brute-Force Login Attacks ###
+ # Exclude MFA verification attempts from regular login throttling
throttle('login/ip', limit: 5, period: 5.minutes) do |req|
- req.ip if req.path_without_extentions == '/auth/sign_in' && req.post?
+ if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
+ # Skip if this is an MFA verification request
+ req.ip
+ end
end
throttle('login/email', limit: 10, period: 15.minutes) do |req|
- if req.path_without_extentions == '/auth/sign_in' && req.post?
+ # Skip if this is an MFA verification request
+ if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
# ref: https://github.com/rack/rack-attack/issues/399
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
# Hence placed in the if block
@@ -114,6 +119,28 @@ class Rack::Attack
req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
end
+ ## MFA throttling - prevent brute force attacks
+ throttle('mfa_verification/ip', limit: 5, period: 1.minute) do |req|
+ if req.path_without_extentions == '/api/v1/profile/mfa'
+ req.ip if req.delete? # Throttle disable attempts
+ elsif req.path_without_extentions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
+ req.ip if req.post? # Throttle verify and backup_codes attempts
+ end
+ end
+
+ # Separate rate limiting for MFA verification attempts
+ throttle('mfa_login/ip', limit: 10, period: 1.minute) do |req|
+ req.ip if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
+ end
+
+ throttle('mfa_login/token', limit: 10, period: 1.minute) do |req|
+ if req.path_without_extentions == '/auth/sign_in' && req.post?
+ # Track by MFA token to prevent brute force on a specific token
+ mfa_token = req.params['mfa_token'].presence
+ (mfa_token.presence)
+ end
+ end
+
## Prevent Brute-Force Signup Attacks ###
throttle('accounts/ip', limit: 5, period: 30.minutes) do |req|
req.ip if req.path_without_extentions == '/api/v1/accounts' && req.post?
diff --git a/config/locales/en.yml b/config/locales/en.yml
index d5345a411..0d869c75f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -103,6 +103,18 @@ en:
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
custom_attribute_definition:
key_conflict: The provided key is not allowed as it might conflict with default attributes.
+ mfa:
+ already_enabled: MFA is already enabled
+ not_enabled: MFA is not enabled
+ invalid_code: Invalid verification code
+ invalid_backup_code: Invalid backup code
+ invalid_token: Invalid or expired MFA token
+ invalid_credentials: Invalid credentials or verification code
+ feature_unavailable: MFA feature is not available. Please configure encryption keys.
+ profile:
+ mfa:
+ enabled: MFA enabled successfully
+ disabled: MFA disabled successfully
account_saml_settings:
invalid_certificate: must be a valid X.509 certificate in PEM format
reports:
diff --git a/config/routes.rb b/config/routes.rb
index 5fc602e0b..244bd67bf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -334,6 +334,14 @@ Rails.application.routes.draw do
post :resend_confirmation
post :reset_access_token
end
+
+ # MFA routes
+ scope module: 'profile' do
+ resource :mfa, controller: 'mfa', only: [:show, :create, :destroy] do
+ post :verify
+ post :backup_codes
+ end
+ end
end
resource :notification_subscriptions, only: [:create, :destroy]
diff --git a/db/migrate/20250820130619_add_two_factor_to_users.rb b/db/migrate/20250820130619_add_two_factor_to_users.rb
new file mode 100644
index 000000000..3178aae05
--- /dev/null
+++ b/db/migrate/20250820130619_add_two_factor_to_users.rb
@@ -0,0 +1,11 @@
+class AddTwoFactorToUsers < ActiveRecord::Migration[7.1]
+ def change
+ add_column :users, :otp_secret, :string
+ add_column :users, :consumed_timestep, :integer
+ add_column :users, :otp_required_for_login, :boolean, default: false, null: false
+ add_column :users, :otp_backup_codes, :text
+
+ add_index :users, :otp_secret, unique: true
+ add_index :users, :otp_required_for_login
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 36a532e86..00b6f9109 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -1175,7 +1175,13 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_16_024703) do
t.jsonb "custom_attributes", default: {}
t.string "type"
t.text "message_signature"
+ t.string "otp_secret"
+ t.integer "consumed_timestep"
+ t.boolean "otp_required_for_login", default: false
+ t.text "otp_backup_codes"
t.index ["email"], name: "index_users_on_email"
+ t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login"
+ t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true
t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["uid", "provider"], name: "index_users_on_uid_and_provider", unique: true
diff --git a/spec/controllers/devise_overrides/sessions_controller_spec.rb b/spec/controllers/devise_overrides/sessions_controller_spec.rb
new file mode 100644
index 000000000..31f308c8e
--- /dev/null
+++ b/spec/controllers/devise_overrides/sessions_controller_spec.rb
@@ -0,0 +1,146 @@
+require 'rails_helper'
+
+RSpec.describe DeviseOverrides::SessionsController, type: :controller do
+ include Devise::Test::ControllerHelpers
+
+ before do
+ request.env['devise.mapping'] = Devise.mappings[:user]
+ end
+
+ describe 'POST #create' do
+ let(:user) { create(:user, password: 'Test@123456') }
+
+ context 'with standard authentication' do
+ it 'authenticates with valid credentials' do
+ post :create, params: { email: user.email, password: 'Test@123456' }
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'rejects invalid credentials' do
+ post :create, params: { email: user.email, password: 'wrong' }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'with MFA authentication' do
+ before do
+ skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
+ user.enable_two_factor!
+ user.update!(otp_required_for_login: true)
+ end
+
+ it 'requires MFA verification after successful password authentication' do
+ post :create, params: { email: user.email, password: 'Test@123456' }
+
+ expect(response).to have_http_status(:partial_content)
+ json_response = response.parsed_body
+ expect(json_response['mfa_required']).to be(true)
+ expect(json_response['mfa_token']).to be_present
+ end
+
+ context 'when verifying MFA' do
+ let(:mfa_token) { Mfa::TokenService.new(user: user).generate_token }
+
+ it 'authenticates with valid OTP' do
+ post :create, params: {
+ mfa_token: mfa_token,
+ otp_code: user.current_otp
+ }
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'authenticates with valid backup code' do
+ backup_codes = user.generate_backup_codes!
+
+ post :create, params: {
+ mfa_token: mfa_token,
+ backup_code: backup_codes.first
+ }
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'rejects invalid OTP' do
+ post :create, params: {
+ mfa_token: mfa_token,
+ otp_code: '000000'
+ }
+
+ expect(response).to have_http_status(:bad_request)
+ expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
+ end
+
+ it 'rejects invalid backup code' do
+ user.generate_backup_codes!
+
+ post :create, params: {
+ mfa_token: mfa_token,
+ backup_code: 'invalid'
+ }
+
+ expect(response).to have_http_status(:bad_request)
+ expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
+ end
+
+ it 'rejects expired MFA token' do
+ expired_token = JWT.encode(
+ { user_id: user.id, exp: 1.minute.ago.to_i },
+ Rails.application.secret_key_base,
+ 'HS256'
+ )
+
+ post :create, params: {
+ mfa_token: expired_token,
+ otp_code: user.current_otp
+ }
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_token'))
+ end
+
+ it 'requires either OTP or backup code' do
+ post :create, params: { mfa_token: mfa_token }
+
+ expect(response).to have_http_status(:bad_request)
+ expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
+ end
+ end
+ end
+
+ context 'with SSO authentication' do
+ it 'authenticates with valid SSO token' do
+ sso_token = user.generate_sso_auth_token
+
+ post :create, params: {
+ email: user.email,
+ sso_auth_token: sso_token
+ }
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'rejects invalid SSO token' do
+ post :create, params: {
+ email: user.email,
+ sso_auth_token: 'invalid'
+ }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'GET #new' do
+ it 'redirects to frontend login page' do
+ allow(ENV).to receive(:fetch).and_call_original
+ allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('/frontend')
+
+ get :new
+
+ expect(response).to redirect_to('/frontend/app/login?error=access-denied')
+ end
+ end
+end
diff --git a/spec/enterprise/models/inbox_spec.rb b/spec/enterprise/models/inbox_spec.rb
index 6b0130c28..4ba1a7021 100644
--- a/spec/enterprise/models/inbox_spec.rb
+++ b/spec/enterprise/models/inbox_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe Inbox do
end
it 'returns all member ids when inbox max_assignment_limit is not configured' do
- expect(inbox.member_ids_with_assignment_capacity).to eq(inbox.members.ids)
+ expect(inbox.member_ids_with_assignment_capacity).to match_array(inbox.members.ids)
end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 845ec0625..213988a0d 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -111,6 +111,113 @@ RSpec.describe User do
end
end
+ describe '2FA/MFA functionality' do
+ before do
+ skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
+ end
+
+ let(:user) { create(:user, password: 'Test@123456') }
+
+ describe '#enable_two_factor!' do
+ it 'generates OTP secret for 2FA setup' do
+ expect(user.otp_secret).to be_nil
+ expect(user.otp_required_for_login).to be_falsey
+
+ user.enable_two_factor!
+
+ expect(user.otp_secret).not_to be_nil
+ # otp_required_for_login is false until verification is complete
+ expect(user.otp_required_for_login).to be_falsey
+ end
+ end
+
+ describe '#disable_two_factor!' do
+ before do
+ user.enable_two_factor!
+ user.update!(otp_required_for_login: true) # Simulate verified 2FA
+ user.generate_backup_codes!
+ end
+
+ it 'disables 2FA and clears OTP secret' do
+ user.disable_two_factor!
+
+ expect(user.otp_secret).to be_nil
+ expect(user.otp_required_for_login).to be_falsey
+ expect(user.otp_backup_codes).to be_blank # Can be nil or empty array
+ end
+ end
+
+ describe '#generate_backup_codes!' do
+ before do
+ user.enable_two_factor!
+ end
+
+ it 'generates 10 backup codes' do
+ codes = user.generate_backup_codes!
+
+ expect(codes).to be_an(Array)
+ expect(codes.length).to eq(10)
+ expect(codes.first).to match(/\A[A-F0-9]{8}\z/) # 8-character hex codes
+ expect(user.otp_backup_codes).not_to be_nil
+ end
+ end
+
+ describe '#two_factor_provisioning_uri' do
+ before do
+ user.enable_two_factor!
+ end
+
+ it 'generates a valid provisioning URI for QR code' do
+ uri = user.two_factor_provisioning_uri
+
+ expect(uri).to include('otpauth://totp/')
+ expect(uri).to include(CGI.escape(user.email))
+ expect(uri).to include('Chatwoot')
+ end
+ end
+
+ describe '#validate_backup_code!' do
+ let(:backup_codes) { user.generate_backup_codes! }
+
+ before do
+ user.enable_two_factor!
+ backup_codes
+ end
+
+ it 'validates and invalidates correct backup code' do
+ code = backup_codes.first
+ result = user.validate_backup_code!(code)
+ expect(result).to be_truthy
+
+ # Verify it's marked as used
+ user.reload
+ expect(user.otp_backup_codes).to include('XXXXXXXX')
+ end
+
+ it 'rejects invalid backup code' do
+ result = user.validate_backup_code!('invalid')
+ expect(result).to be_falsey
+ end
+
+ it 'rejects already used backup code' do
+ code = backup_codes.first
+ user.validate_backup_code!(code)
+
+ # Try to use the same code again
+ result = user.validate_backup_code!(code)
+ expect(result).to be_falsey
+ end
+
+ it 'handles blank code' do
+ result = user.validate_backup_code!(nil)
+ expect(result).to be_falsey
+
+ result = user.validate_backup_code!('')
+ expect(result).to be_falsey
+ end
+ end
+ end
+
describe '#active_account_user' do
let(:user) { create(:user) }
let(:account1) { create(:account) }
diff --git a/spec/requests/api/v1/profile/mfa_controller_spec.rb b/spec/requests/api/v1/profile/mfa_controller_spec.rb
new file mode 100644
index 000000000..97a2e206f
--- /dev/null
+++ b/spec/requests/api/v1/profile/mfa_controller_spec.rb
@@ -0,0 +1,274 @@
+require 'rails_helper'
+
+RSpec.describe 'MFA API', type: :request do
+ before do
+ skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
+ allow(Chatwoot).to receive(:mfa_enabled?).and_return(true)
+ end
+
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account, password: 'Test@123456') }
+
+ describe 'GET /api/v1/profile/mfa' do
+ context 'when 2FA is disabled' do
+ it 'returns MFA disabled status' do
+ get '/api/v1/profile/mfa',
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['enabled']).to be_falsey
+ expect(json_response['backup_codes_generated']).to be_falsey
+ end
+ end
+
+ context 'when 2FA is enabled' do
+ before do
+ user.enable_two_factor!
+ user.update!(otp_required_for_login: true)
+ end
+
+ it 'returns MFA enabled status' do
+ get '/api/v1/profile/mfa',
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['enabled']).to be_truthy
+ end
+
+ context 'with backup codes generated' do
+ before do
+ user.generate_backup_codes!
+ end
+
+ it 'indicates backup codes are generated' do
+ get '/api/v1/profile/mfa',
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['backup_codes_generated']).to be_truthy
+ end
+ end
+ end
+ end
+
+ describe 'POST /api/v1/profile/mfa' do
+ context 'when 2FA is not enabled' do
+ it 'enables 2FA and returns QR code URL' do
+ post '/api/v1/profile/mfa',
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['provisioning_url']).not_to be_nil
+ expect(json_response['provisioning_url']).to include('otpauth://totp')
+ expect(json_response['secret']).not_to be_nil
+
+ user.reload
+ expect(user.otp_secret).not_to be_nil
+ end
+ end
+
+ context 'when 2FA is already enabled' do
+ before do
+ user.enable_two_factor!
+ user.update!(otp_required_for_login: true)
+ end
+
+ it 'returns error message' do
+ post '/api/v1/profile/mfa',
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ json_response = response.parsed_body
+ expect(json_response['error']).to eq(I18n.t('errors.mfa.already_enabled'))
+ end
+ end
+ end
+
+ describe 'POST /api/v1/profile/mfa/verify' do
+ before do
+ user.enable_two_factor!
+ end
+
+ context 'with valid OTP code' do
+ it 'verifies and confirms 2FA setup with backup codes' do
+ otp_code = user.current_otp
+
+ post '/api/v1/profile/mfa/verify',
+ params: { otp_code: otp_code },
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['enabled']).to be_truthy
+ expect(json_response['backup_codes']).to be_an(Array)
+ expect(json_response['backup_codes'].length).to eq(10)
+
+ user.reload
+ expect(user.otp_required_for_login).to be_truthy
+ expect(user.otp_backup_codes).not_to be_nil
+ end
+ end
+
+ context 'with invalid OTP code' do
+ it 'returns error message' do
+ post '/api/v1/profile/mfa/verify',
+ params: { otp_code: '000000' },
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ json_response = response.parsed_body
+ expect(json_response['error']).to eq(I18n.t('errors.mfa.invalid_code'))
+ end
+ end
+
+ context 'when 2FA is already verified' do
+ before do
+ user.update!(otp_required_for_login: true)
+ end
+
+ it 'returns already enabled error' do
+ post '/api/v1/profile/mfa/verify',
+ params: { otp_code: user.current_otp },
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ json_response = response.parsed_body
+ expect(json_response['error']).to eq(I18n.t('errors.mfa.already_enabled'))
+ end
+ end
+ end
+
+ describe 'DELETE /api/v1/profile/mfa' do
+ context 'when 2FA is enabled' do
+ before do
+ user.enable_two_factor!
+ user.update!(otp_required_for_login: true)
+ user.generate_backup_codes!
+ end
+
+ context 'with valid password and OTP' do
+ it 'disables 2FA successfully' do
+ otp_code = user.current_otp
+
+ delete '/api/v1/profile/mfa',
+ params: { password: 'Test@123456', otp_code: otp_code },
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['enabled']).to be_falsey
+
+ user.reload
+ expect(user.otp_required_for_login).to be_falsey
+ expect(user.otp_secret).to be_nil
+ expect(user.otp_backup_codes).to be_blank
+ end
+ end
+
+ context 'with invalid password' do
+ it 'returns error message' do
+ otp_code = user.current_otp
+
+ delete '/api/v1/profile/mfa',
+ params: { password: 'wrong_password', otp_code: otp_code },
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ json_response = response.parsed_body
+ expect(json_response['error']).to include('Invalid')
+ end
+ end
+
+ context 'with invalid OTP' do
+ it 'returns error message' do
+ delete '/api/v1/profile/mfa',
+ params: { password: 'Test@123456', otp_code: '000000' },
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ json_response = response.parsed_body
+ expect(json_response['error']).to include('Invalid')
+ end
+ end
+ end
+
+ context 'when 2FA is not enabled' do
+ it 'returns not enabled error' do
+ delete '/api/v1/profile/mfa',
+ params: { password: 'Test@123456', otp_code: '123456' },
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ json_response = response.parsed_body
+ expect(json_response['error']).to eq(I18n.t('errors.mfa.not_enabled'))
+ end
+ end
+ end
+
+ describe 'POST /api/v1/profile/mfa/backup_codes' do
+ context 'when 2FA is enabled' do
+ before do
+ user.enable_two_factor!
+ user.update!(otp_required_for_login: true)
+ end
+
+ context 'with valid OTP' do
+ it 'generates new backup codes' do
+ otp_code = user.current_otp
+
+ post '/api/v1/profile/mfa/backup_codes',
+ params: { otp_code: otp_code },
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['backup_codes']).to be_an(Array)
+ expect(json_response['backup_codes'].length).to eq(10)
+ end
+ end
+
+ context 'with invalid OTP' do
+ it 'returns error message' do
+ post '/api/v1/profile/mfa/backup_codes',
+ params: { otp_code: '000000' },
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ json_response = response.parsed_body
+ expect(json_response['error']).to eq(I18n.t('errors.mfa.invalid_code'))
+ end
+ end
+ end
+
+ context 'when 2FA is not enabled' do
+ it 'returns not enabled error' do
+ post '/api/v1/profile/mfa/backup_codes',
+ params: { otp_code: '123456' },
+ headers: user.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ json_response = response.parsed_body
+ expect(json_response['error']).to eq(I18n.t('errors.mfa.not_enabled'))
+ end
+ end
+ end
+end
diff --git a/spec/services/base_token_service_spec.rb b/spec/services/base_token_service_spec.rb
new file mode 100644
index 000000000..1b34aedf2
--- /dev/null
+++ b/spec/services/base_token_service_spec.rb
@@ -0,0 +1,42 @@
+require 'rails_helper'
+
+describe BaseTokenService do
+ let(:payload) { { user_id: 1, exp: 5.minutes.from_now.to_i } }
+ let(:token_service) { described_class.new(payload: payload) }
+
+ describe '#generate_token' do
+ it 'generates a JWT token with the provided payload' do
+ token = token_service.generate_token
+ expect(token).to be_present
+ expect(token).to be_a(String)
+ end
+
+ it 'encodes the payload correctly' do
+ token = token_service.generate_token
+ decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
+ expect(decoded['user_id']).to eq(1)
+ end
+ end
+
+ describe '#decode_token' do
+ let(:token) { token_service.generate_token }
+ let(:decoder_service) { described_class.new(token: token) }
+
+ it 'decodes a valid JWT token' do
+ decoded = decoder_service.decode_token
+ expect(decoded[:user_id]).to eq(1)
+ end
+
+ it 'returns empty hash for invalid token' do
+ invalid_service = described_class.new(token: 'invalid_token')
+ expect(invalid_service.decode_token).to eq({})
+ end
+
+ it 'returns empty hash for expired token' do
+ expired_payload = { user_id: 1, exp: 1.minute.ago.to_i }
+ expired_token = JWT.encode(expired_payload, Rails.application.secret_key_base, 'HS256')
+ expired_service = described_class.new(token: expired_token)
+ expect(expired_service.decode_token).to eq({})
+ end
+ end
+end
diff --git a/spec/services/mfa/authentication_service_spec.rb b/spec/services/mfa/authentication_service_spec.rb
new file mode 100644
index 000000000..c4cc5ef5e
--- /dev/null
+++ b/spec/services/mfa/authentication_service_spec.rb
@@ -0,0 +1,106 @@
+require 'rails_helper'
+
+describe Mfa::AuthenticationService do
+ before do
+ skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
+ user.enable_two_factor!
+ user.update!(otp_required_for_login: true)
+ end
+
+ let(:user) { create(:user) }
+
+ describe '#authenticate' do
+ context 'with OTP code' do
+ context 'when OTP is valid' do
+ it 'returns true' do
+ valid_otp = user.current_otp
+ service = described_class.new(user: user, otp_code: valid_otp)
+ expect(service.authenticate).to be_truthy
+ end
+ end
+
+ context 'when OTP is invalid' do
+ it 'returns false' do
+ service = described_class.new(user: user, otp_code: '000000')
+ expect(service.authenticate).to be_falsey
+ end
+ end
+
+ context 'when OTP is nil' do
+ it 'returns false' do
+ service = described_class.new(user: user, otp_code: nil)
+ expect(service.authenticate).to be_falsey
+ end
+ end
+ end
+
+ context 'with backup code' do
+ let(:backup_codes) { user.generate_backup_codes! }
+
+ context 'when backup code is valid' do
+ it 'returns true and invalidates the code' do
+ valid_code = backup_codes.first
+ service = described_class.new(user: user, backup_code: valid_code)
+
+ expect(service.authenticate).to be_truthy
+
+ # Code should be invalidated after use
+ user.reload
+ expect(user.otp_backup_codes).to include('XXXXXXXX')
+ end
+ end
+
+ context 'when backup code is invalid' do
+ it 'returns false' do
+ service = described_class.new(user: user, backup_code: 'invalid')
+ expect(service.authenticate).to be_falsey
+ end
+ end
+
+ context 'when backup code has already been used' do
+ it 'returns false' do
+ valid_code = backup_codes.first
+ # Use the code once
+ service = described_class.new(user: user, backup_code: valid_code)
+ service.authenticate
+
+ # Try to use it again
+ service2 = described_class.new(user: user.reload, backup_code: valid_code)
+ expect(service2.authenticate).to be_falsey
+ end
+ end
+ end
+
+ context 'with neither OTP nor backup code' do
+ it 'returns false' do
+ service = described_class.new(user: user)
+ expect(service.authenticate).to be_falsey
+ end
+ end
+
+ context 'when user is nil' do
+ it 'returns false' do
+ service = described_class.new(user: nil, otp_code: '123456')
+ expect(service.authenticate).to be_falsey
+ end
+ end
+
+ context 'when both OTP and backup code are provided' do
+ it 'uses OTP authentication first' do
+ valid_otp = user.current_otp
+ backup_codes = user.generate_backup_codes!
+
+ service = described_class.new(
+ user: user,
+ otp_code: valid_otp,
+ backup_code: backup_codes.first
+ )
+
+ expect(service.authenticate).to be_truthy
+ # Backup code should not be consumed
+ user.reload
+ expect(user.otp_backup_codes).not_to include('XXXXXXXX')
+ end
+ end
+ end
+end
diff --git a/spec/services/mfa/token_service_spec.rb b/spec/services/mfa/token_service_spec.rb
new file mode 100644
index 000000000..7d4fe55b6
--- /dev/null
+++ b/spec/services/mfa/token_service_spec.rb
@@ -0,0 +1,72 @@
+require 'rails_helper'
+
+describe Mfa::TokenService do
+ before do
+ skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
+ end
+
+ let(:user) { create(:user) }
+ let(:token_service) { described_class.new(user: user) }
+
+ describe '#generate_token' do
+ it 'generates a JWT token with user_id' do
+ token = token_service.generate_token
+ expect(token).to be_present
+ expect(token).to be_a(String)
+ end
+
+ it 'includes user_id in the payload' do
+ token = token_service.generate_token
+ decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
+ expect(decoded['user_id']).to eq(user.id)
+ end
+
+ it 'sets expiration to 5 minutes from now' do
+ allow(Time).to receive(:now).and_return(Time.zone.parse('2024-01-01 12:00:00'))
+ token = token_service.generate_token
+ decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
+ expected_exp = Time.zone.parse('2024-01-01 12:05:00').to_i
+ expect(decoded['exp']).to eq(expected_exp)
+ end
+ end
+
+ describe '#verify_token' do
+ let(:valid_token) { token_service.generate_token }
+
+ context 'with valid token' do
+ it 'returns the user' do
+ verifier = described_class.new(token: valid_token)
+ verified_user = verifier.verify_token
+ expect(verified_user).to eq(user)
+ end
+ end
+
+ context 'with invalid token' do
+ it 'returns nil for malformed token' do
+ verifier = described_class.new(token: 'invalid_token')
+ expect(verifier.verify_token).to be_nil
+ end
+
+ it 'returns nil for expired token' do
+ expired_payload = { user_id: user.id, exp: 1.minute.ago.to_i }
+ expired_token = JWT.encode(expired_payload, Rails.application.secret_key_base, 'HS256')
+ verifier = described_class.new(token: expired_token)
+ expect(verifier.verify_token).to be_nil
+ end
+
+ it 'returns nil for non-existent user' do
+ payload = { user_id: 999_999, exp: 5.minutes.from_now.to_i }
+ token = JWT.encode(payload, Rails.application.secret_key_base, 'HS256')
+ verifier = described_class.new(token: token)
+ expect(verifier.verify_token).to be_nil
+ end
+ end
+
+ context 'with blank token' do
+ it 'returns nil' do
+ verifier = described_class.new(token: nil)
+ expect(verifier.verify_token).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/services/widget/token_service_spec.rb b/spec/services/widget/token_service_spec.rb
new file mode 100644
index 000000000..724728b51
--- /dev/null
+++ b/spec/services/widget/token_service_spec.rb
@@ -0,0 +1,43 @@
+require 'rails_helper'
+
+describe Widget::TokenService do
+ let(:payload) { { source_id: 'contact_123', inbox_id: 1 } }
+ let(:token_service) { described_class.new(payload: payload) }
+
+ describe 'inheritance' do
+ it 'inherits from BaseTokenService' do
+ expect(described_class.superclass).to eq(BaseTokenService)
+ end
+ end
+
+ describe '#generate_token' do
+ it 'generates a JWT token with the provided payload' do
+ token = token_service.generate_token
+ expect(token).to be_present
+ expect(token).to be_a(String)
+ end
+
+ it 'encodes the payload correctly' do
+ token = token_service.generate_token
+ decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
+ expect(decoded['source_id']).to eq('contact_123')
+ expect(decoded['inbox_id']).to eq(1)
+ end
+ end
+
+ describe '#decode_token' do
+ let(:token) { token_service.generate_token }
+ let(:decoder_service) { described_class.new(token: token) }
+
+ it 'decodes a valid JWT token' do
+ decoded = decoder_service.decode_token
+ expect(decoded[:source_id]).to eq('contact_123')
+ expect(decoded[:inbox_id]).to eq(1)
+ end
+
+ it 'returns empty hash for invalid token' do
+ invalid_service = described_class.new(token: 'invalid_token')
+ expect(invalid_service.decode_token).to eq({})
+ end
+ end
+end
From 4014a846f0c94159243ca207cbdf91f1fe52bda5 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Thu, 18 Sep 2025 17:46:06 +0200
Subject: [PATCH 07/14] feat: Add the frontend support for MFA (#12372)
FE support for https://github.com/chatwoot/chatwoot/pull/12290
## Linear:
- https://github.com/chatwoot/chatwoot/issues/486
## Description
This PR implements Multi-Factor Authentication (MFA) support for user
accounts, enhancing security by requiring a second form of verification
during login. The feature adds TOTP (Time-based One-Time Password)
authentication with QR code generation and backup codes for account
recovery.
## Type of change
- [ ] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
- Added comprehensive RSpec tests for MFA controller functionality
- Tested MFA setup flow with QR code generation
- Verified OTP validation and backup code generation
- Tested login flow with MFA enabled/disabled
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Pranav
Co-authored-by: iamsivin
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth
Co-authored-by: Sojan Jose
---
app/javascript/dashboard/api/mfa.js | 28 ++
.../dashboard/components-next/input/Input.vue | 1 +
.../components/auth/MfaVerification.vue | 328 ++++++++++++++++++
.../dashboard/i18n/locale/en/index.js | 2 +
.../dashboard/i18n/locale/en/mfa.json | 106 ++++++
.../dashboard/i18n/locale/en/settings.json | 5 +
.../dashboard/settings/profile/Index.vue | 13 +
.../settings/profile/MfaManagementActions.vue | 248 +++++++++++++
.../settings/profile/MfaSettings.vue | 178 ++++++++++
.../settings/profile/MfaSettingsCard.vue | 47 +++
.../settings/profile/MfaSetupWizard.vue | 323 +++++++++++++++++
.../settings/profile/MfaStatusCard.vue | 63 ++++
.../settings/profile/profile.routes.js | 19 +
app/javascript/shared/helpers/clipboard.js | 19 +
.../shared/helpers/specs/clipboard.spec.js | 112 +++++-
app/javascript/v3/App.vue | 6 +-
app/javascript/v3/api/auth.js | 19 +
app/javascript/v3/views/login/Index.vue | 55 ++-
app/views/layouts/vueapp.html.erb | 1 +
19 files changed, 1568 insertions(+), 5 deletions(-)
create mode 100644 app/javascript/dashboard/api/mfa.js
create mode 100644 app/javascript/dashboard/components/auth/MfaVerification.vue
create mode 100644 app/javascript/dashboard/i18n/locale/en/mfa.json
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/MfaManagementActions.vue
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettingsCard.vue
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/MfaSetupWizard.vue
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/MfaStatusCard.vue
diff --git a/app/javascript/dashboard/api/mfa.js b/app/javascript/dashboard/api/mfa.js
new file mode 100644
index 000000000..c18bea3e9
--- /dev/null
+++ b/app/javascript/dashboard/api/mfa.js
@@ -0,0 +1,28 @@
+/* global axios */
+import ApiClient from './ApiClient';
+
+class MfaAPI extends ApiClient {
+ constructor() {
+ super('profile/mfa', { accountScoped: false });
+ }
+
+ enable() {
+ return axios.post(`${this.url}`);
+ }
+
+ verify(otpCode) {
+ return axios.post(`${this.url}/verify`, { otp_code: otpCode });
+ }
+
+ disable(password, otpCode) {
+ return axios.delete(this.url, {
+ data: { password, otp_code: otpCode },
+ });
+ }
+
+ regenerateBackupCodes(otpCode) {
+ return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode });
+ }
+}
+
+export default new MfaAPI();
diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue
index 71964b4f8..561f98ffe 100644
--- a/app/javascript/dashboard/components-next/input/Input.vue
+++ b/app/javascript/dashboard/components-next/input/Input.vue
@@ -116,6 +116,7 @@ onMounted(() => {
+
+
+
+
+
+
+
+ {{ $t('MFA_VERIFICATION.TITLE') }}
+
+
+ {{ $t('MFA_VERIFICATION.DESCRIPTION') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('MFA_VERIFICATION.HELP_TEXT') }}
+
+
+
+
+
+
+
+
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,
+});
+
+
+
+
+
+
+
+
+
+
+
Date: Fri, 19 Sep 2025 04:22:01 -0300
Subject: [PATCH 09/14] feat: Implement single audio playback functionality
across components (#12226)
## Description
Introduces a global single-audio playback helper and hooks it into
dashboard and widget entrypoints. Adds play/pause event handlers in the
Audio chip to sync UI state. The helper enforces one audio playing at a
time and auto-advances to the next adjacent audio on end.
Co-authored-by: Muhsin Keloth
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin
---
.../components-next/message/chips/Audio.vue | 27 ++++++++++++++++++-
1 file changed, 26 insertions(+), 1 deletion(-)
diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue
index 431058463..667d2d7b6 100644
--- a/app/javascript/dashboard/components-next/message/chips/Audio.vue
+++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue
@@ -1,8 +1,16 @@
+
+
+
+
+