diff --git a/.changeset/four-cherries-kneel.md b/.changeset/four-cherries-kneel.md new file mode 100644 index 000000000000..095d5af0aa76 --- /dev/null +++ b/.changeset/four-cherries-kneel.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Allow to use the token from `room.v` when requesting transcript instead of visitor token. Visitors may change their tokens at any time, rendering old conversations impossible to access for them (or for APIs depending on token) as the visitor token won't match the `room.v` token. diff --git a/.changeset/healthy-rivers-nail.md b/.changeset/healthy-rivers-nail.md new file mode 100644 index 000000000000..a8da9bec846e --- /dev/null +++ b/.changeset/healthy-rivers-nail.md @@ -0,0 +1,8 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +"@rocket.chat/livechat": minor +--- + +Added new setting `Allow visitors to finish conversations` that allows admins to decide if omnichannel visitors can close a conversation or not. This doesn't affect agent's capabilities of room closing, neither apps using the livechat bridge to close rooms. +However, if currently your integration relies on `livechat/room.close` endpoint for closing conversations, it's advised to use the authenticated version `livechat/room.closeByUser` of it before turning off this setting. diff --git a/.changeset/tiny-geckos-kiss.md b/.changeset/tiny-geckos-kiss.md new file mode 100644 index 000000000000..d38150970310 --- /dev/null +++ b/.changeset/tiny-geckos-kiss.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Added a new setting which allows workspace admins to disable email two factor authentication for SSO (OAuth) users. If enabled, SSO users won't be asked for email two factor authentication. diff --git a/apps/meteor/app/2fa/server/code/EmailCheck.spec.ts b/apps/meteor/app/2fa/server/code/EmailCheck.spec.ts new file mode 100644 index 000000000000..5c3574f0b395 --- /dev/null +++ b/apps/meteor/app/2fa/server/code/EmailCheck.spec.ts @@ -0,0 +1,70 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const settingsMock = sinon.stub(); + +const { EmailCheck } = proxyquire.noCallThru().load('./EmailCheck', { + '@rocket.chat/models': { + Users: {}, + }, + 'meteor/accounts-base': { + Accounts: { + _bcryptRounds: () => '123', + }, + }, + '../../../../server/lib/i18n': { + i18n: { + t: (key: string) => key, + }, + }, + '../../../mailer/server/api': { + send: () => undefined, + }, + '../../../settings/server': { + settings: { + get: settingsMock, + }, + }, +}); + +const normalUserMock = { services: { email2fa: { enabled: true } }, emails: [{ email: 'abc@gmail.com', verified: true }] }; +const normalUserWithUnverifiedEmailMock = { + services: { email2fa: { enabled: true } }, + emails: [{ email: 'abc@gmail.com', verified: false }], +}; +const OAuthUserMock = { services: { google: {} }, emails: [{ email: 'abc@gmail.com', verified: true }] }; + +describe('EmailCheck', () => { + let emailCheck: typeof EmailCheck; + beforeEach(() => { + settingsMock.reset(); + + emailCheck = new EmailCheck(); + }); + + it('should return EmailCheck is enabled for a normal user', () => { + settingsMock.returns(true); + + const isEmail2FAEnabled = emailCheck.isEnabled(normalUserMock); + + expect(isEmail2FAEnabled).to.be.equal(true); + }); + + it('should return EmailCheck is not enabled for a normal user with unverified email', () => { + settingsMock.returns(true); + + const isEmail2FAEnabled = emailCheck.isEnabled(normalUserWithUnverifiedEmailMock); + + expect(isEmail2FAEnabled).to.be.equal(false); + }); + + it('should return EmailCheck is not enabled for a OAuth user with setting being false', () => { + settingsMock.returns(true); + + const isEmail2FAEnabled = emailCheck.isEnabled(OAuthUserMock); + + expect(isEmail2FAEnabled).to.be.equal(false); + }); +}); diff --git a/apps/meteor/app/2fa/server/code/EmailCheck.ts b/apps/meteor/app/2fa/server/code/EmailCheck.ts index 123df96ee264..d947c1b30c2e 100644 --- a/apps/meteor/app/2fa/server/code/EmailCheck.ts +++ b/apps/meteor/app/2fa/server/code/EmailCheck.ts @@ -1,4 +1,4 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import { isOAuthUser, type IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import bcrypt from 'bcrypt'; @@ -24,6 +24,10 @@ export class EmailCheck implements ICodeCheck { return false; } + if (!settings.get('Accounts_twoFactorAuthentication_email_available_for_OAuth_users') && isOAuthUser(user)) { + return false; + } + if (!user.services?.email2fa?.enabled) { return false; } diff --git a/apps/meteor/app/2fa/server/code/index.ts b/apps/meteor/app/2fa/server/code/index.ts index 1fbe658e5682..b05157416e31 100644 --- a/apps/meteor/app/2fa/server/code/index.ts +++ b/apps/meteor/app/2fa/server/code/index.ts @@ -45,14 +45,10 @@ function getAvailableMethodNames(user: IUser): string[] { export async function getUserForCheck(userId: string): Promise { return Users.findOneById(userId, { projection: { - 'emails': 1, - 'language': 1, - 'createdAt': 1, - 'services.totp': 1, - 'services.email2fa': 1, - 'services.emailCode': 1, - 'services.password': 1, - 'services.resume.loginTokens': 1, + emails: 1, + language: 1, + createdAt: 1, + services: 1, }, }); } diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index dd4da47bff05..8348b8429e4e 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -1,6 +1,6 @@ import crypto from 'crypto'; -import type { IUser } from '@rocket.chat/core-typings'; +import { isOAuthUser, type IUser } from '@rocket.chat/core-typings'; import { Settings, Users } from '@rocket.chat/models'; import { isShieldSvgProps, @@ -26,7 +26,7 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP import { passwordPolicy } from '../../../lib/server'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; -import { getDefaultUserFields } from '../../../utils/server/functions/getDefaultUserFields'; +import { getBaseUserFields } from '../../../utils/server/functions/getBaseUserFields'; import { isSMTPConfigured } from '../../../utils/server/functions/isSMTPConfigured'; import { getURL } from '../../../utils/server/getURL'; import { API } from '../api'; @@ -176,15 +176,19 @@ API.v1.addRoute( { authRequired: true }, { async get() { - const fields = getDefaultUserFields(); - const { services, ...user } = (await Users.findOneById(this.userId, { projection: fields })) as IUser; + const userFields = { ...getBaseUserFields(), services: 1 }; + const { services, ...user } = (await Users.findOneById(this.userId, { projection: userFields })) as IUser; return API.v1.success( await getUserInfo({ ...user, + isOAuthUser: isOAuthUser({ ...user, services }), ...(services && { services: { - ...services, + ...(services.github && { github: services.github }), + ...(services.gitlab && { gitlab: services.gitlab }), + ...(services.email2fa?.enabled && { email2fa: { enabled: services.email2fa.enabled } }), + ...(services.totp?.enabled && { totp: { enabled: services.totp.enabled } }), password: { // The password hash shouldn't be leaked but the client may need to know if it exists. exists: Boolean(services?.password?.bcrypt), diff --git a/apps/meteor/app/livechat/imports/server/rest/appearance.ts b/apps/meteor/app/livechat/imports/server/rest/appearance.ts index 48863fc9e5d3..7496b6243abe 100644 --- a/apps/meteor/app/livechat/imports/server/rest/appearance.ts +++ b/apps/meteor/app/livechat/imports/server/rest/appearance.ts @@ -51,6 +51,7 @@ API.v1.addRoute( 'Livechat_background', 'Livechat_widget_position', 'Livechat_hide_system_messages', + 'Omnichannel_allow_visitors_to_close_conversation', ]; const valid = settings.every((setting) => validSettingList.includes(setting._id)); diff --git a/apps/meteor/app/livechat/server/api/lib/appearance.ts b/apps/meteor/app/livechat/server/api/lib/appearance.ts index 785413ead9d1..0fc7d3547b2c 100644 --- a/apps/meteor/app/livechat/server/api/lib/appearance.ts +++ b/apps/meteor/app/livechat/server/api/lib/appearance.ts @@ -28,6 +28,7 @@ export async function findAppearance(): Promise<{ appearance: ISetting[] }> { 'Livechat_background', 'Livechat_widget_position', 'Livechat_hide_system_messages', + 'Omnichannel_allow_visitors_to_close_conversation', ], }, }; diff --git a/apps/meteor/app/livechat/server/api/lib/livechat.ts b/apps/meteor/app/livechat/server/api/lib/livechat.ts index a922edd40899..8041566d796e 100644 --- a/apps/meteor/app/livechat/server/api/lib/livechat.ts +++ b/apps/meteor/app/livechat/server/api/lib/livechat.ts @@ -142,6 +142,7 @@ export async function settings({ businessUnit = '' }: { businessUnit?: string } hiddenSystemMessages: initSettings.Livechat_hide_system_messages, livechatLogo: initSettings.Assets_livechat_widget_logo, hideWatermark: initSettings.Livechat_hide_watermark || false, + visitorsCanCloseChat: initSettings.Omnichannel_allow_visitors_to_close_conversation, }, theme: { title: initSettings.Livechat_title, diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 94bd5ed3e11c..7e9457d2f185 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -92,7 +92,7 @@ API.v1.addRoute( { authRequired: true, permissionsRequired: ['create-livechat-contact'], validateParams: isPOSTOmnichannelContactsProps }, { async post() { - if (!process.env.TEST_MODE) { + if (process.env.TEST_MODE?.toUpperCase() !== 'TRUE') { throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode'); } const contactId = await createContact({ ...this.bodyParams, unknown: false }); @@ -106,7 +106,7 @@ API.v1.addRoute( { authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTUpdateOmnichannelContactsProps }, { async post() { - if (!process.env.TEST_MODE) { + if (process.env.TEST_MODE?.toUpperCase() !== 'TRUE') { throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode'); } diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 565f8e0bb3f4..7aacfacb4476 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -107,6 +107,10 @@ API.v1.addRoute( async post() { const { rid, token } = this.bodyParams; + if (!rcSettings.get('Omnichannel_allow_visitors_to_close_conversation')) { + throw new Error('error-not-allowed-to-close-conversation'); + } + const visitor = await findGuest(token); if (!visitor) { throw new Error('invalid-token'); diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts index 58404ce27584..f6f812ce8af8 100644 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ b/apps/meteor/app/livechat/server/lib/Contacts.ts @@ -44,8 +44,8 @@ type RegisterContactProps = { type CreateContactParams = { name: string; - emails: string[]; - phones: string[]; + emails?: string[]; + phones?: string[]; unknown: boolean; customFields?: Record; contactManager?: string; diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index be79d565f6de..89d125033977 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -71,6 +71,7 @@ import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; +import { createContact } from './Contacts'; import { parseAgentCustomFields, updateDepartmentAgents, validateEmail, normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; import { RoutingManager } from './RoutingManager'; @@ -664,6 +665,16 @@ class LivechatClass { } } + if (process.env.TEST_MODE?.toUpperCase() === 'TRUE') { + const contactId = await createContact({ + name: name ?? (visitorDataToUpdate.username as string), + emails: email ? [email] : [], + phones: phone ? [phone.number] : [], + unknown: true, + }); + visitorDataToUpdate.contactId = contactId; + } + const upsertedLivechatVisitor = await LivechatVisitors.updateOneByIdOrToken(visitorDataToUpdate, { upsert: true, returnDocument: 'after', @@ -1068,6 +1079,7 @@ class LivechatClass { 'Livechat_background', 'Assets_livechat_widget_logo', 'Livechat_hide_watermark', + 'Omnichannel_allow_visitors_to_close_conversation', ] as const; type SettingTypes = (typeof validSettings)[number] | 'Livechat_Show_Connecting'; diff --git a/apps/meteor/app/livechat/server/lib/sendTranscript.ts b/apps/meteor/app/livechat/server/lib/sendTranscript.ts index 74032121ee50..bc7c06e0eaae 100644 --- a/apps/meteor/app/livechat/server/lib/sendTranscript.ts +++ b/apps/meteor/app/livechat/server/lib/sendTranscript.ts @@ -3,12 +3,13 @@ import { type IUser, type MessageTypesValues, type IOmnichannelSystemMessage, + type ILivechatVisitor, isFileAttachment, isFileImageAttachment, } from '@rocket.chat/core-typings'; import colors from '@rocket.chat/fuselage-tokens/colors'; import { Logger } from '@rocket.chat/logger'; -import { LivechatRooms, LivechatVisitors, Messages, Uploads, Users } from '@rocket.chat/models'; +import { LivechatRooms, Messages, Uploads, Users } from '@rocket.chat/models'; import { check } from 'meteor/check'; import moment from 'moment-timezone'; @@ -41,16 +42,12 @@ export async function sendTranscript({ const room = await LivechatRooms.findOneById(rid); - const visitor = await LivechatVisitors.getVisitorByToken(token, { - projection: { _id: 1, token: 1, language: 1, username: 1, name: 1 }, - }); - - if (!visitor) { - throw new Error('error-invalid-token'); + const visitor = room?.v as ILivechatVisitor; + if (token !== visitor?.token) { + throw new Error('error-invalid-visitor'); } - // @ts-expect-error - Visitor typings should include language? - const userLanguage = visitor?.language || settings.get('Language') || 'en'; + const userLanguage = settings.get('Language') || 'en'; const timezone = getTimezone(user); logger.debug(`Transcript will be sent using ${timezone} as timezone`); @@ -59,7 +56,7 @@ export async function sendTranscript({ } // allow to only user to send transcripts from their own chats - if (room.t !== 'l' || !room.v || room.v.token !== token) { + if (room.t !== 'l') { throw new Error('error-invalid-room'); } diff --git a/apps/meteor/app/utils/server/functions/getBaseUserFields.ts b/apps/meteor/app/utils/server/functions/getBaseUserFields.ts new file mode 100644 index 000000000000..5e2a3bf2b4d7 --- /dev/null +++ b/apps/meteor/app/utils/server/functions/getBaseUserFields.ts @@ -0,0 +1,34 @@ +type UserFields = { + [k: string]: number; +}; + +export const getBaseUserFields = (): UserFields => ({ + 'name': 1, + 'username': 1, + 'nickname': 1, + 'emails': 1, + 'status': 1, + 'statusDefault': 1, + 'statusText': 1, + 'statusConnection': 1, + 'bio': 1, + 'avatarOrigin': 1, + 'utcOffset': 1, + 'language': 1, + 'settings': 1, + 'enableAutoAway': 1, + 'idleTimeLimit': 1, + 'roles': 1, + 'active': 1, + 'defaultRoom': 1, + 'customFields': 1, + 'requirePasswordChange': 1, + 'requirePasswordChangeReason': 1, + 'statusLivechat': 1, + 'banners': 1, + 'oauth.authorizedClients': 1, + '_updatedAt': 1, + 'avatarETag': 1, + 'extension': 1, + 'openBusinessHours': 1, +}); diff --git a/apps/meteor/app/utils/server/functions/getDefaultUserFields.ts b/apps/meteor/app/utils/server/functions/getDefaultUserFields.ts index 03d0cae77ab9..293eb8607342 100644 --- a/apps/meteor/app/utils/server/functions/getDefaultUserFields.ts +++ b/apps/meteor/app/utils/server/functions/getDefaultUserFields.ts @@ -1,39 +1,14 @@ -type DefaultUserFields = { +import { getBaseUserFields } from './getBaseUserFields'; + +type UserFields = { [k: string]: number; }; -export const getDefaultUserFields = (): DefaultUserFields => ({ - 'name': 1, - 'username': 1, - 'nickname': 1, - 'emails': 1, - 'status': 1, - 'statusDefault': 1, - 'statusText': 1, - 'statusConnection': 1, - 'bio': 1, - 'avatarOrigin': 1, - 'utcOffset': 1, - 'language': 1, - 'settings': 1, - 'enableAutoAway': 1, - 'idleTimeLimit': 1, - 'roles': 1, - 'active': 1, - 'defaultRoom': 1, - 'customFields': 1, - 'requirePasswordChange': 1, - 'requirePasswordChangeReason': 1, +export const getDefaultUserFields = (): UserFields => ({ + ...getBaseUserFields(), 'services.github': 1, 'services.gitlab': 1, 'services.password.bcrypt': 1, 'services.totp.enabled': 1, 'services.email2fa.enabled': 1, - 'statusLivechat': 1, - 'banners': 1, - 'oauth.authorizedClients': 1, - '_updatedAt': 1, - 'avatarETag': 1, - 'extension': 1, - 'openBusinessHours': 1, }); diff --git a/apps/meteor/client/views/account/security/AccountSecurityPage.tsx b/apps/meteor/client/views/account/security/AccountSecurityPage.tsx index 536ba8a04ef7..06619f0618f5 100644 --- a/apps/meteor/client/views/account/security/AccountSecurityPage.tsx +++ b/apps/meteor/client/views/account/security/AccountSecurityPage.tsx @@ -1,6 +1,6 @@ import { Box, Accordion, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; +import { useSetting, useTranslation, useUser } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; @@ -15,6 +15,11 @@ const passwordDefaultValues = { password: '', confirmationPassword: '' }; const AccountSecurityPage = (): ReactElement => { const t = useTranslation(); + const user = useUser(); + + const isEmail2FAAvailableForOAuth = useSetting('Accounts_twoFactorAuthentication_email_available_for_OAuth_users'); + const isOAuthUser = user?.isOAuthUser; + const isEmail2FAAllowed = !isOAuthUser || isEmail2FAAvailableForOAuth; const methods = useForm({ defaultValues: passwordDefaultValues, @@ -30,6 +35,7 @@ const AccountSecurityPage = (): ReactElement => { const twoFactorByEmailEnabled = useSetting('Accounts_TwoFactorAuthentication_By_Email_Enabled'); const e2eEnabled = useSetting('E2E_Enable'); const allowPasswordChange = useSetting('Accounts_AllowPasswordChange'); + const showEmailTwoFactor = twoFactorByEmailEnabled && isEmail2FAAllowed; const passwordFormId = useUniqueId(); @@ -48,10 +54,10 @@ const AccountSecurityPage = (): ReactElement => { )} - {(twoFactorTOTP || twoFactorByEmailEnabled) && twoFactorEnabled && ( + {(twoFactorTOTP || showEmailTwoFactor) && twoFactorEnabled && ( {twoFactorTOTP && } - {twoFactorByEmailEnabled && } + {showEmailTwoFactor && } )} {e2eEnabled && ( diff --git a/apps/meteor/client/views/account/security/TwoFactorEmail.tsx b/apps/meteor/client/views/account/security/TwoFactorEmail.tsx index c890dc61e658..3654d17b5dec 100644 --- a/apps/meteor/client/views/account/security/TwoFactorEmail.tsx +++ b/apps/meteor/client/views/account/security/TwoFactorEmail.tsx @@ -1,11 +1,11 @@ import { Box, Button, Margins } from '@rocket.chat/fuselage'; import { useUser, useTranslation } from '@rocket.chat/ui-contexts'; -import type { ComponentProps, ReactElement } from 'react'; +import type { ComponentProps } from 'react'; import React, { useCallback } from 'react'; import { useEndpointAction } from '../../../hooks/useEndpointAction'; -const TwoFactorEmail = (props: ComponentProps): ReactElement => { +const TwoFactorEmail = (props: ComponentProps) => { const t = useTranslation(); const user = useUser(); diff --git a/apps/meteor/client/views/omnichannel/appearance/AppearanceForm.tsx b/apps/meteor/client/views/omnichannel/appearance/AppearanceForm.tsx index 4253ff023ee9..a4435398d9a9 100644 --- a/apps/meteor/client/views/omnichannel/appearance/AppearanceForm.tsx +++ b/apps/meteor/client/views/omnichannel/appearance/AppearanceForm.tsx @@ -52,6 +52,7 @@ const AppearanceForm = () => { const livechatWidgetPositionField = useUniqueId(); const livechatBackgroundField = useUniqueId(); const livechatHideSystemMessagesField = useUniqueId(); + const omnichannelVisitorsCanCloseConversationField = useUniqueId(); return ( @@ -140,6 +141,20 @@ const AppearanceForm = () => { /> + + + + {t('Omnichannel_allow_visitors_to_close_conversation')} + + ( + + )} + /> + + diff --git a/apps/meteor/client/views/omnichannel/appearance/AppearancePage.tsx b/apps/meteor/client/views/omnichannel/appearance/AppearancePage.tsx index a2cfb7b8103b..b90c32af6a7d 100644 --- a/apps/meteor/client/views/omnichannel/appearance/AppearancePage.tsx +++ b/apps/meteor/client/views/omnichannel/appearance/AppearancePage.tsx @@ -28,6 +28,7 @@ type LivechatAppearanceSettings = { Livechat_conversation_finished_text: string; Livechat_enable_message_character_limit: boolean; Livechat_message_character_limit: number; + Omnichannel_allow_visitors_to_close_conversation: boolean; }; type AppearanceSettings = Partial; diff --git a/apps/meteor/server/settings/accounts.ts b/apps/meteor/server/settings/accounts.ts index a744c47b2a41..b4da1cd913e9 100644 --- a/apps/meteor/server/settings/accounts.ts +++ b/apps/meteor/server/settings/accounts.ts @@ -31,6 +31,18 @@ export const createAccountSettings = () => public: true, }); + await this.add('Accounts_twoFactorAuthentication_email_available_for_OAuth_users', true, { + type: 'boolean', + enableQuery: [ + enable2FA, + { + _id: 'Accounts_TwoFactorAuthentication_By_Email_Enabled', + value: true, + }, + ], + public: true, + }); + await this.add('Accounts_TwoFactorAuthentication_By_Email_Auto_Opt_In', true, { type: 'boolean', enableQuery: [ diff --git a/apps/meteor/server/settings/omnichannel.ts b/apps/meteor/server/settings/omnichannel.ts index ed1daa8ce228..c86cd6674d4e 100644 --- a/apps/meteor/server/settings/omnichannel.ts +++ b/apps/meteor/server/settings/omnichannel.ts @@ -157,6 +157,13 @@ export const createOmniSettings = () => i18nLabel: 'Show_agent_email', }); + await this.add('Omnichannel_allow_visitors_to_close_conversation', true, { + type: 'boolean', + group: 'Omnichannel', + public: true, + enableQuery: omnichannelEnabledQuery, + }); + await this.add('Livechat_request_comment_when_closing_conversation', true, { type: 'boolean', group: 'Omnichannel', diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts index 9532fd4214ab..b5d89762c614 100644 --- a/apps/meteor/tests/data/livechat/rooms.ts +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -33,10 +33,10 @@ export const createLivechatRoom = async (visitorToken: string, extraRoomParams?: return response.body.room; }; -export const createVisitor = (department?: string, visitorName?: string): Promise => +export const createVisitor = (department?: string, visitorName?: string, customEmail?: string): Promise => new Promise((resolve, reject) => { const token = getRandomVisitorToken(); - const email = `${token}@${token}.com`; + const email = customEmail || `${token}@${token}.com`; const phone = `${Math.floor(Math.random() * 10000000000)}`; void request.get(api(`livechat/visitor/${token}`)).end((err: Error, res: DummyResponse) => { if (!err && res && res.body && res.body.visitor) { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts index bf14584ed89f..405e7f82e3c4 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts @@ -2,6 +2,7 @@ import { createFakeVisitor } from '../../mocks/data'; import { createAuxContext } from '../fixtures/createAuxContext'; import { Users } from '../fixtures/userStates'; import { HomeOmnichannel, OmnichannelLiveChat } from '../page-objects'; +import { setSettingValueById } from '../utils'; import { createAgent } from '../utils/omnichannel/agents'; import { test, expect } from '../utils/test'; @@ -93,6 +94,57 @@ test.describe.serial('OC - Livechat', () => { }); }); +test.describe.serial('OC - Livechat - Visitors closing the room is disabled', () => { + let poLiveChat: OmnichannelLiveChat; + let poHomeOmnichannel: HomeOmnichannel; + + test.beforeAll(async ({ api }) => { + await api.post('/livechat/users/agent', { username: 'user1' }); + }); + + test.beforeAll(async ({ browser, api }) => { + const { page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false); + + poLiveChat = new OmnichannelLiveChat(livechatPage, api); + }); + + test.beforeAll(async ({ browser, api }) => { + await setSettingValueById(api, 'Livechat_allow_visitor_closing_chat', false); + const { page: omniPage } = await createAuxContext(browser, Users.user1, '/', true); + poHomeOmnichannel = new HomeOmnichannel(omniPage); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Livechat_allow_visitor_closing_chat', true); + await api.delete('/livechat/users/agent/user1'); + await poLiveChat.page.close(); + }); + + test('OC - Livechat - Close Chat disabled', async () => { + await poLiveChat.page.reload(); + await poLiveChat.openAnyLiveChat(); + await poLiveChat.sendMessage(firstVisitor, false); + await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_user'); + await poLiveChat.btnSendMessageToOnlineAgent.click(); + + await test.step('expect to close a livechat conversation', async () => { + await expect(poLiveChat.btnOptions).not.toBeVisible(); + await expect(poLiveChat.btnCloseChat).not.toBeVisible(); + }); + }); + + test('OC - Livechat - Close chat disabled, agents can close', async () => { + await poHomeOmnichannel.sidenav.openChat(firstVisitor.name); + + await test.step('expect livechat conversation to be closed by agent', async () => { + await poHomeOmnichannel.content.btnCloseChat.click(); + await poHomeOmnichannel.content.closeChatModal.inputComment.fill('this_is_a_test_comment'); + await poHomeOmnichannel.content.closeChatModal.btnConfirm.click(); + await expect(poHomeOmnichannel.toastSuccess).toBeVisible(); + }); + }); +}); + test.describe.serial('OC - Livechat - Resub after close room', () => { let poLiveChat: OmnichannelLiveChat; let poHomeOmnichannel: HomeOmnichannel; diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 7142725a1d99..4388a4d24341 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -689,6 +689,25 @@ describe('LIVECHAT - rooms', () => { expect(latestRoom).to.not.have.property('pdfTranscriptFileId'); }, ); + + describe('Special case: visitors closing is disabled', () => { + before(async () => { + await updateSetting('Omnichannel_allow_visitors_to_close_conversation', false); + }); + after(async () => { + await updateSetting('Omnichannel_allow_visitors_to_close_conversation', true); + }); + it('should not allow visitor to close a conversation', async () => { + const { room, visitor } = await startANewLivechatRoomAndTakeIt(); + await request + .post(api('livechat/room.close')) + .send({ + token: visitor.token, + rid: room._id, + }) + .expect(400); + }); + }); }); describe('livechat/room.forward', () => { diff --git a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts index 5bc961087efc..f02d9d1d1e95 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts @@ -56,6 +56,7 @@ describe('LIVECHAT - visitors', () => { expect(body).to.have.property('success', true); expect(body).to.have.property('visitor'); expect(body.visitor).to.have.property('token', 'test'); + expect(body.visitor).to.have.property('contactId'); // Ensure all new visitors are created as online :) expect(body.visitor).to.have.property('status', 'online'); diff --git a/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts b/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts index c07f7bcecc81..7ce582025538 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts @@ -283,6 +283,27 @@ describe('LIVECHAT - Utils', () => { .send({ token: visitor.token, rid: room._id, email: 'visitor@notadomain.com' }); expect(body).to.have.property('success', true); }); + it('should allow a visitor to get a transcript even if token changed by using an old token that matches room.v', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + await closeOmnichannelRoom(room._id); + const visitor2 = await createVisitor(undefined, undefined, visitor.visitorEmails?.[0].address); + const room2 = await createLivechatRoom(visitor2.token); + await closeOmnichannelRoom(room2._id); + + expect(visitor.token !== visitor2.token).to.be.true; + const { body } = await request + .post(api('livechat/transcript')) + .set(credentials) + .send({ token: visitor.token, rid: room._id, email: 'visitor@notadomain.com' }); + expect(body).to.have.property('success', true); + + const { body: body2 } = await request + .post(api('livechat/transcript')) + .set(credentials) + .send({ token: visitor2.token, rid: room2._id, email: 'visitor@notadomain.com' }); + expect(body2).to.have.property('success', true); + }); }); describe('livechat/transcript/:rid', () => { diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/sendTranscript.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/sendTranscript.spec.ts index 64da050cfd88..ca39a64c21a9 100644 --- a/apps/meteor/tests/unit/app/livechat/server/lib/sendTranscript.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/lib/sendTranscript.spec.ts @@ -6,9 +6,6 @@ const modelsMock = { LivechatRooms: { findOneById: sinon.stub(), }, - LivechatVisitors: { - getVisitorByToken: sinon.stub(), - }, Messages: { findLivechatClosingMessage: sinon.stub(), findVisibleByRoomIdNotContainingTypesBeforeTs: sinon.stub(), @@ -75,7 +72,6 @@ describe('Send transcript', () => { beforeEach(() => { checkMock.reset(); modelsMock.LivechatRooms.findOneById.reset(); - modelsMock.LivechatVisitors.getVisitorByToken.reset(); modelsMock.Messages.findLivechatClosingMessage.reset(); modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.reset(); modelsMock.Users.findOneById.reset(); @@ -87,11 +83,9 @@ describe('Send transcript', () => { await expect(sendTranscript({})).to.be.rejectedWith(Error); }); it('should throw error when visitor not found', async () => { - modelsMock.LivechatVisitors.getVisitorByToken.resolves(null); await expect(sendTranscript({ rid: 'rid', email: 'email', logger: mockLogger })).to.be.rejectedWith(Error); }); it('should attempt to send an email when params are valid using default subject', async () => { - modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } }); modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.resolves([]); tStub.returns('Conversation Transcript'); @@ -117,7 +111,6 @@ describe('Send transcript', () => { ).to.be.true; }); it('should use provided subject', async () => { - modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } }); modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.resolves([]); @@ -143,7 +136,6 @@ describe('Send transcript', () => { ).to.be.true; }); it('should use subject from setting (when configured) when no subject provided', async () => { - modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } }); modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.resolves([]); mockSettingValues.Livechat_transcript_email_subject = 'A custom subject obtained from setting.get'; @@ -170,36 +162,63 @@ describe('Send transcript', () => { }); it('should fail if room provided is invalid', async () => { modelsMock.LivechatRooms.findOneById.resolves(null); - modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); await expect(sendTranscript({ rid: 'rid', email: 'email', logger: mockLogger })).to.be.rejectedWith(Error); }); it('should fail if room provided is of different type', async () => { modelsMock.LivechatRooms.findOneById.resolves({ t: 'c' }); - modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error); }); it('should fail if room is of valid type, but doesnt doesnt have `v` property', async () => { - modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); modelsMock.LivechatRooms.findOneById.resolves({ t: 'l' }); await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error); }); it('should fail if room is of valid type, has `v` prop, but it doesnt contain `token`', async () => { - modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { otherProp: 'xxx' } }); await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error); }); it('should fail if room is of valid type, has `v.token`, but its different from the one on param (room from another visitor)', async () => { - modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null }); modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'xxx' } }); await expect(sendTranscript({ rid: 'rid', email: 'email', token: 'xveasdf' })).to.be.rejectedWith(Error); }); + + it('should throw an error when token is not the one on room.v', async () => { + modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'xxx' } }); + + await expect(sendTranscript({ rid: 'rid', email: 'email', token: 'xveasdf' })).to.be.rejectedWith(Error); + }); + it('should work when token matches room.v', async () => { + modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token-123' } }); + modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.resolves([]); + delete mockSettingValues.Livechat_transcript_email_subject; + tStub.returns('Conversation Transcript'); + + await sendTranscript({ + rid: 'rid', + token: 'token-123', + email: 'email', + user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' }, + }); + + expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true; + expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true; + expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true; + expect( + mailerMock.calledWith({ + to: 'email', + from: 'test@rocket.chat', + subject: 'Conversation Transcript', + replyTo: 'test@rocket.chat', + html: '

', + }), + ).to.be.true; + }); }); diff --git a/packages/core-typings/src/ILivechatContact.ts b/packages/core-typings/src/ILivechatContact.ts index 149dab2b88b1..1e7bd4ff5399 100644 --- a/packages/core-typings/src/ILivechatContact.ts +++ b/packages/core-typings/src/ILivechatContact.ts @@ -14,8 +14,8 @@ export interface ILivechatContactConflictingField { export interface ILivechatContact extends IRocketChatRecord { name: string; - phones: string[]; - emails: string[]; + phones?: string[]; + emails?: string[]; contactManager?: string; unknown?: boolean; hasConflict?: boolean; diff --git a/packages/core-typings/src/ILivechatVisitor.ts b/packages/core-typings/src/ILivechatVisitor.ts index 21819cc23f24..eefb4ebd720c 100644 --- a/packages/core-typings/src/ILivechatVisitor.ts +++ b/packages/core-typings/src/ILivechatVisitor.ts @@ -49,6 +49,7 @@ export interface ILivechatVisitor extends IRocketChatRecord { }; activity?: string[]; disabled?: boolean; + contactId?: string; } export interface ILivechatVisitorDTO { diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index fa411c6f7e47..d6854bef7243 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -45,7 +45,35 @@ export type ILoginUsername = }; export type LoginUsername = string | ILoginUsername; -export interface IUserServices { +export interface IOAuthUserServices { + google?: any; + facebook?: any; + github?: any; + linkedin?: any; + twitter?: any; + gitlab?: any; + saml?: { + inResponseTo?: string; + provider?: string; + idp?: string; + idpSession?: string; + nameID?: string; + }; + ldap?: { + id: string; + idAttribute?: string; + }; + nextcloud?: { + accessToken: string; + refreshToken: string; + serverURL: string; + }; + dolphin?: { + NickName?: string; + }; +} + +export interface IUserServices extends IOAuthUserServices { password?: { exists?: boolean; bcrypt?: string; @@ -62,12 +90,6 @@ export interface IUserServices { refreshToken: string; expiresAt: Date; }; - google?: any; - facebook?: any; - github?: any; - linkedin?: any; - twitter?: any; - gitlab?: any; totp?: { enabled: boolean; hashedBackup: string[]; @@ -79,27 +101,37 @@ export interface IUserServices { changedAt: Date; }; emailCode?: IUserEmailCode; - saml?: { - inResponseTo?: string; - provider?: string; - idp?: string; - idpSession?: string; - nameID?: string; - }; - ldap?: { - id: string; - idAttribute?: string; - }; - nextcloud?: { - accessToken: string; - refreshToken: string; - serverURL: string; - }; - dolphin?: { - NickName?: string; - }; } +type IUserService = keyof IUserServices; +type IOAuthService = keyof IOAuthUserServices; + +const defaultOAuthKeys = [ + 'google', + 'dolphin', + 'facebook', + 'github', + 'gitlab', + 'google', + 'ldap', + 'linkedin', + 'nextcloud', + 'saml', + 'twitter', +] as IOAuthService[]; +const userServiceKeys = ['emailCode', 'email2fa', 'totp', 'resume', 'password', 'passwordHistory', 'cloud', 'email'] as IUserService[]; + +export const isUserServiceKey = (key: string): key is IUserService => + userServiceKeys.includes(key as IUserService) || defaultOAuthKeys.includes(key as IOAuthService); + +export const isDefaultOAuthUser = (user: IUser): boolean => + !!user.services && Object.keys(user.services).some((key) => defaultOAuthKeys.includes(key as IOAuthService)); + +export const isCustomOAuthUser = (user: IUser): boolean => + !!user.services && Object.keys(user.services).some((key) => !isUserServiceKey(key)); + +export const isOAuthUser = (user: IUser): boolean => isDefaultOAuthUser(user) || isCustomOAuthUser(user); + export interface IUserEmail { address: string; verified?: boolean; @@ -183,6 +215,7 @@ export interface IUser extends IRocketChatRecord { _pendingAvatarUrl?: string; requirePasswordChange?: boolean; requirePasswordChangeReason?: string; + isOAuthUser?: boolean; // client only field } export interface IRegisterUser extends IUser { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 272afe7e8bab..864c150c782a 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -282,6 +282,8 @@ "Accounts_TwoFactorAuthentication_By_Email_Code_Expiration": "Time to expire the code sent via email in seconds", "Accounts_TwoFactorAuthentication_By_Email_Enabled": "Enable Two Factor Authentication via Email", "Accounts_TwoFactorAuthentication_By_Email_Enabled_Description": "Users with email verified and the option enabled in their profile page will receive an email with a temporary code to authorize certain actions like login, save the profile, etc.", + "Accounts_twoFactorAuthentication_email_available_for_OAuth_users": "Make two factor via email available for oAuth users", + "Accounts_twoFactorAuthentication_email_available_for_OAuth_users_Description": "People that use oAuth will receive an email with a temporary code to authorize actions like login, save profile, etc.", "Accounts_TwoFactorAuthentication_Enabled": "Enable Two Factor Authentication", "Accounts_TwoFactorAuthentication_Enabled_Description": "If deactivated, this setting will deactivate all Two Factor Authentication. \nTo force users to use Two Factor Authentication, the admin has to configure the 'user' role to enforce it.", "Accounts_TwoFactorAuthentication_Enforce_Password_Fallback": "Enforce password fallback", @@ -4037,6 +4039,8 @@ "Omnichannel_Reports_Summary": "Gain insights into your operation and export your metrics.", "Omnichannel_max_fallback_forward_depth": "Maximum fallback forward departments depth", "Omnichannel_max_fallback_forward_depth_Description": "Maximum number of hops that a room being transfered will do when the target department has a Fallback Forward Department set up. When limit is reached, chat won't be transferred and process will stop. Depending on your configuration, setting a high number may cause performance issues.", + "Omnichannel_allow_visitors_to_close_conversation": "Allow visitors to finish conversations", + "Omnichannel_allow_visitors_to_close_conversation_Description": "When disabled, visitors won't be able to finish an ongoing conversation either via UI or via API.", "On": "On", "on-hold-livechat-room": "On Hold Omnichannel Room", "on-hold-livechat-room_description": "Permission to on hold omnichannel room", diff --git a/packages/livechat/src/routes/Chat/connector.tsx b/packages/livechat/src/routes/Chat/connector.tsx index 36e574b246b1..3c72f9ae88cf 100644 --- a/packages/livechat/src/routes/Chat/connector.tsx +++ b/packages/livechat/src/routes/Chat/connector.tsx @@ -22,6 +22,7 @@ export const ChatConnector: FunctionalComponent<{ path: string; default: boolean nameFieldRegistrationForm, emailFieldRegistrationForm, limitTextLength, + visitorsCanCloseChat, }, messages: { conversationFinishedMessage }, theme: { title = '' } = {}, @@ -94,6 +95,7 @@ export const ChatConnector: FunctionalComponent<{ path: string; default: boolean ongoingCall={ongoingCall} messageListPosition={messageListPosition} theme={theme} + visitorsCanCloseChat={visitorsCanCloseChat} /> ); }; diff --git a/packages/livechat/src/routes/Chat/container.js b/packages/livechat/src/routes/Chat/container.js index 19172cc7fe5a..43ff281c6472 100644 --- a/packages/livechat/src/routes/Chat/container.js +++ b/packages/livechat/src/routes/Chat/container.js @@ -288,8 +288,8 @@ class ChatContainer extends Component { }; canFinishChat = () => { - const { room, connecting } = this.props; - return room !== undefined || connecting; + const { room, connecting, visitorsCanCloseChat } = this.props; + return visitorsCanCloseChat && (room !== undefined || connecting); }; canRemoveUserData = () => { diff --git a/packages/livechat/src/store/index.tsx b/packages/livechat/src/store/index.tsx index f8629ce693cc..e7d4b8caae17 100644 --- a/packages/livechat/src/store/index.tsx +++ b/packages/livechat/src/store/index.tsx @@ -59,6 +59,7 @@ export type StoreState = { hideWatermark?: boolean; livechatLogo?: { url: string }; transcript?: boolean; + visitorsCanCloseChat?: boolean; }; online?: boolean; departments: Department[];