feat: HMAC verification for web widget (#1643)
* feat: HMAC verification for web widget. Let you verify the authenticated contact via HMAC on the web widget to prevent data tampering. * Add docs for identity-validation Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
parent
d758df8807
commit
b6e8173b24
26 changed files with 517 additions and 311 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -42,5 +42,9 @@ export default {
|
|||
font-weight: $font-weight-medium;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.title--section {
|
||||
padding-right: var(--space-large);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -241,6 +241,13 @@
|
|||
>
|
||||
<woot-code :script="inbox.web_widget_script"></woot-code>
|
||||
</settings-section>
|
||||
|
||||
<settings-section
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_DESCRIPTION')"
|
||||
>
|
||||
<woot-code :script="inbox.hmac_token"></woot-code>
|
||||
</settings-section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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 || {};
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
};
|
92
app/javascript/widget/store/modules/conversation/actions.js
Normal file
92
app/javascript/widget/store/modules/conversation/actions.js
Normal file
|
@ -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
|
||||
}
|
||||
},
|
||||
};
|
52
app/javascript/widget/store/modules/conversation/getters.js
Normal file
52
app/javascript/widget/store/modules/conversation/getters.js
Normal file
|
@ -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;
|
||||
},
|
||||
};
|
58
app/javascript/widget/store/modules/conversation/helpers.js
Normal file
58
app/javascript/widget/store/modules/conversation/helpers.js
Normal file
|
@ -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();
|
||||
}
|
||||
};
|
23
app/javascript/widget/store/modules/conversation/index.js
Executable file
23
app/javascript/widget/store/modules/conversation/index.js
Executable file
|
@ -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,
|
||||
};
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { getters } from '../../conversation';
|
||||
import { getters } from '../../conversation/getters';
|
||||
|
||||
describe('#getters', () => {
|
||||
it('getConversation', () => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
findUndeliveredMessage,
|
||||
createTemporaryMessage,
|
||||
} from '../../conversation';
|
||||
} from '../../conversation/helpers';
|
||||
|
||||
describe('#findUndeliveredMessage', () => {
|
||||
it('returns message objects if exist', () => {
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
#
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -1,5 +1,15 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
|
||||
|
||||
|
||||
<%
|
||||
user_id = 1
|
||||
user_hash = OpenSSL::HMAC.hexdigest(
|
||||
'sha256',
|
||||
@web_widget.hmac_token,
|
||||
user_id.to_s
|
||||
)
|
||||
|
||||
%>
|
||||
<script>
|
||||
|
||||
window.chatwootSettings = {
|
||||
|
@ -24,6 +34,11 @@ window.chatwootSettings = {
|
|||
})(document,"script");
|
||||
|
||||
window.addEventListener('chatwoot:ready', function() {
|
||||
console.log(window.$chatwoot)
|
||||
console.log(window.$chatwoot);
|
||||
window.$chatwoot.setUser('<%= user_id %>', {
|
||||
identifier_hash: '<%= user_hash %>',
|
||||
email: 'jane@acme.inc',
|
||||
name: 'Jane Doe'
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
|
15
db/migrate/20210112174124_add_hmac_token_to_inbox.rb
Normal file
15
db/migrate/20210112174124_add_hmac_token_to_inbox.rb
Normal file
|
@ -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
|
|
@ -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"
|
||||
|
|
84
docs/channels/identity-validation.md
Normal file
84
docs/channels/identity-validation.md
Normal file
|
@ -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
|
||||
<?php
|
||||
|
||||
$key = 'webwidget.hmac_token';
|
||||
$message = 'identifier';
|
||||
|
||||
$identifier_hash = hash_hmac('sha256', $message, $key);
|
||||
?>
|
||||
```
|
||||
|
||||
```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()
|
||||
```
|
|
@ -77,6 +77,21 @@ window.$chatwoot.setUser('<unique-identifier-key-of-the-user>', {
|
|||
|
||||
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.
|
||||
|
|
Loading…
Reference in a new issue