diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb
index 0227bd0ba..1d377d69d 100644
--- a/app/controllers/api/v1/widget/base_controller.rb
+++ b/app/controllers/api/v1/widget/base_controller.rb
@@ -7,7 +7,12 @@ class Api::V1::Widget::BaseController < ApplicationController
private
def conversations
- @conversations = @contact_inbox.conversations.where(inbox_id: auth_token_params[:inbox_id])
+ if @contact_inbox.hmac_verified?
+ verified_contact_inbox_ids = @contact.contact_inboxes.where(inbox_id: auth_token_params[:inbox_id], hmac_verified: true).map(&:id)
+ @conversations = @contact.conversations.where(contact_inbox_id: verified_contact_inbox_ids)
+ else
+ @conversations = @contact_inbox.conversations.where(inbox_id: auth_token_params[:inbox_id])
+ end
end
def conversation
diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb
index b58d26373..1fca2ee81 100644
--- a/app/controllers/api/v1/widget/contacts_controller.rb
+++ b/app/controllers/api/v1/widget/contacts_controller.rb
@@ -1,5 +1,6 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
def update
+ process_hmac
contact_identify_action = ContactIdentifyAction.new(
contact: @contact,
params: permitted_params.to_h.deep_symbolize_keys
@@ -9,7 +10,22 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
private
+ def process_hmac
+ return if params[:identifier_hash].blank?
+ raise StandardError, 'HMAC failed: Invalid Identifer Hash Provided' unless valid_hmac?
+
+ @contact_inbox.update(hmac_verified: true)
+ end
+
+ def valid_hmac?
+ params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
+ 'sha256',
+ @web_widget.hmac_token,
+ params[:identifier].to_s
+ )
+ end
+
def permitted_params
- params.permit(:website_token, :identifier, :email, :name, :avatar_url, custom_attributes: {})
+ params.permit(:website_token, :identifier, :identifier_hash, :email, :name, :avatar_url, custom_attributes: {})
end
end
diff --git a/app/javascript/dashboard/components/SettingsSection.vue b/app/javascript/dashboard/components/SettingsSection.vue
index fd930914b..26caff02c 100644
--- a/app/javascript/dashboard/components/SettingsSection.vue
+++ b/app/javascript/dashboard/components/SettingsSection.vue
@@ -42,5 +42,9 @@ export default {
font-weight: $font-weight-medium;
margin-bottom: 0;
}
+
+ .title--section {
+ padding-right: var(--space-large);
+ }
}
diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
index 480498af3..8c3f5624b 100644
--- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
+++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
@@ -241,7 +241,9 @@
"AUTO_ASSIGNMENT": "Enable auto assignment",
"INBOX_UPDATE_TITLE": "Inbox Settings",
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
- "AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox."
+ "AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
+ "HMAC_VERIFICATION": "User Identity Validation",
+ "HMAC_DESCRIPTION": "Inorder validate the users identity, the SDK allows you to pass an `identity_hash` for each user. You can generate HMAC using 'sha256' with the key shown here."
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
index ff16700bf..c45a03b45 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
@@ -241,6 +241,13 @@
>
+
+
+
+
diff --git a/app/javascript/packs/sdk.js b/app/javascript/packs/sdk.js
index b1584a5fa..ab02f1f3a 100755
--- a/app/javascript/packs/sdk.js
+++ b/app/javascript/packs/sdk.js
@@ -3,7 +3,9 @@ import { IFrameHelper } from '../sdk/IFrameHelper';
import { getBubbleView } from '../sdk/bubbleHelpers';
import md5 from 'md5';
-const ALLOWED_LIST_OF_SET_USER_ATTRIBUTES = ['avatar_url', 'email', 'name'];
+const REQUIRED_USER_KEYS = ['avatar_url', 'email', 'name'];
+
+const ALLOWED_USER_ATTRIBUTES = [...REQUIRED_USER_KEYS, 'identifier_hash'];
export const getUserCookieName = () => {
const SET_USER_COOKIE_PREFIX = 'cw_user_';
@@ -12,7 +14,7 @@ export const getUserCookieName = () => {
};
export const getUserString = ({ identifier = '', user }) => {
- const userStringWithSortedKeys = ALLOWED_LIST_OF_SET_USER_ATTRIBUTES.reduce(
+ const userStringWithSortedKeys = ALLOWED_USER_ATTRIBUTES.reduce(
(acc, key) => `${acc}${key}${user[key] || ''}`,
''
);
@@ -22,10 +24,7 @@ export const getUserString = ({ identifier = '', user }) => {
const computeHashForUserData = (...args) => md5(getUserString(...args));
export const hasUserKeys = user =>
- ALLOWED_LIST_OF_SET_USER_ATTRIBUTES.reduce(
- (acc, key) => acc || !!user[key],
- false
- );
+ REQUIRED_USER_KEYS.reduce((acc, key) => acc || !!user[key], false);
const runSDK = ({ baseUrl, websiteToken }) => {
const chatwootSettings = window.chatwootSettings || {};
diff --git a/app/javascript/specs/packs/sdk.spec.js b/app/javascript/specs/packs/sdk.spec.js
index 62e267e43..68663e82f 100644
--- a/app/javascript/specs/packs/sdk.spec.js
+++ b/app/javascript/specs/packs/sdk.spec.js
@@ -15,11 +15,12 @@ describe('#getUserString', () => {
name: 'Pranav',
email: 'pranav@example.com',
avatar_url: 'https://images.chatwoot.com/placeholder',
+ identifier_hash: '12345',
},
identifier: '12345',
})
).toBe(
- 'avatar_urlhttps://images.chatwoot.com/placeholderemailpranav@example.comnamePranavidentifier12345'
+ 'avatar_urlhttps://images.chatwoot.com/placeholderemailpranav@example.comnamePranavidentifier_hash12345identifier12345'
);
expect(
@@ -30,7 +31,7 @@ describe('#getUserString', () => {
},
})
).toBe(
- 'avatar_urlhttps://images.chatwoot.com/placeholderemailpranav@example.comnameidentifier'
+ 'avatar_urlhttps://images.chatwoot.com/placeholderemailpranav@example.comnameidentifier_hashidentifier'
);
});
});
diff --git a/app/javascript/widget/store/modules/contacts.js b/app/javascript/widget/store/modules/contacts.js
index d50b16f73..0ecae3a55 100644
--- a/app/javascript/widget/store/modules/contacts.js
+++ b/app/javascript/widget/store/modules/contacts.js
@@ -2,16 +2,23 @@ import ContactsAPI from '../../api/contacts';
import { refreshActionCableConnector } from '../../helpers/actionCable';
export const actions = {
- update: async (_, { identifier, user: userObject }) => {
+ update: async ({ dispatch }, { identifier, user: userObject }) => {
try {
const user = {
email: userObject.email,
name: userObject.name,
avatar_url: userObject.avatar_url,
+ identifier_hash: userObject.identifier_hash,
};
const {
data: { pubsub_token: pubsubToken },
} = await ContactsAPI.update(identifier, user);
+
+ if (userObject.identifier_hash) {
+ dispatch('conversation/clearConversations', {}, { root: true });
+ dispatch('conversation/fetchOldConversations', {}, { root: true });
+ }
+
refreshActionCableConnector(pubsubToken);
} catch (error) {
// Ingore error
diff --git a/app/javascript/widget/store/modules/conversation.js b/app/javascript/widget/store/modules/conversation.js
deleted file mode 100755
index 7d37dcdb1..000000000
--- a/app/javascript/widget/store/modules/conversation.js
+++ /dev/null
@@ -1,288 +0,0 @@
-/* eslint-disable no-param-reassign */
-import Vue from 'vue';
-import {
- sendMessageAPI,
- getMessagesAPI,
- sendAttachmentAPI,
- toggleTyping,
- setUserLastSeenAt,
-} from 'widget/api/conversation';
-import { MESSAGE_TYPE } from 'widget/helpers/constants';
-import { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper';
-import { formatUnixDate } from 'shared/helpers/DateHelper';
-import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
-
-import getUuid from '../../helpers/uuid';
-const groupBy = require('lodash.groupby');
-
-export const createTemporaryMessage = ({ attachments, content }) => {
- const timestamp = new Date().getTime() / 1000;
- return {
- id: getUuid(),
- content,
- attachments,
- status: 'in_progress',
- created_at: timestamp,
- message_type: MESSAGE_TYPE.INCOMING,
- };
-};
-
-const getSenderName = message => (message.sender ? message.sender.name : '');
-
-const shouldShowAvatar = (message, nextMessage) => {
- const currentSender = getSenderName(message);
- const nextSender = getSenderName(nextMessage);
-
- return (
- currentSender !== nextSender ||
- message.message_type !== nextMessage.message_type ||
- isASubmittedFormMessage(nextMessage)
- );
-};
-
-const groupConversationBySender = conversationsForADate =>
- conversationsForADate.map((message, index) => {
- let showAvatar = false;
- const isLastMessage = index === conversationsForADate.length - 1;
- if (isASubmittedFormMessage(message)) {
- showAvatar = false;
- } else if (isLastMessage) {
- showAvatar = true;
- } else {
- const nextMessage = conversationsForADate[index + 1];
- showAvatar = shouldShowAvatar(message, nextMessage);
- }
- return { showAvatar, ...message };
- });
-
-export const findUndeliveredMessage = (messageInbox, { content }) =>
- Object.values(messageInbox).filter(
- message => message.content === content && message.status === 'in_progress'
- );
-
-export const onNewMessageCreated = data => {
- const { message_type: messageType } = data;
- const isIncomingMessage = messageType === MESSAGE_TYPE.OUTGOING;
-
- if (isIncomingMessage) {
- playNotificationAudio();
- }
-};
-
-export const DEFAULT_CONVERSATION = 'default';
-
-const state = {
- conversations: {},
- meta: {
- userLastSeenAt: undefined,
- },
- uiFlags: {
- allMessagesLoaded: false,
- isFetchingList: false,
- isAgentTyping: false,
- },
-};
-
-export const getters = {
- getAllMessagesLoaded: _state => _state.uiFlags.allMessagesLoaded,
- getIsAgentTyping: _state => _state.uiFlags.isAgentTyping,
- getConversation: _state => _state.conversations,
- getConversationSize: _state => Object.keys(_state.conversations).length,
- getEarliestMessage: _state => {
- const conversation = Object.values(_state.conversations);
- if (conversation.length) {
- return conversation[0];
- }
- return {};
- },
- getGroupedConversation: _state => {
- const conversationGroupedByDate = groupBy(
- Object.values(_state.conversations),
- message => formatUnixDate(message.created_at)
- );
- return Object.keys(conversationGroupedByDate).map(date => ({
- date,
- messages: groupConversationBySender(conversationGroupedByDate[date]),
- }));
- },
- getIsFetchingList: _state => _state.uiFlags.isFetchingList,
- getUnreadMessageCount: _state => {
- const { userLastSeenAt } = _state.meta;
- const count = Object.values(_state.conversations).filter(chat => {
- const { created_at: createdAt, message_type: messageType } = chat;
- const isOutGoing = messageType === MESSAGE_TYPE.OUTGOING;
- const hasNotSeen = userLastSeenAt
- ? createdAt * 1000 > userLastSeenAt * 1000
- : true;
- return hasNotSeen && isOutGoing;
- }).length;
- return count;
- },
- getUnreadTextMessages: (_state, _getters) => {
- const unreadCount = _getters.getUnreadMessageCount;
- const allMessages = [...Object.values(_state.conversations)];
- const unreadAgentMessages = allMessages.filter(message => {
- const { message_type: messageType } = message;
- return messageType === MESSAGE_TYPE.OUTGOING;
- });
- const maxUnreadCount = Math.min(unreadCount, 3);
- const allUnreadMessages = unreadAgentMessages.splice(-maxUnreadCount);
- return allUnreadMessages;
- },
-};
-
-export const actions = {
- sendMessage: async ({ commit }, params) => {
- const { content } = params;
- commit('pushMessageToConversation', createTemporaryMessage({ content }));
- await sendMessageAPI(content);
- },
-
- sendAttachment: async ({ commit }, params) => {
- const {
- attachment: { thumbUrl, fileType },
- } = params;
- const attachment = {
- thumb_url: thumbUrl,
- data_url: thumbUrl,
- file_type: fileType,
- status: 'in_progress',
- };
- const tempMessage = createTemporaryMessage({
- attachments: [attachment],
- });
- commit('pushMessageToConversation', tempMessage);
- try {
- const { data } = await sendAttachmentAPI(params);
- commit('updateAttachmentMessageStatus', {
- message: data,
- tempId: tempMessage.id,
- });
- } catch (error) {
- // Show error
- }
- },
-
- fetchOldConversations: async ({ commit }, { before } = {}) => {
- try {
- commit('setConversationListLoading', true);
- const { data } = await getMessagesAPI({ before });
- commit('setMessagesInConversation', data);
- commit('setConversationListLoading', false);
- } catch (error) {
- commit('setConversationListLoading', false);
- }
- },
-
- addMessage: async ({ commit }, data) => {
- commit('pushMessageToConversation', data);
- onNewMessageCreated(data);
- },
-
- updateMessage({ commit }, data) {
- commit('pushMessageToConversation', data);
- },
-
- toggleAgentTyping({ commit }, data) {
- commit('toggleAgentTypingStatus', data);
- },
-
- toggleUserTyping: async (_, data) => {
- try {
- await toggleTyping(data);
- } catch (error) {
- // IgnoreError
- }
- },
-
- setUserLastSeen: async ({ commit, getters: appGetters }) => {
- if (!appGetters.getConversationSize) {
- return;
- }
-
- const lastSeen = Date.now() / 1000;
- try {
- commit('setMetaUserLastSeenAt', lastSeen);
- await setUserLastSeenAt({ lastSeen });
- } catch (error) {
- // IgnoreError
- }
- },
-};
-
-export const mutations = {
- pushMessageToConversation($state, message) {
- const { id, status, message_type: type } = message;
- const messagesInbox = $state.conversations;
- const isMessageIncoming = type === MESSAGE_TYPE.INCOMING;
- const isTemporaryMessage = status === 'in_progress';
-
- if (!isMessageIncoming || isTemporaryMessage) {
- Vue.set(messagesInbox, id, message);
- return;
- }
-
- const [messageInConversation] = findUndeliveredMessage(
- messagesInbox,
- message
- );
- if (!messageInConversation) {
- Vue.set(messagesInbox, id, message);
- } else {
- Vue.delete(messagesInbox, messageInConversation.id);
- Vue.set(messagesInbox, id, message);
- }
- },
-
- updateAttachmentMessageStatus($state, { message, tempId }) {
- const { id } = message;
- const messagesInbox = $state.conversations;
-
- const messageInConversation = messagesInbox[tempId];
-
- if (messageInConversation) {
- Vue.delete(messagesInbox, tempId);
- Vue.set(messagesInbox, id, { ...message });
- }
- },
-
- setConversationListLoading($state, status) {
- $state.uiFlags.isFetchingList = status;
- },
-
- setMessagesInConversation($state, payload) {
- if (!payload.length) {
- $state.uiFlags.allMessagesLoaded = true;
- return;
- }
-
- payload.map(message => Vue.set($state.conversations, message.id, message));
- },
-
- updateMessage($state, { id, content_attributes }) {
- $state.conversations[id] = {
- ...$state.conversations[id],
- content_attributes: {
- ...($state.conversations[id].content_attributes || {}),
- ...content_attributes,
- },
- };
- },
-
- toggleAgentTypingStatus($state, { status }) {
- const isTyping = status === 'on';
- $state.uiFlags.isAgentTyping = isTyping;
- },
-
- setMetaUserLastSeenAt($state, lastSeen) {
- $state.meta.userLastSeenAt = lastSeen;
- },
-};
-
-export default {
- namespaced: true,
- state,
- getters,
- actions,
- mutations,
-};
diff --git a/app/javascript/widget/store/modules/conversation/actions.js b/app/javascript/widget/store/modules/conversation/actions.js
new file mode 100644
index 000000000..1fb896538
--- /dev/null
+++ b/app/javascript/widget/store/modules/conversation/actions.js
@@ -0,0 +1,92 @@
+import {
+ sendMessageAPI,
+ getMessagesAPI,
+ sendAttachmentAPI,
+ toggleTyping,
+ setUserLastSeenAt,
+} from 'widget/api/conversation';
+
+import { createTemporaryMessage, onNewMessageCreated } from './helpers';
+
+export const actions = {
+ sendMessage: async ({ commit }, params) => {
+ const { content } = params;
+ commit('pushMessageToConversation', createTemporaryMessage({ content }));
+ await sendMessageAPI(content);
+ },
+
+ sendAttachment: async ({ commit }, params) => {
+ const {
+ attachment: { thumbUrl, fileType },
+ } = params;
+ const attachment = {
+ thumb_url: thumbUrl,
+ data_url: thumbUrl,
+ file_type: fileType,
+ status: 'in_progress',
+ };
+ const tempMessage = createTemporaryMessage({
+ attachments: [attachment],
+ });
+ commit('pushMessageToConversation', tempMessage);
+ try {
+ const { data } = await sendAttachmentAPI(params);
+ commit('updateAttachmentMessageStatus', {
+ message: data,
+ tempId: tempMessage.id,
+ });
+ } catch (error) {
+ // Show error
+ }
+ },
+
+ fetchOldConversations: async ({ commit }, { before } = {}) => {
+ try {
+ commit('setConversationListLoading', true);
+ const { data } = await getMessagesAPI({ before });
+ commit('setMessagesInConversation', data);
+ commit('setConversationListLoading', false);
+ } catch (error) {
+ commit('setConversationListLoading', false);
+ }
+ },
+
+ clearConversations: ({ commit }) => {
+ commit('clearConversations');
+ },
+
+ addMessage: async ({ commit }, data) => {
+ commit('pushMessageToConversation', data);
+ onNewMessageCreated(data);
+ },
+
+ updateMessage({ commit }, data) {
+ commit('pushMessageToConversation', data);
+ },
+
+ toggleAgentTyping({ commit }, data) {
+ commit('toggleAgentTypingStatus', data);
+ },
+
+ toggleUserTyping: async (_, data) => {
+ try {
+ await toggleTyping(data);
+ } catch (error) {
+ // IgnoreError
+ }
+ },
+
+ setUserLastSeen: async ({ commit, getters: appGetters }) => {
+ if (!appGetters.getConversationSize) {
+ return;
+ }
+
+ const lastSeen = Date.now() / 1000;
+ try {
+ commit('setMetaUserLastSeenAt', lastSeen);
+ await setUserLastSeenAt({ lastSeen });
+ } catch (error) {
+ // IgnoreError
+ }
+ },
+};
diff --git a/app/javascript/widget/store/modules/conversation/getters.js b/app/javascript/widget/store/modules/conversation/getters.js
new file mode 100644
index 000000000..afe58a0ed
--- /dev/null
+++ b/app/javascript/widget/store/modules/conversation/getters.js
@@ -0,0 +1,52 @@
+import { MESSAGE_TYPE } from 'widget/helpers/constants';
+import groupBy from 'lodash.groupby';
+import { groupConversationBySender } from './helpers';
+import { formatUnixDate } from 'shared/helpers/DateHelper';
+
+export const getters = {
+ getAllMessagesLoaded: _state => _state.uiFlags.allMessagesLoaded,
+ getIsAgentTyping: _state => _state.uiFlags.isAgentTyping,
+ getConversation: _state => _state.conversations,
+ getConversationSize: _state => Object.keys(_state.conversations).length,
+ getEarliestMessage: _state => {
+ const conversation = Object.values(_state.conversations);
+ if (conversation.length) {
+ return conversation[0];
+ }
+ return {};
+ },
+ getGroupedConversation: _state => {
+ const conversationGroupedByDate = groupBy(
+ Object.values(_state.conversations),
+ message => formatUnixDate(message.created_at)
+ );
+ return Object.keys(conversationGroupedByDate).map(date => ({
+ date,
+ messages: groupConversationBySender(conversationGroupedByDate[date]),
+ }));
+ },
+ getIsFetchingList: _state => _state.uiFlags.isFetchingList,
+ getUnreadMessageCount: _state => {
+ const { userLastSeenAt } = _state.meta;
+ const count = Object.values(_state.conversations).filter(chat => {
+ const { created_at: createdAt, message_type: messageType } = chat;
+ const isOutGoing = messageType === MESSAGE_TYPE.OUTGOING;
+ const hasNotSeen = userLastSeenAt
+ ? createdAt * 1000 > userLastSeenAt * 1000
+ : true;
+ return hasNotSeen && isOutGoing;
+ }).length;
+ return count;
+ },
+ getUnreadTextMessages: (_state, _getters) => {
+ const unreadCount = _getters.getUnreadMessageCount;
+ const allMessages = [...Object.values(_state.conversations)];
+ const unreadAgentMessages = allMessages.filter(message => {
+ const { message_type: messageType } = message;
+ return messageType === MESSAGE_TYPE.OUTGOING;
+ });
+ const maxUnreadCount = Math.min(unreadCount, 3);
+ const allUnreadMessages = unreadAgentMessages.splice(-maxUnreadCount);
+ return allUnreadMessages;
+ },
+};
diff --git a/app/javascript/widget/store/modules/conversation/helpers.js b/app/javascript/widget/store/modules/conversation/helpers.js
new file mode 100644
index 000000000..afffeed62
--- /dev/null
+++ b/app/javascript/widget/store/modules/conversation/helpers.js
@@ -0,0 +1,58 @@
+import { MESSAGE_TYPE } from 'widget/helpers/constants';
+import { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper';
+import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
+
+import getUuid from '../../../helpers/uuid';
+export const createTemporaryMessage = ({ attachments, content }) => {
+ const timestamp = new Date().getTime() / 1000;
+ return {
+ id: getUuid(),
+ content,
+ attachments,
+ status: 'in_progress',
+ created_at: timestamp,
+ message_type: MESSAGE_TYPE.INCOMING,
+ };
+};
+
+const getSenderName = message => (message.sender ? message.sender.name : '');
+
+const shouldShowAvatar = (message, nextMessage) => {
+ const currentSender = getSenderName(message);
+ const nextSender = getSenderName(nextMessage);
+
+ return (
+ currentSender !== nextSender ||
+ message.message_type !== nextMessage.message_type ||
+ isASubmittedFormMessage(nextMessage)
+ );
+};
+
+export const groupConversationBySender = conversationsForADate =>
+ conversationsForADate.map((message, index) => {
+ let showAvatar = false;
+ const isLastMessage = index === conversationsForADate.length - 1;
+ if (isASubmittedFormMessage(message)) {
+ showAvatar = false;
+ } else if (isLastMessage) {
+ showAvatar = true;
+ } else {
+ const nextMessage = conversationsForADate[index + 1];
+ showAvatar = shouldShowAvatar(message, nextMessage);
+ }
+ return { showAvatar, ...message };
+ });
+
+export const findUndeliveredMessage = (messageInbox, { content }) =>
+ Object.values(messageInbox).filter(
+ message => message.content === content && message.status === 'in_progress'
+ );
+
+export const onNewMessageCreated = data => {
+ const { message_type: messageType } = data;
+ const isIncomingMessage = messageType === MESSAGE_TYPE.OUTGOING;
+
+ if (isIncomingMessage) {
+ playNotificationAudio();
+ }
+};
diff --git a/app/javascript/widget/store/modules/conversation/index.js b/app/javascript/widget/store/modules/conversation/index.js
new file mode 100755
index 000000000..83bbc65ee
--- /dev/null
+++ b/app/javascript/widget/store/modules/conversation/index.js
@@ -0,0 +1,23 @@
+import { getters } from './getters';
+import { actions } from './actions';
+import { mutations } from './mutations';
+
+const state = {
+ conversations: {},
+ meta: {
+ userLastSeenAt: undefined,
+ },
+ uiFlags: {
+ allMessagesLoaded: false,
+ isFetchingList: false,
+ isAgentTyping: false,
+ },
+};
+
+export default {
+ namespaced: true,
+ state,
+ getters,
+ actions,
+ mutations,
+};
diff --git a/app/javascript/widget/store/modules/conversation/mutations.js b/app/javascript/widget/store/modules/conversation/mutations.js
new file mode 100644
index 000000000..296706171
--- /dev/null
+++ b/app/javascript/widget/store/modules/conversation/mutations.js
@@ -0,0 +1,75 @@
+import Vue from 'vue';
+import { MESSAGE_TYPE } from 'widget/helpers/constants';
+import { findUndeliveredMessage } from './helpers';
+
+export const mutations = {
+ clearConversations($state) {
+ Vue.set($state, 'conversations', {});
+ },
+ pushMessageToConversation($state, message) {
+ const { id, status, message_type: type } = message;
+ const messagesInbox = $state.conversations;
+ const isMessageIncoming = type === MESSAGE_TYPE.INCOMING;
+ const isTemporaryMessage = status === 'in_progress';
+
+ if (!isMessageIncoming || isTemporaryMessage) {
+ Vue.set(messagesInbox, id, message);
+ return;
+ }
+
+ const [messageInConversation] = findUndeliveredMessage(
+ messagesInbox,
+ message
+ );
+ if (!messageInConversation) {
+ Vue.set(messagesInbox, id, message);
+ } else {
+ Vue.delete(messagesInbox, messageInConversation.id);
+ Vue.set(messagesInbox, id, message);
+ }
+ },
+
+ updateAttachmentMessageStatus($state, { message, tempId }) {
+ const { id } = message;
+ const messagesInbox = $state.conversations;
+
+ const messageInConversation = messagesInbox[tempId];
+
+ if (messageInConversation) {
+ Vue.delete(messagesInbox, tempId);
+ Vue.set(messagesInbox, id, { ...message });
+ }
+ },
+
+ setConversationListLoading($state, status) {
+ $state.uiFlags.isFetchingList = status;
+ },
+
+ setMessagesInConversation($state, payload) {
+ if (!payload.length) {
+ $state.uiFlags.allMessagesLoaded = true;
+ return;
+ }
+
+ payload.map(message => Vue.set($state.conversations, message.id, message));
+ },
+
+ updateMessage($state, { id, content_attributes }) {
+ $state.conversations[id] = {
+ ...$state.conversations[id],
+ content_attributes: {
+ ...($state.conversations[id].content_attributes || {}),
+ ...content_attributes,
+ },
+ };
+ },
+
+ toggleAgentTypingStatus($state, { status }) {
+ const isTyping = status === 'on';
+ $state.uiFlags.isAgentTyping = isTyping;
+ },
+
+ setMetaUserLastSeenAt($state, lastSeen) {
+ $state.meta.userLastSeenAt = lastSeen;
+ },
+};
diff --git a/app/javascript/widget/store/modules/specs/conversation/actions.spec.js b/app/javascript/widget/store/modules/specs/conversation/actions.spec.js
index 5a5c1e16a..ebe98b2d9 100644
--- a/app/javascript/widget/store/modules/specs/conversation/actions.spec.js
+++ b/app/javascript/widget/store/modules/specs/conversation/actions.spec.js
@@ -1,5 +1,5 @@
import { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper';
-import { actions } from '../../conversation';
+import { actions } from '../../conversation/actions';
import getUuid from '../../../../helpers/uuid';
import { API } from 'widget/helpers/axios';
@@ -121,4 +121,11 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([]);
});
});
+
+ describe('#clearConversations', () => {
+ it('sends correct mutations', () => {
+ actions.clearConversations({ commit });
+ expect(commit).toBeCalledWith('clearConversations');
+ });
+ });
});
diff --git a/app/javascript/widget/store/modules/specs/conversation/getters.spec.js b/app/javascript/widget/store/modules/specs/conversation/getters.spec.js
index 2eb448a09..85af958ae 100644
--- a/app/javascript/widget/store/modules/specs/conversation/getters.spec.js
+++ b/app/javascript/widget/store/modules/specs/conversation/getters.spec.js
@@ -1,4 +1,4 @@
-import { getters } from '../../conversation';
+import { getters } from '../../conversation/getters';
describe('#getters', () => {
it('getConversation', () => {
diff --git a/app/javascript/widget/store/modules/specs/conversation/utils.spec.js b/app/javascript/widget/store/modules/specs/conversation/helpers.spec.js
similarity index 95%
rename from app/javascript/widget/store/modules/specs/conversation/utils.spec.js
rename to app/javascript/widget/store/modules/specs/conversation/helpers.spec.js
index 1a09f3f64..fd9bab99e 100644
--- a/app/javascript/widget/store/modules/specs/conversation/utils.spec.js
+++ b/app/javascript/widget/store/modules/specs/conversation/helpers.spec.js
@@ -1,7 +1,7 @@
import {
findUndeliveredMessage,
createTemporaryMessage,
-} from '../../conversation';
+} from '../../conversation/helpers';
describe('#findUndeliveredMessage', () => {
it('returns message objects if exist', () => {
diff --git a/app/javascript/widget/store/modules/specs/conversation/mutations.spec.js b/app/javascript/widget/store/modules/specs/conversation/mutations.spec.js
index 1ce076638..c8b4873e1 100644
--- a/app/javascript/widget/store/modules/specs/conversation/mutations.spec.js
+++ b/app/javascript/widget/store/modules/specs/conversation/mutations.spec.js
@@ -1,4 +1,4 @@
-import { mutations } from '../../conversation';
+import { mutations } from '../../conversation/mutations';
const temporaryMessagePayload = {
content: 'hello',
@@ -156,4 +156,12 @@ describe('#mutations', () => {
});
});
});
+
+ describe('#clearConversations', () => {
+ it('clears the state', () => {
+ const state = { conversations: { 1: { id: 1 } } };
+ mutations.clearConversations(state);
+ expect(state.conversations).toEqual({});
+ });
+ });
});
diff --git a/app/models/channel/web_widget.rb b/app/models/channel/web_widget.rb
index 084c09703..377ffbb33 100644
--- a/app/models/channel/web_widget.rb
+++ b/app/models/channel/web_widget.rb
@@ -4,6 +4,7 @@
#
# id :integer not null, primary key
# feature_flags :integer default(3), not null
+# hmac_token :string
# reply_time :integer default("in_a_few_minutes")
# website_token :string
# website_url :string
@@ -16,6 +17,7 @@
#
# Indexes
#
+# index_channel_web_widgets_on_hmac_token (hmac_token) UNIQUE
# index_channel_web_widgets_on_website_token (website_token) UNIQUE
#
@@ -30,6 +32,8 @@ class Channel::WebWidget < ApplicationRecord
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy
has_secure_token :website_token
+ has_secure_token :hmac_token
+
has_flags 1 => :attachments,
2 => :emoji_picker,
:column => 'feature_flags'
diff --git a/app/models/contact_inbox.rb b/app/models/contact_inbox.rb
index c528a9af0..2fe1a76c5 100644
--- a/app/models/contact_inbox.rb
+++ b/app/models/contact_inbox.rb
@@ -2,12 +2,13 @@
#
# Table name: contact_inboxes
#
-# id :bigint not null, primary key
-# created_at :datetime not null
-# updated_at :datetime not null
-# contact_id :bigint
-# inbox_id :bigint
-# source_id :string not null
+# id :bigint not null, primary key
+# hmac_verified :boolean default(FALSE)
+# created_at :datetime not null
+# updated_at :datetime not null
+# contact_id :bigint
+# inbox_id :bigint
+# source_id :string not null
#
# Indexes
#
diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder
index 7fda9cd28..7c580aced 100644
--- a/app/views/api/v1/models/_inbox.json.jbuilder
+++ b/app/views/api/v1/models/_inbox.json.jbuilder
@@ -17,3 +17,4 @@ json.phone_number resource.channel.try(:phone_number)
json.selected_feature_flags resource.channel.try(:selected_feature_flags)
json.reply_time resource.channel.try(:reply_time)
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.facebook?
+json.hmac_token resource.channel.try(:hmac_token) if resource.web_widget?
diff --git a/app/views/widget_tests/index.html.erb b/app/views/widget_tests/index.html.erb
index 256167861..048fb2498 100644
--- a/app/views/widget_tests/index.html.erb
+++ b/app/views/widget_tests/index.html.erb
@@ -1,5 +1,15 @@
+
+<%
+ user_id = 1
+ user_hash = OpenSSL::HMAC.hexdigest(
+ 'sha256',
+ @web_widget.hmac_token,
+ user_id.to_s
+ )
+
+%>
diff --git a/db/migrate/20210112174124_add_hmac_token_to_inbox.rb b/db/migrate/20210112174124_add_hmac_token_to_inbox.rb
new file mode 100644
index 000000000..cd614cd16
--- /dev/null
+++ b/db/migrate/20210112174124_add_hmac_token_to_inbox.rb
@@ -0,0 +1,15 @@
+class AddHmacTokenToInbox < ActiveRecord::Migration[6.0]
+ def change
+ add_column :channel_web_widgets, :hmac_token, :string
+ add_index :channel_web_widgets, :hmac_token, unique: true
+ set_up_existing_webwidgets
+ add_column :contact_inboxes, :hmac_verified, :boolean, default: false
+ end
+
+ def set_up_existing_webwidgets
+ ::Channel::WebWidget.find_in_batches do |webwidgets_batch|
+ Rails.logger.info "migrated till #{webwidgets_batch.first.id}\n"
+ webwidgets_batch.map(&:regenerate_hmac_token)
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 0cb2734a8..150771b94 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -182,6 +182,8 @@ ActiveRecord::Schema.define(version: 2021_01_13_045116) do
t.string "welcome_tagline"
t.integer "feature_flags", default: 3, null: false
t.integer "reply_time", default: 0
+ t.string "hmac_token"
+ 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
@@ -191,6 +193,7 @@ ActiveRecord::Schema.define(version: 2021_01_13_045116) do
t.string "source_id", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
+ t.boolean "hmac_verified", default: false
t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id"
t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true
t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id"
diff --git a/docs/channels/identity-validation.md b/docs/channels/identity-validation.md
new file mode 100644
index 000000000..d4a2adf45
--- /dev/null
+++ b/docs/channels/identity-validation.md
@@ -0,0 +1,84 @@
+---
+path: '/docs/website-sdk/identity-validation'
+title: 'Identity validation in Chatwoot'
+---
+
+To make sure the conversations between the customers and the support agents are private and to disallow impersonation, you can setup identity validation Chatwoot.
+
+Identity validation can be enabled by generating an HMAC. The key used to generate HMAC for each webwidget is different and can be copied from Inboxes -> Settings -> Configuration -> Identity Validation -> Copy the token shown there
+
+You can generate HMAC in different languages as shown below.
+
+
+```php
+
+```
+
+```js
+const crypto = require('crypto');
+
+const key = 'webwidget.hmac_token';
+const message = 'identifier';
+
+const hash = crypto.createHmac('sha256', key).update(message);
+
+hash.digest('hex');
+```
+
+```rb
+require 'openssl'
+require 'base64'
+
+key = 'webwidget.hmac_token'
+message = 'identifier'
+
+OpenSSL::HMAC.hexdigest('sha256', key, message)
+```
+
+```elixir
+key = 'webwidget.hmac_token'
+message = 'identifier'
+
+signature = :crypto.hmac(:sha256, key, message)
+
+Base.encode16(signature, case: :lower)
+```
+
+
+```go
+package main
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+)
+
+func main() {
+ secret := []byte("webwidget.hmac_token")
+ message := []byte("identifier")
+
+ hash := hmac.New(sha256.New, secret)
+ hash.Write(message)
+ hex.EncodeToString(hash.Sum(nil))
+}
+```
+
+```py
+import hashlib
+import hmac
+import base64
+
+message = bytes('webwidget.hmac_token', 'utf-8')
+secret = bytes('identifier', 'utf-8')
+
+hash = hmac.new(secret, message, hashlib.sha256)
+hash.hexdigest()
+```
diff --git a/docs/channels/website-sdk.md b/docs/channels/website-sdk.md
index 094710b22..7c76989aa 100644
--- a/docs/channels/website-sdk.md
+++ b/docs/channels/website-sdk.md
@@ -77,6 +77,21 @@ window.$chatwoot.setUser('', {
Make sure that you reset the session when the user logs out of your app.
+### Identity validation
+
+To disallow impersonation and to keep the conversation with your customers private, we recommend setting up the identity validation in Chatwoot. Identity validation is enabled by generating an HMAC(hash based message authentication code) based on the `identifier` attribute, using SHA256. Along with the `identifier` you can pass `identifier_hash` also as shown below to make sure that the user is correct one.
+
+```js
+window.$chatwoot.setUser(`identifier-hash`, {
+ name: '', // Name of the user
+ avatar_url: '', // Avatar URL
+ email: '', // Email of the user
+ identifier_hash: '' // Identifier Hash generated based on the webwidget hmac_token
+})
+```
+
+To generate HMAC, read [identity validation](/website-sdk/identity-validation)
+
### Set custom attributes
Inorder to set additional information about the customer you can use customer attributes field.