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
|
private
|
||||||
|
|
||||||
def conversations
|
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
|
end
|
||||||
|
|
||||||
def conversation
|
def conversation
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||||
def update
|
def update
|
||||||
|
process_hmac
|
||||||
contact_identify_action = ContactIdentifyAction.new(
|
contact_identify_action = ContactIdentifyAction.new(
|
||||||
contact: @contact,
|
contact: @contact,
|
||||||
params: permitted_params.to_h.deep_symbolize_keys
|
params: permitted_params.to_h.deep_symbolize_keys
|
||||||
|
@ -9,7 +10,22 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||||
|
|
||||||
private
|
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
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -42,5 +42,9 @@ export default {
|
||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-medium;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title--section {
|
||||||
|
padding-right: var(--space-large);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -241,7 +241,9 @@
|
||||||
"AUTO_ASSIGNMENT": "Enable auto assignment",
|
"AUTO_ASSIGNMENT": "Enable auto assignment",
|
||||||
"INBOX_UPDATE_TITLE": "Inbox Settings",
|
"INBOX_UPDATE_TITLE": "Inbox Settings",
|
||||||
"INBOX_UPDATE_SUB_TEXT": "Update your 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": {
|
"FACEBOOK_REAUTHORIZE": {
|
||||||
"TITLE": "Reauthorize",
|
"TITLE": "Reauthorize",
|
||||||
|
|
|
@ -241,6 +241,13 @@
|
||||||
>
|
>
|
||||||
<woot-code :script="inbox.web_widget_script"></woot-code>
|
<woot-code :script="inbox.web_widget_script"></woot-code>
|
||||||
</settings-section>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,7 +3,9 @@ import { IFrameHelper } from '../sdk/IFrameHelper';
|
||||||
import { getBubbleView } from '../sdk/bubbleHelpers';
|
import { getBubbleView } from '../sdk/bubbleHelpers';
|
||||||
import md5 from 'md5';
|
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 = () => {
|
export const getUserCookieName = () => {
|
||||||
const SET_USER_COOKIE_PREFIX = 'cw_user_';
|
const SET_USER_COOKIE_PREFIX = 'cw_user_';
|
||||||
|
@ -12,7 +14,7 @@ export const getUserCookieName = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUserString = ({ identifier = '', user }) => {
|
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] || ''}`,
|
(acc, key) => `${acc}${key}${user[key] || ''}`,
|
||||||
''
|
''
|
||||||
);
|
);
|
||||||
|
@ -22,10 +24,7 @@ export const getUserString = ({ identifier = '', user }) => {
|
||||||
const computeHashForUserData = (...args) => md5(getUserString(...args));
|
const computeHashForUserData = (...args) => md5(getUserString(...args));
|
||||||
|
|
||||||
export const hasUserKeys = user =>
|
export const hasUserKeys = user =>
|
||||||
ALLOWED_LIST_OF_SET_USER_ATTRIBUTES.reduce(
|
REQUIRED_USER_KEYS.reduce((acc, key) => acc || !!user[key], false);
|
||||||
(acc, key) => acc || !!user[key],
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
const runSDK = ({ baseUrl, websiteToken }) => {
|
const runSDK = ({ baseUrl, websiteToken }) => {
|
||||||
const chatwootSettings = window.chatwootSettings || {};
|
const chatwootSettings = window.chatwootSettings || {};
|
||||||
|
|
|
@ -15,11 +15,12 @@ describe('#getUserString', () => {
|
||||||
name: 'Pranav',
|
name: 'Pranav',
|
||||||
email: 'pranav@example.com',
|
email: 'pranav@example.com',
|
||||||
avatar_url: 'https://images.chatwoot.com/placeholder',
|
avatar_url: 'https://images.chatwoot.com/placeholder',
|
||||||
|
identifier_hash: '12345',
|
||||||
},
|
},
|
||||||
identifier: '12345',
|
identifier: '12345',
|
||||||
})
|
})
|
||||||
).toBe(
|
).toBe(
|
||||||
'avatar_urlhttps://images.chatwoot.com/placeholderemailpranav@example.comnamePranavidentifier12345'
|
'avatar_urlhttps://images.chatwoot.com/placeholderemailpranav@example.comnamePranavidentifier_hash12345identifier12345'
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
@ -30,7 +31,7 @@ describe('#getUserString', () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
).toBe(
|
).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';
|
import { refreshActionCableConnector } from '../../helpers/actionCable';
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
update: async (_, { identifier, user: userObject }) => {
|
update: async ({ dispatch }, { identifier, user: userObject }) => {
|
||||||
try {
|
try {
|
||||||
const user = {
|
const user = {
|
||||||
email: userObject.email,
|
email: userObject.email,
|
||||||
name: userObject.name,
|
name: userObject.name,
|
||||||
avatar_url: userObject.avatar_url,
|
avatar_url: userObject.avatar_url,
|
||||||
|
identifier_hash: userObject.identifier_hash,
|
||||||
};
|
};
|
||||||
const {
|
const {
|
||||||
data: { pubsub_token: pubsubToken },
|
data: { pubsub_token: pubsubToken },
|
||||||
} = await ContactsAPI.update(identifier, user);
|
} = await ContactsAPI.update(identifier, user);
|
||||||
|
|
||||||
|
if (userObject.identifier_hash) {
|
||||||
|
dispatch('conversation/clearConversations', {}, { root: true });
|
||||||
|
dispatch('conversation/fetchOldConversations', {}, { root: true });
|
||||||
|
}
|
||||||
|
|
||||||
refreshActionCableConnector(pubsubToken);
|
refreshActionCableConnector(pubsubToken);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ingore 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 { playNotificationAudio } from 'shared/helpers/AudioNotificationHelper';
|
||||||
import { actions } from '../../conversation';
|
import { actions } from '../../conversation/actions';
|
||||||
import getUuid from '../../../../helpers/uuid';
|
import getUuid from '../../../../helpers/uuid';
|
||||||
import { API } from 'widget/helpers/axios';
|
import { API } from 'widget/helpers/axios';
|
||||||
|
|
||||||
|
@ -121,4 +121,11 @@ describe('#actions', () => {
|
||||||
expect(commit.mock.calls).toEqual([]);
|
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', () => {
|
describe('#getters', () => {
|
||||||
it('getConversation', () => {
|
it('getConversation', () => {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import {
|
import {
|
||||||
findUndeliveredMessage,
|
findUndeliveredMessage,
|
||||||
createTemporaryMessage,
|
createTemporaryMessage,
|
||||||
} from '../../conversation';
|
} from '../../conversation/helpers';
|
||||||
|
|
||||||
describe('#findUndeliveredMessage', () => {
|
describe('#findUndeliveredMessage', () => {
|
||||||
it('returns message objects if exist', () => {
|
it('returns message objects if exist', () => {
|
|
@ -1,4 +1,4 @@
|
||||||
import { mutations } from '../../conversation';
|
import { mutations } from '../../conversation/mutations';
|
||||||
|
|
||||||
const temporaryMessagePayload = {
|
const temporaryMessagePayload = {
|
||||||
content: 'hello',
|
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
|
# id :integer not null, primary key
|
||||||
# feature_flags :integer default(3), not null
|
# feature_flags :integer default(3), not null
|
||||||
|
# hmac_token :string
|
||||||
# reply_time :integer default("in_a_few_minutes")
|
# reply_time :integer default("in_a_few_minutes")
|
||||||
# website_token :string
|
# website_token :string
|
||||||
# website_url :string
|
# website_url :string
|
||||||
|
@ -16,6 +17,7 @@
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
# index_channel_web_widgets_on_hmac_token (hmac_token) UNIQUE
|
||||||
# index_channel_web_widgets_on_website_token (website_token) UNIQUE
|
# index_channel_web_widgets_on_website_token (website_token) UNIQUE
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@ -30,6 +32,8 @@ class Channel::WebWidget < ApplicationRecord
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
has_one :inbox, as: :channel, dependent: :destroy
|
has_one :inbox, as: :channel, dependent: :destroy
|
||||||
has_secure_token :website_token
|
has_secure_token :website_token
|
||||||
|
has_secure_token :hmac_token
|
||||||
|
|
||||||
has_flags 1 => :attachments,
|
has_flags 1 => :attachments,
|
||||||
2 => :emoji_picker,
|
2 => :emoji_picker,
|
||||||
:column => 'feature_flags'
|
:column => 'feature_flags'
|
||||||
|
|
|
@ -2,12 +2,13 @@
|
||||||
#
|
#
|
||||||
# Table name: contact_inboxes
|
# Table name: contact_inboxes
|
||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# created_at :datetime not null
|
# hmac_verified :boolean default(FALSE)
|
||||||
# updated_at :datetime not null
|
# created_at :datetime not null
|
||||||
# contact_id :bigint
|
# updated_at :datetime not null
|
||||||
# inbox_id :bigint
|
# contact_id :bigint
|
||||||
# source_id :string not null
|
# inbox_id :bigint
|
||||||
|
# source_id :string not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|
|
@ -17,3 +17,4 @@ json.phone_number resource.channel.try(:phone_number)
|
||||||
json.selected_feature_flags resource.channel.try(:selected_feature_flags)
|
json.selected_feature_flags resource.channel.try(:selected_feature_flags)
|
||||||
json.reply_time resource.channel.try(:reply_time)
|
json.reply_time resource.channel.try(:reply_time)
|
||||||
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.facebook?
|
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" />
|
<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>
|
<script>
|
||||||
|
|
||||||
window.chatwootSettings = {
|
window.chatwootSettings = {
|
||||||
|
@ -24,6 +34,11 @@ window.chatwootSettings = {
|
||||||
})(document,"script");
|
})(document,"script");
|
||||||
|
|
||||||
window.addEventListener('chatwoot:ready', function() {
|
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>
|
</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.string "welcome_tagline"
|
||||||
t.integer "feature_flags", default: 3, null: false
|
t.integer "feature_flags", default: 3, null: false
|
||||||
t.integer "reply_time", default: 0
|
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
|
t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -191,6 +193,7 @@ ActiveRecord::Schema.define(version: 2021_01_13_045116) do
|
||||||
t.string "source_id", null: false
|
t.string "source_id", null: false
|
||||||
t.datetime "created_at", precision: 6, null: false
|
t.datetime "created_at", precision: 6, null: false
|
||||||
t.datetime "updated_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 ["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", "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"
|
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.
|
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
|
### Set custom attributes
|
||||||
|
|
||||||
Inorder to set additional information about the customer you can use customer attributes field.
|
Inorder to set additional information about the customer you can use customer attributes field.
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue