diff --git a/.changeset/chatty-hounds-hammer.md b/.changeset/chatty-hounds-hammer.md new file mode 100644 index 000000000000..1a2d3a7de559 --- /dev/null +++ b/.changeset/chatty-hounds-hammer.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/fuselage-ui-kit": patch +--- + +Fix validations from "UiKit" modal component diff --git a/.changeset/chilled-yaks-beg.md b/.changeset/chilled-yaks-beg.md new file mode 100644 index 000000000000..670fa24887b7 --- /dev/null +++ b/.changeset/chilled-yaks-beg.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed issue in Marketplace that caused a subscription app to show incorrect modals when subscribing diff --git a/.changeset/cuddly-brooms-approve.md b/.changeset/cuddly-brooms-approve.md new file mode 100644 index 000000000000..24905bb91c62 --- /dev/null +++ b/.changeset/cuddly-brooms-approve.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +--- + +Allows admins to customize the `Subject` field of Omnichannel email transcripts via setting. By passing a value to the setting `Custom email subject for transcript`, system will use it as the `Subject` field, unless a custom subject is passed when requesting a transcript. If there's no custom subject and setting value is empty, the current default value will be used diff --git a/.changeset/dry-pumas-draw.md b/.changeset/dry-pumas-draw.md new file mode 100644 index 000000000000..b66ca5157cd5 --- /dev/null +++ b/.changeset/dry-pumas-draw.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/livechat": patch +--- + +Fixed an issue that caused the widget to set the wrong department when using the setDepartment Livechat api endpoint in conjunction with a Livechat Trigger diff --git a/.changeset/empty-readers-teach.md b/.changeset/empty-readers-teach.md new file mode 100644 index 000000000000..b4bd075ef654 --- /dev/null +++ b/.changeset/empty-readers-teach.md @@ -0,0 +1,8 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/tools": patch +"@rocket.chat/account-service": patch +--- + +Fixed an inconsistent evaluation of the `Accounts_LoginExpiration` setting over the codebase. In some places, it was being used as milliseconds while in others as days. Invalid values produced different results. A helper function was created to centralize the setting validation and the proper value being returned to avoid edge cases. +Negative values may be saved on the settings UI panel but the code will interpret any negative, NaN or 0 value to the default expiration which is 90 days. diff --git a/.changeset/funny-wolves-tie.md b/.changeset/funny-wolves-tie.md new file mode 100644 index 000000000000..e2364ccb05e5 --- /dev/null +++ b/.changeset/funny-wolves-tie.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed issue where bad word filtering was not working in the UI for messages diff --git a/.changeset/grumpy-worms-appear.md b/.changeset/grumpy-worms-appear.md new file mode 100644 index 000000000000..fb9fab77b24c --- /dev/null +++ b/.changeset/grumpy-worms-appear.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/i18n": patch +--- + +Fixed wrong wording on a federation setting diff --git a/.changeset/happy-peaches-nail.md b/.changeset/happy-peaches-nail.md new file mode 100644 index 000000000000..2dfb2151ced0 --- /dev/null +++ b/.changeset/happy-peaches-nail.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed issue with livechat agents not being able to leave omnichannel rooms if joining after a room has been closed by the visitor (due to race conditions) diff --git a/.changeset/hungry-wombats-act.md b/.changeset/hungry-wombats-act.md new file mode 100644 index 000000000000..4e50b172e17e --- /dev/null +++ b/.changeset/hungry-wombats-act.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed an issue where non-encrypted attachments were not being downloaded diff --git a/.changeset/large-vans-attack.md b/.changeset/large-vans-attack.md new file mode 100644 index 000000000000..c1008b2ca06f --- /dev/null +++ b/.changeset/large-vans-attack.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed the contextual bar closing when editing thread messages instead of cancelling the message edit diff --git a/.changeset/lucky-countries-look.md b/.changeset/lucky-countries-look.md new file mode 100644 index 000000000000..79deda53edfc --- /dev/null +++ b/.changeset/lucky-countries-look.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed the disappearance of some settings after navigation under network latency. diff --git a/.changeset/many-tables-love.md b/.changeset/many-tables-love.md new file mode 100644 index 000000000000..8f37283c6a96 --- /dev/null +++ b/.changeset/many-tables-love.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/model-typings": minor +--- + +Fixed Livechat rooms being displayed in the Engagement Dashboard's "Channels" tab diff --git a/.changeset/cuddly-ravens-swim.md b/.changeset/mean-hairs-move.md similarity index 84% rename from .changeset/cuddly-ravens-swim.md rename to .changeset/mean-hairs-move.md index 5774ef48202d..c92293d6ae95 100644 --- a/.changeset/cuddly-ravens-swim.md +++ b/.changeset/mean-hairs-move.md @@ -1,5 +1,5 @@ --- -"@rocket.chat/meteor": patch +'@rocket.chat/meteor': minor --- Fixed an issue where adding `OVERWRITE_SETTING_` for any setting wasn't immediately taking effect sometimes, and needed a server restart to reflect. diff --git a/.changeset/new-balloons-speak.md b/.changeset/new-balloons-speak.md new file mode 100644 index 000000000000..7d4e7cd3a57e --- /dev/null +++ b/.changeset/new-balloons-speak.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed web client crashing on Firefox private window. Firefox disables access to service workers inside private windows. Rocket.Chat needs service workers to process E2EE encrypted files on rooms. These types of files won't be available inside private windows, but the rest of E2EE encrypted features should work normally diff --git a/.changeset/new-scissors-love.md b/.changeset/new-scissors-love.md new file mode 100644 index 000000000000..fb962407b353 --- /dev/null +++ b/.changeset/new-scissors-love.md @@ -0,0 +1,12 @@ +--- +'@rocket.chat/omnichannel-services': minor +'@rocket.chat/pdf-worker': minor +'@rocket.chat/core-services': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Added system messages support for Omnichannel PDF transcripts and email transcripts. Currently these transcripts don't render system messages and is shown as an empty message in PDF/email. This PR adds this support for all valid livechat system messages. + +Also added a new setting under transcripts, to toggle the inclusion of system messages in email and PDF transcripts. diff --git a/.changeset/nice-laws-eat.md b/.changeset/nice-laws-eat.md new file mode 100644 index 000000000000..e99e4f219ef9 --- /dev/null +++ b/.changeset/nice-laws-eat.md @@ -0,0 +1,15 @@ +--- +'rocketchat-services': minor +'@rocket.chat/core-services': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/ui-video-conf': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/ui-contexts': minor +'@rocket.chat/models': minor +'@rocket.chat/ui-kit': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +New Feature: Video Conference Persistent Chat. +This feature provides a discussion id for conference provider apps to store the chat messages exchanged during the conferences, so that those users may then access those messages again at any time through Rocket.Chat. \ No newline at end of file diff --git a/.changeset/perfect-coins-camp.md b/.changeset/perfect-coins-camp.md new file mode 100644 index 000000000000..4dbddf965742 --- /dev/null +++ b/.changeset/perfect-coins-camp.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed an issue in the "Create discussion" form, that would have the "Create" action button disabled even though the form is prefilled when opening it from the message action diff --git a/.changeset/polite-foxes-repair.md b/.changeset/polite-foxes-repair.md new file mode 100644 index 000000000000..2f524c7e5f10 --- /dev/null +++ b/.changeset/polite-foxes-repair.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Added a method to the Apps-Engine that allows apps to read multiple messages from a room diff --git a/.changeset/popular-trees-lay.md b/.changeset/popular-trees-lay.md new file mode 100644 index 000000000000..f38ef1f92367 --- /dev/null +++ b/.changeset/popular-trees-lay.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Removed 'Hide' option in the room menu for Omnichannel conversations. diff --git a/.changeset/proud-waves-bathe.md b/.changeset/proud-waves-bathe.md new file mode 100644 index 000000000000..556fa3af80e1 --- /dev/null +++ b/.changeset/proud-waves-bathe.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/model-typings": minor +--- + +Improved Engagement Dashboard's "Channels" tab performance by not returning rooms that had no activity in the analyzed period diff --git a/.changeset/quick-ducks-live.md b/.changeset/quick-ducks-live.md new file mode 100644 index 000000000000..ad628c13d087 --- /dev/null +++ b/.changeset/quick-ducks-live.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed LDAP rooms, teams and roles syncs not being triggered on login even when the "Update User Data on Login" setting is enabled diff --git a/.changeset/red-numbers-happen.md b/.changeset/red-numbers-happen.md new file mode 100644 index 000000000000..61cb0d2b7586 --- /dev/null +++ b/.changeset/red-numbers-happen.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed "Copy link" message action enabled in Starred and Pinned list for End to End Encrypted channels, this action is disabled now diff --git a/.changeset/red-vans-shave.md b/.changeset/red-vans-shave.md new file mode 100644 index 000000000000..ddf76535087e --- /dev/null +++ b/.changeset/red-vans-shave.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed issue that caused unintentional clicks when scrolling the channels sidebar on safari/chrome in iOS diff --git a/.changeset/rich-carpets-brush.md b/.changeset/rich-carpets-brush.md new file mode 100644 index 000000000000..16741e31e54a --- /dev/null +++ b/.changeset/rich-carpets-brush.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed some anomalies related to disabled E2EE rooms. Earlier there are some weird issues with disabled E2EE rooms, this PR fixes these anomalies. diff --git a/.changeset/rotten-eggs-end.md b/.changeset/rotten-eggs-end.md new file mode 100644 index 000000000000..7d0ad6ee5047 --- /dev/null +++ b/.changeset/rotten-eggs-end.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": patch +"@rocket.chat/ui-client": patch +--- + +Implemented a new tab to the users page called 'Active', this tab lists all users who have logged in for the first time and are active. diff --git a/.changeset/selfish-emus-sing.md b/.changeset/selfish-emus-sing.md new file mode 100644 index 000000000000..315d674a1857 --- /dev/null +++ b/.changeset/selfish-emus-sing.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +--- + +Added account setting `Accounts_Default_User_Preferences_sidebarSectionsOrder` to allow users to reorganize sidebar sections diff --git a/.changeset/shaggy-hats-raise.md b/.changeset/shaggy-hats-raise.md new file mode 100644 index 000000000000..40ee9f8fbb55 --- /dev/null +++ b/.changeset/shaggy-hats-raise.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": minor +--- + +Added a new setting `Livechat_transcript_send_always` that allows admins to decide if email transcript should be sent all the times when a conversation is closed. This setting bypasses agent's preferences. For this setting to work, `Livechat_enable_transcript` should be off, meaning that visitors will no longer receive the option to decide if they want a transcript or not. diff --git a/.changeset/sixty-nails-clean.md b/.changeset/sixty-nails-clean.md new file mode 100644 index 000000000000..7d13e02f0bd3 --- /dev/null +++ b/.changeset/sixty-nails-clean.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed an issue that prevented the option to start a discussion from being shown on the message actions diff --git a/.changeset/smooth-lobsters-flash.md b/.changeset/smooth-lobsters-flash.md new file mode 100644 index 000000000000..541d5069ee9c --- /dev/null +++ b/.changeset/smooth-lobsters-flash.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix show correct user roles after updating user roles on admin edit user panel. diff --git a/.changeset/soft-donkeys-thank.md b/.changeset/soft-donkeys-thank.md new file mode 100644 index 000000000000..7273ddcffca4 --- /dev/null +++ b/.changeset/soft-donkeys-thank.md @@ -0,0 +1,8 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/mock-providers": patch +"@rocket.chat/ui-contexts": patch +"@rocket.chat/web-ui-registration": patch +--- + +Fixed an issue with blocked login when dismissed 2FA modal by clicking outside of it or pressing the escape key diff --git a/.changeset/sour-forks-breathe.md b/.changeset/sour-forks-breathe.md new file mode 100644 index 000000000000..2d1076845fa9 --- /dev/null +++ b/.changeset/sour-forks-breathe.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": minor +--- + +Extended apps-engine events for users leaving a room to also fire when being removed by another user. Also added the triggering user's information to the event's context payload. diff --git a/.changeset/thin-windows-reply.md b/.changeset/thin-windows-reply.md new file mode 100644 index 000000000000..1a32e1ddebfb --- /dev/null +++ b/.changeset/thin-windows-reply.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue not displaying all groups in settings list diff --git a/.changeset/violet-brooms-press.md b/.changeset/violet-brooms-press.md new file mode 100644 index 000000000000..632026d6fe2e --- /dev/null +++ b/.changeset/violet-brooms-press.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/guides/security/security-updates) diff --git a/.changeset/weak-insects-sort.md b/.changeset/weak-insects-sort.md new file mode 100644 index 000000000000..cbbe7c4aa08c --- /dev/null +++ b/.changeset/weak-insects-sort.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Improving UX by change the position of room info actions buttons and menu order to avoid missclick in destructive actions. diff --git a/.changeset/weak-pets-talk.md b/.changeset/weak-pets-talk.md new file mode 100644 index 000000000000..abaa9c683d65 --- /dev/null +++ b/.changeset/weak-pets-talk.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/omnichannel-services': patch +'@rocket.chat/core-services': patch +'@rocket.chat/meteor': patch +--- + +Reduced time on generation of PDF transcripts. Earlier Rocket.Chat was fetching the required translations everytime a PDF transcript was requested, this process was async and was being unnecessarily being performed on every pdf transcript request. This PR improves this and now the translations are loaded at the start and kept in memory to process further pdf transcripts requests. This reduces the time of asynchronously fetching translations again and again. diff --git a/.changeset/weak-taxis-design.md b/.changeset/weak-taxis-design.md new file mode 100644 index 000000000000..a2d435495cd7 --- /dev/null +++ b/.changeset/weak-taxis-design.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Added handling of attachments in Omnichannel email transcripts. Earlier attachments were being skipped and were being shown as empty space, now it should render the image attachments and should show relevant error message for unsupported attachments. diff --git a/.changeset/weak-tigers-suffer.md b/.changeset/weak-tigers-suffer.md new file mode 100644 index 000000000000..91748a43c677 --- /dev/null +++ b/.changeset/weak-tigers-suffer.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/model-typings": minor +"@rocket.chat/rest-typings": minor +--- + +Added the ability to filter chats by `queued` on the Current Chats Omnichannel page diff --git a/.changeset/witty-bats-develop.md b/.changeset/witty-bats-develop.md new file mode 100644 index 000000000000..42c9409d9ef3 --- /dev/null +++ b/.changeset/witty-bats-develop.md @@ -0,0 +1,13 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/apps": patch +"@rocket.chat/core-services": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/fuselage-ui-kit": patch +"@rocket.chat/rest-typings": patch +"@rocket.chat/ddp-streamer": patch +"@rocket.chat/presence": patch +"rocketchat-services": patch +--- + +Added the `user` param to apps-engine update method call, allowing apps' new `onUpdate` hook to know who triggered the update. diff --git a/README.md b/README.md index 64dec811e1ca..56e38c111e97 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ yarn dsv # run only meteor (front and back) with pre-built packages After initialized, you can access the server at http://localhost:3000 +More details at: [Developer Docs](https://developer.rocket.chat/v1/docs/server-environment-setup) +PS: For Windows you MUST use WSL2 and have +12Gb RAM + + # Gitpod Setup 1. Click the button below to open this project in Gitpod. diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index a3458db7863b..75ffb7f02d7a 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,5 +1,47 @@ # @rocket.chat/meteor +## 6.10.1 + +### Patch Changes + +- Bump @rocket.chat/meteor version. + +- Bump @rocket.chat/meteor version. + +- ([#32819](https://github.com/RocketChat/Rocket.Chat/pull/32819) by [@dionisio-bot](https://github.com/dionisio-bot)) Fixed issue with livechat agents not being able to leave omnichannel rooms if joining after a room has been closed by the visitor (due to race conditions) + +- ([#32894](https://github.com/RocketChat/Rocket.Chat/pull/32894)) Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) + +- ([#32829](https://github.com/RocketChat/Rocket.Chat/pull/32829) by [@dionisio-bot](https://github.com/dionisio-bot)) Fixes an issue not displaying all groups in settings list + +- ([#32836](https://github.com/RocketChat/Rocket.Chat/pull/32836) by [@dionisio-bot](https://github.com/dionisio-bot)) Security Hotfix (https://docs.rocket.chat/guides/security/security-updates) + +-
Updated dependencies []: + + - @rocket.chat/core-typings@6.10.1 + - @rocket.chat/rest-typings@6.10.1 + - @rocket.chat/api-client@0.2.1 + - @rocket.chat/license@0.2.1 + - @rocket.chat/omnichannel-services@0.2.1 + - @rocket.chat/pdf-worker@0.1.1 + - @rocket.chat/presence@0.2.1 + - @rocket.chat/apps@0.1.1 + - @rocket.chat/core-services@0.4.1 + - @rocket.chat/cron@0.1.1 + - @rocket.chat/fuselage-ui-kit@8.0.1 + - @rocket.chat/gazzodown@8.0.1 + - @rocket.chat/model-typings@0.5.1 + - @rocket.chat/ui-contexts@8.0.1 + - @rocket.chat/server-cloud-communication@0.0.2 + - @rocket.chat/models@0.1.1 + - @rocket.chat/ui-theming@0.2.0 + - @rocket.chat/ui-avatar@4.0.1 + - @rocket.chat/ui-client@8.0.1 + - @rocket.chat/ui-video-conf@8.0.1 + - @rocket.chat/web-ui-registration@8.0.1 + - @rocket.chat/instance-status@0.1.1 +
+ ## 6.10.0 ### Minor Changes diff --git a/apps/meteor/app/2fa/server/methods/enable.ts b/apps/meteor/app/2fa/server/methods/enable.ts index 3b9f35dfcd9d..6b786c0743e9 100644 --- a/apps/meteor/app/2fa/server/methods/enable.ts +++ b/apps/meteor/app/2fa/server/methods/enable.ts @@ -34,6 +34,10 @@ Meteor.methods({ }); } + if (user.services?.totp?.enabled) { + throw new Meteor.Error('error-2fa-already-enabled'); + } + const secret = TOTP.generateSecret(); await Users.disable2FAAndSetTempSecretByUserId(userId, secret.base32); diff --git a/apps/meteor/app/api/server/lib/getUploadFormData.ts b/apps/meteor/app/api/server/lib/getUploadFormData.ts index 85fc0658542d..3136a6c16e13 100644 --- a/apps/meteor/app/api/server/lib/getUploadFormData.ts +++ b/apps/meteor/app/api/server/lib/getUploadFormData.ts @@ -63,7 +63,7 @@ export async function getUploadFormData< function onFile( fieldname: string, file: Readable & { truncated: boolean }, - { filename, encoding }: { filename: string; encoding: string }, + { filename, encoding, mimeType: mimetype }: { filename: string; encoding: string; mimeType: string }, ) { if (options.field && fieldname !== options.field) { file.resume(); @@ -85,7 +85,7 @@ export async function getUploadFormData< file, filename, encoding, - mimetype: getMimeType(filename), + mimetype: getMimeType(mimetype, filename), fieldname, fields, fileBuffer: Buffer.concat(fileChunks), diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index c482e3bb784d..3ccc9caeafa0 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -18,6 +18,7 @@ import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage' import { OEmbed } from '../../../oembed/server/server'; import { executeSetReaction } from '../../../reactions/server/setReaction'; import { settings } from '../../../settings/server'; +import { MessageTypes } from '../../../ui-utils/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; @@ -217,6 +218,10 @@ API.v1.addRoute( throw new Meteor.Error('error-invalid-params', 'The "message" parameter must be provided.'); } + if (MessageTypes.isSystemMessage(this.bodyParams.message)) { + throw new Error("Cannot send system messages using 'chat.sendMessage'"); + } + const sent = await executeSendMessage(this.userId, this.bodyParams.message as Pick, this.bodyParams.previewUrls); const [message] = await normalizeMessagesForUser([sent], this.userId); diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index c26957fa1991..26ef2fa30ff2 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -19,6 +19,7 @@ import { isUsersCheckUsernameAvailabilityParamsGET, isUsersSendConfirmationEmailParamsPOST, } from '@rocket.chat/rest-typings'; +import { getLoginExpirationInMs } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -43,6 +44,7 @@ import { setStatusText } from '../../../lib/server/functions/setStatusText'; import { setUserAvatar } from '../../../lib/server/functions/setUserAvatar'; import { setUsernameWithValidation } from '../../../lib/server/functions/setUsername'; import { validateCustomFields } from '../../../lib/server/functions/validateCustomFields'; +import { validateNameChars } from '../../../lib/server/functions/validateNameChars'; import { notifyOnUserChange, notifyOnUserChangeAsync } from '../../../lib/server/lib/notifyListener'; import { generateAccessToken } from '../../../lib/server/methods/createToken'; import { settings } from '../../../settings/server'; @@ -94,6 +96,10 @@ API.v1.addRoute( async post() { const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data }; + if (userData.name && !validateNameChars(userData.name)) { + return API.v1.failure('Name contains invalid characters'); + } + await saveUser(this.userId, userData); if (this.bodyParams.data.customFields) { @@ -138,6 +144,10 @@ API.v1.addRoute( typedPassword: this.bodyParams.data.currentPassword, }; + if (userData.realname && !validateNameChars(userData.realname)) { + return API.v1.failure('Name contains invalid characters'); + } + // saveUserProfile now uses the default two factor authentication procedures, so we need to provide that const twoFactorOptions = !userData.typedPassword ? null @@ -280,6 +290,10 @@ API.v1.addRoute( this.bodyParams.joinDefaultChannels = true; } + if (this.bodyParams.name && !validateNameChars(this.bodyParams.name)) { + return API.v1.failure('Name contains invalid characters'); + } + if (this.bodyParams.customFields) { validateCustomFields(this.bodyParams.customFields); } @@ -627,16 +641,20 @@ API.v1.addRoute( }, { async post() { + const { secret: secretURL, ...params } = this.bodyParams; + if (this.userId) { return API.v1.failure('Logged in users can not register again.'); } + if (params.name && !validateNameChars(params.name)) { + return API.v1.failure('Name contains invalid characters'); + } + if (!(await checkUsernameAvailability(this.bodyParams.username))) { return API.v1.failure('Username is already in use'); } - const { secret: secretURL, ...params } = this.bodyParams; - if (this.bodyParams.customFields) { try { await validateCustomFields(this.bodyParams.customFields); @@ -1048,8 +1066,9 @@ API.v1.addRoute( const token = me.services?.resume?.loginTokens?.find((token) => token.hashedToken === hashedToken); - const tokenExpires = - (token && 'when' in token && new Date(token.when.getTime() + settings.get('Accounts_LoginExpiration') * 1000)) || undefined; + const loginExp = settings.get('Accounts_LoginExpiration'); + + const tokenExpires = (token && 'when' in token && new Date(token.when.getTime() + getLoginExpirationInMs(loginExp))) || undefined; return API.v1.success({ token: xAuthToken, diff --git a/apps/meteor/app/apps/server/bridges/listeners.js b/apps/meteor/app/apps/server/bridges/listeners.js index ab2632c912b0..13db1179310c 100644 --- a/apps/meteor/app/apps/server/bridges/listeners.js +++ b/apps/meteor/app/apps/server/bridges/listeners.js @@ -143,10 +143,11 @@ export class AppListenerBridge { }; case AppInterface.IPreRoomUserLeave: case AppInterface.IPostRoomUserLeave: - const [leavingUser] = payload; + const [leavingUser, removedBy] = payload; return { room: rm, leavingUser: this.orch.getConverters().get('users').convertToApp(leavingUser), + removedBy: this.orch.getConverters().get('users').convertToApp(removedBy), }; default: return rm; diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 4e2f8e69acad..ec5cff29a99b 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -246,7 +246,8 @@ export class AppLivechatBridge extends LivechatBridge { username, name, type, - }; + userType: 'user', + } as const; let userId; let transferredTo; diff --git a/apps/meteor/app/apps/server/bridges/rooms.ts b/apps/meteor/app/apps/server/bridges/rooms.ts index 86817c5721e2..344acc74bda4 100644 --- a/apps/meteor/app/apps/server/bridges/rooms.ts +++ b/apps/meteor/app/apps/server/bridges/rooms.ts @@ -1,11 +1,13 @@ import type { IAppServerOrchestrator } from '@rocket.chat/apps'; -import type { IMessage } from '@rocket.chat/apps-engine/definition/messages'; +import type { IMessage, IMessageRaw } from '@rocket.chat/apps-engine/definition/messages'; import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; import { RoomType } from '@rocket.chat/apps-engine/definition/rooms'; import type { IUser } from '@rocket.chat/apps-engine/definition/users'; +import type { GetMessagesOptions } from '@rocket.chat/apps-engine/server/bridges/RoomBridge'; import { RoomBridge } from '@rocket.chat/apps-engine/server/bridges/RoomBridge'; -import type { ISubscription, IUser as ICoreUser, IRoom as ICoreRoom } from '@rocket.chat/core-typings'; -import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; +import type { ISubscription, IUser as ICoreUser, IRoom as ICoreRoom, IMessage as ICoreMessage } from '@rocket.chat/core-typings'; +import { Subscriptions, Users, Rooms, Messages } from '@rocket.chat/models'; +import type { FindOptions, Sort } from 'mongodb'; import { createDirectMessage } from '../../../../server/methods/createDirectMessage'; import { createDiscussion } from '../../../discussion/server/methods/createDiscussion'; @@ -103,6 +105,38 @@ export class AppRoomBridge extends RoomBridge { return this.orch.getConverters()?.get('users').convertById(room.u._id); } + protected async getMessages(roomId: string, options: GetMessagesOptions, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the messages of the room: "${roomId}" with options:`, options); + + const { limit, skip = 0, sort: _sort } = options; + + const messageConverter = this.orch.getConverters()?.get('messages'); + if (!messageConverter) { + throw new Error('Message converter not found'); + } + + // We support only one field for now + const sort: Sort | undefined = _sort?.createdAt ? { ts: _sort.createdAt } : undefined; + + const messageQueryOptions: FindOptions = { + limit, + skip, + sort, + }; + + const query = { + rid: roomId, + _hidden: { $ne: true }, + t: { $exists: false }, + }; + + const cursor = Messages.find(query, messageQueryOptions); + + const messagePromises: Promise[] = await cursor.map((message) => messageConverter.convertMessageRaw(message)).toArray(); + + return Promise.all(messagePromises); + } + protected async getMembers(roomId: string, appId: string): Promise> { this.orch.debugLog(`The App ${appId} is getting the room's members by room id: "${roomId}"`); const subscriptions = await Subscriptions.findByRoomId(roomId, {}); @@ -220,12 +254,4 @@ export class AppRoomBridge extends RoomBridge { const members = await Users.findUsersByUsernames(usernames, { limit: 50 }).toArray(); await Promise.all(members.map((user) => removeUserFromRoom(roomId, user))); } - - protected getMessages( - _roomId: string, - _options: { limit: number; skip?: number; sort?: Record }, - _appId: string, - ): Promise { - throw new Error('Method not implemented.'); - } } diff --git a/apps/meteor/app/apps/server/bridges/videoConferences.ts b/apps/meteor/app/apps/server/bridges/videoConferences.ts index bebcb25a6f51..efab0f201f87 100644 --- a/apps/meteor/app/apps/server/bridges/videoConferences.ts +++ b/apps/meteor/app/apps/server/bridges/videoConferences.ts @@ -59,6 +59,10 @@ export class AppVideoConferenceBridge extends VideoConferenceBridge { if (data.status > oldData.status) { await VideoConf.setStatus(call._id, data.status); } + + if (data.discussionRid !== oldData.discussionRid) { + await VideoConf.assignDiscussionToConference(call._id, data.discussionRid); + } } protected async registerProvider(info: IVideoConfProvider, appId: string): Promise { diff --git a/apps/meteor/app/apps/server/converters/cachedFunction.ts b/apps/meteor/app/apps/server/converters/cachedFunction.ts new file mode 100644 index 000000000000..3310574f0160 --- /dev/null +++ b/apps/meteor/app/apps/server/converters/cachedFunction.ts @@ -0,0 +1,17 @@ +export const cachedFunction = any>(fn: F) => { + const cache = new Map(); + + return ((...args) => { + const cacheKey = JSON.stringify(args); + + if (cache.has(cacheKey)) { + return cache.get(cacheKey) as ReturnType; + } + + const result = fn(...args); + + cache.set(cacheKey, result); + + return result; + }) as F; +}; diff --git a/apps/meteor/app/apps/server/converters/messages.js b/apps/meteor/app/apps/server/converters/messages.js index 187a6519339a..d7dae512e9a8 100644 --- a/apps/meteor/app/apps/server/converters/messages.js +++ b/apps/meteor/app/apps/server/converters/messages.js @@ -1,9 +1,13 @@ +import { isMessageFromVisitor } from '@rocket.chat/core-typings'; import { Messages, Rooms, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; +import { cachedFunction } from './cachedFunction'; import { transformMappedData } from './transformMappedData'; export class AppMessagesConverter { + mem = new WeakMap(); + constructor(orch) { this.orch = orch; } @@ -14,11 +18,54 @@ export class AppMessagesConverter { return this.convertMessage(msg); } + async convertMessageRaw(msgObj) { + if (!msgObj) { + return undefined; + } + + const { attachments, ...message } = msgObj; + const getAttachments = async () => this._convertAttachmentsToApp(attachments); + + const map = { + id: '_id', + threadId: 'tmid', + reactions: 'reactions', + parseUrls: 'parseUrls', + text: 'msg', + createdAt: 'ts', + updatedAt: '_updatedAt', + editedAt: 'editedAt', + emoji: 'emoji', + avatarUrl: 'avatar', + alias: 'alias', + file: 'file', + customFields: 'customFields', + groupable: 'groupable', + token: 'token', + blocks: 'blocks', + roomId: 'rid', + editor: 'editedBy', + attachments: getAttachments, + sender: 'u', + }; + + return transformMappedData(message, map); + } + async convertMessage(msgObj) { if (!msgObj) { return undefined; } + const cache = + this.mem.get(msgObj) ?? + new Map([ + ['room', cachedFunction(this.orch.getConverters().get('rooms').convertById.bind(this.orch.getConverters().get('rooms')))], + ['user', cachedFunction(this.orch.getConverters().get('users').convertById.bind(this.orch.getConverters().get('users')))], + ]); + + this.mem.set(msgObj, cache); + const map = { id: '_id', threadId: 'tmid', @@ -37,7 +84,7 @@ export class AppMessagesConverter { token: 'token', blocks: 'blocks', room: async (message) => { - const result = await this.orch.getConverters().get('rooms').convertById(message.rid); + const result = await cache.get('room')(message.rid); delete message.rid; return result; }, @@ -49,7 +96,7 @@ export class AppMessagesConverter { return undefined; } - return this.orch.getConverters().get('users').convertById(editedBy._id); + return cache.get('user')(editedBy._id); }, attachments: async (message) => { const result = await this._convertAttachmentsToApp(message.attachments); @@ -61,16 +108,19 @@ export class AppMessagesConverter { return undefined; } - let user = await this.orch.getConverters().get('users').convertById(message.u._id); - - // When the sender of the message is a Guest (livechat) and not a user - if (!user) { - user = this.orch.getConverters().get('users').convertToApp(message.u); - } + // When the message contains token, means the message is from the visitor(omnichannel) + const user = await (isMessageFromVisitor(msgObj) + ? this.orch.getConverters().get('users').convertToApp(message.u) + : cache.get('user')(message.u._id)); delete message.u; - return user; + /** + * Old System Messages from visitor doesn't have the `token` field, to not return + * `sender` as undefined, so we need to add this fallback here. + */ + + return user || this.orch.getConverters().get('users').convertToApp(message.u); }, }; diff --git a/apps/meteor/app/apps/server/converters/threads.ts b/apps/meteor/app/apps/server/converters/threads.ts index 840f4f1613eb..e31ee094b4d7 100644 --- a/apps/meteor/app/apps/server/converters/threads.ts +++ b/apps/meteor/app/apps/server/converters/threads.ts @@ -5,6 +5,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { isEditedMessage, type IMessage } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; +import { cachedFunction } from './cachedFunction'; import { transformMappedData } from './transformMappedData'; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -18,24 +19,6 @@ interface Orchestrator { }; } -const cachedFunction = any>(fn: F) => { - const cache = new Map(); - - return ((...args) => { - const cacheKey = JSON.stringify(args); - - if (cache.has(cacheKey)) { - return cache.get(cacheKey) as ReturnType; - } - - const result = fn(...args); - - cache.set(cacheKey, result); - - return result; - }) as F; -}; - export class AppThreadsConverter implements IAppThreadsConverter { constructor( private readonly orch: { diff --git a/apps/meteor/app/authentication/server/startup/index.js b/apps/meteor/app/authentication/server/startup/index.js index bffbe1f9876d..2e4c599ce558 100644 --- a/apps/meteor/app/authentication/server/startup/index.js +++ b/apps/meteor/app/authentication/server/startup/index.js @@ -2,6 +2,7 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { User } from '@rocket.chat/core-services'; import { Roles, Settings, Users } from '@rocket.chat/models'; import { escapeRegExp, escapeHTML } from '@rocket.chat/string-helpers'; +import { getLoginExpirationInDays } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -31,7 +32,7 @@ Accounts.config({ Meteor.startup(() => { settings.watchMultiple(['Accounts_LoginExpiration', 'Site_Name', 'From_Email'], () => { - Accounts._options.loginExpirationInDays = settings.get('Accounts_LoginExpiration'); + Accounts._options.loginExpirationInDays = getLoginExpirationInDays(settings.get('Accounts_LoginExpiration')); Accounts.emailTemplates.siteName = settings.get('Site_Name'); diff --git a/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts b/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts index 3ad61c4c42f0..ecf014248830 100644 --- a/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts +++ b/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts @@ -44,7 +44,7 @@ Meteor.startup(() => { subscription, user, }) { - if (drid || !Number.isNaN(dcount)) { + if (drid || !Number.isNaN(Number(dcount))) { return false; } if (!subscription) { diff --git a/apps/meteor/app/discussion/client/index.ts b/apps/meteor/app/discussion/client/index.ts index 62e11191b493..7c0a6f72e6cc 100644 --- a/apps/meteor/app/discussion/client/index.ts +++ b/apps/meteor/app/discussion/client/index.ts @@ -1,3 +1,2 @@ // Other UI extensions -import './lib/messageTypes/discussionMessage'; import './createDiscussionMessageAction'; diff --git a/apps/meteor/app/discussion/client/lib/messageTypes/discussionMessage.js b/apps/meteor/app/discussion/client/lib/messageTypes/discussionMessage.js deleted file mode 100644 index a7f0ef0a1d97..000000000000 --- a/apps/meteor/app/discussion/client/lib/messageTypes/discussionMessage.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { MessageTypes } from '../../../../ui-utils/client'; - -Meteor.startup(() => { - MessageTypes.registerType({ - id: 'discussion-created', - system: false, - message: 'discussion-created', - data(message) { - return { - message: ` ${message.msg}`, - }; - }, - }); -}); diff --git a/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts b/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts index 0f42f495e962..d8e3637575ab 100644 --- a/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts +++ b/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts @@ -1,5 +1,5 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { Messages, Rooms } from '@rocket.chat/models'; +import { Messages, Rooms, VideoConference } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; @@ -108,6 +108,8 @@ callbacks.add( }, }, ); + + await VideoConference.unsetDiscussionRid(drid); return drid; }, callbacks.priority.LOW, diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 0cc344ff5152..bbd6f208f35a 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -257,7 +257,7 @@ class E2E extends Emitter { return null; } - if (room.encrypted !== true && !room.e2eKeyId) { + if (!room.encrypted) { return null; } @@ -272,7 +272,7 @@ class E2E extends Emitter { delete this.instancesByRoomId[rid]; } - async persistKeys( + private async persistKeys( { public_key, private_key }: KeyPair, password: string, { force }: { force: boolean } = { force: false }, diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 57ea20f00cb1..b6ffc0ca4629 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -15,9 +15,15 @@ import { notifyOnRoomChangedById } from '../lib/notifyListener'; export const addUserToRoom = async function ( rid: string, - user: Pick | string, + user: Pick | string, inviter?: Pick, - silenced?: boolean, + { + skipSystemMessage, + skipAlertSound, + }: { + skipSystemMessage?: boolean; + skipAlertSound?: boolean; + } = {}, ): Promise { const now = new Date(); const room = await Rooms.findOneById(rid); @@ -43,12 +49,12 @@ export const addUserToRoom = async function ( } try { - await callbacks.run('federation.beforeAddUserToARoom', { user, inviter }, room); + await callbacks.run('federation.beforeAddUserToARoom', { user: userToBeAdded, inviter }, room); } catch (error) { throw new Meteor.Error((error as any)?.message); } - await callbacks.run('beforeAddedToRoom', { user: userToBeAdded, inviter: userToBeAdded }); + await callbacks.run('beforeAddedToRoom', { user: userToBeAdded, inviter }); // Check if user is already in room const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id); @@ -79,7 +85,7 @@ export const addUserToRoom = async function ( await Subscriptions.createWithRoomAndUser(room, userToBeAdded as IUser, { ts: now, open: true, - alert: true, + alert: !skipAlertSound, unread: 1, userMentions: 1, groupMentions: 0, @@ -93,7 +99,7 @@ export const addUserToRoom = async function ( throw new Meteor.Error('error-invalid-user', 'Cannot add an user to a room without a username'); } - if (!silenced) { + if (!skipSystemMessage) { if (inviter) { const extraData = { ts: now, diff --git a/apps/meteor/app/lib/server/functions/checkUrlForSsrf.ts b/apps/meteor/app/lib/server/functions/checkUrlForSsrf.ts new file mode 100644 index 000000000000..c90065d7ad8f --- /dev/null +++ b/apps/meteor/app/lib/server/functions/checkUrlForSsrf.ts @@ -0,0 +1,100 @@ +import { lookup } from 'dns'; + +// https://en.wikipedia.org/wiki/Reserved_IP_addresses + Alibaba Metadata IP +const ranges: string[] = [ + '0.0.0.0/8', + '10.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.0.0.0/24', + '192.0.2.0/24', + '192.88.99.0/24', + '192.168.0.0/16', + '198.18.0.0/15', + '198.51.100.0/24', + '203.0.113.0/24', + '224.0.0.0/4', + '240.0.0.0/4', + '255.255.255.255', + '100.100.100.200/32', +]; + +export const nslookup = async (hostname: string): Promise => { + return new Promise((resolve, reject) => { + lookup(hostname, (error, address) => { + if (error) { + reject(error); + } else { + resolve(address); + } + }); + }); +}; + +export const ipToLong = (ip: string): number => { + return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; +}; + +export const isIpInRange = (ip: string, range: string): boolean => { + const [rangeIp, subnet] = range.split('/'); + const ipLong = ipToLong(ip); + const rangeIpLong = ipToLong(rangeIp); + const mask = ~(2 ** (32 - Number(subnet)) - 1); + return (ipLong & mask) === (rangeIpLong & mask); +}; + +export const isIpInAnyRange = (ip: string): boolean => ranges.some((range) => isIpInRange(ip, range)); + +export const isValidIPv4 = (ip: string): boolean => { + const octets = ip.split('.'); + if (octets.length !== 4) return false; + return octets.every((octet) => { + const num = Number(octet); + return num >= 0 && num <= 255 && octet === num.toString(); + }); +}; + +export const isValidDomain = (domain: string): boolean => { + const domainPattern = /^(?!-)(?!.*--)[A-Za-z0-9-]{1,63}(? => { + if (!(url.startsWith('http://') || url.startsWith('https://'))) { + return false; + } + + const [, address] = url.split('://'); + const ipOrDomain = address.includes('/') ? address.split('/')[0] : address; + + if (!(isValidIPv4(ipOrDomain) || isValidDomain(ipOrDomain))) { + return false; + } + + if (isValidIPv4(ipOrDomain) && isIpInAnyRange(ipOrDomain)) { + return false; + } + + if (isValidDomain(ipOrDomain) && /metadata.google.internal/.test(ipOrDomain.toLowerCase())) { + return false; + } + + if (isValidDomain(ipOrDomain)) { + try { + const ipAddress = await nslookup(ipOrDomain); + if (isIpInAnyRange(ipAddress)) { + return false; + } + } catch (error) { + console.log(error); + return false; + } + } + + return true; +}; diff --git a/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts b/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts new file mode 100644 index 000000000000..b716be044d57 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts @@ -0,0 +1,81 @@ +import type { IUser, IRoom, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms, Subscriptions } from '@rocket.chat/models'; + +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import type { CloseRoomParams } from '../../../livechat/server/lib/LivechatTyped'; +import { Livechat } from '../../../livechat/server/lib/LivechatTyped'; + +export const closeLivechatRoom = async ( + user: IUser, + roomId: IRoom['_id'], + { + comment, + tags, + generateTranscriptPdf, + transcriptEmail, + }: { + comment?: string; + tags?: string[]; + generateTranscriptPdf?: boolean; + transcriptEmail?: + | { + sendToVisitor: false; + } + | { + sendToVisitor: true; + requestData: Pick, 'email' | 'subject'>; + }; + }, +): Promise => { + const room = await LivechatRooms.findOneById(roomId); + if (!room || !isOmnichannelRoom(room)) { + throw new Error('error-invalid-room'); + } + + if (!room.open) { + const subscriptionsLeft = await Subscriptions.countByRoomId(roomId); + if (subscriptionsLeft) { + await Subscriptions.removeByRoomId(roomId); + return; + } + throw new Error('error-room-already-closed'); + } + + const subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, user._id, { projection: { _id: 1 } }); + if (!subscription && !(await hasPermissionAsync(user._id, 'close-others-livechat-room'))) { + throw new Error('error-not-authorized'); + } + + const options: CloseRoomParams['options'] = { + clientAction: true, + tags, + ...(generateTranscriptPdf && { pdfTranscript: { requestedBy: user._id } }), + ...(transcriptEmail && { + ...(transcriptEmail.sendToVisitor + ? { + emailTranscript: { + sendToVisitor: true, + requestData: { + email: transcriptEmail.requestData.email, + subject: transcriptEmail.requestData.subject, + requestedAt: new Date(), + requestedBy: user, + }, + }, + } + : { + emailTranscript: { + sendToVisitor: false, + }, + }), + }), + }; + + await Livechat.closeRoom({ + room, + user, + options, + comment, + }); +}; diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index 3b065c68f15c..c55ee382f10c 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -10,11 +10,7 @@ import { beforeLeaveRoomCallback } from '../../../../lib/callbacks/beforeLeaveRo import { settings } from '../../../settings/server'; import { notifyOnRoomChangedById } from '../lib/notifyListener'; -export const removeUserFromRoom = async function ( - rid: string, - user: IUser, - options?: { byUser: Pick }, -): Promise { +export const removeUserFromRoom = async function (rid: string, user: IUser, options?: { byUser: IUser }): Promise { const room = await Rooms.findOneById(rid); if (!room) { @@ -22,7 +18,7 @@ export const removeUserFromRoom = async function ( } try { - await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user); + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user, options?.byUser); } catch (error: any) { if (error.name === AppsEngineException.name) { throw new Meteor.Error('error-app-prevented', error.message); @@ -75,5 +71,5 @@ export const removeUserFromRoom = async function ( void notifyOnRoomChangedById(rid); - await Apps.self?.triggerEvent(AppEvents.IPostRoomUserLeave, room, user); + await Apps.self?.triggerEvent(AppEvents.IPostRoomUserLeave, room, user, options?.byUser); }; diff --git a/apps/meteor/app/lib/server/functions/saveUser.js b/apps/meteor/app/lib/server/functions/saveUser.js index 1931333038b6..ef6a7e9fe7bd 100644 --- a/apps/meteor/app/lib/server/functions/saveUser.js +++ b/apps/meteor/app/lib/server/functions/saveUser.js @@ -69,6 +69,13 @@ async function _sendUserEmail(subject, html, userData) { async function validateUserData(userId, userData) { const existingRoles = _.pluck(await getRoles(), '_id'); + if (userData.verified && userData._id && userId === userData._id) { + throw new Meteor.Error('error-action-not-allowed', 'Editing email verification is not allowed', { + method: 'insertOrUpdateUser', + action: 'Editing_user', + }); + } + if (userData._id && userId !== userData._id && !(await hasPermissionAsync(userId, 'edit-other-user-info'))) { throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed', { method: 'insertOrUpdateUser', diff --git a/apps/meteor/app/lib/server/functions/setUserAvatar.ts b/apps/meteor/app/lib/server/functions/setUserAvatar.ts index b46f0ff8cd50..13ccd2de6954 100644 --- a/apps/meteor/app/lib/server/functions/setUserAvatar.ts +++ b/apps/meteor/app/lib/server/functions/setUserAvatar.ts @@ -10,6 +10,7 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP import { FileUpload } from '../../../file-upload/server'; import { RocketChatFile } from '../../../file/server'; import { settings } from '../../../settings/server'; +import { checkUrlForSsrf } from './checkUrlForSsrf'; export const setAvatarFromServiceWithValidation = async ( userId: string, @@ -88,8 +89,17 @@ export async function setUserAvatar( const { buffer, type } = await (async (): Promise<{ buffer: Buffer; type: string }> => { if (service === 'url' && typeof dataURI === 'string') { let response: Response; + + const isSsrfSafe = await checkUrlForSsrf(dataURI); + if (!isSsrfSafe) { + throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${encodeURI(dataURI)}`, { + function: 'setUserAvatar', + url: dataURI, + }); + } + try { - response = await fetch(dataURI); + response = await fetch(dataURI, { redirect: 'error' }); } catch (e) { SystemLogger.info(`Not a valid response, from the avatar url: ${encodeURI(dataURI)}`); throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${encodeURI(dataURI)}`, { diff --git a/apps/meteor/app/lib/server/functions/validateNameChars.ts b/apps/meteor/app/lib/server/functions/validateNameChars.ts new file mode 100644 index 000000000000..07330c66b762 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/validateNameChars.ts @@ -0,0 +1,21 @@ +export const validateNameChars = (name: string | undefined): boolean => { + if (typeof name !== 'string') { + return false; + } + + const invalidChars = /[<>\\/]/; + if (invalidChars.test(name)) { + return false; + } + + try { + const decodedName = decodeURI(name); + if (invalidChars.test(decodedName)) { + return false; + } + } catch (err) { + return false; + } + + return true; +}; diff --git a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts index 44654428ae8f..49fcc0ea4725 100644 --- a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts +++ b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts @@ -266,7 +266,7 @@ export async function sendMessageNotifications(message: IMessage, room: IRoom, u return; } - const sender = await roomCoordinator.getRoomDirectives(room.t).getMsgSender(message.u._id); + const sender = await roomCoordinator.getRoomDirectives(room.t).getMsgSender(message); if (!sender) { return message; } diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index a490b5c4c67f..a61ab499ce87 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -12,6 +12,7 @@ import { canSendMessageAsync } from '../../../authorization/server/functions/can import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; +import { MessageTypes } from '../../../ui-utils/server'; import { sendMessage } from '../functions/sendMessage'; import { RateLimiter } from '../lib'; @@ -78,6 +79,8 @@ export async function executeSendMessage(uid: IUser['_id'], message: AtLeast({ }); } + if (MessageTypes.isSystemMessage(message)) { + throw new Error("Cannot send system messages using 'sendMessage'"); + } + try { return await executeSendMessage(uid, message, previewUrls); } catch (error: any) { diff --git a/apps/meteor/app/livechat/imports/server/rest/rooms.ts b/apps/meteor/app/livechat/imports/server/rest/rooms.ts index f7d5ddb314c9..f80ed61a131e 100644 --- a/apps/meteor/app/livechat/imports/server/rest/rooms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/rooms.ts @@ -30,7 +30,7 @@ API.v1.addRoute( async get() { const { offset, count } = await getPaginationItems(this.queryParams); const { sort, fields } = await this.parseJsonQuery(); - const { agents, departmentId, open, tags, roomName, onhold } = this.queryParams; + const { agents, departmentId, open, tags, roomName, onhold, queued } = this.queryParams; const { createdAt, customFields, closedAt } = this.queryParams; const createdAtParam = validateDateParams('createdAt', createdAt); @@ -69,6 +69,7 @@ API.v1.addRoute( tags, customFields: parsedCf, onhold, + queued, options: { offset, count, sort, fields }, }), ); diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index b6669b5f0d89..6f8ce64bc635 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -17,6 +17,7 @@ import { Meteor } from 'meteor/meteor'; import { getFileExtension } from '../../../../../lib/utils/getFileExtension'; import { API } from '../../../../api/server'; import { FileUpload } from '../../../../file-upload/server'; +import { checkUrlForSsrf } from '../../../../lib/server/functions/checkUrlForSsrf'; import { settings } from '../../../../settings/server'; import type { ILivechatMessage } from '../../../server/lib/LivechatTyped'; import { Livechat as LivechatTyped } from '../../../server/lib/LivechatTyped'; @@ -24,7 +25,12 @@ import { Livechat as LivechatTyped } from '../../../server/lib/LivechatTyped'; const logger = new Logger('SMS'); const getUploadFile = async (details: Omit, fileUrl: string) => { - const response = await fetch(fileUrl); + const isSsrfSafe = await checkUrlForSsrf(fileUrl); + if (!isSsrfSafe) { + throw new Meteor.Error('error-invalid-url', 'Invalid URL'); + } + + const response = await fetch(fileUrl, { redirect: 'error' }); const content = Buffer.from(await response.arrayBuffer()); diff --git a/apps/meteor/app/livechat/server/api/lib/rooms.ts b/apps/meteor/app/livechat/server/api/lib/rooms.ts index b130e5c2c73a..26449dce3963 100644 --- a/apps/meteor/app/livechat/server/api/lib/rooms.ts +++ b/apps/meteor/app/livechat/server/api/lib/rooms.ts @@ -14,6 +14,7 @@ export async function findRooms({ tags, customFields, onhold, + queued, options: { offset, count, fields, sort }, }: { agents?: Array; @@ -31,6 +32,7 @@ export async function findRooms({ tags?: Array; customFields?: Record; onhold?: string | boolean; + queued?: string | boolean; options: { offset: number; count: number; fields: Record; sort: Record }; }): Promise }>> { const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); @@ -44,6 +46,7 @@ export async function findRooms({ tags, customFields, onhold: ['t', 'true', '1'].includes(`${onhold}`), + queued: ['t', 'true', '1'].includes(`${queued}`), options: { sort: sort || { ts: -1 }, offset, diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 8a663fb0bd6d..b0f45a63ff87 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -1,7 +1,7 @@ import { Omnichannel } from '@rocket.chat/core-services'; import type { ILivechatAgent, IUser, SelectedAgent, TransferByData } from '@rocket.chat/core-typings'; import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings'; -import { LivechatVisitors, Users, LivechatRooms, Subscriptions, Messages } from '@rocket.chat/models'; +import { LivechatVisitors, Users, LivechatRooms, Messages } from '@rocket.chat/models'; import { isLiveChatRoomForwardProps, isPOSTLivechatRoomCloseParams, @@ -21,6 +21,7 @@ import { isWidget } from '../../../../api/server/helpers/isWidget'; import { canAccessRoomAsync, roomAccessAttributes } from '../../../../authorization/server'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { addUserToRoom } from '../../../../lib/server/functions/addUserToRoom'; +import { closeLivechatRoom } from '../../../../lib/server/functions/closeLivechatRoom'; import { settings as rcSettings } from '../../../../settings/server'; import { normalizeTransferredByData } from '../../lib/Helper'; import type { CloseRoomParams } from '../../lib/LivechatTyped'; @@ -178,51 +179,7 @@ API.v1.addRoute( async post() { const { rid, comment, tags, generateTranscriptPdf, transcriptEmail } = this.bodyParams; - const room = await LivechatRooms.findOneById(rid); - if (!room || !isOmnichannelRoom(room)) { - throw new Error('error-invalid-room'); - } - - if (!room.open) { - throw new Error('error-room-already-closed'); - } - - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, this.userId, { projection: { _id: 1 } }); - if (!subscription && !(await hasPermissionAsync(this.userId, 'close-others-livechat-room'))) { - throw new Error('error-not-authorized'); - } - - const options: CloseRoomParams['options'] = { - clientAction: true, - tags, - ...(generateTranscriptPdf && { pdfTranscript: { requestedBy: this.userId } }), - ...(transcriptEmail && { - ...(transcriptEmail.sendToVisitor - ? { - emailTranscript: { - sendToVisitor: true, - requestData: { - email: transcriptEmail.requestData.email, - subject: transcriptEmail.requestData.subject, - requestedAt: new Date(), - requestedBy: this.user, - }, - }, - } - : { - emailTranscript: { - sendToVisitor: false, - }, - }), - }), - }; - - await LivechatTyped.closeRoom({ - room, - user: this.user, - options, - comment, - }); + await closeLivechatRoom(this.user, rid, { comment, tags, generateTranscriptPdf, transcriptEmail }); return API.v1.success(); }, @@ -336,8 +293,7 @@ API.v1.addRoute( throw new Error('error-invalid-visitor'); } - const transferedBy = this.user satisfies TransferByData; - transferData.transferredBy = normalizeTransferredByData(transferedBy, room); + transferData.transferredBy = normalizeTransferredByData(this.user, room); if (transferData.userId) { const userToTransfer = await Users.findOneById(transferData.userId); if (userToTransfer) { diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 01bb2ff34e9a..c0e85a8c7c2b 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -142,7 +142,9 @@ export const createLivechatRoom = async < await callbacks.run('livechat.newRoom', room); - await sendMessage(guest, { t: 'livechat-started', msg: '', groupable: false }, room); + // TODO: replace with `Message.saveSystemMessage` + + await sendMessage(guest, { t: 'livechat-started', msg: '', groupable: false, token: guest.token }, room); return result.value as IOmnichannelRoom; }; @@ -646,6 +648,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi '', { _id, username }, { + ...(transferData.transferredBy.userType === 'visitor' && { token: room.v.token }), transferData: { ...transferData, prevDepartment: transferData.originalDepartmentName, @@ -681,18 +684,23 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi return true; }; -export const normalizeTransferredByData = (transferredBy: TransferByData, room: IOmnichannelRoom) => { +type MakePropertyOptional = Omit & { [P in K]?: T[P] }; + +export const normalizeTransferredByData = ( + transferredBy: MakePropertyOptional, + room: IOmnichannelRoom, +): TransferByData => { if (!transferredBy || !room) { throw new Error('You must provide "transferredBy" and "room" params to "getTransferredByData"'); } const { servedBy: { _id: agentId } = {} } = room; const { _id, username, name, userType: transferType } = transferredBy; - const type = transferType || (_id === agentId ? 'agent' : 'user'); + const userType = transferType || (_id === agentId ? 'agent' : 'user'); return { _id, username, ...(name && { name }), - type, + userType, }; }; diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index bb56eb81ceb3..ccca7a8eb68e 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -9,7 +9,6 @@ import type { IUser, MessageTypesValues, ILivechatVisitor, - IOmnichannelSystemMessage, SelectedAgent, ILivechatAgent, IMessage, @@ -41,7 +40,6 @@ import { import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import moment from 'moment-timezone'; import type { Filter, FindCursor } from 'mongodb'; import UAParser from 'ua-parser-js'; @@ -68,12 +66,14 @@ import { import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; -import { getTimezone } from '../../../utils/server/lib/getTimezone'; import { businessHourManager } from '../business-hour'; import { parseAgentCustomFields, updateDepartmentAgents, validateEmail, normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; import { RoutingManager } from './RoutingManager'; import { isDepartmentCreationAvailable } from './isDepartmentCreationAvailable'; +import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes'; +import { parseTranscriptRequest } from './parseTranscriptRequest'; +import { sendTranscript as sendTranscriptFunc } from './sendTranscript'; type RegisterGuestType = Partial> & { id?: string; @@ -82,36 +82,6 @@ type RegisterGuestType = Partial; - }; - pdfTranscript?: { - requestedBy: string; - }; - }; -}; - -export type CloseRoomParamsByUser = { - user: IUser | null; -} & GenericCloseRoomParams; - -export type CloseRoomParamsByVisitor = { - visitor: ILivechatVisitor; -} & GenericCloseRoomParams; - -export type CloseRoomParams = CloseRoomParamsByUser | CloseRoomParamsByVisitor; - type OfflineMessageData = { message: string; name: string; @@ -326,12 +296,8 @@ class LivechatClass { this.logger.debug(`DB updated for room ${room._id}`); - const message = { - t: 'livechat-close', - msg: comment, - groupable: false, - transcriptRequested: !!transcriptRequest, - }; + const transcriptRequested = + !!transcriptRequest || (!settings.get('Livechat_enable_transcript') && settings.get('Livechat_transcript_send_always')); // Retrieve the closed room const newRoom = await LivechatRooms.findOneById(rid); @@ -341,9 +307,21 @@ class LivechatClass { } this.logger.debug(`Sending closing message to room ${room._id}`); - await sendMessage(chatCloser, message, newRoom); + await sendMessage( + chatCloser, + { + t: 'livechat-close', + msg: comment, + groupable: false, + transcriptRequested, + ...(isRoomClosedByVisitorParams(params) && { token: chatCloser.token }), + }, + newRoom, + ); - await Message.saveSystemMessage('command', rid, 'promptTranscript', closeData.closedBy); + if (settings.get('Livechat_enable_transcript') && !settings.get('Livechat_transcript_send_always')) { + await Message.saveSystemMessage('command', rid, 'promptTranscript', closeData.closedBy); + } this.logger.debug(`Running callbacks for room ${newRoom._id}`); @@ -355,15 +333,18 @@ class LivechatClass { void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, newRoom); void Apps.self?.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatRoomClosed, newRoom); }); + + const visitor = isRoomClosedByVisitorParams(params) ? params.visitor : undefined; + const opts = await parseTranscriptRequest(params.room, options, visitor); if (process.env.TEST_MODE) { await callbacks.run('livechat.closeRoom', { room: newRoom, - options, + options: opts, }); } else { callbacks.runAsync('livechat.closeRoom', { room: newRoom, - options, + options: opts, }); } @@ -562,127 +543,6 @@ class LivechatClass { } } - async sendTranscript({ - token, - rid, - email, - subject, - user, - }: { - token: string; - rid: string; - email: string; - subject?: string; - user?: Pick | null; - }): Promise { - check(rid, String); - check(email, String); - this.logger.debug(`Sending conversation transcript of room ${rid} to user with token ${token}`); - - 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'); - } - - // @ts-expect-error - Visitor typings should include language? - const userLanguage = visitor?.language || settings.get('Language') || 'en'; - const timezone = getTimezone(user); - this.logger.debug(`Transcript will be sent using ${timezone} as timezone`); - - if (!room) { - throw new Error('error-invalid-room'); - } - - // allow to only user to send transcripts from their own chats - if (room.t !== 'l' || !room.v || room.v.token !== token) { - throw new Error('error-invalid-room'); - } - - const showAgentInfo = settings.get('Livechat_show_agent_info'); - const closingMessage = await Messages.findLivechatClosingMessage(rid, { projection: { ts: 1 } }); - const ignoredMessageTypes: MessageTypesValues[] = [ - 'livechat_navigation_history', - 'livechat_transcript_history', - 'command', - 'livechat-close', - 'livechat-started', - 'livechat_video_call', - ]; - const messages = await Messages.findVisibleByRoomIdNotContainingTypesBeforeTs( - rid, - ignoredMessageTypes, - closingMessage?.ts ? new Date(closingMessage.ts) : new Date(), - { - sort: { ts: 1 }, - }, - ); - - let html = '

'; - await messages.forEach((message) => { - let author; - if (message.u._id === visitor._id) { - author = i18n.t('You', { lng: userLanguage }); - } else { - author = showAgentInfo ? message.u.name || message.u.username : i18n.t('Agent', { lng: userLanguage }); - } - - const datetime = moment.tz(message.ts, timezone).locale(userLanguage).format('LLL'); - const singleMessage = ` -

${author} ${datetime}

-

${message.msg}

- `; - html += singleMessage; - }); - - html = `${html}
`; - - const fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); - let emailFromRegexp = ''; - if (fromEmail) { - emailFromRegexp = fromEmail[0]; - } else { - emailFromRegexp = settings.get('From_Email'); - } - - const mailSubject = subject || i18n.t('Transcript_of_your_livechat_conversation', { lng: userLanguage }); - - await this.sendEmail(emailFromRegexp, email, emailFromRegexp, mailSubject, html); - - setImmediate(() => { - void callbacks.run('livechat.sendTranscript', messages, email); - }); - - const requestData: IOmnichannelSystemMessage['requestData'] = { - type: 'user', - visitor, - user, - }; - - if (!user?.username) { - const cat = await Users.findOneById('rocket.cat', { projection: { _id: 1, username: 1, name: 1 } }); - if (cat) { - requestData.user = cat; - requestData.type = 'visitor'; - } - } - - if (!requestData.user) { - this.logger.error('rocket.cat user not found'); - throw new Error('No user provided and rocket.cat not found'); - } - - await Message.saveSystemMessage('livechat_transcript_history', room._id, '', requestData.user, { - requestData, - }); - - return true; - } - async registerGuest({ id, token, @@ -712,7 +572,9 @@ class LivechatClass { visitorDataToUpdate.visitorEmails = [{ address: visitorEmail }]; } - if (department) { + const livechatVisitor = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); + + if (livechatVisitor?.department !== department && department) { Livechat.logger.debug(`Attempt to find a department with id/name ${department}`); const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } }); if (!dep) { @@ -723,8 +585,6 @@ class LivechatClass { visitorDataToUpdate.department = dep._id; } - const livechatVisitor = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); - visitorDataToUpdate.token = livechatVisitor?.token || token; let existingUser = null; @@ -1267,7 +1127,7 @@ class LivechatClass { if (guest.name) { message.alias = guest.name; } - return Object.assign(await sendMessage(guest, message, room), { + return Object.assign(await sendMessage(guest, { ...message, token: guest.token }, room), { newRoom, showConnecting: this.showConnecting(), }); @@ -1386,7 +1246,7 @@ class LivechatClass { _id: String, username: String, name: Match.Maybe(String), - type: String, + userType: String, }), ); @@ -1394,34 +1254,31 @@ class LivechatClass { const scopeData = scope || (nextDepartment ? 'department' : 'agent'); this.logger.info(`Storing new chat transfer of ${room._id} [Transfered by: ${_id} to ${scopeData}]`); - const transfer = { - transferData: { - transferredBy, + await sendMessage( + transferredBy, + { + t: 'livechat_transfer_history', + rid: room._id, ts: new Date(), - scope: scopeData, - comment, - ...(previousDepartment && { previousDepartment }), - ...(nextDepartment && { nextDepartment }), - ...(transferredTo && { transferredTo }), - }, - }; - - const type = 'livechat_transfer_history'; - const transferMessage = { - t: type, - rid: room._id, - ts: new Date(), - msg: '', - u: { - _id, - username, + msg: '', + u: { + _id, + username, + }, + groupable: false, + ...(transferData.transferredBy.userType === 'visitor' && { token: room.v.token }), + transferData: { + transferredBy, + ts: new Date(), + scope: scopeData, + comment, + ...(previousDepartment && { previousDepartment }), + ...(nextDepartment && { nextDepartment }), + ...(transferredTo && { transferredTo }), + }, }, - groupable: false, - }; - - Object.assign(transferMessage, transfer); - - await sendMessage(transferredBy, transferMessage, room); + room, + ); } async saveGuest(guestData: Pick & { email?: string; phone?: string }, userId: string) { @@ -1984,6 +1841,23 @@ class LivechatClass { return departmentDB; } + + async sendTranscript({ + token, + rid, + email, + subject, + user, + }: { + token: string; + rid: string; + email: string; + subject?: string; + user?: Pick | null; + }): Promise { + return sendTranscriptFunc({ token, rid, email, subject, user }); + } } export const Livechat = new LivechatClass(); +export * from './localTypes'; diff --git a/apps/meteor/app/livechat/server/lib/localTypes.ts b/apps/meteor/app/livechat/server/lib/localTypes.ts new file mode 100644 index 000000000000..c6acbbc5bcbd --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/localTypes.ts @@ -0,0 +1,31 @@ +import type { IOmnichannelRoom, IUser, ILivechatVisitor } from '@rocket.chat/core-typings'; + +export type GenericCloseRoomParams = { + room: IOmnichannelRoom; + comment?: string; + options?: { + clientAction?: boolean; + tags?: string[]; + emailTranscript?: + | { + sendToVisitor: false; + } + | { + sendToVisitor: true; + requestData: NonNullable; + }; + pdfTranscript?: { + requestedBy: string; + }; + }; +}; + +export type CloseRoomParamsByUser = { + user: IUser | null; +} & GenericCloseRoomParams; + +export type CloseRoomParamsByVisitor = { + visitor: ILivechatVisitor; +} & GenericCloseRoomParams; + +export type CloseRoomParams = CloseRoomParamsByUser | CloseRoomParamsByVisitor; diff --git a/apps/meteor/app/livechat/server/lib/parseTranscriptRequest.ts b/apps/meteor/app/livechat/server/lib/parseTranscriptRequest.ts new file mode 100644 index 000000000000..76595a7ff640 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/parseTranscriptRequest.ts @@ -0,0 +1,61 @@ +import type { ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; +import { LivechatVisitors, Users } from '@rocket.chat/models'; + +import { settings } from '../../../settings/server'; +import type { CloseRoomParams } from './localTypes'; + +export const parseTranscriptRequest = async ( + room: IOmnichannelRoom, + options: CloseRoomParams['options'], + visitor?: ILivechatVisitor, + user?: IUser, +): Promise => { + const visitorDecideTranscript = settings.get('Livechat_enable_transcript'); + // visitor decides, no changes + if (visitorDecideTranscript) { + return options; + } + + // send always is disabled, no changes + const sendAlways = settings.get('Livechat_transcript_send_always'); + if (!sendAlways) { + return options; + } + + const visitorData = + visitor || + (await LivechatVisitors.findOneById>(room.v._id, { projection: { visitorEmails: 1 } })); + // no visitor, no changes + if (!visitorData) { + return options; + } + const visitorEmail = visitorData?.visitorEmails?.[0]?.address; + // visitor doesnt have email, no changes + if (!visitorEmail) { + return options; + } + + const defOptions = { projection: { _id: 1, username: 1, name: 1 } }; + const requestedBy = + user || + (room.servedBy && (await Users.findOneById(room.servedBy._id, defOptions))) || + (await Users.findOneById('rocket.cat', defOptions)); + + // no user available for backing request, no changes + if (!requestedBy) { + return options; + } + + return { + ...options, + emailTranscript: { + sendToVisitor: true, + requestData: { + email: visitorEmail, + requestedAt: new Date(), + subject: '', + requestedBy, + }, + }, + }; +}; diff --git a/apps/meteor/app/livechat/server/lib/sendTranscript.ts b/apps/meteor/app/livechat/server/lib/sendTranscript.ts new file mode 100644 index 000000000000..74032121ee50 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/sendTranscript.ts @@ -0,0 +1,227 @@ +import { Message } from '@rocket.chat/core-services'; +import { + type IUser, + type MessageTypesValues, + type IOmnichannelSystemMessage, + 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 { check } from 'meteor/check'; +import moment from 'moment-timezone'; + +import { callbacks } from '../../../../lib/callbacks'; +import { i18n } from '../../../../server/lib/i18n'; +import { FileUpload } from '../../../file-upload/server'; +import * as Mailer from '../../../mailer/server/api'; +import { settings } from '../../../settings/server'; +import { MessageTypes } from '../../../ui-utils/lib/MessageTypes'; +import { getTimezone } from '../../../utils/server/lib/getTimezone'; + +const logger = new Logger('Livechat-SendTranscript'); + +export async function sendTranscript({ + token, + rid, + email, + subject, + user, +}: { + token: string; + rid: string; + email: string; + subject?: string; + user?: Pick | null; +}): Promise { + check(rid, String); + check(email, String); + logger.debug(`Sending conversation transcript of room ${rid} to user with token ${token}`); + + 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'); + } + + // @ts-expect-error - Visitor typings should include language? + const userLanguage = visitor?.language || settings.get('Language') || 'en'; + const timezone = getTimezone(user); + logger.debug(`Transcript will be sent using ${timezone} as timezone`); + + if (!room) { + throw new Error('error-invalid-room'); + } + + // allow to only user to send transcripts from their own chats + if (room.t !== 'l' || !room.v || room.v.token !== token) { + throw new Error('error-invalid-room'); + } + + const showAgentInfo = settings.get('Livechat_show_agent_info'); + const showSystemMessages = settings.get('Livechat_transcript_show_system_messages'); + const closingMessage = await Messages.findLivechatClosingMessage(rid, { projection: { ts: 1 } }); + const ignoredMessageTypes: MessageTypesValues[] = [ + 'livechat_navigation_history', + 'livechat_transcript_history', + 'command', + 'livechat-close', + 'livechat-started', + 'livechat_video_call', + 'omnichannel_priority_change_history', + ]; + const acceptableImageMimeTypes = ['image/jpeg', 'image/png', 'image/jpg']; + const messages = await Messages.findVisibleByRoomIdNotContainingTypesBeforeTs( + rid, + ignoredMessageTypes, + closingMessage?.ts ? new Date(closingMessage.ts) : new Date(), + showSystemMessages, + { + sort: { ts: 1 }, + }, + ); + + let html = '

'; + const InvalidFileMessage = `
${i18n.t( + 'This_attachment_is_not_supported', + { lng: userLanguage }, + )}
`; + + for await (const message of messages) { + let author; + if (message.u._id === visitor._id) { + author = i18n.t('You', { lng: userLanguage }); + } else { + author = showAgentInfo ? message.u.name || message.u.username : i18n.t('Agent', { lng: userLanguage }); + } + + const isSystemMessage = MessageTypes.isSystemMessage(message); + const messageType = isSystemMessage && MessageTypes.getType(message); + + let messageContent = messageType + ? `${i18n.t( + messageType.message, + messageType.data + ? { ...messageType.data(message), interpolation: { escapeValue: false } } + : { interpolation: { escapeValue: false } }, + )}` + : message.msg; + + let filesHTML = ''; + + if (message.attachments && message.attachments?.length > 0) { + messageContent = message.attachments[0].description || ''; + + for await (const attachment of message.attachments) { + if (!isFileAttachment(attachment)) { + // ignore other types of attachments + continue; + } + + if (!isFileImageAttachment(attachment)) { + filesHTML += `
${attachment.title || ''}${InvalidFileMessage}
`; + continue; + } + + if (!attachment.image_type || !acceptableImageMimeTypes.includes(attachment.image_type)) { + filesHTML += `
${attachment.title || ''}${InvalidFileMessage}
`; + continue; + } + + // Image attachment can be rendered in email body + const file = message.files?.find((file) => file.name === attachment.title); + + if (!file) { + filesHTML += `
${attachment.title || ''}${InvalidFileMessage}
`; + continue; + } + + const uploadedFile = await Uploads.findOneById(file._id); + + if (!uploadedFile) { + filesHTML += `
${file.name}${InvalidFileMessage}
`; + continue; + } + + const uploadedFileBuffer = await FileUpload.getBuffer(uploadedFile); + filesHTML += `

${file.name}

`; + } + } + + const datetime = moment.tz(message.ts, timezone).locale(userLanguage).format('LLL'); + const singleMessage = ` +

${author} ${datetime}

+

${messageContent}

+

${filesHTML}

+ `; + html += singleMessage; + } + + html = `${html}
`; + + const fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); + let emailFromRegexp = ''; + if (fromEmail) { + emailFromRegexp = fromEmail[0]; + } else { + emailFromRegexp = settings.get('From_Email'); + } + + // Some endpoints allow the caller to pass a different `subject` via parameter. + // IF subject is passed, we'll use that one and treat it as an override + // IF no subject is passed, we fallback to the setting `Livechat_transcript_email_subject` + // IF that is not configured, we fallback to 'Transcript of your livechat conversation', which is the default value + // As subject and setting value are user input, we don't translate them + const mailSubject = + subject || + settings.get('Livechat_transcript_email_subject') || + i18n.t('Transcript_of_your_livechat_conversation', { lng: userLanguage }); + + await Mailer.send({ + to: email, + from: emailFromRegexp, + replyTo: emailFromRegexp, + subject: mailSubject, + html, + }); + + setImmediate(() => { + void callbacks.run('livechat.sendTranscript', messages, email); + }); + + const requestData: IOmnichannelSystemMessage['requestData'] = { + type: 'user', + visitor, + user, + }; + + if (!user?.username) { + const cat = await Users.findOneById('rocket.cat', { projection: { _id: 1, username: 1, name: 1 } }); + if (cat) { + requestData.user = cat; + requestData.type = 'visitor'; + } + } + + if (!requestData.user) { + logger.error('rocket.cat user not found'); + throw new Error('No user provided and rocket.cat not found'); + } + + await Message.saveSystemMessage('livechat_transcript_history', room._id, '', requestData.user, { + requestData, + }); + + return true; +} diff --git a/apps/meteor/app/livechat/server/methods/closeRoom.ts b/apps/meteor/app/livechat/server/methods/closeRoom.ts index 1374d86ab9f7..5fdf9e7d504f 100644 --- a/apps/meteor/app/livechat/server/methods/closeRoom.ts +++ b/apps/meteor/app/livechat/server/methods/closeRoom.ts @@ -60,6 +60,16 @@ Meteor.methods({ }); } + const subscription = await SubscriptionRaw.findOneByRoomIdAndUserId(roomId, userId, { + projection: { + _id: 1, + }, + }); + if (!room.open && subscription) { + await SubscriptionRaw.removeByRoomId(roomId); + return; + } + if (!room.open) { throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:closeRoom' }); } @@ -71,11 +81,6 @@ Meteor.methods({ }); } - const subscription = await SubscriptionRaw.findOneByRoomIdAndUserId(roomId, user._id, { - projection: { - _id: 1, - }, - }); if (!subscription && !(await hasPermissionAsync(userId, 'close-others-livechat-room'))) { throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'livechat:closeRoom', diff --git a/apps/meteor/app/settings/server/SettingsRegistry.ts b/apps/meteor/app/settings/server/SettingsRegistry.ts index 443e38ce5d63..e86a391ad8fa 100644 --- a/apps/meteor/app/settings/server/SettingsRegistry.ts +++ b/apps/meteor/app/settings/server/SettingsRegistry.ts @@ -73,7 +73,7 @@ const compareSettingsIgnoringKeys = .filter((key) => !keys.includes(key as keyof ISetting)) .every((key) => isEqual(a[key as keyof ISetting], b[key as keyof ISetting])); -const compareSettings = compareSettingsIgnoringKeys([ +export const compareSettings = compareSettingsIgnoringKeys([ 'value', 'ts', 'createdAt', @@ -138,22 +138,17 @@ export class SettingsRegistry { const settingFromCodeOverwritten = overwriteSetting(settingFromCode); - const settingOverwrittenDefault = overrideSetting(settingFromCode); - const settingStored = this.store.getSetting(_id); - const settingStoredOverwritten = settingStored && overwriteSetting(settingStored); - const isOverwritten = settingFromCode !== settingFromCodeOverwritten || (settingStored && settingStored !== settingStoredOverwritten); - - const updatedSettingAfterApplyingOverwrite = isOverwritten ? settingFromCodeOverwritten : settingOverwrittenDefault; - try { validateSetting(settingFromCode._id, settingFromCode.type, settingFromCode.value); } catch (e) { IS_DEVELOPMENT && SystemLogger.error(`Invalid setting code ${_id}: ${(e as Error).message}`); } + const isOverwritten = settingFromCode !== settingFromCodeOverwritten || (settingStored && settingStored !== settingStoredOverwritten); + const { _id: _, ...settingProps } = settingFromCodeOverwritten; if (settingStored && !compareSettings(settingStored, settingFromCodeOverwritten)) { @@ -171,9 +166,7 @@ export class SettingsRegistry { })(); await this.saveUpdatedSetting(_id, updatedProps, removedKeys); - - this.store.set(updatedSettingAfterApplyingOverwrite); - + this.store.set(settingFromCodeOverwritten); return; } @@ -183,8 +176,7 @@ export class SettingsRegistry { const removedKeys = Object.keys(settingStored).filter((key) => !['_updatedAt'].includes(key) && !overwrittenKeys.includes(key)); await this.saveUpdatedSetting(_id, settingProps, removedKeys); - - this.store.set(updatedSettingAfterApplyingOverwrite); + this.store.set(settingFromCodeOverwritten); } return; } @@ -198,9 +190,13 @@ export class SettingsRegistry { return; } - await this.model.insertOne(updatedSettingAfterApplyingOverwrite); // no need to emit unless we remove the oplog + const settingOverwrittenDefault = overrideSetting(settingFromCode); + + const setting = isOverwritten ? settingFromCodeOverwritten : settingOverwrittenDefault; + + await this.model.insertOne(setting); // no need to emit unless we remove the oplog - this.store.set(updatedSettingAfterApplyingOverwrite); + this.store.set(setting); } /* diff --git a/apps/meteor/app/settings/server/functions/settings.mocks.ts b/apps/meteor/app/settings/server/functions/settings.mocks.ts index 9cd409ba0b83..fb31c3021b1b 100644 --- a/apps/meteor/app/settings/server/functions/settings.mocks.ts +++ b/apps/meteor/app/settings/server/functions/settings.mocks.ts @@ -9,6 +9,12 @@ type Dictionary = { class SettingsClass { settings: ICachedSettings; + private delay = 0; + + setDelay(delay: number): void { + this.delay = delay; + } + find(): any[] { return []; } @@ -65,22 +71,41 @@ class SettingsClass { throw new Error('Invalid upsert'); } - // console.log(query, data); - this.data.set(query._id, data); - - // Can't import before the mock command on end of this file! - // eslint-disable-next-line @typescript-eslint/no-var-requires - this.settings.set(data); + if (this.delay) { + setTimeout(() => { + // console.log(query, data); + this.data.set(query._id, data); + + // Can't import before the mock command on end of this file! + // eslint-disable-next-line @typescript-eslint/no-var-requires + this.settings.set(data); + }, this.delay); + } else { + this.data.set(query._id, data); + // Can't import before the mock command on end of this file! + // eslint-disable-next-line @typescript-eslint/no-var-requires + this.settings.set(data); + } this.upsertCalls++; } + findOneAndUpdate({ _id }: { _id: string }, value: any, options?: any) { + this.updateOne({ _id }, value, options); + return { value: this.findOne({ _id }) }; + } + updateValueById(id: string, value: any): void { this.data.set(id, { ...this.data.get(id), value }); - // Can't import before the mock command on end of this file! // eslint-disable-next-line @typescript-eslint/no-var-requires - this.settings.set(this.data.get(id) as ISetting); + if (this.delay) { + setTimeout(() => { + this.settings.set(this.data.get(id) as ISetting); + }, this.delay); + } else { + this.settings.set(this.data.get(id) as ISetting); + } } } diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.js b/apps/meteor/app/slackbridge/server/SlackAdapter.js index 78d48deb4993..0263d5369a4c 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.js +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.js @@ -1341,7 +1341,7 @@ export default class SlackAdapter { const user = (await this.rocket.findUser(member)) || (await this.rocket.addUser(member)); if (user) { slackLogger.debug('Adding user to room', user.username, rid); - await addUserToRoom(rid, user, null, true); + await addUserToRoom(rid, user, null, { skipSystemMessage: true }); } } } diff --git a/apps/meteor/app/ui-utils/lib/MessageTypes.ts b/apps/meteor/app/ui-utils/lib/MessageTypes.ts index a4f77d10cbf7..c108fe55f168 100644 --- a/apps/meteor/app/ui-utils/lib/MessageTypes.ts +++ b/apps/meteor/app/ui-utils/lib/MessageTypes.ts @@ -5,8 +5,6 @@ export type MessageType = { id: MessageTypesValues; system?: boolean; /* deprecated */ - render?: (message: IMessage) => string; - /* deprecated */ template?: (message: IMessage) => unknown; message: TranslationKey; data?: (message: IMessage) => Record; diff --git a/apps/meteor/app/ui-utils/server/Message.ts b/apps/meteor/app/ui-utils/server/Message.ts index 18cf842b1993..06ae59238b42 100644 --- a/apps/meteor/app/ui-utils/server/Message.ts +++ b/apps/meteor/app/ui-utils/server/Message.ts @@ -11,9 +11,6 @@ export const Message = { parse(msg: IMessage, language: string) { const messageType = MessageTypes.getType(msg); if (messageType) { - if (messageType.render) { - return messageType.render(msg); - } if (messageType.template) { // Render message return; diff --git a/apps/meteor/app/utils/lib/mimeTypes.spec.ts b/apps/meteor/app/utils/lib/mimeTypes.spec.ts new file mode 100644 index 000000000000..d0fbd4360e24 --- /dev/null +++ b/apps/meteor/app/utils/lib/mimeTypes.spec.ts @@ -0,0 +1,89 @@ +import { expect } from 'chai'; + +import { getExtension, getMimeType } from './mimeTypes'; + +const mimeTypeToExtension = { + 'text/plain': 'txt', + 'image/x-icon': 'ico', + 'image/vnd.microsoft.icon': 'ico', + 'image/png': 'png', + 'image/jpeg': 'jpeg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'image/bmp': 'bmp', + 'image/tiff': 'tif', + 'audio/wav': 'wav', + 'audio/wave': 'wav', + 'audio/aac': 'aac', + 'audio/x-aac': 'aac', + 'audio/mp4': 'm4a', + 'audio/mpeg': 'mpga', + 'audio/ogg': 'oga', + 'application/octet-stream': 'bin', +}; + +const extensionToMimeType = { + lst: 'text/plain', + txt: 'text/plain', + ico: 'image/x-icon', + png: 'image/png', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + bmp: 'image/bmp', + tiff: 'image/tiff', + tif: 'image/tiff', + wav: 'audio/wav', + aac: 'audio/aac', + mp3: 'audio/mpeg', + ogg: 'audio/ogg', + oga: 'audio/ogg', + m4a: 'audio/mp4', + mpga: 'audio/mpeg', + mp4: 'video/mp4', + bin: 'application/octet-stream', +}; + +describe('mimeTypes', () => { + describe('getExtension', () => { + for (const [mimeType, extension] of Object.entries(mimeTypeToExtension)) { + it(`should return the correct extension ${extension} for the given mimeType ${mimeType}`, async () => { + expect(getExtension(mimeType)).to.be.eql(extension); + }); + } + + it('should return an empty string if the mimeType is not found', async () => { + expect(getExtension('application/unknown')).to.be.eql(''); + }); + }); + + describe('getMimeType', () => { + for (const [extension, mimeType] of Object.entries(extensionToMimeType)) { + it(`should return the correct mimeType ${mimeType} for the given fileName file.${extension} passing the correct mimeType`, async () => { + expect(getMimeType(mimeType, `file.${extension}`)).to.be.eql(mimeType); + }); + } + + it('should return the correct mimeType for the given fileName', async () => { + for (const [extension, mimeType] of Object.entries(extensionToMimeType)) { + expect(getMimeType('application/unknown', `file.${extension}`)).to.be.eql(mimeType); + } + }); + + it('should return the correct mimeType for the given fileName when informed mimeType is application/octet-stream', async () => { + for (const [extension, mimeType] of Object.entries(extensionToMimeType)) { + expect(getMimeType('application/octet-stream', `file.${extension}`)).to.be.eql(mimeType); + } + }); + + it('should return the mimeType if it is not application/octet-stream', async () => { + expect(getMimeType('audio/wav', 'file.wav')).to.be.eql('audio/wav'); + }); + + it('should return application/octet-stream if the mimeType is not found', async () => { + expect(getMimeType('application/octet-stream', 'file.unknown')).to.be.eql('application/octet-stream'); + }); + }); +}); diff --git a/apps/meteor/app/utils/lib/mimeTypes.ts b/apps/meteor/app/utils/lib/mimeTypes.ts index 909a955d6724..df670145b494 100644 --- a/apps/meteor/app/utils/lib/mimeTypes.ts +++ b/apps/meteor/app/utils/lib/mimeTypes.ts @@ -3,8 +3,8 @@ import mime from 'mime-type/with-db'; mime.types.wav = 'audio/wav'; mime.types.lst = 'text/plain'; mime.define('image/vnd.microsoft.icon', { source: '', extensions: ['ico'] }, mime.dupAppend); -mime.define('image/x-icon', { source: '', extensions: ['ico'] }, mime.dupAppend); -mime.types.ico = 'image/x-icon'; +mime.define('image/x-icon', { source: '', extensions: ['ico'] }, mime.dupOverwrite); +mime.define('audio/aac', { source: '', extensions: ['aac'] }, mime.dupOverwrite); const getExtension = (param: string): string => { const extension = mime.extension(param); @@ -12,7 +12,14 @@ const getExtension = (param: string): string => { return !extension || typeof extension === 'boolean' ? '' : extension; }; -const getMimeType = (fileName: string): string => { +const getMimeType = (mimetype: string, fileName: string): string => { + // If the extension from the mimetype is different from the file extension, the file + // extension may be wrong so use the informed mimetype + const extension = mime.extension(mimetype); + if (mimetype !== 'application/octet-stream' && extension && extension !== fileName.split('.').pop()) { + return mimetype; + } + const fileMimeType = mime.lookup(fileName); return typeof fileMimeType === 'string' ? fileMimeType : 'application/octet-stream'; }; diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx index fd4498f5fb8e..531ff8a74b66 100644 --- a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/UserMenu.tsx @@ -1,5 +1,6 @@ import type { IUser } from '@rocket.chat/core-typings'; import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentProps } from 'react'; import React, { memo, useState } from 'react'; import GenericMenu from '../../../components/GenericMenu/GenericMenu'; @@ -8,9 +9,9 @@ import { useHandleMenuAction } from '../../../components/GenericMenu/hooks/useHa import UserMenuButton from './UserMenuButton'; import { useUserMenu } from './hooks/useUserMenu'; -type UserMenuProps = { user: IUser; className?: string }; +type UserMenuProps = { user: IUser } & Omit, 'sections' | 'items' | 'title'>; -const UserMenu = function UserMenu({ user }: UserMenuProps) { +const UserMenu = function UserMenu({ user, ...props }: UserMenuProps) { const t = useTranslation(); const [isOpen, setIsOpen] = useState(false); @@ -21,6 +22,7 @@ const UserMenu = function UserMenu({ user }: UserMenuProps) { return ( {t('Topic')} - + } + /> {t('Displayed_next_to_name')} @@ -243,7 +246,7 @@ const CreateDiscussion = ({ onClose, defaultParentRoom, parentMessageId, nameSug - diff --git a/apps/meteor/client/components/GenericMenu/GenericMenu.spec.tsx b/apps/meteor/client/components/GenericMenu/GenericMenu.spec.tsx new file mode 100644 index 000000000000..99e62bac1a60 --- /dev/null +++ b/apps/meteor/client/components/GenericMenu/GenericMenu.spec.tsx @@ -0,0 +1,60 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import GenericMenu from './GenericMenu'; + +const mockedFunction = jest.fn(); +const regular = { + items: [ + { + id: 'edit', + content: 'Edit', + icon: 'pencil' as const, + onClick: mockedFunction, + }, + ], +}; +const danger = { + items: [ + { + id: 'delete', + content: 'Delete', + icon: 'trash' as const, + onClick: () => null, + variant: 'danger', + }, + ], +}; + +const sections = [regular, danger]; + +describe('Room Actions Menu', () => { + it('should render kebab menu with the list content', async () => { + render(); + + userEvent.click(screen.getByRole('button')); + + expect(await screen.findByText('Edit')).toBeInTheDocument(); + expect(await screen.findByText('Delete')).toBeInTheDocument(); + }); + + it('should have two different sections, regular and danger', async () => { + render(); + + userEvent.click(screen.getByRole('button')); + + expect(screen.getAllByRole('presentation')).toHaveLength(2); + expect(screen.getByRole('separator')).toBeInTheDocument(); + }); + + it('should call the action when item clicked', async () => { + render(); + + userEvent.click(screen.getByRole('button')); + userEvent.click(screen.getAllByRole('menuitem')[0]); + + expect(mockedFunction).toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/client/components/GenericMenu/GenericMenuItem.tsx b/apps/meteor/client/components/GenericMenu/GenericMenuItem.tsx index 44feedf86115..c01a64d708a0 100644 --- a/apps/meteor/client/components/GenericMenu/GenericMenuItem.tsx +++ b/apps/meteor/client/components/GenericMenu/GenericMenuItem.tsx @@ -13,6 +13,7 @@ export type GenericMenuItemProps = { description?: ReactNode; gap?: boolean; tooltip?: string; + variant?: string; }; const GenericMenuItem = ({ icon, content, addon, status, gap, tooltip }: GenericMenuItemProps) => ( diff --git a/apps/meteor/client/components/GenericModal/GenericModal.spec.tsx b/apps/meteor/client/components/GenericModal/GenericModal.spec.tsx new file mode 100644 index 000000000000..0ef7235729c4 --- /dev/null +++ b/apps/meteor/client/components/GenericModal/GenericModal.spec.tsx @@ -0,0 +1,87 @@ +import { useSetModal } from '@rocket.chat/ui-contexts'; +import { act, screen } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import React, { Suspense } from 'react'; + +import ModalProviderWithRegion from '../../providers/ModalProvider/ModalProviderWithRegion'; +import GenericModal from './GenericModal'; + +import '@testing-library/jest-dom'; + +const renderModal = (modalElement: ReactElement) => { + const { + result: { current: setModal }, + } = renderHook(() => useSetModal(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + act(() => { + setModal(modalElement); + }); + + return { setModal }; +}; + +describe('callbacks', () => { + it('should call onClose callback when dismissed', async () => { + const handleClose = jest.fn(); + + renderModal(); + + expect(await screen.findByRole('heading', { name: 'Modal', exact: true })).toBeInTheDocument(); + + userEvent.keyboard('{Escape}'); + + expect(screen.queryByRole('heading', { name: 'Modal', exact: true })).not.toBeInTheDocument(); + + expect(handleClose).toHaveBeenCalled(); + }); + + it('should NOT call onClose callback when confirmed', async () => { + const handleConfirm = jest.fn(); + const handleClose = jest.fn(); + + const { setModal } = renderModal(); + + expect(await screen.findByRole('heading', { name: 'Modal', exact: true })).toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: 'Ok', exact: true })); + + expect(handleConfirm).toHaveBeenCalled(); + + act(() => { + setModal(null); + }); + + expect(screen.queryByRole('heading', { name: 'Modal', exact: true })).not.toBeInTheDocument(); + + expect(handleClose).not.toHaveBeenCalled(); + }); + + it('should NOT call onClose callback when cancelled', async () => { + const handleCancel = jest.fn(); + const handleClose = jest.fn(); + + const { setModal } = renderModal(); + + expect(await screen.findByRole('heading', { name: 'Modal', exact: true })).toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: 'Cancel', exact: true })); + + expect(handleCancel).toHaveBeenCalled(); + + act(() => { + setModal(null); + }); + + expect(screen.queryByRole('heading', { name: 'Modal', exact: true })).not.toBeInTheDocument(); + + expect(handleClose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/client/components/GenericModal/GenericModal.tsx b/apps/meteor/client/components/GenericModal/GenericModal.tsx index 914928d4d423..d371e1ff4ef2 100644 --- a/apps/meteor/client/components/GenericModal/GenericModal.tsx +++ b/apps/meteor/client/components/GenericModal/GenericModal.tsx @@ -1,9 +1,9 @@ import { Button, Modal } from '@rocket.chat/fuselage'; -import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useEffectEvent, useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { Keys as IconName } from '@rocket.chat/icons'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement, ReactNode, ComponentPropsWithoutRef } from 'react'; -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import type { RequiredModalProps } from './withDoNotAskAgain'; import { withDoNotAskAgain } from './withDoNotAskAgain'; @@ -78,6 +78,31 @@ const GenericModal = ({ const t = useTranslation(); const genericModalId = useUniqueId(); + const dismissedRef = useRef(true); + + const handleConfirm = useEffectEvent(() => { + dismissedRef.current = false; + onConfirm?.(); + }); + + const handleCancel = useEffectEvent(() => { + dismissedRef.current = false; + onCancel?.(); + }); + + const handleCloseButtonClick = useEffectEvent(() => { + dismissedRef.current = true; + onClose?.(); + }); + + useEffect( + () => () => { + if (!dismissedRef.current) return; + onClose?.(); + }, + [onClose], + ); + return ( @@ -86,7 +111,7 @@ const GenericModal = ({ {tagline && {tagline}} {title ?? t('Are_you_sure')} - + {children} @@ -94,7 +119,7 @@ const GenericModal = ({ {annotation && !dontAskAgain && {annotation}} {onCancel && ( - )} @@ -104,7 +129,7 @@ const GenericModal = ({ )} {!wrapperFunction && onConfirm && ( - )} diff --git a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx index 401448ceb396..7c028fb5c876 100644 --- a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx @@ -52,6 +52,8 @@ const CloseChatModal = ({ } = useForm(); const commentRequired = useSetting('Livechat_request_comment_when_closing_conversation') as boolean; + const alwaysSendTranscript = useSetting('Livechat_transcript_send_always'); + const customSubject = useSetting('Livechat_transcript_email_subject'); const [tagRequired, setTagRequired] = useState(false); const tags = watch('tags'); @@ -65,7 +67,7 @@ const CloseChatModal = ({ const transcriptPDFPermission = usePermission('request-pdf-transcript'); const transcriptEmailPermission = usePermission('send-omnichannel-chat-transcript'); - const canSendTranscriptEmail = transcriptEmailPermission && visitorEmail; + const canSendTranscriptEmail = transcriptEmailPermission && visitorEmail && !alwaysSendTranscript; const canSendTranscriptPDF = transcriptPDFPermission && hasLicense; const canSendTranscript = canSendTranscriptEmail || canSendTranscriptPDF; @@ -77,7 +79,7 @@ const CloseChatModal = ({ ({ comment, tags, transcriptPDF, transcriptEmail, subject }): void => { const preferences = { omnichannelTranscriptPDF: !!transcriptPDF, - omnichannelTranscriptEmail: !!transcriptEmail, + omnichannelTranscriptEmail: alwaysSendTranscript ? true : !!transcriptEmail, }; const requestData = transcriptEmail && visitorEmail ? { email: visitorEmail, subject } : undefined; @@ -97,7 +99,7 @@ const CloseChatModal = ({ onConfirm(comment, tags, preferences, requestData); } }, - [commentRequired, tagRequired, visitorEmail, errors, setError, t, onConfirm], + [commentRequired, tagRequired, visitorEmail, errors, setError, t, onConfirm, alwaysSendTranscript], ); const cannotSubmit = useMemo(() => { @@ -132,9 +134,9 @@ const CloseChatModal = ({ dispatchToastMessage({ type: 'error', message: t('Customer_without_registered_email') }); return; } - setValue('subject', subject || t('Transcript_of_your_livechat_conversation')); + setValue('subject', subject || customSubject || t('Transcript_of_your_livechat_conversation')); } - }, [transcriptEmail, setValue, visitorEmail, subject, t]); + }, [transcriptEmail, setValue, visitorEmail, subject, t, customSubject]); if (commentRequired || tagRequired || canSendTranscript) { return ( diff --git a/apps/meteor/client/components/Page/PageScrollableContent.tsx b/apps/meteor/client/components/Page/PageScrollableContent.tsx index c3ac6869f277..f8c3bb5ba54b 100644 --- a/apps/meteor/client/components/Page/PageScrollableContent.tsx +++ b/apps/meteor/client/components/Page/PageScrollableContent.tsx @@ -1,4 +1,3 @@ -import { css } from '@rocket.chat/css-in-js'; import type { Scrollable } from '@rocket.chat/fuselage'; import { Box } from '@rocket.chat/fuselage'; import type { ComponentProps } from 'react'; @@ -26,17 +25,7 @@ const PageScrollableContent = forwardRef - + ); diff --git a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx index b7bcd7d1e9dd..86223cd7f2b4 100644 --- a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx @@ -38,15 +38,21 @@ const GenericFileAttachment = ({ const { t } = useTranslation(); const handleTitleClick = (event: UIEvent): void => { - if (openDocumentViewer && link) { + if (!link) { + return; + } + + if (openDocumentViewer && format === 'PDF') { event.preventDefault(); - if (format === 'PDF') { - const url = new URL(getURL(link), window.location.origin); - url.searchParams.set('contentDisposition', 'inline'); - openDocumentViewer(url.toString(), format, ''); - return; - } + const url = new URL(getURL(link), window.location.origin); + url.searchParams.set('contentDisposition', 'inline'); + openDocumentViewer(url.toString(), format, ''); + return; + } + + if (link.includes('/file-decrypt/')) { + event.preventDefault(); registerDownloadForUid(uid, t, title); forAttachmentDownload(uid, link); diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentDownloadBase.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentDownloadBase.tsx index dae6b9024776..284cb0cecbf2 100644 --- a/apps/meteor/client/components/message/content/attachments/structure/AttachmentDownloadBase.tsx +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentDownloadBase.tsx @@ -6,18 +6,19 @@ import Action from '../../Action'; type AttachmentDownloadBaseProps = Omit, 'icon'> & { title?: string | undefined; href: string }; -const AttachmentDownloadBase = ({ title, href, ...props }: AttachmentDownloadBaseProps) => { +const AttachmentDownloadBase = ({ title, href, disabled, ...props }: AttachmentDownloadBaseProps) => { const t = useTranslation(); return ( ); diff --git a/apps/meteor/client/components/message/variants/SystemMessage.tsx b/apps/meteor/client/components/message/variants/SystemMessage.tsx index e2e1d9bf04bd..eeba342c3f31 100644 --- a/apps/meteor/client/components/message/variants/SystemMessage.tsx +++ b/apps/meteor/client/components/message/variants/SystemMessage.tsx @@ -94,14 +94,9 @@ const SystemMessage = ({ message, showUserAvatar, ...props }: SystemMessageProps )} {messageType && ( - + + {t(messageType.message, messageType.data ? messageType.data(message) : {})} + )} {formatTime(message.ts)} diff --git a/apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts b/apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts index 31be4b2300ee..cbc2a594eb6e 100644 --- a/apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts @@ -21,7 +21,7 @@ export const useCallsRoomAction = () => { return { id: 'calls', - groups: ['channel', 'group', 'team'], + groups: ['channel', 'group', 'team', 'direct', 'direct_multiple'], icon: 'phone', title: 'Calls', ...(federated && { diff --git a/apps/meteor/client/hooks/useDownloadFromServiceWorker.ts b/apps/meteor/client/hooks/useDownloadFromServiceWorker.ts index 5ab7f804fec7..199d1507e284 100644 --- a/apps/meteor/client/hooks/useDownloadFromServiceWorker.ts +++ b/apps/meteor/client/hooks/useDownloadFromServiceWorker.ts @@ -7,13 +7,15 @@ import { downloadAs } from '../lib/download'; const ee = new Emitter>(); -navigator.serviceWorker.addEventListener('message', (event) => { - if (event.data.type === 'attachment-download-result') { - const { result } = event.data as { result: ArrayBuffer; id: string }; +if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data.type === 'attachment-download-result') { + const { result } = event.data as { result: ArrayBuffer; id: string }; - ee.emit(event.data.id, { result, id: event.data.id }); - } -}); + ee.emit(event.data.id, { result, id: event.data.id }); + } + }); +} export const registerDownloadForUid = (uid: string, t: ReturnType['t'], title?: string) => { ee.once(uid, ({ result }) => { @@ -23,8 +25,13 @@ export const registerDownloadForUid = (uid: string, t: ReturnType { if (!controller) { - controller = navigator.serviceWorker.controller; + controller = navigator?.serviceWorker?.controller; + } + + if (!controller) { + return; } + controller?.postMessage({ type: 'attachment-download', url: href, @@ -33,7 +40,7 @@ export const forAttachmentDownload = (uid: string, href: string, controller?: Se }; export const useDownloadFromServiceWorker = (href: string, title?: string) => { - const { controller } = navigator.serviceWorker; + const { controller } = navigator?.serviceWorker || {}; const uid = useUniqueId(); diff --git a/apps/meteor/client/lib/imperativeModal.tsx b/apps/meteor/client/lib/imperativeModal.tsx index 28db6fa107ed..3740eb1ebc9c 100644 --- a/apps/meteor/client/lib/imperativeModal.tsx +++ b/apps/meteor/client/lib/imperativeModal.tsx @@ -1,15 +1,15 @@ import { Emitter } from '@rocket.chat/emitter'; import React, { Suspense, createElement } from 'react'; -import type { ComponentProps, ElementType, ReactNode } from 'react'; +import type { ComponentProps, ComponentType, ReactNode } from 'react'; import { modalStore } from '../providers/ModalProvider/ModalStore'; -type ReactModalDescriptor = { +type ReactModalDescriptor = ComponentType> = { component: TComponent; props?: ComponentProps; }; -type ModalDescriptor = ReactModalDescriptor | null; +type ModalDescriptor = ReactModalDescriptor | null; type ModalInstance = { close: () => void; @@ -41,11 +41,11 @@ class ImperativeModalEmmiter extends Emitter<{ update: ModalDescriptor }> { this.store = store; } - open = (descriptor: ReactModalDescriptor): ModalInstance => { + open = >(descriptor: ReactModalDescriptor): ModalInstance => { return this.store.open(mapCurrentModal(descriptor as ModalDescriptor)); }; - push = (descriptor: ReactModalDescriptor): ModalInstance => { + push = >(descriptor: ReactModalDescriptor): ModalInstance => { return this.store.push(mapCurrentModal(descriptor as ModalDescriptor)); }; diff --git a/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts b/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts index b0276e753922..6f4e1c95a5fa 100644 --- a/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts +++ b/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts @@ -14,7 +14,7 @@ class PrivateSettingsCachedCollection extends CachedCollection { async setupListener(): Promise { sdk.stream('notify-logged', [this.eventName as 'private-settings-changed'], async (t: string, { _id, ...record }: { _id: string }) => { this.log('record received', t, { _id, ...record }); - this.collection.upsert({ _id }, record); + this.collection.update({ _id }, { $set: record }, { upsert: true }); this.sync(); }); } diff --git a/apps/meteor/client/polyfills/hoverTouchClick.ts b/apps/meteor/client/polyfills/hoverTouchClick.ts deleted file mode 100644 index 53706a45fb33..000000000000 --- a/apps/meteor/client/polyfills/hoverTouchClick.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as domEvents from '../lib/utils/domEvents'; -import { isIOsDevice } from '../lib/utils/isIOsDevice'; - -((): void => { - if (!isIOsDevice || !window.matchMedia('(hover: none)').matches) { - return; - } - - domEvents.delegate({ - parent: document.body, - eventName: 'touchend', - elementSelector: 'a:hover', - listener: (_, element): void => { - domEvents.triggerClick(element); - }, - }); -})(); diff --git a/apps/meteor/client/polyfills/index.ts b/apps/meteor/client/polyfills/index.ts index bc91265b04ba..be470f261e26 100644 --- a/apps/meteor/client/polyfills/index.ts +++ b/apps/meteor/client/polyfills/index.ts @@ -3,5 +3,4 @@ import 'url-polyfill'; import './childNodeRemove'; import './cssVars'; import './customEventPolyfill'; -import './hoverTouchClick'; import './promiseFinally'; diff --git a/apps/meteor/client/portals/ModalPortal.tsx b/apps/meteor/client/portals/ModalPortal.tsx index d7c9ae9caa2d..6b2210d56926 100644 --- a/apps/meteor/client/portals/ModalPortal.tsx +++ b/apps/meteor/client/portals/ModalPortal.tsx @@ -1,18 +1,32 @@ -import type { ReactElement, ReactNode } from 'react'; -import React, { memo, useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import { memo } from 'react'; import { createPortal } from 'react-dom'; -import { createAnchor } from '../lib/utils/createAnchor'; -import { deleteAnchor } from '../lib/utils/deleteAnchor'; +const createModalRoot = (): HTMLElement => { + const id = 'modal-root'; + const existing = document.getElementById(id); + + if (existing) return existing; + + const newOne = document.createElement('div'); + newOne.id = id; + document.body.append(newOne); + + return newOne; +}; + +let modalRoot: HTMLElement | null = null; type ModalPortalProps = { children?: ReactNode; }; -const ModalPortal = ({ children }: ModalPortalProps): ReactElement => { - const [modalRoot] = useState(() => createAnchor('modal-root')); - useEffect(() => (): void => deleteAnchor(modalRoot), [modalRoot]); - return <>{createPortal(children, modalRoot)}; +const ModalPortal = ({ children }: ModalPortalProps) => { + if (!modalRoot) { + modalRoot = createModalRoot(); + } + + return createPortal(children, modalRoot); }; export default memo(ModalPortal); diff --git a/apps/meteor/client/providers/ModalProvider/ModalProvider.spec.tsx b/apps/meteor/client/providers/ModalProvider/ModalProvider.spec.tsx index f77933337456..ea062c324807 100644 --- a/apps/meteor/client/providers/ModalProvider/ModalProvider.spec.tsx +++ b/apps/meteor/client/providers/ModalProvider/ModalProvider.spec.tsx @@ -1,115 +1,138 @@ -// import type { IMessage } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; import { useSetModal } from '@rocket.chat/ui-contexts'; -import { render, screen } from '@testing-library/react'; -import { expect } from 'chai'; -import type { ReactNode } from 'react'; -import React, { Suspense, createContext, useContext, useEffect } from 'react'; +import { act, render, screen } from '@testing-library/react'; +import type { ForwardedRef, ReactElement } from 'react'; +import React, { Suspense, createContext, createRef, forwardRef, useContext, useImperativeHandle } from 'react'; import GenericModal from '../../components/GenericModal'; import { imperativeModal } from '../../lib/imperativeModal'; import ModalRegion from '../../views/modal/ModalRegion'; import ModalProvider from './ModalProvider'; import ModalProviderWithRegion from './ModalProviderWithRegion'; +import '@testing-library/jest-dom'; -const TestContext = createContext({ title: 'default' }); -const emitter = new Emitter(); +const renderWithSuspense = (ui: ReactElement) => + render(ui, { + wrapper: ({ children }) => {children}, + }); -const TestModal = ({ emitterEvent, modalFunc }: { emitterEvent: string; modalFunc?: () => ReactNode }) => { - const setModal = useSetModal(); - const { title } = useContext(TestContext); +describe('via useSetModal', () => { + const ModalTitleContext = createContext('default'); - useEffect(() => { - emitter.on(emitterEvent, () => { - setModal(modalFunc || undefined}>); - }); - }, [emitterEvent, setModal, title, modalFunc]); + type ModalOpenerAPI = { open: () => void }; - return <>; -}; + const ModalOpener = forwardRef((_: unknown, ref: ForwardedRef) => { + const setModal = useSetModal(); + const title = useContext(ModalTitleContext); + useImperativeHandle(ref, () => ({ + open: () => { + setModal(); + }, + })); + + return null; + }); -describe('Modal Provider', () => { it('should render a modal', async () => { - render( - + const modalOpenerRef = createRef(); + + renderWithSuspense( + + + , + ); + + act(() => { + modalOpenerRef.current?.open(); + }); + + expect(await screen.findByRole('dialog', { name: 'default' })).toBeInTheDocument(); + }); + + it('should render a modal that consumes a context', async () => { + const modalOpenerRef = createRef(); + + renderWithSuspense( + - + - , + , ); - emitter.emit('open'); - expect(await screen.findByText('default')).to.exist; + act(() => { + modalOpenerRef.current?.open(); + }); + + expect(await screen.findByRole('dialog', { name: 'title from context' })).toBeInTheDocument(); }); - it('should render a modal that is passed as a function', async () => { - render( - + it('should render a modal in another region', async () => { + const modalOpener1Ref = createRef(); + const modalOpener2Ref = createRef(); + + renderWithSuspense( + - undefined} />} /> + - , + + + + + + , ); - emitter.emit('open'); - expect(await screen.findByText('function modal')).to.exist; + + act(() => { + modalOpener1Ref.current?.open(); + }); + + expect(await screen.findByRole('dialog', { name: 'modal1' })).toBeInTheDocument(); + + act(() => { + modalOpener2Ref.current?.open(); + }); + + expect(await screen.findByRole('dialog', { name: 'modal2' })).toBeInTheDocument(); }); +}); + +describe('via imperativeModal', () => { + it('should render a modal through imperative modal', async () => { + renderWithSuspense( + + + , + ); - it('should render a modal through imperative modal', () => { - async () => { - render( - - - - - , - ); - - const { close } = imperativeModal.open({ + act(() => { + imperativeModal.open({ component: GenericModal, - props: { title: 'imperativeModal' }, + props: { title: 'imperativeModal', open: true }, }); + }); - expect(await screen.findByText('imperativeModal')).to.exist; + expect(await screen.findByRole('dialog', { name: 'imperativeModal' })).toBeInTheDocument(); - close(); + act(() => { + imperativeModal.close(); + }); - expect(screen.queryByText('imperativeModal')).to.not.exist; - }; + expect(screen.queryByText('imperativeModal')).not.toBeInTheDocument(); }); it('should not render a modal if no corresponding region exists', async () => { // ModalProviderWithRegion will always have a region identifier set // and imperativeModal will only render modals in the default region (e.g no region identifier) - render( - - - , - ); - - imperativeModal.open({ - component: GenericModal, - props: { title: 'imperativeModal' }, - }); - expect(screen.queryByText('imperativeModal')).to.not.exist; - }); + renderWithSuspense(); - it('should render a modal in another region', () => { - render( - - - - - - - - - - , - ); + act(() => { + imperativeModal.open({ + component: GenericModal, + props: { title: 'imperativeModal', open: true }, + }); + }); - emitter.emit('openModal1'); - expect(screen.getByText('modal1')).to.exist; - emitter.emit('openModal2'); - expect(screen.getByText('modal2')).to.exist; + expect(screen.queryByRole('dialog', { name: 'imperativeModal' })).not.toBeInTheDocument(); }); }); diff --git a/apps/meteor/client/providers/ModalProvider/ModalProvider.tsx b/apps/meteor/client/providers/ModalProvider/ModalProvider.tsx index 6c3f1026bc51..27092ea602b6 100644 --- a/apps/meteor/client/providers/ModalProvider/ModalProvider.tsx +++ b/apps/meteor/client/providers/ModalProvider/ModalProvider.tsx @@ -33,7 +33,7 @@ const ModalProvider = ({ children, region }: ModalProviderProps) => { }, region, }), - [currentModal, region, setModal], + [currentModal?.node, currentModal?.region, region, setModal], ); return ; diff --git a/apps/meteor/client/providers/RouterProvider.tsx b/apps/meteor/client/providers/RouterProvider.tsx index 8ba6e699b2b1..d7fb25a1ed31 100644 --- a/apps/meteor/client/providers/RouterProvider.tsx +++ b/apps/meteor/client/providers/RouterProvider.tsx @@ -1,3 +1,4 @@ +import type { RoomType, RoomRouteData } from '@rocket.chat/core-typings'; import type { RouterContextValue, RouteName, @@ -15,6 +16,7 @@ import type { ReactNode } from 'react'; import React from 'react'; import { appLayout } from '../lib/appLayout'; +import { roomCoordinator } from '../lib/rooms/roomCoordinator'; import { queueMicrotask } from '../lib/utils/queueMicrotask'; const subscribers = new Set<() => void>(); @@ -195,6 +197,9 @@ export const router: RouterContextValue = { defineRoutes, getRoutes, subscribeToRoutesChange, + getRoomRoute(roomType: RoomType, routeData: RoomRouteData) { + return { path: roomCoordinator.getRouteLink(roomType, routeData) || '/' }; + }, }; type RouterProviderProps = { diff --git a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx index f9ec077e9e43..cc7cdfbe7761 100644 --- a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx @@ -8,6 +8,7 @@ import React, { memo, useMemo } from 'react'; import { RoomIcon } from '../../components/RoomIcon'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { isIOsDevice } from '../../lib/utils/isIOsDevice'; import { useOmnichannelPriorities } from '../../omnichannel/hooks/useOmnichannelPriorities'; import RoomMenu from '../RoomMenu'; import { OmnichannelBadges } from '../badges/OmnichannelBadges'; @@ -195,6 +196,7 @@ function SideBarItemTemplateWithData({ avatar={AvatarTemplate && } actions={actions} menu={ + !isIOsDevice && !isAnonymous && (!isQueued || (isQueued && isPriorityEnabled)) && ((): ReactElement => ( diff --git a/apps/meteor/client/sidebar/RoomMenu.tsx b/apps/meteor/client/sidebar/RoomMenu.tsx index 8df55bd5d359..06b1352d2803 100644 --- a/apps/meteor/client/sidebar/RoomMenu.tsx +++ b/apps/meteor/client/sidebar/RoomMenu.tsx @@ -200,10 +200,14 @@ const RoomMenu = ({ const menuOptions = useMemo( () => ({ ...(!hideDefaultOptions && { - hideRoom: { - label: { label: t('Hide'), icon: 'eye-off' }, - action: handleHide, - }, + ...(isOmnichannelRoom + ? {} + : { + hideRoom: { + label: { label: t('Hide'), icon: 'eye-off' }, + action: handleHide, + }, + }), toggleRead: { label: { label: isUnread ? t('Mark_read') : t('Mark_unread'), icon: 'flag' }, action: handleToggleRead, diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.ts b/apps/meteor/client/sidebar/hooks/useRoomList.ts index fa5dfd2797cb..afdc57086dc4 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.ts +++ b/apps/meteor/client/sidebar/hooks/useRoomList.ts @@ -12,12 +12,39 @@ const query = { open: { $ne: false } }; const emptyQueue: ILivechatInquiryRecord[] = []; +const order: ( + | 'Incoming_Calls' + | 'Incoming_Livechats' + | 'Open_Livechats' + | 'On_Hold_Chats' + | 'Unread' + | 'Favorites' + | 'Teams' + | 'Discussions' + | 'Channels' + | 'Direct_Messages' + | 'Conversations' +)[] = [ + 'Incoming_Calls', + 'Incoming_Livechats', + 'Open_Livechats', + 'On_Hold_Chats', + 'Unread', + 'Favorites', + 'Teams', + 'Discussions', + 'Channels', + 'Direct_Messages', + 'Conversations', +]; + export const useRoomList = (): Array => { const [roomList, setRoomList] = useDebouncedState<(ISubscription & IRoom)[]>([], 150); const showOmnichannel = useOmnichannelEnabled(); const sidebarGroupByType = useUserPreference('sidebarGroupByType'); const favoritesEnabled = useUserPreference('sidebarShowFavorites'); + const sidebarOrder = useUserPreference('sidebarSectionsOrder') ?? order; const isDiscussionEnabled = useSetting('Discussion_enabled'); const sidebarShowUnread = useUserPreference('sidebarShowUnread'); @@ -92,7 +119,7 @@ export const useRoomList = (): Array => { }); const groups = new Map(); - incomingCall.size && groups.set('Incoming Calls', incomingCall); + incomingCall.size && groups.set('Incoming_Calls', incomingCall); showOmnichannel && inquiries.enabled && queue.length && groups.set('Incoming_Livechats', queue); showOmnichannel && omnichannel.size && groups.set('Open_Livechats', omnichannel); showOmnichannel && onHold.size && groups.set('On_Hold_Chats', onHold); @@ -103,7 +130,16 @@ export const useRoomList = (): Array => { sidebarGroupByType && channels.size && groups.set('Channels', channels); sidebarGroupByType && direct.size && groups.set('Direct_Messages', direct); !sidebarGroupByType && groups.set('Conversations', conversation); - return [...groups.entries()].flatMap(([key, group]) => [key, ...group]); + return sidebarOrder + .map((key) => { + const group = groups.get(key); + if (!group) { + return []; + } + + return [key, ...group]; + }) + .flat(); }); }, [ rooms, @@ -116,6 +152,7 @@ export const useRoomList = (): Array => { sidebarGroupByType, setRoomList, isDiscussionEnabled, + sidebarOrder, ]); return roomList; diff --git a/apps/meteor/client/sidebarv2/RoomList/SideBarItemTemplateWithData.tsx b/apps/meteor/client/sidebarv2/RoomList/SideBarItemTemplateWithData.tsx index 60444540f6fd..4eaba8cc37f0 100644 --- a/apps/meteor/client/sidebarv2/RoomList/SideBarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebarv2/RoomList/SideBarItemTemplateWithData.tsx @@ -8,6 +8,7 @@ import React, { memo, useMemo } from 'react'; import { RoomIcon } from '../../components/RoomIcon'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; +import { isIOsDevice } from '../../lib/utils/isIOsDevice'; import { useOmnichannelPriorities } from '../../omnichannel/hooks/useOmnichannelPriorities'; import RoomMenu from '../RoomMenu'; import { OmnichannelBadges } from '../badges/OmnichannelBadges'; @@ -195,6 +196,7 @@ const SideBarItemTemplateWithData = ({ avatar={AvatarTemplate && } actions={actions} menu={ + !isIOsDevice && !isAnonymous && (!isQueued || (isQueued && isPriorityEnabled)) && ((): ReactElement => ( diff --git a/apps/meteor/client/sidebarv2/header/SearchSection.tsx b/apps/meteor/client/sidebarv2/header/SearchSection.tsx index 66d8eb65371f..c1f3f8cf8c21 100644 --- a/apps/meteor/client/sidebarv2/header/SearchSection.tsx +++ b/apps/meteor/client/sidebarv2/header/SearchSection.tsx @@ -72,7 +72,7 @@ const SearchSection = () => { { }, order: 5, group: 'menu', + disabled({ message }) { + return isE2EEMessage(message); + }, }); }); diff --git a/apps/meteor/client/startup/actionButtons/permalinkStar.ts b/apps/meteor/client/startup/actionButtons/permalinkStar.ts index bf6deede9b10..a1ee8d79fc44 100644 --- a/apps/meteor/client/startup/actionButtons/permalinkStar.ts +++ b/apps/meteor/client/startup/actionButtons/permalinkStar.ts @@ -1,3 +1,4 @@ +import { isE2EEMessage } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { MessageAction } from '../../../app/ui-utils/client'; @@ -32,5 +33,8 @@ Meteor.startup(() => { }, order: 10, group: 'menu', + disabled({ message }) { + return isE2EEMessage(message); + }, }); }); diff --git a/apps/meteor/client/uikit/hooks/useModalContextValue.ts b/apps/meteor/client/uikit/hooks/useModalContextValue.ts index e10a59902b6b..4a0932c8e1e7 100644 --- a/apps/meteor/client/uikit/hooks/useModalContextValue.ts +++ b/apps/meteor/client/uikit/hooks/useModalContextValue.ts @@ -14,6 +14,7 @@ type UseModalContextValueParams = { blockId?: string | undefined; }; }; + errors?: { [field: string]: string }[] | { [field: string]: string }; updateValues: Dispatch<{ actionId: string; payload: { @@ -25,7 +26,7 @@ type UseModalContextValueParams = { type UseModalContextValueReturn = ContextType; -export const useModalContextValue = ({ view, values, updateValues }: UseModalContextValueParams): UseModalContextValueReturn => { +export const useModalContextValue = ({ view, errors, values, updateValues }: UseModalContextValueParams): UseModalContextValueReturn => { const actionManager = useUiKitActionManager(); const emitInteraction = useMemo(() => actionManager.emitInteraction.bind(actionManager), [actionManager]); @@ -62,6 +63,7 @@ export const useModalContextValue = ({ view, values, updateValues }: UseModalCon }); }, ...view, + errors, values, viewId: view.id, }; diff --git a/apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx b/apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx index 9abec01df1d0..8489b3cb4b8b 100644 --- a/apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx +++ b/apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx @@ -1,5 +1,5 @@ import { ButtonGroup, Button, Box, Accordion } from '@rocket.chat/fuselage'; -import { useToastMessageDispatch, useTranslation, useEndpoint, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch, useTranslation, useEndpoint, useUserPreference, useSetting } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; import { useForm, FormProvider } from 'react-hook-form'; @@ -17,12 +17,17 @@ const OmnichannelPreferencesPage = (): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); + const alwaysSendEmailTranscript = useSetting('Livechat_transcript_send_always'); const omnichannelTranscriptPDF = useUserPreference('omnichannelTranscriptPDF') ?? false; const omnichannelTranscriptEmail = useUserPreference('omnichannelTranscriptEmail') ?? false; const omnichannelHideConversationAfterClosing = useUserPreference('omnichannelHideConversationAfterClosing') ?? true; const methods = useForm({ - defaultValues: { omnichannelTranscriptPDF, omnichannelTranscriptEmail, omnichannelHideConversationAfterClosing }, + defaultValues: { + omnichannelTranscriptPDF, + omnichannelTranscriptEmail: alwaysSendEmailTranscript || omnichannelTranscriptEmail, + omnichannelHideConversationAfterClosing, + }, }); const { diff --git a/apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx b/apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx index 11bf6634a0e9..a003861c8d57 100644 --- a/apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx +++ b/apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx @@ -1,6 +1,6 @@ import { Accordion, Box, Field, FieldGroup, FieldLabel, FieldRow, FieldHint, Tag, ToggleSwitch } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useTranslation, usePermission } from '@rocket.chat/ui-contexts'; +import { useTranslation, usePermission, useSetting } from '@rocket.chat/ui-contexts'; import React from 'react'; import { useFormContext } from 'react-hook-form'; @@ -12,8 +12,10 @@ const PreferencesConversationTranscript = () => { const { register } = useFormContext(); const hasLicense = useHasLicenseModule('livechat-enterprise'); + const alwaysSendEmailTranscript = useSetting('Livechat_transcript_send_always'); const canSendTranscriptPDF = usePermission('request-pdf-transcript'); - const canSendTranscriptEmail = usePermission('send-omnichannel-chat-transcript'); + const canSendTranscriptEmailPermission = usePermission('send-omnichannel-chat-transcript'); + const canSendTranscriptEmail = canSendTranscriptEmailPermission && !alwaysSendEmailTranscript; const cantSendTranscriptPDF = !canSendTranscriptPDF || !hasLicense; const omnichannelTranscriptPDF = useUniqueId(); @@ -42,7 +44,7 @@ const PreferencesConversationTranscript = () => { {t('Omnichannel_transcript_email')} - {!canSendTranscriptEmail && ( + {!canSendTranscriptEmailPermission && ( {t('No_permission')} diff --git a/apps/meteor/client/views/admin/engagementDashboard/channels/useChannelsList.ts b/apps/meteor/client/views/admin/engagementDashboard/channels/useChannelsList.ts index cd1a338eeab5..f45c620c8b49 100644 --- a/apps/meteor/client/views/admin/engagementDashboard/channels/useChannelsList.ts +++ b/apps/meteor/client/views/admin/engagementDashboard/channels/useChannelsList.ts @@ -24,6 +24,7 @@ export const useChannelsList = ({ period, offset, count }: UseChannelsListOption end: end.toISOString(), offset, count, + hideRoomsWithNoActivity: true, }); return response diff --git a/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx b/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx index 1ed21c1234a9..d52d45415c8a 100644 --- a/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx +++ b/apps/meteor/client/views/admin/rooms/RoomsTableFilters.tsx @@ -9,7 +9,6 @@ const initialRoomTypeFilterStructure = [ { id: 'filter_by_room', text: 'Filter_by_room', - isGroupTitle: true, }, { id: 'd', @@ -71,7 +70,7 @@ const RoomsTableFilters = ({ setFilters }: { setFilters: Dispatch>; + ); return ( { const t = useTranslation(); const [fullScreen, toggleFullScreen] = useToggle(false); - const fullScreenStyle = css` - position: fixed; - z-index: 100; - top: 0; - right: 0; - bottom: 0; - left: 0; - - display: flex; - - flex-direction: column; - - width: auto; - height: auto; - - padding: 40px; - - align-items: stretch; - `; - - return ( - - {fullScreen && ( + if (fullScreen) { + return createPortal( + {label} - )} + + {children} + + + + + + + , + document.getElementById('main-content') as HTMLElement, + ); + } + + return ( + {children} diff --git a/apps/meteor/client/views/admin/users/AdminUserFormWithData.tsx b/apps/meteor/client/views/admin/users/AdminUserFormWithData.tsx index 63fe2691d972..e595acc46951 100644 --- a/apps/meteor/client/views/admin/users/AdminUserFormWithData.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserFormWithData.tsx @@ -1,6 +1,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { isUserFederated } from '@rocket.chat/core-typings'; import { Box, Callout } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; @@ -16,7 +17,12 @@ type AdminUserFormWithDataProps = { const AdminUserFormWithData = ({ uid, onReload }: AdminUserFormWithDataProps): ReactElement => { const t = useTranslation(); - const { data, isLoading, isError } = useUserInfoQuery({ userId: uid }); + const { data, isLoading, isError, refetch } = useUserInfoQuery({ userId: uid }); + + const handleReload = useEffectEvent(() => { + onReload(); + refetch(); + }); if (isLoading) { return ( @@ -42,7 +48,7 @@ const AdminUserFormWithData = ({ uid, onReload }: AdminUserFormWithDataProps): R ); } - return ; + return ; }; export default AdminUserFormWithData; diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index d49f3bdafa7f..b7243af14739 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -1,8 +1,10 @@ import type { IAdminUserTabs, LicenseInfo } from '@rocket.chat/core-typings'; import { Button, ButtonGroup, Callout, ContextualbarIcon, Skeleton, Tabs, TabsItem } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import type { OptionProp } from '@rocket.chat/ui-client'; import { ExternalLink } from '@rocket.chat/ui-client'; -import { usePermission, useRouteParameter, useTranslation, useRouter } from '@rocket.chat/ui-contexts'; +import { usePermission, useRouteParameter, useTranslation, useRouter, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Trans } from 'react-i18next'; @@ -33,6 +35,7 @@ import { useSeatsCap } from './useSeatsCap'; export type UsersFilters = { text: string; + roles: OptionProp[]; }; export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status' | 'active'; @@ -55,11 +58,14 @@ const AdminUsersPage = (): ReactElement => { const isCreateUserDisabled = useShouldPreventAction('activeUsers'); + const getRoles = useEndpoint('GET', '/v1/roles.list'); + const { data } = useQuery(['roles'], async () => getRoles()); + const paginationData = usePagination(); const sortData = useSort('name'); const [tab, setTab] = useState('all'); - const [userFilters, setUserFilters] = useState({ text: '' }); + const [userFilters, setUserFilters] = useState({ text: '', roles: [] }); const searchTerm = useDebouncedValue(userFilters.text, 500); const prevSearchTerm = useRef(''); @@ -70,6 +76,7 @@ const AdminUsersPage = (): ReactElement => { sortData, paginationData, tab, + selectedRoles: useMemo(() => userFilters.roles.map((role) => role.id), [userFilters.roles]), }); const pendingUsersCount = usePendingUsersCount(filteredUsersQueryResult.data?.users); @@ -153,6 +160,7 @@ const AdminUsersPage = (): ReactElement => { sortData={sortData} tab={tab} isSeatsCapExceeded={isSeatsCapExceeded} + roleData={data} /> diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index fa35df715fc5..01d7007561eb 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -1,13 +1,12 @@ -import type { IAdminUserTabs, Serialized } from '@rocket.chat/core-typings'; +import type { IAdminUserTabs, IRole, Serialized } from '@rocket.chat/core-typings'; import { Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage'; import { useEffectEvent, useBreakpoints } from '@rocket.chat/fuselage-hooks'; import type { PaginatedResult, DefaultUserInfo } from '@rocket.chat/rest-typings'; import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import type { ReactElement, Dispatch, SetStateAction } from 'react'; -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; -import FilterByText from '../../../../components/FilterByText'; import GenericNoResults from '../../../../components/GenericNoResults'; import { GenericTable, @@ -19,10 +18,12 @@ import { import type { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; import type { useSort } from '../../../../components/GenericTable/hooks/useSort'; import type { UsersFilters, UsersTableSortingOptions } from '../AdminUsersPage'; +import UsersTableFilters from './UsersTableFilters'; import UsersTableRow from './UsersTableRow'; type UsersTableProps = { tab: IAdminUserTabs; + roleData: { roles: IRole[] } | undefined; onReload: () => void; setUserFilters: Dispatch>; filteredUsersQueryResult: UseQueryResult[] }>>; @@ -34,6 +35,7 @@ type UsersTableProps = { const UsersTable = ({ filteredUsersQueryResult, setUserFilters, + roleData, tab, onReload, paginationData, @@ -113,15 +115,10 @@ const UsersTable = ({ [isLaptop, isMobile, setSort, sortBy, sortDirection, t, tab], ); - const handleSearchTextChange = useCallback( - ({ text }) => { - setUserFilters({ text }); - }, - [setUserFilters], - ); return ( <> - + + {isLoading && ( {headers} diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx new file mode 100644 index 000000000000..28508ac94ac5 --- /dev/null +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx @@ -0,0 +1,78 @@ +import type { IRole } from '@rocket.chat/core-typings'; +import { useBreakpoints } from '@rocket.chat/fuselage-hooks'; +import type { OptionProp } from '@rocket.chat/ui-client'; +import { MultiSelectCustom } from '@rocket.chat/ui-client'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import FilterByText from '../../../../components/FilterByText'; +import type { UsersFilters } from '../AdminUsersPage'; + +type UsersTableFiltersProps = { + setUsersFilters: React.Dispatch>; + roleData: { roles: IRole[] } | undefined; +}; + +const UsersTableFilters = ({ roleData, setUsersFilters }: UsersTableFiltersProps) => { + const { t } = useTranslation(); + + const [selectedRoles, setSelectedRoles] = useState([]); + const [text, setText] = useState(''); + + const handleSearchTextChange = useCallback( + ({ text }) => { + setUsersFilters({ text, roles: selectedRoles }); + setText(text); + }, + [selectedRoles, setUsersFilters], + ); + + const handleRolesChange = useCallback( + (roles: OptionProp[]) => { + setUsersFilters({ text, roles }); + setSelectedRoles(roles); + }, + [setUsersFilters, text], + ); + + const userRolesFilterStructure = useMemo( + () => [ + { + id: 'filter_by_role', + text: 'Filter_by_role', + }, + { + id: 'all', + text: 'All_roles', + checked: false, + }, + ...(roleData + ? roleData.roles.map((role) => ({ + id: role._id, + text: role.description || role.name || role._id, + checked: false, + })) + : []), + ], + [roleData], + ); + + const breakpoints = useBreakpoints(); + const fixFiltersSize = breakpoints.includes('lg') ? { maxWidth: 'x224', minWidth: 'x224' } : null; + + return ( + + + + ); +}; + +export default UsersTableFilters; diff --git a/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts b/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts index f8ea02a34d82..9a592d5e449f 100644 --- a/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts +++ b/apps/meteor/client/views/admin/users/hooks/useFilteredUsers.ts @@ -15,9 +15,10 @@ type UseFilteredUsersOptions = { tab: IAdminUserTabs; paginationData: ReturnType; sortData: ReturnType>; + selectedRoles: string[]; }; -const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData, tab }: UseFilteredUsersOptions) => { +const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData, tab, selectedRoles }: UseFilteredUsersOptions) => { const { setCurrent, itemsPerPage, current } = paginationData; const { sortBy, sortDirection } = sortData; @@ -45,11 +46,12 @@ const useFilteredUsers = ({ searchTerm, prevSearchTerm, sortData, paginationData return { ...listUsersPayload[tab], searchTerm, + roles: selectedRoles, sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`, count: itemsPerPage, offset: searchTerm === prevSearchTerm.current ? current : 0, }; - }, [current, itemsPerPage, prevSearchTerm, searchTerm, setCurrent, sortBy, sortDirection, tab]); + }, [current, itemsPerPage, prevSearchTerm, searchTerm, selectedRoles, setCurrent, sortBy, sortDirection, tab]); const getUsers = useEndpoint('GET', '/v1/users.listByStatus'); const dispatchToastMessage = useToastMessageDispatch(); const usersListQueryResult = useQuery(['users.list', payload, tab], async () => getUsers(payload), { diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.tsx index 26adddffae79..db46d87c18d8 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppStatus/AppStatus.tsx @@ -76,13 +76,13 @@ const AppStatus = ({ app, showStatus = true, isAppDetailsPage, installed, ...pro isAppPurchased, onDismiss: cancelAction, onSuccess: confirmAction, + setIsPurchased: setPurchased, }); const handleAcquireApp = useCallback(() => { setLoading(true); - setPurchased(true); appInstallationHandler(); - }, [appInstallationHandler, setLoading, setPurchased]); + }, [appInstallationHandler, setLoading]); // @TODO we should refactor this to not use the label to determine the variant const getStatusVariant = (status: appStatusSpanResponseProps) => { diff --git a/apps/meteor/client/views/marketplace/hooks/useAppInstallationHandler.tsx b/apps/meteor/client/views/marketplace/hooks/useAppInstallationHandler.tsx index 3c0c41798172..ab5962150f04 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppInstallationHandler.tsx +++ b/apps/meteor/client/views/marketplace/hooks/useAppInstallationHandler.tsx @@ -19,9 +19,17 @@ export type AppInstallationHandlerParams = { isAppPurchased?: boolean; onDismiss: () => void; onSuccess: (action: Actions | '', appPermissions?: App['permissions']) => void; + setIsPurchased: (purchased: boolean) => void; }; -export function useAppInstallationHandler({ app, action, isAppPurchased, onDismiss, onSuccess }: AppInstallationHandlerParams) { +export function useAppInstallationHandler({ + app, + action, + isAppPurchased, + onDismiss, + onSuccess, + setIsPurchased, +}: AppInstallationHandlerParams) { const dispatchToastMessage = useToastMessageDispatch(); const setModal = useSetModal(); @@ -62,7 +70,16 @@ export function useAppInstallationHandler({ app, action, isAppPurchased, onDismi if (action === 'purchase' && !isAppPurchased) { try { const data = await appsOrchestrator.buildExternalUrl(app.id, app.purchaseType, false); - setModal(); + setModal( + { + setIsPurchased(true); + openPermissionModal(); + }} + />, + ); } catch (error) { handleAPIError(error); } @@ -70,7 +87,7 @@ export function useAppInstallationHandler({ app, action, isAppPurchased, onDismi } openPermissionModal(); - }, [action, isAppPurchased, openPermissionModal, appsOrchestrator, app.id, app.purchaseType, setModal, onDismiss]); + }, [action, isAppPurchased, openPermissionModal, appsOrchestrator, app.id, app.purchaseType, setModal, onDismiss, setIsPurchased]); return useCallback(async () => { if (app?.versionIncompatible) { diff --git a/apps/meteor/client/views/marketplace/hooks/useAppMenu.tsx b/apps/meteor/client/views/marketplace/hooks/useAppMenu.tsx index 25ae9bc0ead8..bd2071fe2d82 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppMenu.tsx +++ b/apps/meteor/client/views/marketplace/hooks/useAppMenu.tsx @@ -91,10 +91,6 @@ export const useAppMenu = (app: App, isAppDetailsPage: boolean) => { const installationSuccess = useCallback( async (action: Actions | '', permissionsGranted) => { if (action) { - if (action === 'purchase') { - setPurchased(true); - } - if (action === 'request') { setRequestedEndUser(true); } else { @@ -119,6 +115,7 @@ export const useAppMenu = (app: App, isAppDetailsPage: boolean) => { action, onDismiss: closeModal, onSuccess: installationSuccess, + setIsPurchased: setPurchased, }); const handleAcquireApp = useCallback(() => { diff --git a/apps/meteor/client/views/modal/ModalRegion.tsx b/apps/meteor/client/views/modal/ModalRegion.tsx index 5cbad2b52bc1..284c460ee043 100644 --- a/apps/meteor/client/views/modal/ModalRegion.tsx +++ b/apps/meteor/client/views/modal/ModalRegion.tsx @@ -1,6 +1,7 @@ -import { useModal, useCurrentModal } from '@rocket.chat/ui-contexts'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useCurrentModal, useModal } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import React, { lazy, useCallback } from 'react'; +import React, { lazy } from 'react'; import ModalBackdrop from '../../components/ModalBackdrop'; import ModalPortal from '../../portals/ModalPortal'; @@ -10,7 +11,9 @@ const FocusScope = lazy(() => import('react-aria').then((module) => ({ default: const ModalRegion = (): ReactElement | null => { const currentModal = useCurrentModal(); const { setModal } = useModal(); - const handleDismiss = useCallback(() => setModal(null), [setModal]); + const handleDismiss = useEffectEvent(() => { + setModal(null); + }); if (!currentModal) { return null; diff --git a/apps/meteor/client/views/modal/uikit/UiKitModal.tsx b/apps/meteor/client/views/modal/uikit/UiKitModal.tsx index 56242d399ac5..55c6c8a32d71 100644 --- a/apps/meteor/client/views/modal/uikit/UiKitModal.tsx +++ b/apps/meteor/client/views/modal/uikit/UiKitModal.tsx @@ -20,7 +20,7 @@ type UiKitModalProps = { const UiKitModal = ({ initialView }: UiKitModalProps) => { const actionManager = useUiKitActionManager(); const { view, errors, values, updateValues, state } = useUiKitView(initialView); - const contextValue = useModalContextValue({ view, values, updateValues }); + const contextValue = useModalContextValue({ view, errors, values, updateValues }); const handleSubmit = useEffectEvent((e: FormEvent) => { preventSyntheticEvent(e); diff --git a/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx b/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx index 95fb9a54c3ce..c439cc838874 100644 --- a/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx +++ b/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx @@ -54,6 +54,7 @@ type CurrentChatQuery = { customFields?: string; sort: string; count?: number; + queued?: boolean; }; type useQueryType = ( @@ -95,8 +96,9 @@ const currentChatQuery: useQueryType = ( } if (status !== 'all') { - query.open = status === 'opened' || status === 'onhold'; + query.open = status === 'opened' || status === 'onhold' || status === 'queued'; query.onhold = status === 'onhold'; + query.queued = status === 'queued'; } if (servedBy && servedBy !== 'all') { query.agents = [servedBy]; @@ -170,8 +172,9 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s const renderRow = useCallback( (room) => { const { _id, fname, servedBy, ts, lm, department, open, onHold, priorityWeight } = room; - const getStatusText = (open: boolean, onHold: boolean): string => { + const getStatusText = (open: boolean, onHold: boolean, servedBy: boolean): string => { if (!open) return t('Closed'); + if (open && !servedBy) return t('Queued'); return onHold ? t('On_Hold_Chats') : t('Room_Status_Open'); }; @@ -198,7 +201,7 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s {moment(lm).format('L LTS')} - {getStatusText(open, onHold)} + {getStatusText(open, onHold, !!servedBy?.username)} {canRemoveClosedChats && !open && } diff --git a/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx b/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx index 131aa06f0a70..cda579387ea1 100644 --- a/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx +++ b/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx @@ -30,6 +30,7 @@ const FilterByText = ({ setFilter, reload, customFields, setCustomFields, hasCus ['closed', t('Closed')], ['opened', t('Room_Status_Open')], ['onhold', t('On_Hold_Chats')], + ['queued', t('Queued')], ]; const [guest, setGuest] = useLocalStorage('guest', ''); diff --git a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx index 7446d0630b09..edf5ffcbdc8a 100644 --- a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx +++ b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx @@ -10,6 +10,7 @@ import { useMethod, useTranslation, useRouter, + useUserSubscription, } from '@rocket.chat/ui-contexts'; import React, { useCallback, useState, useEffect } from 'react'; @@ -47,6 +48,7 @@ export const useQuickActions = (): { const visitorRoomId = room.v._id; const rid = room._id; const uid = useUserId(); + const subscription = useUserSubscription(rid); const roomLastMessage = room.lastMessage; const getVisitorInfo = useEndpoint('GET', '/v1/livechat/visitors.info'); @@ -330,7 +332,7 @@ export const useQuickActions = (): { case QuickActionsEnum.TranscriptPDF: return hasLicense && !isRoomOverMacLimit && canSendTranscriptPDF; case QuickActionsEnum.CloseChat: - return !!roomOpen && (canCloseRoom || canCloseOthersRoom); + return (subscription && (canCloseRoom || canCloseOthersRoom)) || (!!roomOpen && canCloseOthersRoom); case QuickActionsEnum.OnHoldChat: return !!roomOpen && canPlaceChatOnHold; default: diff --git a/apps/meteor/client/views/room/RoomSkeleton.tsx b/apps/meteor/client/views/room/RoomSkeleton.tsx index 22abbcd3f3a1..083a729c309e 100644 --- a/apps/meteor/client/views/room/RoomSkeleton.tsx +++ b/apps/meteor/client/views/room/RoomSkeleton.tsx @@ -1,14 +1,25 @@ +import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; import React from 'react'; import MessageListSkeleton from '../../components/message/list/MessageListSkeleton'; import HeaderSkeleton from './Header/HeaderSkeleton'; +import HeaderSkeletonV2 from './HeaderV2/HeaderSkeleton'; import RoomComposerSkeleton from './composer/RoomComposer/RoomComposerSkeleton'; import RoomLayout from './layout/RoomLayout'; const RoomSkeleton = (): ReactElement => ( } + header={ + + + + + + + + + } body={ <> diff --git a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts index 414b91c52493..314eb64304b5 100644 --- a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts +++ b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts @@ -54,7 +54,7 @@ export const useFileUploadDropTarget = (): readonly [ const uniqueFiles = getUniqueFiles(); const uploads = Array.from(uniqueFiles).map((file) => { - Object.defineProperty(file, 'type', { value: getMimeType(file.name) }); + Object.defineProperty(file, 'type', { value: getMimeType(file.type, file.name) }); return file; }); diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index 4b7a466d6de0..00d9c66a2046 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -14,15 +14,7 @@ import { } from '@rocket.chat/ui-composer'; import { useTranslation, useUserPreference, useLayout, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; -import type { - ReactElement, - MouseEventHandler, - FormEvent, - KeyboardEventHandler, - KeyboardEvent, - ClipboardEventHandler, - MouseEvent, -} from 'react'; +import type { ReactElement, MouseEventHandler, FormEvent, ClipboardEventHandler, MouseEvent } from 'react'; import React, { memo, useRef, useReducer, useCallback } from 'react'; import { Trans } from 'react-i18next'; import { useSubscription } from 'use-subscription'; @@ -60,11 +52,7 @@ const reducer = (_: unknown, event: FormEvent): boolean => { return Boolean(target.value.trim()); }; -const handleFormattingShortcut = ( - event: KeyboardEvent, - formattingButtons: FormattingButton[], - composer: ComposerAPI, -) => { +const handleFormattingShortcut = (event: KeyboardEvent, formattingButtons: FormattingButton[], composer: ComposerAPI) => { const isMacOS = navigator.platform.indexOf('Mac') !== -1; const isCmdOrCtrlPressed = (isMacOS && event.metaKey) || (!isMacOS && event.ctrlKey); @@ -196,7 +184,7 @@ const MessageBox = ({ } }; - const handler: KeyboardEventHandler = useMutableCallback((event) => { + const handler = useMutableCallback((event: KeyboardEvent) => { const { which: keyCode } = event; const input = event.target as HTMLTextAreaElement; @@ -357,7 +345,19 @@ const MessageBox = ({ configurations: composerPopupConfig, }); - const mergedRefs = useMessageComposerMergedRefs(c, textareaRef, callbackRef, autofocusRef); + const keyDownHandlerCallbackRef = useCallback( + (node: HTMLTextAreaElement) => { + if (node === null) { + return; + } + node.addEventListener('keydown', (e: KeyboardEvent) => { + handler(e); + }); + }, + [handler], + ); + + const mergedRefs = useMessageComposerMergedRefs(c, textareaRef, callbackRef, autofocusRef, keyDownHandlerCallbackRef); const shouldPopupPreview = useEnablePopupPreview(filter, popup); @@ -411,7 +411,6 @@ const MessageBox = ({ onChange={setTyping} style={textAreaStyle} placeholder={composerPlaceholder} - onKeyDown={handler} onPaste={handlePaste} aria-activedescendant={ariaActiveDescendant} /> diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts index 03229c5dceb3..f911b2b63b1f 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts @@ -26,7 +26,7 @@ export const useFileUploadAction = (disabled: boolean): GenericMenuItemProps => const { getMimeType } = await import('../../../../../../../app/utils/lib/mimeTypes'); const filesToUpload = Array.from(fileInputRef?.current?.files ?? []).map((file) => { Object.defineProperty(file, 'type', { - value: getMimeType(file.name), + value: getMimeType(file.type, file.name), }); return file; }); diff --git a/apps/meteor/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.tsx b/apps/meteor/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.tsx index 678e5ec9f871..b0bf1d083a71 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.tsx +++ b/apps/meteor/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.tsx @@ -1,8 +1,8 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { Box, Callout, Menu, Option } from '@rocket.chat/fuselage'; +import { Box, Callout, IconButton } from '@rocket.chat/fuselage'; import { RoomAvatar } from '@rocket.chat/ui-avatar'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import React, { useMemo } from 'react'; +import React from 'react'; import { ContextualbarHeader, @@ -12,9 +12,9 @@ import { ContextualbarClose, ContextualbarTitle, } from '../../../../../components/Contextualbar'; +import GenericMenu from '../../../../../components/GenericMenu/GenericMenu'; import { InfoPanel, - InfoPanelAction, InfoPanelActionGroup, InfoPanelAvatar, InfoPanelField, @@ -25,10 +25,10 @@ import { } from '../../../../../components/InfoPanel'; import RetentionPolicyCallout from '../../../../../components/InfoPanel/RetentionPolicyCallout'; import MarkdownText from '../../../../../components/MarkdownText'; -import type { Action } from '../../../../hooks/useActionSpread'; -import { useActionSpread } from '../../../../hooks/useActionSpread'; import { useRetentionPolicy } from '../../../hooks/useRetentionPolicy'; import { useRoomActions } from '../hooks/useRoomActions'; +import { useSplitRoomActions } from '../hooks/useSplitRoomActions'; +import RoomInfoActions from './RoomInfoActions'; type RoomInfoProps = { room: IRoom; @@ -47,35 +47,8 @@ const RoomInfo = ({ room, icon, onClickBack, onClickClose, onClickEnterRoom, onC const isDiscussion = 'prid' in room; const retentionPolicy = useRetentionPolicy(room); - const memoizedActions = useRoomActions(room, { onClickEnterRoom, onClickEdit }, resetState); - const { actions: actionsDefinition, menu: menuOptions } = useActionSpread(memoizedActions); - - const menu = useMemo(() => { - if (!menuOptions) { - return null; - } - - return ( -