Merge branch 'develop' into feat/CW-5624
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -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"
|
||||
|
||||
+52
@@ -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')"
|
||||
>
|
||||
|
||||
+1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user