Merge branch 'develop' into feat/voice-channel

This commit is contained in:
Sojan Jose
2025-09-20 14:49:30 +05:30
committed by GitHub
112 changed files with 4410 additions and 198 deletions

View File

@@ -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

99
.github/workflows/run_mfa_spec.yml vendored Normal file
View File

@@ -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/

View File

@@ -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'

View File

@@ -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)
@@ -722,7 +727,8 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.4.1)
rexml (3.4.4)
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

View File

@@ -52,3 +52,5 @@ class AgentBuilder
}.compact))
end
end
AgentBuilder.prepend_mod_with('AgentBuilder')

View File

@@ -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?

View File

@@ -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

View File

@@ -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')

View File

@@ -70,7 +70,12 @@ class WidgetsController < ActionController::Base
end
def allow_iframe_requests
if @web_widget.allowed_domains.blank?
response.headers.delete('X-Frame-Options')
else
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
end
end
end

View File

@@ -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,

View File

@@ -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();

View File

@@ -1,6 +1,6 @@
<script setup>
import { computed, ref } from 'vue';
import { useToggle } from '@vueuse/core';
import { useToggle, useWindowSize, useElementBounding } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { picoSearch } from '@scmmishra/pico-search';
@@ -26,10 +26,24 @@ const props = defineProps({
const emit = defineEmits(['add']);
const BUFFER_SPACE = 20;
const [showPopover, togglePopover] = useToggle();
const buttonRef = ref();
const dropdownRef = ref();
const searchValue = ref('');
const { width: windowWidth, height: windowHeight } = useWindowSize();
const {
top: buttonTop,
left: buttonLeft,
width: buttonWidth,
height: buttonHeight,
} = useElementBounding(buttonRef);
const { width: dropdownWidth, height: dropdownHeight } =
useElementBounding(dropdownRef);
const filteredItems = computed(() => {
if (!searchValue.value) return props.items;
const query = searchValue.value.toLowerCase();
@@ -42,6 +56,26 @@ const handleAdd = item => {
togglePopover(false);
};
const shouldShowAbove = computed(() => {
if (!buttonRef.value || !dropdownRef.value) return false;
const spaceBelow =
windowHeight.value - (buttonTop.value + buttonHeight.value);
const spaceAbove = buttonTop.value;
return (
spaceBelow < dropdownHeight.value + BUFFER_SPACE && spaceAbove > spaceBelow
);
});
const shouldAlignRight = computed(() => {
if (!buttonRef.value || !dropdownRef.value) return false;
const spaceRight = windowWidth.value - buttonLeft.value;
const spaceLeft = buttonLeft.value + buttonWidth.value;
return (
spaceRight < dropdownWidth.value + BUFFER_SPACE && spaceLeft > spaceRight
);
});
const handleClickOutside = () => {
if (showPopover.value) {
togglePopover(false);
@@ -55,6 +89,7 @@ const handleClickOutside = () => {
class="relative flex items-center group"
>
<Button
ref="buttonRef"
slate
type="button"
icon="i-lucide-plus"
@@ -64,7 +99,12 @@ const handleClickOutside = () => {
/>
<div
v-if="showPopover"
class="top-full mt-2 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
ref="dropdownRef"
class="z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
:class="[
shouldShowAbove ? 'bottom-full mb-2' : 'top-full mt-2',
shouldAlignRight ? 'right-0' : 'left-0',
]"
>
<div class="flex flex-col divide-y divide-n-slate-4 w-full">
<Input
@@ -90,7 +130,7 @@ const handleClickOutside = () => {
<Icon
v-if="item.icon"
:icon="item.icon"
class="size-2 text-n-slate-12 flex-shrink-0 mt-0.5"
class="size-4 text-n-slate-12 flex-shrink-0 mt-0.5"
/>
<span
v-else-if="item.color"
@@ -105,24 +145,19 @@ const handleClickOutside = () => {
:size="20"
rounded-full
/>
<div class="flex flex-col items-start gap-2 min-w-0">
<div class="flex items-center gap-1 min-w-0">
<div class="flex flex-col items-start gap-2 min-w-0 flex-1">
<div class="flex items-center gap-1 min-w-0 w-full">
<span
:title="item.name || item.title"
class="text-sm text-n-slate-12 truncate min-w-0"
class="text-sm text-n-slate-12 truncate min-w-0 flex-1"
>
{{ item.name || item.title }}
</span>
<span
v-if="item.id"
class="text-xs text-n-slate-11 flex-shrink-0"
>
{{ `#${item.id}` }}
</span>
</div>
<span
v-if="item.email || item.phoneNumber"
class="text-sm text-n-slate-11 truncate min-w-0"
:title="item.email || item.phoneNumber"
class="text-sm text-n-slate-11 truncate min-w-0 w-full block"
>
{{ item.email || item.phoneNumber }}
</span>

View File

@@ -119,7 +119,7 @@ onMounted(() => {
)
"
:items="filteredTags"
class="[&>button]:!text-n-blue-text"
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
@add="onClickAddTag"
/>
</div>

View File

@@ -110,23 +110,36 @@ const getInboxName = inboxId => {
<div
v-for="(limit, index) in inboxCapacityLimits"
:key="limit.id || `temp-${index}`"
class="flex items-center gap-3"
class="flex flex-col xs:flex-row items-stretch gap-3"
>
<div
class="flex items-start rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full"
class="flex items-center rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full min-w-0"
:title="getInboxName(limit.inboxId)"
>
<span class="truncate min-w-0">
{{ getInboxName(limit.inboxId) }}
</span>
</div>
<div class="flex items-center gap-3 w-full xs:w-auto">
<div
class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-shrink-0 flex items-center"
class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-1 xs:flex-shrink-0 flex items-center min-w-0"
:class="[
!isLimitValid(limit) ? 'outline-n-ruby-8' : 'outline-n-weak',
]"
>
<label class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2">
<label
class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2 truncate min-w-0 flex-shrink"
:title="
t(
`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
)
"
>
{{
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`)
t(
`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`
)
}}
</label>
@@ -137,7 +150,7 @@ const getInboxName = inboxId => {
type="number"
:min="MIN_CONVERSATION_LIMIT"
:max="MAX_CONVERSATION_LIMIT"
class="reset-base bg-transparent focus:outline-none max-w-20 text-sm"
class="reset-base bg-transparent focus:outline-none min-w-16 w-24 text-sm flex-shrink-0"
:class="[
!isLimitValid(limit)
? 'placeholder:text-n-ruby-9 !text-n-ruby-9'
@@ -160,4 +173,5 @@ const getInboxName = inboxId => {
</div>
</div>
</div>
</div>
</template>

View File

@@ -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 {

View File

@@ -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;
@@ -100,11 +116,13 @@ onMounted(() => {
<slot name="prefix" />
<input
:id="uniqueId"
v-bind="$attrs"
ref="inputRef"
:value="modelValue"
:class="[
customInputClass,
inputOutlineClass,
sizeClass,
{
error: messageType === 'error',
focus: isFocused,
@@ -119,7 +137,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"

View File

@@ -131,6 +131,8 @@ const props = defineProps({
sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties
});
const emit = defineEmits(['retry']);
const contextMenuPosition = ref({});
const showBackgroundHighlight = ref(false);
const showContextMenu = ref(false);
@@ -525,6 +527,7 @@ provideMessageContext({
class="[grid-area:meta]"
:class="flexOrientationClass"
:error="contentAttributes.externalError"
@retry="emit('retry')"
/>
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">

View File

@@ -1,16 +1,22 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from './provider.js';
import { ORIENTATION } from './constants';
import { hasOneDayPassed } from 'shared/helpers/timeHelper';
import { ORIENTATION, MESSAGE_STATUS } from './constants';
defineProps({
error: { type: String, required: true },
});
const { orientation } = useMessageContext();
const emit = defineEmits(['retry']);
const { orientation, status, createdAt } = useMessageContext();
const { t } = useI18n();
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
</script>
<template>
@@ -35,5 +41,14 @@ const { t } = useI18n();
{{ error }}
</div>
</div>
<button
v-if="canRetry"
type="button"
:disabled="status !== MESSAGE_STATUS.FAILED"
class="bg-n-alpha-2 rounded-md size-5 grid place-content-center cursor-pointer"
@click="emit('retry')"
>
<Icon icon="i-lucide-refresh-ccw" class="text-n-ruby-11 size-[14px]" />
</button>
</div>
</template>

View File

@@ -37,6 +37,8 @@ const props = defineProps({
},
});
const emit = defineEmits(['retry']);
const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true });
});
@@ -113,6 +115,7 @@ const getInReplyToMessage = parentMessage => {
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
@retry="emit('retry', message)"
/>
</template>
<slot name="after" />

View File

@@ -1,8 +1,16 @@
<script setup>
import { computed, onMounted, useTemplateRef, ref } from 'vue';
import {
computed,
onMounted,
useTemplateRef,
ref,
getCurrentInstance,
} from 'vue';
import Icon from 'next/icon/Icon.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
import { downloadFile } from '@chatwoot/utils';
import { useEmitter } from 'dashboard/composables/emitter';
import { emitter } from 'shared/helpers/mitt';
const { attachment } = defineProps({
attachment: {
@@ -27,6 +35,8 @@ const currentTime = ref(0);
const duration = ref(0);
const playbackSpeed = ref(1);
const { uid } = getCurrentInstance();
const onLoadedMetadata = () => {
duration.value = audioPlayer.value?.duration;
};
@@ -43,6 +53,18 @@ onMounted(() => {
audioPlayer.value.playbackRate = playbackSpeed.value;
});
// Listen for global audio play events and pause if it's not this audio
useEmitter('pause_playing_audio', currentPlayingId => {
if (currentPlayingId !== uid && isPlaying.value) {
try {
audioPlayer.value.pause();
} catch {
/* ignore pause errors */
}
isPlaying.value = false;
}
});
const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60);
@@ -70,6 +92,8 @@ const playOrPause = () => {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
// Emit event to pause all other audio
emitter.emit('pause_playing_audio', uid);
audioPlayer.value.play();
isPlaying.value = true;
}
@@ -101,6 +125,7 @@ const downloadAudio = async () => {
ref="audioPlayer"
controls
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"

View File

@@ -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"
/>
</div>
<div v-if="isDocumentTemplate" class="flex items-center mb-2.5">
<Input
:model-value="processedParams.header?.media_name || ''"
type="text"
class="flex-1"
:placeholder="
t('WHATSAPP_TEMPLATES.PARSER.DOCUMENT_NAME_PLACEHOLDER')
"
@update:model-value="updateMediaName"
/>
</div>
</div>
<!-- Body Variables Section -->

View File

@@ -0,0 +1,328 @@
<script setup>
import axios from 'axios';
import { ref, computed, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { handleOtpPaste } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { useAccount } from 'dashboard/composables/useAccount';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import FormInput from 'v3/components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
mfaToken: {
type: String,
required: true,
},
});
const emit = defineEmits(['verified', 'cancel']);
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const OTP = 'otp';
const BACKUP = 'backup';
// State
const verificationMethod = ref(OTP);
const otpDigits = ref(['', '', '', '', '', '']);
const backupCode = ref('');
const isVerifying = ref(false);
const errorMessage = ref('');
const helpModalRef = ref(null);
const otpInputRefs = ref([]);
// Computed
const otpCode = computed(() => otpDigits.value.join(''));
const canSubmit = computed(() =>
verificationMethod.value === OTP
? otpCode.value.length === 6
: backupCode.value.length === 8
);
const contactDescKey = computed(() =>
isOnChatwootCloud.value ? 'CONTACT_DESC_CLOUD' : 'CONTACT_DESC_SELF_HOSTED'
);
const focusInput = i => otpInputRefs.value[i]?.focus();
// Verification
const handleVerification = async () => {
if (!canSubmit.value || isVerifying.value) return;
isVerifying.value = true;
errorMessage.value = '';
try {
const payload = {
mfa_token: props.mfaToken,
};
if (verificationMethod.value === OTP) {
payload.otp_code = otpCode.value;
} else {
payload.backup_code = backupCode.value;
}
const response = await axios.post('/auth/sign_in', payload);
// Set auth credentials and redirect
if (response.data && response.headers) {
// Store auth credentials in cookies
const authData = {
'access-token': response.headers['access-token'],
'token-type': response.headers['token-type'],
client: response.headers.client,
expiry: response.headers.expiry,
uid: response.headers.uid,
};
// Store in cookies for auth
document.cookie = `cw_d_session_info=${encodeURIComponent(JSON.stringify(authData))}; path=/; SameSite=Lax`;
// Redirect to dashboard
window.location.href = '/app/';
} else {
emit('verified', response.data);
}
} catch (error) {
errorMessage.value =
parseAPIErrorResponse(error) || t('MFA_VERIFICATION.VERIFICATION_FAILED');
// Clear inputs on error
if (verificationMethod.value === OTP) {
otpDigits.value.fill('');
await nextTick();
focusInput(0);
} else {
backupCode.value = '';
}
} finally {
isVerifying.value = false;
}
};
// OTP Input Handling
const handleOtpInput = async i => {
const v = otpDigits.value[i];
// Only allow numbers
if (!/^\d*$/.test(v)) {
otpDigits.value[i] = '';
return;
}
// Move to next input if value entered
if (v && i < 5) {
await nextTick();
focusInput(i + 1);
}
// Auto-submit if all digits entered
if (otpCode.value.length === 6) {
handleVerification();
}
};
const handleBackspace = (e, i) => {
if (!otpDigits.value[i] && i > 0) {
e.preventDefault();
focusInput(i - 1);
otpDigits.value[i - 1] = '';
}
};
const handleOtpCodePaste = e => {
e.preventDefault();
const code = handleOtpPaste(e, 6);
if (code) {
otpDigits.value = code.split('');
handleVerification();
}
};
// Alternative Actions
const handleTryAnotherMethod = () => {
// Toggle between methods
verificationMethod.value = verificationMethod.value === OTP ? BACKUP : OTP;
otpDigits.value.fill('');
backupCode.value = '';
errorMessage.value = '';
};
</script>
<template>
<div class="w-full max-w-md mx-auto">
<div
class="bg-white shadow sm:mx-auto sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
>
<!-- Header -->
<div class="text-center mb-6">
<div
class="inline-flex items-center justify-center size-14 bg-n-solid-1 outline outline-n-weak rounded-full mb-4"
>
<Icon icon="i-lucide-lock-keyhole" class="size-6 text-n-slate-10" />
</div>
<h2 class="text-2xl font-semibold text-n-slate-12">
{{ $t('MFA_VERIFICATION.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11 mt-2">
{{ $t('MFA_VERIFICATION.DESCRIPTION') }}
</p>
</div>
<!-- Tab Selection -->
<div class="flex rounded-lg bg-n-alpha-black2 p-1 mb-6">
<button
v-for="method in [OTP, BACKUP]"
:key="method"
class="flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors"
:class="
verificationMethod === method
? 'bg-n-solid-active text-n-slate-12 shadow-sm'
: 'text-n-slate-12'
"
@click="verificationMethod = method"
>
{{
$t(
`MFA_VERIFICATION.${method === OTP ? 'AUTHENTICATOR_APP' : 'BACKUP_CODE'}`
)
}}
</button>
</div>
<!-- Verification Form -->
<form class="space-y-4" @submit.prevent="handleVerification">
<!-- OTP Code Input -->
<div v-if="verificationMethod === OTP">
<label class="block text-sm font-medium text-n-slate-12 mb-2">
{{ $t('MFA_VERIFICATION.ENTER_OTP_CODE') }}
</label>
<div class="flex justify-between gap-2">
<input
v-for="(_, i) in otpDigits"
:key="i"
ref="otpInputRefs"
v-model="otpDigits[i]"
type="text"
maxlength="1"
pattern="[0-9]"
inputmode="numeric"
class="w-12 h-12 text-center text-lg font-semibold border-2 border-n-weak hover:border-n-strong rounded-lg focus:border-n-brand bg-n-alpha-black2 text-n-slate-12 placeholder:text-n-slate-10"
@input="handleOtpInput(i)"
@keydown.left.prevent="focusInput(i - 1)"
@keydown.right.prevent="focusInput(i + 1)"
@keydown.backspace="handleBackspace($event, i)"
@paste="handleOtpCodePaste"
/>
</div>
</div>
<!-- Backup Code Input -->
<div v-if="verificationMethod === BACKUP">
<FormInput
v-model="backupCode"
name="backup_code"
type="text"
data-testid="backup_code_input"
:tabindex="1"
required
:label="$t('MFA_VERIFICATION.ENTER_BACKUP_CODE')"
:placeholder="
$t('MFA_VERIFICATION.BACKUP_CODE_PLACEHOLDER') || '000000'
"
@keyup.enter="handleVerification"
/>
</div>
<!-- Error Message -->
<div
v-if="errorMessage"
class="p-3 bg-n-ruby-3 outline outline-n-ruby-5 outline-1 rounded-lg"
>
<p class="text-sm text-n-ruby-9">{{ errorMessage }}</p>
</div>
<!-- Submit Button -->
<NextButton
lg
type="submit"
data-testid="submit_button"
class="w-full"
:tabindex="2"
:label="$t('MFA_VERIFICATION.VERIFY_BUTTON')"
:disabled="!canSubmit || isVerifying"
:is-loading="isVerifying"
/>
<!-- Alternative Actions -->
<div class="text-center flex items-center flex-col gap-2 pt-4">
<NextButton
sm
link
type="button"
class="w-full hover:!no-underline"
:tabindex="2"
:label="$t('MFA_VERIFICATION.TRY_ANOTHER_METHOD')"
@click="handleTryAnotherMethod"
/>
<NextButton
sm
slate
link
type="button"
class="w-full hover:!no-underline"
:tabindex="3"
:label="$t('MFA_VERIFICATION.CANCEL_LOGIN')"
@click="() => emit('cancel')"
/>
</div>
</form>
</div>
<!-- Help Text -->
<div class="mt-6 text-center">
<p class="text-sm text-n-slate-11">
{{ $t('MFA_VERIFICATION.HELP_TEXT') }}
</p>
<NextButton
sm
link
type="button"
class="w-full hover:!no-underline"
:tabindex="4"
:label="$t('MFA_VERIFICATION.LEARN_MORE')"
@click="helpModalRef?.open()"
/>
</div>
<!-- Help Modal -->
<Dialog
ref="helpModalRef"
:title="$t('MFA_VERIFICATION.HELP_MODAL.TITLE')"
:show-confirm-button="false"
class="[&>dialog>div]:bg-n-alpha-3 [&>dialog>div]:rounded-lg"
@confirm="helpModalRef?.close()"
>
<div class="space-y-4 text-sm text-n-slate-11">
<div v-for="section in ['AUTHENTICATOR', 'BACKUP']" :key="section">
<h4 class="font-medium text-n-slate-12 mb-2">
{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_TITLE`) }}
</h4>
<p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_DESC`) }}</p>
</div>
<div>
<h4 class="font-medium text-n-slate-12 mb-2">
{{ $t('MFA_VERIFICATION.HELP_MODAL.CONTACT_TITLE') }}
</h4>
<p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${contactDescKey}`) }}</p>
</div>
</div>
</Dialog>
</div>
</template>

View File

@@ -4,6 +4,7 @@ import { ref, provide } from 'vue';
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
// components
import ReplyBox from './ReplyBox.vue';
@@ -441,6 +442,11 @@ export default {
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
async handleMessageRetry(message) {
if (!message) return;
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
},
},
};
</script>
@@ -469,6 +475,7 @@ export default {
:is-an-email-channel="isAnEmailChannel"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:messages="getMessages"
@retry="handleMessageRetry"
>
<template #beforeAll>
<transition name="slide-up">

View File

@@ -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,

View File

@@ -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];

View File

@@ -218,6 +218,7 @@ describe('templateHelper', () => {
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
media_name: '',
});
expect(result.body).toEqual({
1: '',

View File

@@ -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

View File

@@ -177,7 +177,8 @@
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
"PRIORITY": "Priority"
"PRIORITY": "Priority",
"LABELS": "Labels"
}
}
}

View File

@@ -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"

View File

@@ -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,
};

View File

@@ -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": {

View File

@@ -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."
}
}

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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,13 +302,17 @@ onMounted(() => {
<template #controls>
<div
v-if="shouldShowDropdown"
class="mb-4 -mt-3 flex justify-between items-center w-fit py-1"
class="mb-4 -mt-3 flex justify-between items-center py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3':
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
bulkSelectionState.hasSelected,
}"
>
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
<div
v-if="!bulkSelectionState.hasSelected"
class="flex gap-3 justify-between w-full items-center"
>
<div class="flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
<Button
:label="selectedStatusLabel"
@@ -322,6 +336,15 @@ onMounted(() => {
@update="handleAssistantFilterChange"
/>
</div>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
autofocus
@input="debouncedSearch"
/>
</div>
<transition
name="slide-fade"

View File

@@ -5,6 +5,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import camelcaseKeys from 'camelcase-keys';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
@@ -58,7 +59,19 @@ const allAgents = computed(() =>
const allLabels = computed(() => buildList(labelsList.value));
const allInboxes = computed(() => buildList(inboxes.value));
const allInboxes = computed(
() =>
inboxes.value
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || []
);
const formData = computed(() => ({
name: selectedPolicy.value?.name || '',

View File

@@ -215,8 +215,19 @@ defineExpose({
v-model:window-unit="state.windowUnit"
/>
</div>
</div>
<div v-if="showInboxSection" class="py-4 flex-col flex gap-4">
<Button
type="submit"
:label="buttonLabel"
:disabled="!validationState.isValid || isLoading"
:is-loading="isLoading"
/>
<div
v-if="showInboxSection"
class="py-4 flex-col flex gap-4 border-t border-n-weak mt-6"
>
<div class="flex items-end gap-4 w-full justify-between">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
@@ -242,13 +253,5 @@ defineExpose({
@delete="$emit('deleteInbox', $event)"
/>
</div>
</div>
<Button
type="submit"
:label="buttonLabel"
:disabled="!validationState.isValid || isLoading"
:is-loading="isLoading"
/>
</form>
</template>

View File

@@ -145,7 +145,7 @@ defineExpose({
<template>
<form @submit.prevent="handleSubmit">
<div class="flex flex-col gap-4 divide-y divide-n-weak">
<div class="flex flex-col gap-4 mb-2 divide-y divide-n-weak">
<BaseInfo
v-model:policy-name="state.name"
v-model:description="state.description"

View File

@@ -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: [
{

View File

@@ -331,7 +331,7 @@ export default {
this.continuityViaEmail = this.inbox.continuity_via_email;
this.channelWebsiteUrl = this.inbox.website_url;
this.channelWelcomeTitle = this.inbox.welcome_title;
this.channelWelcomeTagline = this.inbox.welcome_tagline;
this.channelWelcomeTagline = this.inbox.welcome_tagline || '';
this.selectedFeatureFlags = this.inbox.selected_feature_flags || [];
this.replyTime = this.inbox.reply_time;
this.locktoSingleConversation = this.inbox.lock_to_single_conversation;

View File

@@ -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 {
>
<ChangePassword />
</FormSection>
<FormSection
v-if="isMfaEnabled"
:title="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.NOTE')"
>
<MfaSettingsCard />
</FormSection>
<Policy :permissions="audioNotificationPermissions">
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"

View File

@@ -0,0 +1,248 @@
<script setup>
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,
});
</script>
<template>
<div v-if="mfaEnabled">
<!-- Actions Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Regenerate Backup Codes -->
<div class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-5">
<div class="flex-1 flex flex-col gap-2">
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-key"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<h4 class="font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES') }}
</h4>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES_DESC') }}
</p>
<Button
faded
slate
:label="$t('MFA_SETTINGS.MANAGEMENT.REGENERATE')"
@click="regenerateDialogRef?.open()"
/>
</div>
</div>
<!-- Disable MFA -->
<div class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-5">
<div class="flex-1 flex flex-col gap-2">
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-lock-keyhole-open"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<h4 class="font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA') }}
</h4>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA_DESC') }}
</p>
<Button
faded
ruby
:label="$t('MFA_SETTINGS.MANAGEMENT.DISABLE_BUTTON')"
@click="disableDialogRef?.open()"
/>
</div>
</div>
</div>
<!-- Disable MFA Dialog -->
<Dialog
ref="disableDialogRef"
type="alert"
:title="$t('MFA_SETTINGS.DISABLE.TITLE')"
:description="$t('MFA_SETTINGS.DISABLE.DESCRIPTION')"
:confirm-button-label="$t('MFA_SETTINGS.DISABLE.CONFIRM')"
:cancel-button-label="$t('MFA_SETTINGS.DISABLE.CANCEL')"
@confirm="handleDisableMfa"
>
<div class="space-y-4">
<Input
v-model="disablePassword"
type="password"
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
/>
<Input
v-model="disableOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
/>
</div>
</Dialog>
<!-- Regenerate Backup Codes Dialog -->
<Dialog
ref="regenerateDialogRef"
type="edit"
:title="$t('MFA_SETTINGS.REGENERATE.TITLE')"
:description="$t('MFA_SETTINGS.REGENERATE.DESCRIPTION')"
:confirm-button-label="$t('MFA_SETTINGS.REGENERATE.CONFIRM')"
:cancel-button-label="$t('MFA_SETTINGS.DISABLE.CANCEL')"
@confirm="handleRegenerateBackupCodes"
>
<Input
v-model="regenerateOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.REGENERATE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.REGENERATE.OTP_CODE_PLACEHOLDER')"
/>
</Dialog>
<!-- Backup Codes Display Dialog -->
<Dialog
ref="backupCodesDialogRef"
type="edit"
width="2xl"
:title="$t('MFA_SETTINGS.REGENERATE.NEW_CODES_TITLE')"
:description="$t('MFA_SETTINGS.REGENERATE.NEW_CODES_DESC')"
:show-cancel-button="false"
:confirm-button-label="$t('MFA_SETTINGS.REGENERATE.CODES_SAVED')"
@confirm="backupCodesDialogRef?.close()"
>
<!-- Warning Alert -->
<div
class="flex items-start gap-2 p-4 bg-n-solid-1 outline outline-n-weak rounded-xl outline-1"
>
<Icon
icon="i-lucide-alert-circle"
class="size-4 text-n-slate-10 flex-shrink-0 mt-0.5"
/>
<p class="text-sm text-n-slate-11">
<strong>{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}</strong>
{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
</p>
</div>
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline flex flex-col gap-6 p-6"
>
<div class="grid grid-cols-2 xs:grid-cols-4 sm:grid-cols-5 gap-3">
<span
v-for="(code, index) in backupCodes"
:key="index"
class="px-1 py-2 font-mono text-base text-center text-n-slate-12"
>
{{ code }}
</span>
</div>
<div class="flex items-center justify-center gap-3">
<Button
outline
slate
sm
icon="i-lucide-download"
:label="$t('MFA_SETTINGS.BACKUP.DOWNLOAD')"
@click="downloadBackupCodes"
/>
<Button
outline
slate
sm
icon="i-lucide-clipboard"
:label="$t('MFA_SETTINGS.BACKUP.COPY_ALL')"
@click="copyBackupCodes"
/>
</div>
</div>
</Dialog>
</div>
<template v-else />
</template>

View File

@@ -0,0 +1,178 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { parseBoolean } from '@chatwoot/utils';
import mfaAPI from 'dashboard/api/mfa';
import { useAlert } from 'dashboard/composables';
import MfaStatusCard from './MfaStatusCard.vue';
import MfaSetupWizard from './MfaSetupWizard.vue';
import MfaManagementActions from './MfaManagementActions.vue';
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
// State
const mfaEnabled = ref(false);
const backupCodesGenerated = ref(false);
const showSetup = ref(false);
const provisioningUri = ref('');
const qrCodeUrl = ref('');
const secretKey = ref('');
const backupCodes = ref([]);
// Component refs
const setupWizardRef = ref(null);
const managementActionsRef = ref(null);
// Load MFA status on mount
onMounted(async () => {
// Check if MFA is enabled globally
if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) {
// Redirect to profile settings if MFA is disabled
router.push({
name: 'profile_settings_index',
params: {
accountId: route.params.accountId,
},
});
return;
}
try {
const response = await mfaAPI.get();
mfaEnabled.value = response.data.enabled;
backupCodesGenerated.value = response.data.backup_codes_generated;
} catch (error) {
// Handle error silently
}
});
// Start MFA setup
const startMfaSetup = async () => {
try {
const response = await mfaAPI.enable();
// Store the provisioning URI
provisioningUri.value =
response.data.provisioning_uri || response.data.provisioning_url;
// Store QR code URL if provided by backend
if (response.data.qr_code_url) {
qrCodeUrl.value = response.data.qr_code_url;
}
secretKey.value = response.data.secret;
// Backup codes are now generated after verification, not during enable
backupCodes.value = [];
showSetup.value = true;
} catch (error) {
useAlert(t('MFA_SETTINGS.SETUP.ERROR_STARTING'));
}
};
// Verify OTP code
const verifyCode = async verificationCode => {
try {
const response = await mfaAPI.verify(verificationCode);
// Store backup codes returned from verification
if (response.data.backup_codes) {
backupCodes.value = response.data.backup_codes;
}
return true;
} catch (error) {
setupWizardRef.value?.handleVerificationError(
error.response?.data?.error || t('MFA_SETTINGS.SETUP.INVALID_CODE')
);
throw error;
}
};
// Complete MFA setup
const completeMfaSetup = () => {
mfaEnabled.value = true;
backupCodesGenerated.value = true;
showSetup.value = false;
useAlert(t('MFA_SETTINGS.SETUP.SUCCESS'));
};
// Cancel setup
const cancelSetup = () => {
showSetup.value = false;
};
// Disable MFA
const disableMfa = async ({ password, otpCode }) => {
try {
await mfaAPI.disable(password, otpCode);
mfaEnabled.value = false;
backupCodesGenerated.value = false;
managementActionsRef.value?.resetDisableForm();
useAlert(t('MFA_SETTINGS.DISABLE.SUCCESS'));
} catch (error) {
useAlert(t('MFA_SETTINGS.DISABLE.ERROR'));
}
};
// Regenerate backup codes
const regenerateBackupCodes = async ({ otpCode }) => {
try {
const response = await mfaAPI.regenerateBackupCodes(otpCode);
backupCodes.value = response.data.backup_codes;
managementActionsRef.value?.resetRegenerateForm();
managementActionsRef.value?.showBackupCodesDialog();
useAlert(t('MFA_SETTINGS.REGENERATE.SUCCESS'));
} catch (error) {
useAlert(t('MFA_SETTINGS.REGENERATE.ERROR'));
}
};
</script>
<template>
<div
class="grid py-16 px-5 font-inter mx-auto gap-16 sm:max-w-screen-md w-full"
>
<!-- Page Header -->
<div class="flex flex-col gap-6">
<h2 class="text-2xl font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.SUBTITLE') }}
</p>
</div>
<div class="grid gap-4 w-full">
<!-- MFA Status Card -->
<MfaStatusCard
:mfa-enabled="mfaEnabled"
:show-setup="showSetup"
@enable-mfa="startMfaSetup"
/>
<!-- MFA Setup Wizard -->
<MfaSetupWizard
ref="setupWizardRef"
:show-setup="showSetup"
:mfa-enabled="mfaEnabled"
:provisioning-uri="provisioningUri"
:secret-key="secretKey"
:backup-codes="backupCodes"
:qr-code-url-prop="qrCodeUrl"
@cancel="cancelSetup"
@verify="verifyCode"
@complete="completeMfaSetup"
/>
<!-- MFA Management Actions -->
<MfaManagementActions
ref="managementActionsRef"
:mfa-enabled="mfaEnabled"
:backup-codes="backupCodes"
@disable-mfa="disableMfa"
@regenerate-backup-codes="regenerateBackupCodes"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script setup>
import { useRouter, useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const router = useRouter();
const route = useRoute();
const navigateToMfa = () => {
router.push({
name: 'profile_settings_mfa',
params: {
accountId: route.params.accountId,
},
});
};
</script>
<template>
<div class="bg-n-background rounded-xl p-4 border border-n-slate-4">
<div class="flex flex-col xs:flex-row items-center justify-between gap-4">
<div class="flex flex-col items-start gap-1.5">
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-lock-keyhole"
class="size-4 text-n-slate-10 flex-shrink-0"
/>
<h5 class="text-sm font-semibold text-n-slate-12">
{{ $t('MFA_SETTINGS.TITLE') }}
</h5>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.DESCRIPTION') }}
</p>
</div>
<Button
type="button"
faded
:label="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.MFA_BUTTON')"
icon="i-lucide-settings"
class="flex-shrink-0"
@click="navigateToMfa"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,323 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import QRCode from 'qrcode';
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';
const props = defineProps({
showSetup: {
type: Boolean,
required: true,
},
mfaEnabled: {
type: Boolean,
required: true,
},
provisioningUri: {
type: String,
default: '',
},
secretKey: {
type: String,
default: '',
},
backupCodes: {
type: Array,
default: () => [],
},
qrCodeUrlProp: {
type: String,
default: '',
},
});
const emit = defineEmits(['cancel', 'verify', 'complete']);
const { t } = useI18n();
// Local state
const setupStep = ref('qr');
const qrCodeUrl = ref('');
const verificationCode = ref('');
const verificationError = ref('');
const backupCodesConfirmed = ref(false);
// Generate QR code from provisioning URI
const generateQRCode = async provisioningUrl => {
try {
const qrCodeDataUrl = await QRCode.toDataURL(provisioningUrl, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
return qrCodeDataUrl;
} catch (error) {
return null;
}
};
// Watch for provisioning URI changes
watch(
() => props.provisioningUri,
async newUri => {
if (newUri) {
qrCodeUrl.value = await generateQRCode(newUri);
} else if (props.qrCodeUrlProp) {
qrCodeUrl.value = props.qrCodeUrlProp;
}
},
{ immediate: true }
);
const verifyCode = async () => {
verificationError.value = '';
try {
emit('verify', verificationCode.value);
setupStep.value = 'backup';
verificationCode.value = '';
} catch (error) {
verificationError.value = t('MFA_SETTINGS.SETUP.INVALID_CODE');
}
};
const copySecret = async () => {
await copyTextToClipboard(props.secretKey);
useAlert(t('MFA_SETTINGS.SETUP.SECRET_COPIED'));
};
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 cancelSetup = () => {
setupStep.value = 'qr';
verificationCode.value = '';
verificationError.value = '';
backupCodesConfirmed.value = false;
emit('cancel');
};
const completeMfaSetup = () => {
setupStep.value = 'qr';
backupCodesConfirmed.value = false;
emit('complete');
};
// Reset when showSetup changes
watch(
() => props.showSetup,
newVal => {
if (newVal) {
setupStep.value = 'qr';
verificationCode.value = '';
verificationError.value = '';
backupCodesConfirmed.value = false;
}
}
);
// Handle verification error
const handleVerificationError = error => {
verificationError.value = error || t('MFA_SETTINGS.SETUP.INVALID_CODE');
};
defineExpose({
handleVerificationError,
setupStep,
});
</script>
<template>
<div v-if="showSetup && !mfaEnabled">
<!-- Step 1: QR Code -->
<div v-if="setupStep === 'qr'" class="space-y-6">
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-10 flex flex-col gap-4"
>
<div class="text-center">
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
{{ $t('MFA_SETTINGS.SETUP.STEP1_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.SETUP.STEP1_DESCRIPTION') }}
</p>
</div>
<div class="flex justify-center">
<div
class="bg-n-background p-4 rounded-lg outline outline-1 outline-n-weak"
>
<img
v-if="qrCodeUrl"
:src="qrCodeUrl"
alt="MFA QR Code"
class="w-48 h-48 dark:invert-0"
/>
<div
v-else
class="w-48 h-48 flex items-center justify-center bg-n-slate-2 dark:bg-n-slate-3"
>
<span class="text-n-slate-10">
{{ $t('MFA_SETTINGS.SETUP.LOADING_QR') }}
</span>
</div>
</div>
</div>
<details class="border border-n-slate-4 rounded-lg">
<summary
class="px-4 py-3 cursor-pointer hover:bg-n-slate-2 dark:hover:bg-n-slate-3 text-sm font-medium text-n-slate-11"
>
{{ $t('MFA_SETTINGS.SETUP.MANUAL_ENTRY') }}
</summary>
<div class="px-4 pb-4">
<label class="block text-xs text-n-slate-10 mb-2">
{{ $t('MFA_SETTINGS.SETUP.SECRET_KEY') }}
</label>
<div class="flex items-center gap-2">
<Input :model-value="secretKey" readonly class="flex-1" />
<Button
variant="outline"
color="slate"
size="sm"
:label="$t('MFA_SETTINGS.SETUP.COPY')"
@click="copySecret"
/>
</div>
</div>
</details>
<div class="flex flex-col items-start gap-3 w-full">
<Input
v-model="verificationCode"
type="text"
maxlength="6"
pattern="[0-9]{6}"
:label="$t('MFA_SETTINGS.SETUP.ENTER_CODE')"
:placeholder="$t('MFA_SETTINGS.SETUP.ENTER_CODE_PLACEHOLDER')"
:message="verificationError"
:message-type="verificationError ? 'error' : 'info'"
class="w-full"
@keyup.enter="verifyCode"
/>
<div class="flex gap-3 mt-1 w-full justify-between">
<Button
faded
color="slate"
class="flex-1"
:label="$t('MFA_SETTINGS.SETUP.CANCEL')"
@click="cancelSetup"
/>
<Button
class="flex-1"
:disabled="verificationCode.length !== 6"
:label="$t('MFA_SETTINGS.SETUP.VERIFY_BUTTON')"
@click="verifyCode"
/>
</div>
</div>
</div>
</div>
<!-- Step 2: Backup Codes -->
<div v-if="setupStep === 'backup'" class="space-y-6">
<div class="text-start">
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
{{ $t('MFA_SETTINGS.BACKUP.TITLE') }}
</h3>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.BACKUP.DESCRIPTION') }}
</p>
</div>
<!-- Warning Alert -->
<div
class="flex items-start gap-2 p-4 bg-n-solid-1 outline outline-n-weak rounded-xl outline-1"
>
<Icon
icon="i-lucide-alert-circle"
class="size-4 text-n-slate-10 flex-shrink-0 mt-0.5"
/>
<p class="text-sm text-n-slate-11">
<strong>{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}</strong>
{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
</p>
</div>
<!-- Backup Codes Grid -->
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline flex flex-col gap-6 p-6"
>
<div class="grid grid-cols-2 xs:grid-cols-4 sm:grid-cols-5 gap-3">
<span
v-for="(code, index) in backupCodes"
:key="index"
class="px-1 py-2 font-mono text-base text-center text-n-slate-12"
>
{{ code }}
</span>
</div>
<div class="flex items-center justify-center gap-3">
<Button
outline
slate
sm
icon="i-lucide-download"
:label="$t('MFA_SETTINGS.BACKUP.DOWNLOAD')"
@click="downloadBackupCodes"
/>
<Button
outline
slate
sm
icon="i-lucide-clipboard"
:label="$t('MFA_SETTINGS.BACKUP.COPY_ALL')"
@click="copyBackupCodes"
/>
</div>
</div>
<!-- Confirmation -->
<div class="space-y-4">
<label class="flex items-start gap-3">
<input
v-model="backupCodesConfirmed"
type="checkbox"
class="mt-1 rounded border-n-slate-4 text-n-blue-9 focus:ring-n-blue-8"
/>
<span class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.BACKUP.CONFIRM') }}
</span>
</label>
<Button
:disabled="!backupCodesConfirmed"
:label="$t('MFA_SETTINGS.BACKUP.COMPLETE_SETUP')"
@click="completeMfaSetup"
/>
</div>
</div>
</div>
<template v-else />
</template>

View File

@@ -0,0 +1,63 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
mfaEnabled: {
type: Boolean,
required: true,
},
showSetup: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['enableMfa']);
const startSetup = () => {
emit('enableMfa');
};
</script>
<template>
<div v-if="!mfaEnabled && !showSetup" class="space-y-6">
<div
class="bg-n-solid-1 rounded-lg p-6 outline outline-n-weak outline-1 text-center"
>
<Icon
icon="i-lucide-lock-keyhole"
class="size-8 text-n-slate-10 mx-auto mb-4 block"
/>
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
{{ $t('MFA_SETTINGS.ENHANCE_SECURITY') }}
</h3>
<p class="text-sm text-n-slate-11 mb-6 max-w-md mx-auto">
{{ $t('MFA_SETTINGS.ENHANCE_SECURITY_DESC') }}
</p>
<Button
icon="i-lucide-settings"
:label="$t('MFA_SETTINGS.ENABLE_BUTTON')"
@click="startSetup"
/>
</div>
</div>
<div v-else-if="mfaEnabled && !showSetup">
<div
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-4 flex-1 flex flex-col gap-2"
>
<div class="flex items-center gap-2">
<Icon
icon="i-lucide-lock-keyhole"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<h4 class="text-sm font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.STATUS_ENABLED') }}
</h4>
</div>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.STATUS_ENABLED_DESC') }}
</p>
</div>
</div>
</template>

View File

@@ -1,7 +1,9 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { parseBoolean } from '@chatwoot/utils';
import SettingsContent from './Wrapper.vue';
import Index from './Index.vue';
import MfaSettings from './MfaSettings.vue';
export default {
routes: [
@@ -21,6 +23,23 @@ export default {
permissions: ['administrator', 'agent', 'custom_role'],
},
},
{
path: 'mfa',
name: 'profile_settings_mfa',
component: MfaSettings,
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
beforeEnter: (to, from, next) => {
// Check if MFA is enabled globally
if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) {
// Redirect to profile settings if MFA is disabled
next({ name: 'profile_settings_index' });
} else {
next();
}
},
},
],
},
],

View File

@@ -17,3 +17,22 @@ export const copyTextToClipboard = async data => {
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
}
};
/**
* Handles OTP paste events by extracting numeric digits from clipboard data.
*
* @param {ClipboardEvent} event - The paste event from the clipboard
* @param {number} maxLength - Maximum number of digits to extract (default: 6)
* @returns {string|null} - Extracted numeric string or null if invalid
*/
export const handleOtpPaste = (event, maxLength = 6) => {
if (!event?.clipboardData) return null;
const pastedData = event.clipboardData
.getData('text')
.replace(/\D/g, '') // Remove all non-digit characters
.slice(0, maxLength); // Limit to maxLength digits
// Only return if we have the exact expected length
return pastedData.length === maxLength ? pastedData : null;
};

View File

@@ -1,4 +1,4 @@
import { copyTextToClipboard } from '../clipboard';
import { copyTextToClipboard, handleOtpPaste } from '../clipboard';
const mockWriteText = vi.fn();
Object.assign(navigator, {
@@ -172,3 +172,113 @@ describe('copyTextToClipboard', () => {
});
});
});
describe('handleOtpPaste', () => {
// Helper function to create mock clipboard event
const createMockPasteEvent = text => ({
clipboardData: {
getData: vi.fn().mockReturnValue(text),
},
});
describe('valid OTP paste scenarios', () => {
it('extracts 6-digit OTP from clean numeric string', () => {
const event = createMockPasteEvent('123456');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
expect(event.clipboardData.getData).toHaveBeenCalledWith('text');
});
it('extracts 6-digit OTP from string with spaces', () => {
const event = createMockPasteEvent('1 2 3 4 5 6');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('extracts 6-digit OTP from string with dashes', () => {
const event = createMockPasteEvent('123-456');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('handles negative numbers by extracting digits only', () => {
const event = createMockPasteEvent('-123456');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('handles decimal numbers by extracting digits only', () => {
const event = createMockPasteEvent('123.456');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('extracts 6-digit OTP from mixed alphanumeric string', () => {
const event = createMockPasteEvent('Your code is: 987654');
const result = handleOtpPaste(event);
expect(result).toBe('987654');
});
it('extracts first 6 digits when more than 6 digits present', () => {
const event = createMockPasteEvent('12345678901234');
const result = handleOtpPaste(event);
expect(result).toBe('123456');
});
it('handles custom maxLength parameter', () => {
const event = createMockPasteEvent('12345678');
const result = handleOtpPaste(event, 8);
expect(result).toBe('12345678');
});
it('extracts 4-digit OTP with custom maxLength', () => {
const event = createMockPasteEvent('Your PIN: 9876');
const result = handleOtpPaste(event, 4);
expect(result).toBe('9876');
});
});
describe('invalid OTP paste scenarios', () => {
it('returns null for insufficient digits', () => {
const event = createMockPasteEvent('12345');
const result = handleOtpPaste(event);
expect(result).toBeNull();
});
it('returns null for text with no digits', () => {
const event = createMockPasteEvent('Hello World');
const result = handleOtpPaste(event);
expect(result).toBeNull();
});
it('returns null for empty string', () => {
const event = createMockPasteEvent('');
const result = handleOtpPaste(event);
expect(result).toBeNull();
});
it('returns null when event is null', () => {
const result = handleOtpPaste(null);
expect(result).toBeNull();
});
it('returns null when event is undefined', () => {
const result = handleOtpPaste(undefined);
expect(result).toBeNull();
});
});
});

View File

@@ -4,6 +4,8 @@ import {
dynamicTime,
dateFormat,
shortTimestamp,
getDayDifferenceFromNow,
hasOneDayPassed,
} from 'shared/helpers/timeHelper';
beforeEach(() => {
@@ -90,3 +92,148 @@ describe('#shortTimestamp', () => {
expect(shortTimestamp('4 years ago', true)).toEqual('4y ago');
});
});
describe('#getDayDifferenceFromNow', () => {
it('returns 0 for timestamps from today', () => {
// Mock current date: May 5, 2023
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // 12:00 PM
const todayTimestamp = Math.floor(now.getTime() / 1000); // Same day
expect(getDayDifferenceFromNow(now, todayTimestamp)).toEqual(0);
});
it('returns 2 for timestamps from 2 days ago', () => {
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
const twoDaysAgoTimestamp = Math.floor(
new Date(Date.UTC(2023, 4, 3, 10, 0, 0)).getTime() / 1000
); // May 3, 2023
expect(getDayDifferenceFromNow(now, twoDaysAgoTimestamp)).toEqual(2);
});
it('returns 7 for timestamps from a week ago', () => {
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
const weekAgoTimestamp = Math.floor(
new Date(Date.UTC(2023, 3, 28, 8, 0, 0)).getTime() / 1000
); // April 28, 2023
expect(getDayDifferenceFromNow(now, weekAgoTimestamp)).toEqual(7);
});
it('returns 30 for timestamps from a month ago', () => {
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
const monthAgoTimestamp = Math.floor(
new Date(Date.UTC(2023, 3, 5, 12, 0, 0)).getTime() / 1000
); // April 5, 2023
expect(getDayDifferenceFromNow(now, monthAgoTimestamp)).toEqual(30);
});
it('handles edge case with different times on same day', () => {
const now = new Date(Date.UTC(2023, 4, 5, 23, 59, 59)); // May 5, 2023 11:59:59 PM
const morningTimestamp = Math.floor(
new Date(Date.UTC(2023, 4, 5, 0, 0, 1)).getTime() / 1000
); // May 5, 2023 12:00:01 AM
expect(getDayDifferenceFromNow(now, morningTimestamp)).toEqual(0);
});
it('handles cross-month boundaries correctly', () => {
const now = new Date(Date.UTC(2023, 4, 1, 12, 0, 0)); // May 1, 2023
const lastMonthTimestamp = Math.floor(
new Date(Date.UTC(2023, 3, 30, 12, 0, 0)).getTime() / 1000
); // April 30, 2023
expect(getDayDifferenceFromNow(now, lastMonthTimestamp)).toEqual(1);
});
it('handles cross-year boundaries correctly', () => {
const now = new Date(Date.UTC(2023, 0, 2, 12, 0, 0)); // January 2, 2023
const lastYearTimestamp = Math.floor(
new Date(Date.UTC(2022, 11, 31, 12, 0, 0)).getTime() / 1000
); // December 31, 2022
expect(getDayDifferenceFromNow(now, lastYearTimestamp)).toEqual(2);
});
});
describe('#hasOneDayPassed', () => {
beforeEach(() => {
// Mock current date: May 5, 2023, 12:00 PM UTC (1683288000)
const mockDate = new Date(1683288000 * 1000);
vi.setSystemTime(mockDate);
});
it('returns false for timestamps from today', () => {
// Same day, different time - May 5, 2023 8:00 AM UTC
const todayTimestamp = 1683273600;
expect(hasOneDayPassed(todayTimestamp)).toBe(false);
});
it('returns false for timestamps from yesterday (less than 24 hours)', () => {
// Yesterday but less than 24 hours ago - May 4, 2023 6:00 PM UTC (18 hours ago)
const yesterdayTimestamp = 1683230400;
expect(hasOneDayPassed(yesterdayTimestamp)).toBe(false);
});
it('returns true for timestamps from exactly 1 day ago', () => {
// Exactly 24 hours ago - May 4, 2023 12:00 PM UTC
const oneDayAgoTimestamp = 1683201600;
expect(hasOneDayPassed(oneDayAgoTimestamp)).toBe(true);
});
it('returns true for timestamps from more than 1 day ago', () => {
// 2 days ago - May 3, 2023 10:00 AM UTC
const twoDaysAgoTimestamp = 1683108000;
expect(hasOneDayPassed(twoDaysAgoTimestamp)).toBe(true);
});
it('returns true for timestamps from a week ago', () => {
// 7 days ago - April 28, 2023 8:00 AM UTC
const weekAgoTimestamp = 1682668800;
expect(hasOneDayPassed(weekAgoTimestamp)).toBe(true);
});
it('returns true for null timestamp (defensive check)', () => {
expect(hasOneDayPassed(null)).toBe(true);
});
it('returns true for undefined timestamp (defensive check)', () => {
expect(hasOneDayPassed(undefined)).toBe(true);
});
it('returns true for zero timestamp (defensive check)', () => {
expect(hasOneDayPassed(0)).toBe(true);
});
it('returns true for empty string timestamp (defensive check)', () => {
expect(hasOneDayPassed('')).toBe(true);
});
it('handles cross-month boundaries correctly', () => {
// Set current time to May 1, 2023 12:00 PM UTC (1682942400)
const mayFirst = new Date(1682942400 * 1000);
vi.setSystemTime(mayFirst);
// April 29, 2023 12:00 PM UTC (1682769600) - 2 days ago, crossing month boundary
const crossMonthTimestamp = 1682769600;
expect(hasOneDayPassed(crossMonthTimestamp)).toBe(true);
});
it('handles cross-year boundaries correctly', () => {
// Set current time to January 2, 2023 12:00 PM UTC (1672660800)
const newYear = new Date(1672660800 * 1000);
vi.setSystemTime(newYear);
// December 30, 2022 12:00 PM UTC (1672401600) - 3 days ago, crossing year boundary
const crossYearTimestamp = 1672401600;
expect(hasOneDayPassed(crossYearTimestamp)).toBe(true);
});
});

View File

@@ -3,6 +3,7 @@ import {
isSameYear,
fromUnixTime,
formatDistanceToNow,
differenceInDays,
} from 'date-fns';
/**
@@ -91,3 +92,25 @@ export const shortTimestamp = (time, withAgo = false) => {
.replace(' years ago', `y${suffix}`);
return convertToShortTime;
};
/**
* Calculates the difference in days between now and a given timestamp.
* @param {Date} now - Current date/time.
* @param {number} timestampInSeconds - Unix timestamp in seconds.
* @returns {number} Number of days difference.
*/
export const getDayDifferenceFromNow = (now, timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000);
return differenceInDays(now, date);
};
/**
* Checks if more than 24 hours have passed since a given timestamp.
* Useful for determining if retry/refresh actions should be disabled.
* @param {number} timestamp - Unix timestamp.
* @returns {boolean} True if more than 24 hours have passed.
*/
export const hasOneDayPassed = timestamp => {
if (!timestamp) return true; // Defensive check
return getDayDifferenceFromNow(new Date(), timestamp) >= 1;
};

View File

@@ -15,8 +15,10 @@ export default {
setColorTheme() {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
this.theme = 'dark';
document.documentElement.classList.add('dark');
} else {
this.theme = 'light';
document.documentElement.classList.remove('dark');
}
},
listenToThemeChanges() {
@@ -25,8 +27,10 @@ export default {
mql.onchange = e => {
if (e.matches) {
this.theme = 'dark';
document.documentElement.classList.add('dark');
} else {
this.theme = 'light';
document.documentElement.classList.remove('dark');
}
};
},

View File

@@ -13,6 +13,16 @@ export const login = async ({
}) => {
try {
const response = await wootAPI.post('auth/sign_in', credentials);
// Check if MFA is required
if (response.status === 206 && response.data.mfa_required) {
// Return MFA data instead of throwing error
return {
mfaRequired: true,
mfaToken: response.data.mfa_token,
};
}
setAuthCredentials(response);
clearLocalStorageOnLogout();
window.location = getLoginRedirectURL({
@@ -20,8 +30,17 @@ export const login = async ({
ssoConversationId,
user: response.data.data,
});
return null;
} catch (error) {
// Check if it's an MFA required response
if (error.response?.status === 206 && error.response?.data?.mfa_required) {
return {
mfaRequired: true,
mfaToken: error.response.data.mfa_token,
};
}
throwErrorMessage(error);
return null;
}
};

View File

@@ -15,6 +15,7 @@ import FormInput from '../../components/Form/Input.vue';
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
const ERROR_MESSAGES = {
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
@@ -29,6 +30,7 @@ export default {
GoogleOAuthButton,
Spinner,
NextButton,
MfaVerification,
},
props: {
ssoAuthToken: { type: String, default: '' },
@@ -58,6 +60,8 @@ export default {
hasErrored: false,
},
error: '',
mfaRequired: false,
mfaToken: null,
};
},
validations() {
@@ -87,8 +91,10 @@ export default {
this.submitLogin();
}
if (this.authError) {
const message = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
useAlert(this.$t(message));
const messageKey = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
// Use a method to get the translated text to avoid dynamic key warning
const translatedMessage = this.getTranslatedMessage(messageKey);
useAlert(translatedMessage);
// wait for idle state
this.requestIdleCallbackPolyfill(() => {
// Remove the error query param from the url
@@ -98,6 +104,18 @@ export default {
}
},
methods: {
getTranslatedMessage(key) {
// Avoid dynamic key warning by handling each case explicitly
switch (key) {
case 'LOGIN.OAUTH.NO_ACCOUNT_FOUND':
return this.$t('LOGIN.OAUTH.NO_ACCOUNT_FOUND');
case 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY':
return this.$t('LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY');
case 'LOGIN.API.UNAUTH':
default:
return this.$t('LOGIN.API.UNAUTH');
}
},
// TODO: Remove this when Safari gets wider support
// Ref: https://caniuse.com/requestidlecallback
//
@@ -140,7 +158,15 @@ export default {
};
login(credentials)
.then(() => {
.then(result => {
// Check if MFA is required
if (result?.mfaRequired) {
this.loginApi.showLoading = false;
this.mfaRequired = true;
this.mfaToken = result.mfaToken;
return;
}
this.handleImpersonation();
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
})
@@ -163,6 +189,17 @@ export default {
this.submitLogin();
},
handleMfaVerified() {
// MFA verification successful, continue with login
this.handleImpersonation();
window.location = '/app';
},
handleMfaCancel() {
// User cancelled MFA, reset state
this.mfaRequired = false;
this.mfaToken = null;
this.credentials.password = '';
},
},
};
</script>
@@ -193,7 +230,19 @@ export default {
</router-link>
</p>
</section>
<!-- MFA Verification Section -->
<section v-if="mfaRequired" class="mt-11">
<MfaVerification
:mfa-token="mfaToken"
@verified="handleMfaVerified"
@cancel="handleMfaCancel"
/>
</section>
<!-- Regular Login Section -->
<section
v-else
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
:class="{
'mb-8 mt-15': !showGoogleOAuth,

View File

@@ -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

View File

@@ -3,6 +3,7 @@
# Table name: channel_web_widgets
#
# id :integer not null, primary key
# allowed_domains :text default("")
# continuity_via_email :boolean default(TRUE), not null
# feature_flags :integer default(7), not null
# hmac_mandatory :boolean default(FALSE)
@@ -31,7 +32,7 @@ class Channel::WebWidget < ApplicationRecord
self.table_name = 'channel_web_widgets'
EDITABLE_ATTRS = [:website_url, :widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled,
:continuity_via_email, :hmac_mandatory,
:continuity_via_email, :hmac_mandatory, :allowed_domains,
{ pre_chat_form_options: [:pre_chat_message, :require_email,
{ pre_chat_fields:
[:field_type, :label, :placeholder, :name, :enabled, :type, :enabled, :required,

View File

@@ -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)

View File

@@ -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

View File

@@ -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
@@ -34,6 +38,8 @@
# Indexes
#
# 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

View File

@@ -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
@@ -34,6 +38,8 @@
# Indexes
#
# 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
@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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' }]
# }
# }

View File

@@ -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

View File

@@ -1,21 +1,27 @@
class Widget::TokenService
pattr_initialize [:payload, :token]
class Widget::TokenService < BaseTokenService
DEFAULT_EXPIRY_DAYS = 180
def generate_token
JWT.encode payload, 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 secret_key
Rails.application.secret_key_base
def token_payload
(payload || {}).merge(exp: exp, iat: iat)
end
def iat
Time.zone.now.to_i
end
def exp
iat + expire_in.days.to_i
end
def expire_in
# Value is stored in days, defaulting to 6 months (180 days)
token_expiry_value = InstallationConfig.find_by(name: 'WIDGET_TOKEN_EXPIRY')&.value
(token_expiry_value.presence || DEFAULT_EXPIRY_DAYS).to_i
end
end

View File

@@ -33,6 +33,7 @@ end
json.tweets_enabled resource.channel.try(:tweets_enabled) if resource.twitter?
## WebWidget Attributes
json.allowed_domains resource.channel.try(:allowed_domains)
json.widget_color resource.channel.try(:widget_color)
json.website_url resource.channel.try(:website_url)
json.hmac_mandatory resource.channel.try(:hmac_mandatory)

View File

@@ -0,0 +1 @@
json.backup_codes @backup_codes

View File

@@ -0,0 +1,2 @@
json.provisioning_url @user.mfa_service.two_factor_provisioning_uri
json.secret @user.otp_secret

View File

@@ -0,0 +1 @@
json.enabled @user.mfa_enabled?

View File

@@ -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?

View File

@@ -0,0 +1,2 @@
json.enabled @user.mfa_enabled?
json.backup_codes @backup_codes if @backup_codes.present?

View File

@@ -44,6 +44,7 @@
whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>',
signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
isMfaEnabled: '<%= Chatwoot.mfa_enabled? %>',
<% if @global_config['IS_ENTERPRISE'] %>
enterprisePlanName: '<%= @global_config['INSTALLATION_PRICING_PLAN'] %>',
<% end %>

View File

@@ -1,5 +1,5 @@
shared: &shared
version: '4.5.2'
version: '4.6.0'
development:
<<: *shared

View File

@@ -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

View File

@@ -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'

View File

@@ -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?

View File

@@ -439,3 +439,11 @@
locked: false
description: 'Zone ID for the Cloudflare domain'
## ------ End of Configs added for Cloudflare ------ ##
## ------ Customizations for Customers ------ ##
- name: WIDGET_TOKEN_EXPIRY
display_title: 'Widget Token Expiry'
value: 180
locked: false
description: 'Token expiry in days'
## ------ End of Customizations for Customers ------ ##

View File

@@ -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:

View File

@@ -344,6 +344,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]

View File

@@ -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

View File

@@ -0,0 +1,5 @@
class AddAllowedDomainsToChannelWidgets < ActiveRecord::Migration[7.1]
def change
add_column :channel_web_widgets, :allowed_domains, :text, default: ''
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_08_26_000000) do
ActiveRecord::Schema[7.1].define(version: 2025_09_16_024703) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -533,6 +533,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_26_000000) do
t.jsonb "pre_chat_form_options", default: {}
t.boolean "hmac_mandatory", default: false
t.boolean "continuity_via_email", default: true, null: false
t.text "allowed_domains", default: ""
t.index ["hmac_token"], name: "index_channel_web_widgets_on_hmac_token", unique: true
t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true
end
@@ -1174,7 +1175,13 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_26_000000) 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

View File

@@ -0,0 +1,13 @@
module Enterprise::AgentBuilder
def perform
super.tap do |user|
convert_to_saml_provider(user) if user.persisted? && account.saml_enabled?
end
end
private
def convert_to_saml_provider(user)
user.update!(provider: 'saml') unless user.provider == 'saml'
end
end

View File

@@ -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

View File

@@ -0,0 +1,45 @@
<p>Hi <%= @resource.name %>,</p>
<% account_user = @resource&.account_users&.first %>
<% is_saml_account = account_user&.account&.saml_enabled? %>
<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
<% if is_saml_account %>
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to access <%= global_config['BRAND_NAME'] || 'Chatwoot' %> via Single Sign-On (SSO).</p>
<p>Your organization uses SSO for secure authentication. You will not need a password to access your account.</p>
<% else %>
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.</p>
<% end %>
<% end %>
<% if @resource.confirmed? %>
<p>You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:</p>
<% else %>
<% if account_user&.inviter.blank? %>
<p>
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.
</p>
<% end %>
<% unless is_saml_account %>
<p>Please take a moment and click the link below and activate your account.</p>
<% end %>
<% end %>
<% if @resource.unconfirmed_email.present? %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% elsif @resource.confirmed? %>
<% if is_saml_account %>
<p>You can now access your account by logging in through your organization's SSO portal.</p>
<% else %>
<p><%= link_to 'Login to my account', frontend_url('auth/sign_in') %></p>
<% end %>
<% elsif account_user&.inviter.present? %>
<% if is_saml_account %>
<p>You can access your account by logging in through your organization's SSO portal.</p>
<% else %>
<p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
<% end %>
<% else %>
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
<% end %>

View File

@@ -138,7 +138,7 @@ contacts:
- "does_not_contain"
phone_number:
attribute_type: "standard"
data_type: "text_case_insensitive"
data_type: "text" # Text is not explicity defined in filters, default filter will be used
filter_operators:
- "equal_to"
- "not_equal_to"

View File

@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.5.2",
"version": "4.6.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -17,8 +17,7 @@ RSpec.describe AdministratorNotifications::BaseMailer do
# Call the private method
admin_emails = mailer.send(:admin_emails)
expect(admin_emails).to include(admin1.email)
expect(admin_emails).to include(admin2.email)
expect(admin_emails).to contain_exactly(admin1.email, admin2.email)
expect(admin_emails).not_to include(agent.email)
end
end
@@ -49,7 +48,7 @@ RSpec.describe AdministratorNotifications::BaseMailer do
# Mock the send_mail_with_liquid method
expect(mailer).to receive(:send_mail_with_liquid).with(
to: [admin1.email, admin2.email],
to: contain_exactly(admin1.email, admin2.email),
subject: subject
).and_return(true)

View File

@@ -9,6 +9,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
let(:class_instance) { described_class.new }
let!(:account) { create(:account) }
let!(:administrator) { create(:user, :administrator, email: 'agent1@example.com', account: account) }
let!(:another_administrator) { create(:user, :administrator, email: 'agent2@example.com', account: account) }
describe 'facebook_disconnect' do
before do
@@ -26,7 +27,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
end
it 'renders the receiver email' do
expect(mail.to).to eq([administrator.email])
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
end
end
end
@@ -41,7 +42,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
end
it 'renders the receiver email' do
expect(mail.to).to eq([administrator.email])
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
end
end
@@ -55,7 +56,7 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
end
it 'renders the receiver email' do
expect(mail.to).to eq([administrator.email])
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
end
end
end

Some files were not shown because too many files have changed in this diff Show More