Merge branch 'develop' into feat/CW-5624

This commit is contained in:
Sivin Varghese
2025-09-24 18:58:41 +05:30
committed by GitHub
27 changed files with 329 additions and 88 deletions
@@ -1,8 +1,10 @@
<script setup>
import { defineProps, computed } from 'vue';
import { defineProps, computed, reactive } from 'vue';
import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useMapGetter } from 'dashboard/composables/store.js';
import MessageApi from 'dashboard/api/inbox/message.js';
/**
* Props definition for the component
@@ -43,6 +45,48 @@ const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true });
});
const currentChat = useMapGetter('getSelectedChat');
// Cache for fetched reply messages to avoid duplicate API calls
const fetchedReplyMessages = reactive(new Map());
/**
* Fetches a specific message from the API by trying to get messages around it
* @param {number} messageId - The ID of the message to fetch
* @param {number} conversationId - The ID of the conversation
* @returns {Promise<Object|null>} - The fetched message or null if not found/error
*/
const fetchReplyMessage = async (messageId, conversationId) => {
// Return cached result if already fetched
if (fetchedReplyMessages.has(messageId)) {
return fetchedReplyMessages.get(messageId);
}
try {
const response = await MessageApi.getPreviousMessages({
conversationId,
before: messageId + 100,
after: messageId - 100,
});
const messages = response.data?.payload || [];
const targetMessage = messages.find(msg => msg.id === messageId);
if (targetMessage) {
const camelCaseMessage = useCamelCase(targetMessage);
fetchedReplyMessages.set(messageId, camelCaseMessage);
return camelCaseMessage;
}
// Cache null result to avoid repeated API calls
fetchedReplyMessages.set(messageId, null);
return null;
} catch (error) {
fetchedReplyMessages.set(messageId, null);
return null;
}
};
/**
* Determines if a message should be grouped with the next message
* @param {Number} index - Index of the current message
@@ -90,10 +134,26 @@ const getInReplyToMessage = parentMessage => {
if (!inReplyToMessageId) return null;
// Find in-reply-to message in the messages prop
const replyMessage = props.messages?.find(
message => message.id === inReplyToMessageId
);
// Try to find in current messages first
let replyMessage = props.messages?.find(msg => msg.id === inReplyToMessageId);
// Then try store messages
if (!replyMessage && currentChat.value?.messages) {
replyMessage = currentChat.value.messages.find(
msg => msg.id === inReplyToMessageId
);
}
// Then check fetch cache
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
}
// If still not found and we have conversation context, fetch it
if (!replyMessage && currentChat.value?.id) {
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
return null; // Let UI handle loading state
}
return replyMessage ? useCamelCase(replyMessage) : null;
};
+1
View File
@@ -49,4 +49,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.CAPTAIN_V2,
FEATURE_FLAGS.SAML,
];
@@ -145,3 +145,34 @@ export const extractFilenameFromUrl = url => {
return match ? match[1] : url;
}
};
/**
* Normalizes a comma/newline separated list of domains
* @param {string} domains - The comma/newline separated list of domains
* @returns {string} - The normalized list of domains
* - Converts newlines to commas
* - Trims whitespace
* - Lowercases entries
* - Removes empty values
* - De-duplicates while preserving original order
*/
export const sanitizeAllowedDomains = domains => {
if (!domains) return '';
const tokens = domains
.replace(/\r\n/g, '\n')
.replace(/\s*\n\s*/g, ',')
.split(',')
.map(d => d.trim().toLowerCase())
.filter(d => d.length > 0);
// De-duplicate while preserving order using Set and filter index
const seen = new Set();
const unique = tokens.filter(d => {
if (seen.has(d)) return false;
seen.add(d);
return true;
});
return unique.join(',');
};
@@ -8,6 +8,7 @@ import {
timeStampAppendedURL,
getHostNameFromURL,
extractFilenameFromUrl,
sanitizeAllowedDomains,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -318,4 +319,32 @@ describe('#URL Helpers', () => {
).toBe('file.doc');
});
});
describe('sanitizeAllowedDomains', () => {
it('returns empty string for falsy input', () => {
expect(sanitizeAllowedDomains('')).toBe('');
expect(sanitizeAllowedDomains(null)).toBe('');
expect(sanitizeAllowedDomains(undefined)).toBe('');
});
it('trims whitespace and converts newlines to commas', () => {
const input = ' example.com \n foo.bar\nbar.baz ';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
it('handles Windows newlines and mixed spacing', () => {
const input = ' example.com\r\n\tfoo.bar , bar.baz ';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
it('removes empty values from repeated commas', () => {
const input = ',,example.com,,foo.bar,,';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar');
});
it('lowercases entries and de-duplicates preserving order', () => {
const input = 'Example.com,FOO.bar,example.com,Bar.Baz,foo.BAR';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
});
});
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -1,10 +1,14 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
title: { type: String, required: true },
description: { type: String, required: true },
withBorder: { type: Boolean, default: false },
hideContent: { type: Boolean, default: false },
beta: { type: Boolean, default: false },
});
const { t } = useI18n();
</script>
<template>
@@ -17,8 +21,15 @@ defineProps({
>
<header class="grid grid-cols-4">
<div class="col-span-3">
<h4 class="text-lg font-medium text-n-slate-12">
<h4 class="text-lg font-medium text-n-slate-12 flex items-center gap-2">
<slot name="title">{{ title }}</slot>
<div
v-if="beta"
v-tooltip.top="t('GENERAL.BETA_DESCRIPTION')"
class="text-xs uppercase text-n-iris-11 border border-1 border-n-iris-10 leading-none rounded-lg px-1 py-0.5"
>
{{ t('GENERAL.BETA') }}
</div>
</h4>
<p class="text-n-slate-11 text-sm mt-2">
<slot name="description">{{ description }}</slot>
@@ -30,6 +30,10 @@ const props = defineProps({
type: String,
default: '',
},
provider: {
type: String,
default: '',
},
customRoleId: {
type: Number,
default: null,
@@ -203,6 +207,7 @@ const resetPassword = async () => {
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
<div class="w-[50%] ltr:text-left rtl:text-right">
<Button
v-if="provider !== 'saml'"
ghost
type="button"
icon="i-lucide-lock-keyhole"
@@ -261,6 +261,7 @@ const confirmDeletion = () => {
v-if="showEditPopup"
:id="currentAgent.id"
:name="currentAgent.name"
:provider="currentAgent.provider"
:type="currentAgent.role"
:email="currentAgent.email"
:availability="currentAgent.availability_status"
@@ -7,7 +7,9 @@ import SmtpSettings from '../SmtpSettings.vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TextArea from 'next/textarea/TextArea.vue';
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
export default {
components: {
@@ -15,6 +17,7 @@ export default {
ImapSettings,
SmtpSettings,
NextButton,
TextArea,
WhatsappReauthorize,
},
mixins: [inboxMixin],
@@ -33,6 +36,8 @@ export default {
whatsAppInboxAPIKey: '',
isRequestingReauthorization: false,
isSyncingTemplates: false,
allowedDomains: '',
isUpdatingAllowedDomains: false,
};
},
validations: {
@@ -57,6 +62,7 @@ export default {
methods: {
setDefaults() {
this.hmacMandatory = this.inbox.hmac_mandatory || false;
this.allowedDomains = this.inbox.allowed_domains || '';
},
handleHmacFlag() {
this.updateInbox();
@@ -76,6 +82,28 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async updateAllowedDomains() {
this.isUpdatingAllowedDomains = true;
const sanitizedAllowedDomains = sanitizeAllowedDomains(
this.allowedDomains
);
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
allowed_domains: sanitizedAllowedDomains,
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
this.allowedDomains = sanitizedAllowedDomains;
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isUpdatingAllowedDomains = false;
}
},
async updateWhatsAppInboxAPIKey() {
try {
const payload = {
@@ -180,6 +208,30 @@ export default {
/>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.SUBTITLE')"
>
<div class="flex flex-col w-full max-w-3xl gap-4">
<TextArea
v-model="allowedDomains"
:placeholder="
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.PLACEHOLDER')
"
auto-height
min-height="8rem"
class="w-full"
/>
<div>
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdatingAllowedDomains"
@click="updateAllowedDomains"
/>
</div>
</div>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
>
@@ -171,6 +171,7 @@ onMounted(() => {
<SectionLayout
:title="t('SECURITY_SETTINGS.SAML.TITLE')"
:description="t('SECURITY_SETTINGS.SAML.NOTE')"
beta
:hide-content="!hasFeature || !isEnabled || isLoading"
>
<template #headerActions>
+1 -1
View File
@@ -541,7 +541,7 @@ const countries = [
},
{
name: 'Guyana',
dial_code: '+595',
dial_code: '+592',
emoji: '🇬🇾',
id: 'GY',
},
@@ -5,6 +5,10 @@ export default {
type: String,
default: '',
},
bg: {
type: String,
default: 'bg-white dark:bg-n-solid-2',
},
},
};
</script>
@@ -15,7 +19,7 @@ export default {
<div class="w-full border-t border-n-strong" />
</div>
<div v-if="label" class="relative flex justify-center text-sm">
<span class="bg-white dark:bg-n-solid-2 px-2 text-n-slate-10">
<span class="px-2 text-n-slate-10" :class="bg">
{{ label }}
</span>
</div>
@@ -1,9 +1,8 @@
import { shallowMount } from '@vue/test-utils';
import GoogleOAuthButton from './Button.vue';
function getWrapper(showSeparator) {
function getWrapper() {
return shallowMount(GoogleOAuthButton, {
propsData: { showSeparator: showSeparator },
mocks: { $t: text => text },
});
}
@@ -20,16 +19,6 @@ describe('GoogleOAuthButton.vue', () => {
window.chatwootConfig = {};
});
it('renders the OR separator if showSeparator is true', () => {
const wrapper = getWrapper(true);
expect(wrapper.findComponent({ ref: 'divider' }).exists()).toBe(true);
});
it('does not render the OR separator if showSeparator is false', () => {
const wrapper = getWrapper(false);
expect(wrapper.findComponent({ ref: 'divider' }).exists()).toBe(false);
});
it('generates the correct Google Auth URL', () => {
const wrapper = getWrapper();
const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl());
@@ -42,7 +31,5 @@ describe('GoogleOAuthButton.vue', () => {
);
expect(params.get('response_type')).toBe('code');
expect(params.get('scope')).toBe('email profile');
expect(wrapper.findComponent({ ref: 'divider' }).exists()).toBe(true);
});
});
@@ -1,16 +1,5 @@
<script>
import SimpleDivider from '../Divider/SimpleDivider.vue';
export default {
components: {
SimpleDivider,
},
props: {
showSeparator: {
type: Boolean,
default: true,
},
},
methods: {
getGoogleAuthUrl() {
// Ideally a request to /auth/google_oauth2 should be made
@@ -52,11 +41,5 @@ export default {
{{ $t('LOGIN.OAUTH.GOOGLE_LOGIN') }}
</span>
</a>
<SimpleDivider
v-if="showSeparator"
ref="divider"
:label="$t('COMMON.OR')"
class="uppercase"
/>
</div>
</template>
@@ -5,6 +5,7 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import SimpleDivider from '../../../../../components/Divider/SimpleDivider.vue';
import FormInput from '../../../../../components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { isValidPassword } from 'shared/helpers/Validators';
@@ -17,6 +18,7 @@ export default {
FormInput,
GoogleOAuthButton,
NextButton,
SimpleDivider,
VueHcaptcha,
},
setup() {
@@ -210,9 +212,17 @@ export default {
:is-loading="isSignupInProgress"
/>
</form>
<GoogleOAuthButton v-if="showGoogleOAuth" class="flex-col-reverse">
{{ $t('REGISTER.OAUTH.GOOGLE_SIGNUP') }}
</GoogleOAuthButton>
<div class="flex flex-col">
<SimpleDivider
v-if="showGoogleOAuth || showSamlLogin"
:label="$t('COMMON.OR')"
bg="bg-n-background"
class="uppercase"
/>
<GoogleOAuthButton v-if="showGoogleOAuth">
{{ $t('REGISTER.OAUTH.GOOGLE_SIGNUP') }}
</GoogleOAuthButton>
</div>
<p
class="text-sm mb-1 mt-5 text-n-slate-12 [&>a]:text-n-brand [&>a]:font-medium [&>a]:hover:brightness-110"
v-html="termsLink"
+21 -9
View File
@@ -11,6 +11,7 @@ import SessionStorage from 'shared/helpers/sessionStorage';
import { useBranding } from 'shared/composables/useBranding';
// components
import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
import FormInput from '../../components/Form/Input.vue';
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
import Spinner from 'shared/components/Spinner.vue';
@@ -30,6 +31,7 @@ export default {
GoogleOAuthButton,
Spinner,
NextButton,
SimpleDivider,
MfaVerification,
},
props: {
@@ -253,7 +255,25 @@ export default {
}"
>
<div v-if="!email">
<GoogleOAuthButton v-if="showGoogleOAuth" />
<div class="flex flex-col">
<GoogleOAuthButton v-if="showGoogleOAuth" />
<div v-if="showSamlLogin" class="mt-4 text-center">
<router-link
to="/app/login/sso"
class="inline-flex justify-center w-full px-4 py-3 bg-n-background dark:bg-n-solid-3 rounded-md shadow-sm ring-1 ring-inset ring-n-container dark:ring-n-container focus:outline-offset-0 hover:bg-n-alpha-2 dark:hover:bg-n-alpha-2"
>
<span class="i-lucide-key h-6 text-n-slate-11" />
<span class="ml-2 text-base font-medium text-n-slate-12">
{{ $t('LOGIN.SAML.LABEL') }}
</span>
</router-link>
</div>
<SimpleDivider
v-if="showGoogleOAuth || showSamlLogin"
:label="$t('COMMON.OR')"
class="uppercase"
/>
</div>
<form class="space-y-5" @submit.prevent="submitFormLogin">
<FormInput
v-model="credentials.email"
@@ -305,13 +325,5 @@ export default {
<Spinner color-scheme="primary" size="" />
</div>
</section>
<div v-if="showSamlLogin" class="mt-6 text-center">
<router-link
to="/app/login/sso"
class="inline-flex items-center text-sm font-medium text-n-brand hover:text-n-brand-dark"
>
{{ $t('LOGIN.SAML.LABEL') }}
</router-link>
</div>
</main>
</template>
+28 -3
View File
@@ -1,14 +1,22 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, nextTick, computed, onMounted } from 'vue';
import { useStore } from 'vuex';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
// components
import FormInput from '../../components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
authError: {
type: String,
default: '',
},
});
const store = useStore();
const { t } = useI18n();
@@ -21,6 +29,16 @@ const loginApi = ref({
hasErrored: false,
});
const handleAuthError = () => {
if (!props.authError) {
return;
}
const translatedMessage = t('LOGIN.SAML.API.ERROR_MESSAGE');
useAlert(translatedMessage);
loginApi.value.hasErrored = true;
};
const validations = {
credentials: {
email: {
@@ -35,11 +53,13 @@ const v$ = useVuelidate(validations, { credentials });
const globalConfig = computed(() => store.getters['globalConfig/get']);
const csrfToken = ref('');
onMounted(() => {
onMounted(async () => {
csrfToken.value =
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || '';
await nextTick(handleAuthError);
});
</script>
@@ -70,7 +90,6 @@ onMounted(() => {
}"
>
<form class="space-y-5" method="POST" action="/api/v1/auth/saml_login">
<input type="hidden" name="authenticity_token" :value="csrfToken" I />
<FormInput
v-model="credentials.email"
name="email"
@@ -82,6 +101,12 @@ onMounted(() => {
:has-error="v$.credentials.email.$error"
@input="v$.credentials.email.$touch"
/>
<input
type="hidden"
class="h-0"
name="authenticity_token"
:value="csrfToken"
/>
<NextButton
lg
type="submit"
+3
View File
@@ -26,6 +26,9 @@ export default [
name: 'sso_login',
component: SamlLogin,
meta: { requireEnterprise: true },
props: route => ({
authError: route.query.error,
}),
},
{
path: frontendURL('auth/signup'),