diff --git a/.changeset/blue-ladybugs-raise.md b/.changeset/blue-ladybugs-raise.md deleted file mode 100644 index 44d7a06b4111..000000000000 --- a/.changeset/blue-ladybugs-raise.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Translation files are requested multiple times diff --git a/.changeset/breezy-bugs-jam.md b/.changeset/breezy-bugs-jam.md deleted file mode 100644 index 7e7cc7b8283b..000000000000 --- a/.changeset/breezy-bugs-jam.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: Managers allowed to make deactivated agent's available diff --git a/.changeset/bright-carpets-fly.md b/.changeset/bright-carpets-fly.md deleted file mode 100644 index 6a8ac2608569..000000000000 --- a/.changeset/bright-carpets-fly.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@rocket.chat/core-typings': minor -'@rocket.chat/rest-typings': minor -'@rocket.chat/meteor': minor ---- - -new: ring mobile users on direct conference calls diff --git a/.changeset/bright-snakes-vanish.md b/.changeset/bright-snakes-vanish.md deleted file mode 100644 index f198bfe93ae9..000000000000 --- a/.changeset/bright-snakes-vanish.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed an issue causing `queue time` to be calculated from current time when a room was closed without being served. -Now: -- For served rooms: queue time = servedBy time - queuedAt -- For not served, but open rooms = now - queuedAt -- For not served and closed rooms = closedAt - queuedAt diff --git a/.changeset/brown-clouds-add.md b/.changeset/brown-clouds-add.md deleted file mode 100644 index 6b69289177b5..000000000000 --- a/.changeset/brown-clouds-add.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: Performance issue on `Messages.countByType` aggregation caused by unindexed property on messages collection diff --git a/.changeset/brown-comics-cheat.md b/.changeset/brown-comics-cheat.md new file mode 100644 index 000000000000..a7907979881b --- /dev/null +++ b/.changeset/brown-comics-cheat.md @@ -0,0 +1,8 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/model-typings": patch +--- + +chore: Calculate & Store MAC stats +Added new info to the stats: `omnichannelContactsBySource`, `uniqueContactsOfLastMonth`, `uniqueContactsOfLastWeek`, `uniqueContactsOfYesterday` diff --git a/.changeset/bump-patch-1694741499930.md b/.changeset/bump-patch-1694741499930.md deleted file mode 100644 index e1eaa7980afb..000000000000 --- a/.changeset/bump-patch-1694741499930.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1694827499043.md b/.changeset/bump-patch-1694827499043.md deleted file mode 100644 index e1eaa7980afb..000000000000 --- a/.changeset/bump-patch-1694827499043.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1695163548038.md b/.changeset/bump-patch-1695163548038.md deleted file mode 100644 index e1eaa7980afb..000000000000 --- a/.changeset/bump-patch-1695163548038.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Bump @rocket.chat/meteor version. diff --git a/.changeset/bump-patch-1695165575069.md b/.changeset/bump-patch-1695165575069.md deleted file mode 100644 index e1eaa7980afb..000000000000 --- a/.changeset/bump-patch-1695165575069.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Bump @rocket.chat/meteor version. diff --git a/.changeset/chilled-flies-fold.md b/.changeset/chilled-flies-fold.md deleted file mode 100644 index 17a0f9eb6dc5..000000000000 --- a/.changeset/chilled-flies-fold.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -feat: add sections to room header and user infos menus with menuV2 diff --git a/.changeset/chilled-phones-give.md b/.changeset/chilled-phones-give.md deleted file mode 100644 index cb0887db0883..000000000000 --- a/.changeset/chilled-phones-give.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/core-typings": patch -"@rocket.chat/rest-typings": patch ---- - -Fixed `overrideDestinationChannelEnabled` treated as a required param in `integrations.create` and `integration.update` endpoints diff --git a/.changeset/cool-students-tan.md b/.changeset/cool-students-tan.md deleted file mode 100644 index 07760541628a..000000000000 --- a/.changeset/cool-students-tan.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -feat(apps): `ActionManagerBusyState` component for apps `ui.interaction` diff --git a/.changeset/cool-zoos-move.md b/.changeset/cool-zoos-move.md new file mode 100644 index 000000000000..dda6fbe2b02e --- /dev/null +++ b/.changeset/cool-zoos-move.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed threads breaking when sending messages too fast diff --git a/.changeset/cuddly-houses-tie.md b/.changeset/cuddly-houses-tie.md deleted file mode 100644 index 76d86a690388..000000000000 --- a/.changeset/cuddly-houses-tie.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Hide Reset TOTP option if 2FA is disabled diff --git a/.changeset/cuddly-ties-bake.md b/.changeset/cuddly-ties-bake.md deleted file mode 100644 index d912d2969d75..000000000000 --- a/.changeset/cuddly-ties-bake.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/fuselage-ui-kit": minor -"@rocket.chat/uikit-playground": minor ---- - -feat: Add missing variants to UIKit button diff --git a/.changeset/fast-pumpkins-smoke.md b/.changeset/cuddly-ties-run.md similarity index 59% rename from .changeset/fast-pumpkins-smoke.md rename to .changeset/cuddly-ties-run.md index 2374776bf3b5..cb3873899841 100644 --- a/.changeset/fast-pumpkins-smoke.md +++ b/.changeset/cuddly-ties-run.md @@ -2,4 +2,4 @@ '@rocket.chat/meteor': patch --- -fix: finnish translation +fix: custom-css injection diff --git a/.changeset/curly-shoes-burn.md b/.changeset/curly-shoes-burn.md deleted file mode 100644 index 67d453ab7245..000000000000 --- a/.changeset/curly-shoes-burn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Added ability to freeze or completely disable integration scripts through envvars diff --git a/.changeset/custom-emoji-fs.md b/.changeset/custom-emoji-fs.md deleted file mode 100644 index a9a797f35bc8..000000000000 --- a/.changeset/custom-emoji-fs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: custom emoji upload with FileSystem method diff --git a/.changeset/gold-horses-pretend.md b/.changeset/cyan-mangos-do.md similarity index 50% rename from .changeset/gold-horses-pretend.md rename to .changeset/cyan-mangos-do.md index a8908b68a23e..e188686c82d5 100644 --- a/.changeset/gold-horses-pretend.md +++ b/.changeset/cyan-mangos-do.md @@ -2,4 +2,4 @@ "@rocket.chat/meteor": patch --- -Fixed CAS login after popup closes +fix: UI issue on marketplace filters diff --git a/.changeset/dropdown.md b/.changeset/dropdown.md deleted file mode 100644 index 935c12aebe85..000000000000 --- a/.changeset/dropdown.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -New filters to the Rooms Table at `Workspace > Rooms` diff --git a/.changeset/dull-trainers-drive.md b/.changeset/dull-trainers-drive.md new file mode 100644 index 000000000000..f5a673cd8c30 --- /dev/null +++ b/.changeset/dull-trainers-drive.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: Remove model-level query restrictions for monitors diff --git a/.changeset/eighty-kids-jog.md b/.changeset/eighty-kids-jog.md deleted file mode 100644 index 6410813d80a6..000000000000 --- a/.changeset/eighty-kids-jog.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@rocket.chat/livechat': minor -'@rocket.chat/meteor': minor ---- - -Added new Omnichannel's trigger condition "After starting a chat". diff --git a/.changeset/eleven-gorillas-deliver.md b/.changeset/eleven-gorillas-deliver.md new file mode 100644 index 000000000000..403bd294828b --- /dev/null +++ b/.changeset/eleven-gorillas-deliver.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix trying to upload same file again and again. diff --git a/.changeset/eleven-icons-tan.md b/.changeset/eleven-icons-tan.md deleted file mode 100644 index c51124c05dd2..000000000000 --- a/.changeset/eleven-icons-tan.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@rocket.chat/meteor": minor -"@rocket.chat/core-typings": minor -"@rocket.chat/model-typings": minor -"@rocket.chat/rest-typings": minor ---- - -Introduce the ability to report an user diff --git a/.changeset/empty-ants-enjoy.md b/.changeset/empty-ants-enjoy.md deleted file mode 100644 index 4a55f82d0abf..000000000000 --- a/.changeset/empty-ants-enjoy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': minor ---- - -fix: Wrong toast message while creating a new custom sound with an existing name diff --git a/.changeset/empty-files-know.md b/.changeset/empty-files-know.md new file mode 100644 index 000000000000..5e6fb8f751b2 --- /dev/null +++ b/.changeset/empty-files-know.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix unnecessary username validation on accounts profile form diff --git a/.changeset/fair-cats-destroy.md b/.changeset/fair-cats-destroy.md deleted file mode 100644 index 7dfb74955a94..000000000000 --- a/.changeset/fair-cats-destroy.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/model-typings": patch ---- - -When setting a room as read-only, do not allow previously unmuted users to send messages. diff --git a/.changeset/fast-yaks-collect.md b/.changeset/fast-yaks-collect.md deleted file mode 100644 index 60dd92030163..000000000000 --- a/.changeset/fast-yaks-collect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -fixed an issue where 2fa was not working after an OAuth redirect diff --git a/.changeset/fifty-cars-divide.md b/.changeset/fifty-cars-divide.md deleted file mode 100644 index 6c09cf6869c8..000000000000 --- a/.changeset/fifty-cars-divide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed issue with custom OAuth services' settings not being be fully removed diff --git a/.changeset/fluffy-beds-buy.md b/.changeset/fluffy-beds-buy.md deleted file mode 100644 index f90513b946c3..000000000000 --- a/.changeset/fluffy-beds-buy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": minor ---- - -Added support for threaded conversation in Federated rooms. diff --git a/.changeset/fluffy-lions-rage.md b/.changeset/fluffy-lions-rage.md deleted file mode 100644 index 09437a2cb88e..000000000000 --- a/.changeset/fluffy-lions-rage.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/livechat": patch ---- - -chore: (Livechat) Replace all `dangerouslySetInnerHTML` with `gazzodown` diff --git a/.changeset/forty-hotels-pretend.md b/.changeset/forty-hotels-pretend.md deleted file mode 100644 index b23825d5a02a..000000000000 --- a/.changeset/forty-hotels-pretend.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: Prevent `RoomProvider.useEffect` from subscribing to room-data stream multiple times diff --git a/.changeset/four-parents-cheer.md b/.changeset/four-parents-cheer.md deleted file mode 100644 index 2fbb8e2b279f..000000000000 --- a/.changeset/four-parents-cheer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -load sounds right before playing them diff --git a/.changeset/fuzzy-glasses-divide.md b/.changeset/fuzzy-glasses-divide.md deleted file mode 100644 index cf77bbde5507..000000000000 --- a/.changeset/fuzzy-glasses-divide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': minor ---- - -Added a new Roles bridge to RC Apps-Engine for reading and retrieving role details. diff --git a/.changeset/fuzzy-plums-travel.md b/.changeset/fuzzy-plums-travel.md new file mode 100644 index 000000000000..fec86862a711 --- /dev/null +++ b/.changeset/fuzzy-plums-travel.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/gazzodown": patch +--- + +fixed an issue with mentions showing usernames instead of names on smaller screens diff --git a/.changeset/fuzzy-schools-brake.md b/.changeset/fuzzy-schools-brake.md deleted file mode 100644 index c6af54a2ef57..000000000000 --- a/.changeset/fuzzy-schools-brake.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fix users being created without the `roles` field diff --git a/.changeset/gentle-radios-relate.md b/.changeset/gentle-radios-relate.md new file mode 100644 index 000000000000..8d5f12b3a286 --- /dev/null +++ b/.changeset/gentle-radios-relate.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed DM room with "guest" user kept as "read only" after reactivating user diff --git a/.changeset/gold-moose-press.md b/.changeset/gold-moose-press.md deleted file mode 100644 index 605fb7c649ea..000000000000 --- a/.changeset/gold-moose-press.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fix moment timestamps language change diff --git a/.changeset/good-elephants-live.md b/.changeset/good-elephants-live.md deleted file mode 100644 index 8cb3e9d87fc4..000000000000 --- a/.changeset/good-elephants-live.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed message fetching method in LivechatBridge for Apps diff --git a/.changeset/green-adults-peel.md b/.changeset/green-adults-peel.md deleted file mode 100644 index b07f5ea3e6bf..000000000000 --- a/.changeset/green-adults-peel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fix pruning messages in a room results in an incorrect message counter diff --git a/.changeset/grumpy-candles-rule.md b/.changeset/grumpy-candles-rule.md deleted file mode 100644 index 28673ce91a73..000000000000 --- a/.changeset/grumpy-candles-rule.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -fix: rejected conference calls continue to ring diff --git a/.changeset/heavy-ads-carry.md b/.changeset/heavy-ads-carry.md new file mode 100644 index 000000000000..c04e52fb48a0 --- /dev/null +++ b/.changeset/heavy-ads-carry.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: Change plan name from Enterprise to Premium on marketplace filtering diff --git a/.changeset/heavy-baboons-laugh.md b/.changeset/heavy-baboons-laugh.md deleted file mode 100644 index 5c32965dcf62..000000000000 --- a/.changeset/heavy-baboons-laugh.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -User information crashing for some locales diff --git a/.changeset/heavy-cougars-marry.md b/.changeset/heavy-cougars-marry.md deleted file mode 100644 index 893f53352114..000000000000 --- a/.changeset/heavy-cougars-marry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fix performance issue on Engagement Dashboard aggregation diff --git a/.changeset/heavy-zebras-wonder.md b/.changeset/heavy-zebras-wonder.md deleted file mode 100644 index a1904a81c514..000000000000 --- a/.changeset/heavy-zebras-wonder.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Show correct date for last day time diff --git a/.changeset/hip-hounds-ring.md b/.changeset/hip-hounds-ring.md deleted file mode 100644 index 79dfba6dd031..000000000000 --- a/.changeset/hip-hounds-ring.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Added ability to disable private app installation via envvar (DISABLE_PRIVATE_APP_INSTALLATION) diff --git a/.changeset/hip-mugs-promise.md b/.changeset/hip-mugs-promise.md deleted file mode 100644 index 7100fec026e3..000000000000 --- a/.changeset/hip-mugs-promise.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -improve: System messages for omni-visitor abandonment feature diff --git a/.changeset/hip-pans-argue.md b/.changeset/hip-pans-argue.md new file mode 100644 index 000000000000..af8050383467 --- /dev/null +++ b/.changeset/hip-pans-argue.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: Omnichannel webhook is not retrying requests diff --git a/.changeset/honest-glasses-roll.md b/.changeset/honest-glasses-roll.md deleted file mode 100644 index 679f46fb8420..000000000000 --- a/.changeset/honest-glasses-roll.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -chore: Add danger variant to apps action button menus diff --git a/.changeset/honest-mirrors-sit.md b/.changeset/honest-mirrors-sit.md deleted file mode 100644 index 4e4298cb8110..000000000000 --- a/.changeset/honest-mirrors-sit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Disabled call to tags enterprise endpoint when on community license diff --git a/.changeset/honest-numbers-compete.md b/.changeset/honest-numbers-compete.md deleted file mode 100644 index 1fd017e7fc16..000000000000 --- a/.changeset/honest-numbers-compete.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixes SAML full name updates not being mirrored to DM rooms. diff --git a/.changeset/importer-progress-bar.md b/.changeset/importer-progress-bar.md deleted file mode 100644 index 49c04289ddcb..000000000000 --- a/.changeset/importer-progress-bar.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed Importer Progress Bar progress indicator - diff --git a/.changeset/khaki-feet-dance.md b/.changeset/khaki-feet-dance.md new file mode 100644 index 000000000000..a419afa34143 --- /dev/null +++ b/.changeset/khaki-feet-dance.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +feat: Save visitor's activity on agent's interaction diff --git a/.changeset/kind-students-worry.md b/.changeset/kind-students-worry.md deleted file mode 100644 index 554c1c1204ea..000000000000 --- a/.changeset/kind-students-worry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Make user default role setting public diff --git a/.changeset/late-pants-switch.md b/.changeset/late-pants-switch.md new file mode 100644 index 000000000000..d376ee7b87f8 --- /dev/null +++ b/.changeset/late-pants-switch.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/i18n': patch +--- + +Updated slack bridge to add support for connecting using slack apps in addition to the slack legacy bot diff --git a/.changeset/lazy-ghosts-design.md b/.changeset/lazy-ghosts-design.md deleted file mode 100644 index 080e9986cebb..000000000000 --- a/.changeset/lazy-ghosts-design.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed misleading of 'total' in team members list inside Channel diff --git a/.changeset/lazy-shoes-teach.md b/.changeset/lazy-shoes-teach.md new file mode 100644 index 000000000000..7737f39cd671 --- /dev/null +++ b/.changeset/lazy-shoes-teach.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +chore: adding some portugueses translations to the app details page diff --git a/.changeset/long-cars-dream.md b/.changeset/long-cars-dream.md new file mode 100644 index 000000000000..95f226d6dfb4 --- /dev/null +++ b/.changeset/long-cars-dream.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed intermittent errors caused by the removal of subscriptions and inquiries when lacking permissions. diff --git a/.changeset/loud-sheep-try.md b/.changeset/loud-sheep-try.md deleted file mode 100644 index f82d0d069554..000000000000 --- a/.changeset/loud-sheep-try.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/fuselage-ui-kit": minor -"@rocket.chat/uikit-playground": minor ---- - -feat: Adding new UIKit components: Callout, Checkbox, Radio Button, Time Picker, Toast Bar, Toggle Switch, Tab Navigation diff --git a/.changeset/lovely-snails-drop.md b/.changeset/lovely-snails-drop.md deleted file mode 100644 index 4e28c6a43c20..000000000000 --- a/.changeset/lovely-snails-drop.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/model-typings": patch ---- - -Fix spotlight search does not find rooms with special or non-latin characters diff --git a/.changeset/lucky-balloons-divide.md b/.changeset/lucky-balloons-divide.md deleted file mode 100644 index beb4cbfe3b57..000000000000 --- a/.changeset/lucky-balloons-divide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fix engagement dashboard not showing data diff --git a/.changeset/lucky-hounds-sing.md b/.changeset/lucky-hounds-sing.md deleted file mode 100644 index 20b09afaf545..000000000000 --- a/.changeset/lucky-hounds-sing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -fixed wrong user status displayed during mentioning a user in a channel diff --git a/.changeset/lucky-vans-develop.md b/.changeset/lucky-vans-develop.md new file mode 100644 index 000000000000..e57b7a1e68d5 --- /dev/null +++ b/.changeset/lucky-vans-develop.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed issue with file attachments in rooms' messages export having no content diff --git a/.changeset/many-icons-provide.md b/.changeset/many-icons-provide.md deleted file mode 100644 index bf82407980ad..000000000000 --- a/.changeset/many-icons-provide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Don't allow to report self messages diff --git a/.changeset/mighty-walls-smash.md b/.changeset/mighty-walls-smash.md deleted file mode 100644 index 54b2846901de..000000000000 --- a/.changeset/mighty-walls-smash.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fixed scrollbar over content in Federated Room List diff --git a/.changeset/moody-comics-cheat.md b/.changeset/moody-comics-cheat.md deleted file mode 100644 index b8b372306d0e..000000000000 --- a/.changeset/moody-comics-cheat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/release-action': minor ---- - -Add back "Engine Versions" to the release notes diff --git a/.changeset/moody-pans-act.md b/.changeset/moody-pans-act.md deleted file mode 100644 index 6c307604eaa9..000000000000 --- a/.changeset/moody-pans-act.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fix seat counter including bots users diff --git a/.changeset/nice-chairs-add.md b/.changeset/nice-chairs-add.md new file mode 100644 index 000000000000..dfc9d763e1c0 --- /dev/null +++ b/.changeset/nice-chairs-add.md @@ -0,0 +1,13 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +--- + +Added `push` statistic, containing three bits. Each bit represents a boolean: +``` +1 1 1 +| | | +| | +- push enabled = 0b1 = 1 +| +--- push gateway enabled = 0b10 = 2 ++----- push gateway changed = 0b100 = 4 +``` diff --git a/.changeset/nine-bottles-press.md b/.changeset/nine-bottles-press.md deleted file mode 100644 index f9a57fa676ad..000000000000 --- a/.changeset/nine-bottles-press.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -feat: Add flag to disable teams mention via troubleshoot page diff --git a/.changeset/nine-carrots-listen.md b/.changeset/nine-carrots-listen.md deleted file mode 100644 index bf5dc72e6cc0..000000000000 --- a/.changeset/nine-carrots-listen.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fixed layout changing from embedded view when navigating diff --git a/.changeset/odd-hounds-thank.md b/.changeset/odd-hounds-thank.md new file mode 100644 index 000000000000..aaddc5d51a38 --- /dev/null +++ b/.changeset/odd-hounds-thank.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +chore: Change plan name Enterprise to Premium on marketplace diff --git a/.changeset/old-federation-card.md b/.changeset/old-federation-card.md deleted file mode 100644 index fa9879d84426..000000000000 --- a/.changeset/old-federation-card.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Removed old/deprecated Rocket.Chat Federation card from Info page diff --git a/.changeset/old-zoos-hang.md b/.changeset/old-zoos-hang.md new file mode 100644 index 000000000000..eb39a6c9d83c --- /dev/null +++ b/.changeset/old-zoos-hang.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: mobile ringing notification missing call id diff --git a/.changeset/perfect-adults-travel.md b/.changeset/perfect-adults-travel.md deleted file mode 100644 index 61ae4ab6dad5..000000000000 --- a/.changeset/perfect-adults-travel.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/fuselage-ui-kit": patch -"@rocket.chat/uikit-playground": patch ---- - -feat(fuselage-ui-kit): Introduce `TabsNavigationBlock` diff --git a/.changeset/perfect-onions-develop.md b/.changeset/perfect-onions-develop.md new file mode 100644 index 000000000000..3ca5c3e00bb7 --- /dev/null +++ b/.changeset/perfect-onions-develop.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix i18n translations using sprintf post processor diff --git a/.changeset/perfect-pianos-yawn.md b/.changeset/perfect-pianos-yawn.md new file mode 100644 index 000000000000..349bca33ecf7 --- /dev/null +++ b/.changeset/perfect-pianos-yawn.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/presence': minor +--- + +Add peak connections monitoring and methods to get and reset the counter diff --git a/.changeset/pink-zoos-join.md b/.changeset/pink-zoos-join.md deleted file mode 100644 index dcc1088de0b5..000000000000 --- a/.changeset/pink-zoos-join.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fix the code that was setting email URL to an invalid value when SMTP was not set diff --git a/.changeset/seven-jobs-tickle.md b/.changeset/popular-actors-cheat.md similarity index 56% rename from .changeset/seven-jobs-tickle.md rename to .changeset/popular-actors-cheat.md index 870bafbb7d9d..aad5ec6ae638 100644 --- a/.changeset/seven-jobs-tickle.md +++ b/.changeset/popular-actors-cheat.md @@ -3,4 +3,4 @@ "@rocket.chat/model-typings": patch --- -fix: agent role being removed upon user deactivation +Do not allow auto-translation to be enabled in E2E rooms diff --git a/.changeset/pre.json b/.changeset/pre.json deleted file mode 100644 index 3bc535a4b50f..000000000000 --- a/.changeset/pre.json +++ /dev/null @@ -1,169 +0,0 @@ -{ - "mode": "pre", - "tag": "rc", - "initialVersions": { - "@rocket.chat/meteor": "6.4.0-develop", - "rocketchat-services": "1.1.4", - "@rocket.chat/account-service": "0.2.4", - "@rocket.chat/authorization-service": "0.2.4", - "@rocket.chat/ddp-streamer": "0.1.4", - "@rocket.chat/omnichannel-transcript": "0.2.4", - "@rocket.chat/presence-service": "0.2.4", - "@rocket.chat/queue-worker": "0.2.4", - "@rocket.chat/stream-hub-service": "0.2.4", - "@rocket.chat/api-client": "0.1.4", - "@rocket.chat/ddp-client": "0.1.4", - "@rocket.chat/omnichannel-services": "0.0.10", - "@rocket.chat/pdf-worker": "0.0.10", - "@rocket.chat/presence": "0.0.10", - "@rocket.chat/ui-theming": "0.0.1", - "@rocket.chat/account-utils": "0.0.1", - "@rocket.chat/agenda": "0.0.2", - "@rocket.chat/base64": "1.0.12", - "@rocket.chat/cas-validate": "0.0.1", - "@rocket.chat/core-services": "0.1.4", - "@rocket.chat/core-typings": "6.3.4", - "@rocket.chat/cron": "0.0.6", - "@rocket.chat/eslint-config": "0.5.2", - "@rocket.chat/favicon": "0.0.1", - "@rocket.chat/fuselage-ui-kit": "1.0.4", - "@rocket.chat/gazzodown": "1.0.4", - "@rocket.chat/i18n": "0.0.1", - "@rocket.chat/instance-status": "0.0.10", - "@rocket.chat/livechat": "1.13.4", - "@rocket.chat/log-format": "0.0.1", - "@rocket.chat/logger": "0.0.1", - "@rocket.chat/mock-providers": "0.0.1", - "@rocket.chat/model-typings": "0.0.10", - "@rocket.chat/models": "0.0.10", - "@rocket.chat/poplib": "0.0.1", - "@rocket.chat/random": "1.2.1", - "@rocket.chat/release-action": "2.1.0", - "@rocket.chat/rest-typings": "6.3.4", - "@rocket.chat/server-fetch": "0.0.1", - "@rocket.chat/sha256": "1.0.9", - "@rocket.chat/tools": "0.0.1", - "@rocket.chat/ui-client": "1.0.4", - "@rocket.chat/ui-composer": "0.0.1", - "@rocket.chat/ui-contexts": "1.0.4", - "@rocket.chat/ui-video-conf": "1.0.4", - "@rocket.chat/uikit-playground": "0.1.4", - "@rocket.chat/web-ui-registration": "1.0.4" - }, - "changesets": [ - "blue-ladybugs-raise", - "breezy-bugs-jam", - "bright-carpets-fly", - "bright-snakes-vanish", - "brown-clouds-add", - "bump-patch-1694741499930", - "bump-patch-1694827499043", - "bump-patch-1695163548038", - "bump-patch-1695165575069", - "chilled-flies-fold", - "chilled-phones-give", - "cool-students-tan", - "cuddly-houses-tie", - "cuddly-ties-bake", - "curly-shoes-burn", - "custom-emoji-fs", - "dropdown", - "eighty-kids-jog", - "eleven-icons-tan", - "empty-ants-enjoy", - "fair-cats-destroy", - "fast-pumpkins-smoke", - "fast-yaks-collect", - "fifty-cars-divide", - "fluffy-beds-buy", - "fluffy-lions-rage", - "forty-hotels-pretend", - "four-parents-cheer", - "friendly-glasses-mate", - "fuzzy-glasses-divide", - "fuzzy-schools-brake", - "gold-horses-pretend", - "gold-moose-press", - "good-elephants-live", - "green-adults-peel", - "grumpy-candles-rule", - "heavy-baboons-laugh", - "heavy-cougars-marry", - "heavy-zebras-wonder", - "hip-hounds-ring", - "hip-mugs-promise", - "honest-glasses-roll", - "honest-mirrors-sit", - "honest-numbers-compete", - "importer-progress-bar", - "kind-students-worry", - "lazy-ghosts-design", - "loud-sheep-try", - "lovely-snails-drop", - "lucky-balloons-divide", - "lucky-hounds-sing", - "many-icons-provide", - "mighty-walls-smash", - "moody-comics-cheat", - "moody-pans-act", - "nine-bottles-press", - "nine-carrots-listen", - "odd-elephants-promise", - "old-federation-card", - "perfect-adults-travel", - "pink-zoos-join", - "pretty-bees-give", - "quick-emus-march", - "quiet-phones-sell", - "rare-sheep-yawn", - "real-pets-visit", - "red-windows-admire", - "red-zebras-clap", - "rotten-turtles-agree", - "serious-garlics-clean", - "serious-geckos-drive", - "serious-shrimps-try", - "seven-jobs-tickle", - "shaggy-beans-poke", - "shiny-garlics-carry", - "shiny-tools-worry", - "short-cobras-tell", - "silly-actors-laugh", - "silver-mugs-unite", - "six-buckets-eat", - "slimy-cheetahs-heal", - "slimy-wasps-double", - "slow-lizards-breathe", - "small-rice-repair", - "smooth-planes-cough", - "soft-yaks-matter", - "sour-cows-refuse", - "sour-parrots-nail", - "stale-roses-knock", - "strong-laws-pump", - "swift-birds-build", - "swift-walls-protect", - "tall-pumpkins-cross", - "tame-pens-occur", - "three-ants-give", - "three-birds-tickle", - "tidy-bears-camp", - "tiny-turkeys-burn", - "tough-candles-heal", - "tricky-years-swim", - "unlucky-turtles-search", - "user-mention", - "violet-frogs-cheer", - "warm-hornets-ring", - "wet-frogs-kiss", - "wet-walls-lie", - "wild-spiders-smell", - "wise-onions-trade", - "wise-walls-tan", - "wise-ways-fetch", - "witty-feet-warn", - "yellow-buttons-agree", - "yellow-schools-tell", - "young-trains-glow" - ] -} diff --git a/.changeset/pretty-bees-give.md b/.changeset/pretty-bees-give.md deleted file mode 100644 index 8891420308c5..000000000000 --- a/.changeset/pretty-bees-give.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": minor -"@rocket.chat/rest-typings": minor ---- - -Add option to select what URL previews should be generated for each message. diff --git a/.changeset/proud-shrimps-cheat.md b/.changeset/proud-shrimps-cheat.md new file mode 100644 index 000000000000..cad8bc8bfa32 --- /dev/null +++ b/.changeset/proud-shrimps-cheat.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: Unable to send attachments via email as an omni-agent diff --git a/.changeset/quick-emus-march.md b/.changeset/quick-emus-march.md deleted file mode 100644 index 7a6d7b444654..000000000000 --- a/.changeset/quick-emus-march.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@rocket.chat/ddp-client': minor -'@rocket.chat/core-services': minor -'@rocket.chat/meteor': minor ---- - -Add new event to notify users directly about new banners diff --git a/.changeset/quiet-phones-reply.md b/.changeset/quiet-phones-reply.md new file mode 100644 index 000000000000..f2735e615491 --- /dev/null +++ b/.changeset/quiet-phones-reply.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Search users using full name too on share message modal diff --git a/.changeset/quiet-phones-sell.md b/.changeset/quiet-phones-sell.md deleted file mode 100644 index a6222cba16c9..000000000000 --- a/.changeset/quiet-phones-sell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -fixed an issue where oauth login was not working with some providers diff --git a/.changeset/rare-sheep-yawn.md b/.changeset/rare-sheep-yawn.md deleted file mode 100644 index 86c2d7283223..000000000000 --- a/.changeset/rare-sheep-yawn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/livechat": patch ---- - -fix: Issue caused by spaces in the `config.url` setting diff --git a/.changeset/real-pets-visit.md b/.changeset/real-pets-visit.md deleted file mode 100644 index d6531285597c..000000000000 --- a/.changeset/real-pets-visit.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/core-typings": patch ---- - -Fixed `default` field not being returned from the `setDefault` endpoints when setting to false diff --git a/.changeset/red-windows-admire.md b/.changeset/red-windows-admire.md deleted file mode 100644 index 48a82b5902cb..000000000000 --- a/.changeset/red-windows-admire.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed an issue where timeout for http requests in Apps-Engine bridges was too short diff --git a/.changeset/friendly-glasses-mate.md b/.changeset/rich-dogs-smell.md similarity index 50% rename from .changeset/friendly-glasses-mate.md rename to .changeset/rich-dogs-smell.md index 6a7a7b4f8546..be27db28e227 100644 --- a/.changeset/friendly-glasses-mate.md +++ b/.changeset/rich-dogs-smell.md @@ -2,4 +2,4 @@ '@rocket.chat/meteor': minor --- -fix: Time format of Retention Policy +Fix typing indicator of Apps user diff --git a/.changeset/rotten-dryers-allow.md b/.changeset/rotten-dryers-allow.md new file mode 100644 index 000000000000..154dea572780 --- /dev/null +++ b/.changeset/rotten-dryers-allow.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Add pagination & tooltips to agent's dropdown on forwarding modal diff --git a/.changeset/rotten-turtles-agree.md b/.changeset/rotten-turtles-agree.md deleted file mode 100644 index f915aa38f758..000000000000 --- a/.changeset/rotten-turtles-agree.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: stop blinking "Room not found" before dm creation diff --git a/.changeset/selfish-hounds-pay.md b/.changeset/selfish-hounds-pay.md new file mode 100644 index 000000000000..3ca321bd392f --- /dev/null +++ b/.changeset/selfish-hounds-pay.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: Monitors now able to forward a chat without taking it first diff --git a/.changeset/serious-cats-fetch.md b/.changeset/serious-cats-fetch.md new file mode 100644 index 000000000000..4718d3597e59 --- /dev/null +++ b/.changeset/serious-cats-fetch.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed a problem that would prevent private apps from being shown on air-gapped environments diff --git a/.changeset/serious-garlics-clean.md b/.changeset/serious-garlics-clean.md deleted file mode 100644 index ccdc3c94dda4..000000000000 --- a/.changeset/serious-garlics-clean.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@rocket.chat/core-services': minor -'@rocket.chat/rest-typings': minor -'@rocket.chat/meteor': minor ---- - -New AddUser workflow for Federated Rooms diff --git a/.changeset/serious-geckos-drive.md b/.changeset/serious-geckos-drive.md deleted file mode 100644 index 454337399772..000000000000 --- a/.changeset/serious-geckos-drive.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@rocket.chat/core-typings': minor -'@rocket.chat/rest-typings': minor -'@rocket.chat/ui-client': minor -'@rocket.chat/meteor': minor ---- - -Added Reports Metrics Dashboard to Omnichannel diff --git a/.changeset/serious-shrimps-try.md b/.changeset/serious-shrimps-try.md deleted file mode 100644 index 114293aa104e..000000000000 --- a/.changeset/serious-shrimps-try.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fixed an issue with the positioning of the message menu diff --git a/.changeset/seven-carpets-march.md b/.changeset/seven-carpets-march.md new file mode 100644 index 000000000000..46fd1b7ddb62 --- /dev/null +++ b/.changeset/seven-carpets-march.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Add new permission to allow kick users from rooms without being a member diff --git a/.changeset/seven-emus-pay.md b/.changeset/seven-emus-pay.md new file mode 100644 index 000000000000..169c42d5ab54 --- /dev/null +++ b/.changeset/seven-emus-pay.md @@ -0,0 +1,10 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-services": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/rest-typings": patch +"@rocket.chat/ddp-client": patch +--- + +feat: Improve UI when MAC limits are reached +feat: Limit endpoints on MAC limit reached diff --git a/.changeset/shaggy-beans-poke.md b/.changeset/shaggy-beans-poke.md deleted file mode 100644 index 31a480638952..000000000000 --- a/.changeset/shaggy-beans-poke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fix `mention-here` and `mention-all` permissions not being honored diff --git a/.changeset/shiny-garlics-carry.md b/.changeset/shiny-garlics-carry.md deleted file mode 100644 index 117063d93f6f..000000000000 --- a/.changeset/shiny-garlics-carry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fix CORS headers not being set for assets diff --git a/.changeset/odd-elephants-promise.md b/.changeset/shiny-pillows-run.md similarity index 55% rename from .changeset/odd-elephants-promise.md rename to .changeset/shiny-pillows-run.md index a12817ed175b..9a85d37a2f9d 100644 --- a/.changeset/odd-elephants-promise.md +++ b/.changeset/shiny-pillows-run.md @@ -2,4 +2,4 @@ "@rocket.chat/meteor": patch --- -Fix LinkedIn OAuth broken +fix: cloud alerts not working diff --git a/.changeset/shiny-tools-worry.md b/.changeset/shiny-tools-worry.md deleted file mode 100644 index f024eca38d04..000000000000 --- a/.changeset/shiny-tools-worry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": minor ---- - -feat: remove enforce password fallback dependency diff --git a/.changeset/short-cobras-tell.md b/.changeset/short-cobras-tell.md deleted file mode 100644 index 1c28ce7bad11..000000000000 --- a/.changeset/short-cobras-tell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": minor ---- - -Reorganized the message menu diff --git a/.changeset/silly-actors-laugh.md b/.changeset/silly-actors-laugh.md deleted file mode 100644 index aab23e14e5f1..000000000000 --- a/.changeset/silly-actors-laugh.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed Slackbridge was not handling correctly received events from Slack anymore. Events: (Send, edit, delete, react meassages) diff --git a/.changeset/silver-mugs-unite.md b/.changeset/silver-mugs-unite.md deleted file mode 100644 index be74b1bef215..000000000000 --- a/.changeset/silver-mugs-unite.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: show requested filters only on requested apps view diff --git a/.changeset/six-buckets-eat.md b/.changeset/six-buckets-eat.md deleted file mode 100644 index f99bcfb71c30..000000000000 --- a/.changeset/six-buckets-eat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fix users not able to login after block time perdiod has passed diff --git a/.changeset/six-pens-look.md b/.changeset/six-pens-look.md new file mode 100644 index 000000000000..0ddc1100654c --- /dev/null +++ b/.changeset/six-pens-look.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +feat: Setup wizard content updates and enforcing cloud connectivity diff --git a/.changeset/slimy-cheetahs-heal.md b/.changeset/slimy-cheetahs-heal.md deleted file mode 100644 index 44233bc87766..000000000000 --- a/.changeset/slimy-cheetahs-heal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed selected departments not being displayed due to pagination diff --git a/.changeset/slimy-wasps-double.md b/.changeset/slimy-wasps-double.md deleted file mode 100644 index b28de342b274..000000000000 --- a/.changeset/slimy-wasps-double.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@rocket.chat/meteor': minor -'@rocket.chat/ui-contexts': minor ---- - -UX improvement for the Moderation Console Context bar for viewing the reported messages. The Report reason is now displayed in the reported messages context bar. -The Moderation Action Modal confirmation description is updated to be more clear and concise. diff --git a/.changeset/slow-coats-shout.md b/.changeset/slow-coats-shout.md new file mode 100644 index 000000000000..4a226e84d161 --- /dev/null +++ b/.changeset/slow-coats-shout.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": minor +--- + +Add the daily and monthly peaks of concurrent connections to statistics + - Added `dailyPeakConnections` statistic for monitoring the daily peak of concurrent connections in a workspace; + - Added `maxMonthlyPeakConnections` statistic for monitoring the last 30 days peak of concurrent connections in a workspace; diff --git a/.changeset/slow-lizards-breathe.md b/.changeset/slow-lizards-breathe.md deleted file mode 100644 index fd773b17f5c8..000000000000 --- a/.changeset/slow-lizards-breathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed Apps-Engine event `IPostUserCreated` execution diff --git a/.changeset/small-rice-repair.md b/.changeset/small-rice-repair.md deleted file mode 100644 index 67fdff5ca758..000000000000 --- a/.changeset/small-rice-repair.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fix validation in app status call that allowed Enterprise apps to be enabled in invalid environments diff --git a/.changeset/smooth-planes-cough.md b/.changeset/smooth-planes-cough.md deleted file mode 100644 index 9ad4239f0342..000000000000 --- a/.changeset/smooth-planes-cough.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@rocket.chat/ui-theming': minor -'@rocket.chat/rest-typings': minor -'@rocket.chat/meteor': minor ---- - -feat: high-contrast theme diff --git a/.changeset/soft-cows-juggle.md b/.changeset/soft-cows-juggle.md new file mode 100644 index 000000000000..6fcb20506483 --- /dev/null +++ b/.changeset/soft-cows-juggle.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +download translation files through CDN diff --git a/.changeset/soft-yaks-matter.md b/.changeset/soft-yaks-matter.md deleted file mode 100644 index c326eb7dca70..000000000000 --- a/.changeset/soft-yaks-matter.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": minor ---- - -feat: return all broken password policies at once diff --git a/.changeset/sour-cows-refuse.md b/.changeset/sour-cows-refuse.md deleted file mode 100644 index d907c063f568..000000000000 --- a/.changeset/sour-cows-refuse.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed inviter not informed when inviting member to room via `/invite` slashcommand diff --git a/.changeset/sour-parrots-nail.md b/.changeset/sour-parrots-nail.md deleted file mode 100644 index 1c1eaa3173a8..000000000000 --- a/.changeset/sour-parrots-nail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed "teams" icon not being displayed on spotlight sidebar search diff --git a/.changeset/stale-masks-learn.md b/.changeset/stale-masks-learn.md new file mode 100644 index 000000000000..1523b02b0c95 --- /dev/null +++ b/.changeset/stale-masks-learn.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/server-fetch': patch +--- + +Fixed an issue where the payload of an HTTP request made by an app wouldn't be correctly encoded in some cases diff --git a/.changeset/stale-roses-knock.md b/.changeset/stale-roses-knock.md deleted file mode 100644 index 25e93fa8c346..000000000000 --- a/.changeset/stale-roses-knock.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: User timezone not being respected on Current Chat's filter diff --git a/.changeset/strong-laws-pump.md b/.changeset/strong-laws-pump.md deleted file mode 100644 index a4afefd65316..000000000000 --- a/.changeset/strong-laws-pump.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@rocket.chat/model-typings': patch -'@rocket.chat/meteor': patch ---- - -Change SAU aggregation to consider only sessions from few days ago instead of the whole past. - -This is particularly important for large workspaces in case the cron job did not run for some time, in that case the amount of sessions would accumulate and the aggregation would take a long time to run. diff --git a/.changeset/sweet-chefs-exist.md b/.changeset/sweet-chefs-exist.md new file mode 100644 index 000000000000..6ceee63dd762 --- /dev/null +++ b/.changeset/sweet-chefs-exist.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Check for room scoped permissions for starting discussions diff --git a/.changeset/sweet-feet-relate.md b/.changeset/sweet-feet-relate.md new file mode 100644 index 000000000000..f7da740ebcc0 --- /dev/null +++ b/.changeset/sweet-feet-relate.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: user dropdown menu position on RTL layout diff --git a/.changeset/swift-birds-build.md b/.changeset/swift-birds-build.md deleted file mode 100644 index 4af3bddd875b..000000000000 --- a/.changeset/swift-birds-build.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed unable to create admin user using ADMIN\_\* environment variables diff --git a/.changeset/swift-walls-protect.md b/.changeset/swift-walls-protect.md deleted file mode 100644 index 6e3057775c32..000000000000 --- a/.changeset/swift-walls-protect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed failing user data exports diff --git a/.changeset/tall-moons-beam.md b/.changeset/tall-moons-beam.md new file mode 100644 index 000000000000..d712e5b7531c --- /dev/null +++ b/.changeset/tall-moons-beam.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Enable the option `Only allow verified users to login` to SaaS environment diff --git a/.changeset/tall-pumpkins-cross.md b/.changeset/tall-pumpkins-cross.md deleted file mode 100644 index e6cfd8a309b9..000000000000 --- a/.changeset/tall-pumpkins-cross.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/presence": patch ---- - -Fixed presence broadcast being disabled on server restart diff --git a/.changeset/tame-pens-occur.md b/.changeset/tame-pens-occur.md deleted file mode 100644 index 8cb729531fae..000000000000 --- a/.changeset/tame-pens-occur.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": minor -"@rocket.chat/model-typings": minor ---- - -Fixed read receipts not getting deleted after corresponding message is deleted \ No newline at end of file diff --git a/.changeset/thick-spoons-compete.md b/.changeset/thick-spoons-compete.md new file mode 100644 index 000000000000..cf6e9eb2697d --- /dev/null +++ b/.changeset/thick-spoons-compete.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Added new Omnichannel setting 'Hide conversation after closing' diff --git a/.changeset/thirty-jokes-compete.md b/.changeset/thirty-jokes-compete.md new file mode 100644 index 000000000000..9d4095e7771b --- /dev/null +++ b/.changeset/thirty-jokes-compete.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +chore: Deprecate un-used meteor method for omnichannel analytics diff --git a/.changeset/three-ants-give.md b/.changeset/three-ants-give.md deleted file mode 100644 index 4d33fad05f39..000000000000 --- a/.changeset/three-ants-give.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@rocket.chat/cron": patch -"@rocket.chat/meteor": patch ---- - -Increase cron job check delay to 1 min from 5s. - -This reduces MongoDB requests introduced on 6.3. diff --git a/.changeset/three-birds-tickle.md b/.changeset/three-birds-tickle.md deleted file mode 100644 index 0ce911d9f6fa..000000000000 --- a/.changeset/three-birds-tickle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -chore: Increase cache time from 5s to 10s on `getUnits` helpers. This should reduce the number of DB calls made by this method to fetch the unit limitations for a user. diff --git a/.changeset/tidy-bears-applaud.md b/.changeset/tidy-bears-applaud.md new file mode 100644 index 000000000000..cff12f3dc7d3 --- /dev/null +++ b/.changeset/tidy-bears-applaud.md @@ -0,0 +1,10 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/model-typings": minor +"@rocket.chat/rest-typings": minor +--- + +Create a deployment fingerprint to identify possible deployment changes caused by database cloning. A question to the admin will confirm if it's a regular deployment change or an intent of a new deployment and correct identification values as needed. +The fingerprint is composed by `${siteUrl}${dbConnectionString}` and hashed via `sha256` in `base64`. +An environment variable named `AUTO_ACCEPT_FINGERPRINT`, when set to `true`, can be used to auto-accept an expected fingerprint change as a regular deployment update. diff --git a/.changeset/tidy-bears-camp.md b/.changeset/tidy-bears-camp.md deleted file mode 100644 index 3c2013f79023..000000000000 --- a/.changeset/tidy-bears-camp.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": minor ---- - -Introduced upsells for the engagement dashboard and device management admin sidebar items in CE workspaces. Additionally, restructured the admin sidebar items to enhance organization. diff --git a/.changeset/tidy-cows-destroy.md b/.changeset/tidy-cows-destroy.md new file mode 100644 index 000000000000..0b222f8157a9 --- /dev/null +++ b/.changeset/tidy-cows-destroy.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +feat: Community users will now be able to customize their Business hour timezone diff --git a/.changeset/tiny-turkeys-burn.md b/.changeset/tiny-turkeys-burn.md deleted file mode 100644 index a146bd6a0eae..000000000000 --- a/.changeset/tiny-turkeys-burn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -fixed an issue on oauth login that caused missing emails to be detected as changed data diff --git a/.changeset/tiny-wolves-deliver.md b/.changeset/tiny-wolves-deliver.md new file mode 100644 index 000000000000..f89564a9b53c --- /dev/null +++ b/.changeset/tiny-wolves-deliver.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/ui-theming': patch +--- + +fix: light-theme font-disabled color diff --git a/.changeset/tough-apples-turn.md b/.changeset/tough-apples-turn.md new file mode 100644 index 000000000000..056a0645186e --- /dev/null +++ b/.changeset/tough-apples-turn.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Forward headers when using proxy for file uploads diff --git a/.changeset/tough-candles-heal.md b/.changeset/tough-candles-heal.md deleted file mode 100644 index 59ad9c1fb3a1..000000000000 --- a/.changeset/tough-candles-heal.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/core-typings": patch -"@rocket.chat/model-typings": patch ---- - -Fixes a problem where the calculated time for considering the visitor abandonment was the first message from the visitor and not the visitor's reply to the agent. diff --git a/.changeset/tough-carrots-walk.md b/.changeset/tough-carrots-walk.md new file mode 100644 index 000000000000..2851e697b85e --- /dev/null +++ b/.changeset/tough-carrots-walk.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/license': patch +'@rocket.chat/meteor': patch +--- + +feat: added `licenses.info` endpoint diff --git a/.changeset/tricky-years-swim.md b/.changeset/tricky-years-swim.md deleted file mode 100644 index 2ab1254525b2..000000000000 --- a/.changeset/tricky-years-swim.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/rest-typings": patch ---- - -Deprecate `livechat:getOverviewData` and `livechat:getAgentOverviewData` methods and create API endpoints `livechat/analytics/overview` and `livechat/analytics/agent-overview` to fetch analytics data diff --git a/.changeset/unlucky-turtles-search.md b/.changeset/unlucky-turtles-search.md deleted file mode 100644 index fffa51020e30..000000000000 --- a/.changeset/unlucky-turtles-search.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Fixed Accounts profile form name change was not working diff --git a/.changeset/user-mention.md b/.changeset/user-mention.md deleted file mode 100644 index a896a7c12ee4..000000000000 --- a/.changeset/user-mention.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed user mentioning when prepending the username with `>` diff --git a/.changeset/violet-frogs-cheer.md b/.changeset/violet-frogs-cheer.md deleted file mode 100644 index db48243c40ed..000000000000 --- a/.changeset/violet-frogs-cheer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/fuselage-ui-kit': patch ---- - -Handle invalid context on `VideoConferenceBlock` component diff --git a/.changeset/warm-hornets-ring.md b/.changeset/warm-hornets-ring.md deleted file mode 100644 index f81cf1efbe92..000000000000 --- a/.changeset/warm-hornets-ring.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/core-typings": patch ---- - -Use group filter when set to LDAP sync process diff --git a/.changeset/warm-melons-type.md b/.changeset/warm-melons-type.md new file mode 100644 index 000000000000..5b187b8a7f11 --- /dev/null +++ b/.changeset/warm-melons-type.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/omnichannel-services": patch +--- + +feat: Disable and annonimize visitors instead of removing diff --git a/.changeset/weak-cameras-pay.md b/.changeset/weak-cameras-pay.md new file mode 100644 index 000000000000..724f3af69a29 --- /dev/null +++ b/.changeset/weak-cameras-pay.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed issue with message read receipts not being created when accessing a room the first time diff --git a/.changeset/wet-frogs-kiss.md b/.changeset/wet-frogs-kiss.md deleted file mode 100644 index 24395a78f85d..000000000000 --- a/.changeset/wet-frogs-kiss.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fix: Missing padding on Omnichannel contacts Contextualbar loading state diff --git a/.changeset/wet-walls-lie.md b/.changeset/wet-walls-lie.md deleted file mode 100644 index 6b18eb497686..000000000000 --- a/.changeset/wet-walls-lie.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixes a problem that allowed users to send empty spaces as comment to bypass the "comment required" setting diff --git a/.changeset/red-zebras-clap.md b/.changeset/wicked-humans-hang.md similarity index 53% rename from .changeset/red-zebras-clap.md rename to .changeset/wicked-humans-hang.md index cd8f832b1835..e793bc978902 100644 --- a/.changeset/red-zebras-clap.md +++ b/.changeset/wicked-humans-hang.md @@ -2,4 +2,4 @@ "@rocket.chat/meteor": patch --- -Fix importer filters not working +Improve cache of static files diff --git a/.changeset/wicked-jars-double.md b/.changeset/wicked-jars-double.md new file mode 100644 index 000000000000..23deffe8606f --- /dev/null +++ b/.changeset/wicked-jars-double.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Handle the username update in the background diff --git a/.changeset/wild-spiders-smell.md b/.changeset/wild-spiders-smell.md deleted file mode 100644 index 9694d6259d3a..000000000000 --- a/.changeset/wild-spiders-smell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixed an issue where a mailer error was being sent to customers using offline message's form on Omnichannel instead of the translated one diff --git a/.changeset/wise-onions-trade.md b/.changeset/wise-onions-trade.md deleted file mode 100644 index cb5c731fb6fb..000000000000 --- a/.changeset/wise-onions-trade.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@rocket.chat/meteor": patch -"@rocket.chat/i18n": patch -"@rocket.chat/livechat": patch -"@rocket.chat/mock-providers": patch -"@rocket.chat/ui-client": patch -"@rocket.chat/ui-contexts": patch -"@rocket.chat/web-ui-registration": patch ---- - -Fixed the login page language switcher, now the component has a new look, is reactive and the language selection becomes concrete upon login in. Also changed the default language of the login page to be the browser language. diff --git a/.changeset/wise-walls-tan.md b/.changeset/wise-walls-tan.md deleted file mode 100644 index f558de82ec4c..000000000000 --- a/.changeset/wise-walls-tan.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@rocket.chat/rest-typings': minor -'@rocket.chat/meteor': minor ---- - -fix: missing params on updateOwnBasicInfo endpoint diff --git a/.changeset/wise-ways-fetch.md b/.changeset/wise-ways-fetch.md deleted file mode 100644 index a81063813c35..000000000000 --- a/.changeset/wise-ways-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fixed the unread messages mark not showing diff --git a/.changeset/witty-feet-warn.md b/.changeset/witty-feet-warn.md deleted file mode 100644 index faaa5d44c134..000000000000 --- a/.changeset/witty-feet-warn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -fixed the video recorder window not closing after permission is denied. diff --git a/.changeset/yellow-buttons-agree.md b/.changeset/yellow-buttons-agree.md deleted file mode 100644 index a86d172a4544..000000000000 --- a/.changeset/yellow-buttons-agree.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@rocket.chat/ui-client': minor -'@rocket.chat/meteor': minor ---- - -feat: add ChangePassword field to Account/Security diff --git a/.changeset/yellow-schools-tell.md b/.changeset/yellow-schools-tell.md deleted file mode 100644 index c1040fa0856a..000000000000 --- a/.changeset/yellow-schools-tell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/eslint-config': minor ---- - -Unpublished changes in ESLint config diff --git a/.changeset/young-trains-glow.md b/.changeset/young-trains-glow.md deleted file mode 100644 index 77f50812143f..000000000000 --- a/.changeset/young-trains-glow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': minor ---- - -Fixed the issue of apps icon uneven alignment in case of missing icons inside message composer toolbar & message toolbar menu. diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml index 808b8acdcbe3..284a0985b78e 100644 --- a/.github/actions/build-docker/action.yml +++ b/.github/actions/build-docker/action.yml @@ -19,6 +19,7 @@ runs: steps: - name: Login to GitHub Container Registry + if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') uses: docker/login-action@v2 with: registry: ghcr.io @@ -62,6 +63,7 @@ runs: docker compose -f docker-compose-ci.yml build "${args[@]}" - name: Publish Docker images to GitHub Container Registry + if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') shell: bash run: | args=(rocketchat) diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index e14857a97a09..d77966f186b3 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -97,6 +97,14 @@ jobs: cache-modules: true install: true + # if we are testing a PR from a fork, we need to build the docker image at this point + - uses: ./.github/actions/build-docker + if: github.event.pull_request.head.repo.full_name != github.repository + with: + CR_USER: ${{ secrets.CR_USER }} + CR_PAT: ${{ secrets.CR_PAT }} + node-version: ${{ inputs.node-version }} + - uses: dtinth/setup-github-actions-caching-for-turbo@v1 - name: Start httpbin container and wait for it to be ready diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31c2c42718b6..ec8e905cd803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,7 +172,6 @@ jobs: build-gh-docker-coverage: name: 🚢 Build Docker Images for Testing needs: [build, release-versions] - if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') runs-on: ubuntu-20.04 env: @@ -189,7 +188,10 @@ jobs: steps: - uses: actions/checkout@v3 + + # we only build and publish the actual docker images if not a PR from a fork - uses: ./.github/actions/build-docker + if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') with: CR_USER: ${{ secrets.CR_USER }} CR_PAT: ${{ secrets.CR_PAT }} diff --git a/FEATURES.md b/FEATURES.md index e4b11c141184..5601cc0c7ccc 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,10 +1,10 @@ # Features - Self Host - - Docker - - Multiple Deployment Options (Heroku, Digital Ocean, Sandstorm, etc.) + - Docker + - Multiple Deployment Options (Heroku, Digital Ocean, Sandstorm, etc.) - Authentication Options - - OAuth + - OAuth - SAML - LDAP - CAS (1.0, 2.0 + attribute sync) @@ -19,7 +19,7 @@ - Rich Media - Audio Calls - Video Conferencing - - Screensharing + - Screen Sharing - Notifications - Desktop and Mobile - Use your own gateway diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index 18e6e45e8f48..9c2e0b63e240 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,5 +1,216 @@ # @rocket.chat/meteor +## 6.4.0 + +### Minor Changes + +- 239a34e877: new: ring mobile users on direct conference calls +- 04fe492555: Added new Omnichannel's trigger condition "After starting a chat". +- 4186eecf05: Introduce the ability to report an user +- 92b690d206: fix: Wrong toast message while creating a new custom sound with an existing name +- f83ea5d6e8: Added support for threaded conversation in Federated rooms. +- 682d0bc05a: fix: Time format of Retention Policy +- 1b42dfc6c1: Added a new Roles bridge to RC Apps-Engine for reading and retrieving role details. +- 2db32f0d4a: Add option to select what URL previews should be generated for each message. +- 982ef6f459: Add new event to notify users directly about new banners +- 19aec23cda: New AddUser workflow for Federated Rooms +- ebab8c4dd8: Added Reports Metrics Dashboard to Omnichannel +- 85a936220c: feat: remove enforce password fallback dependency +- 5832be2e1b: Reorganized the message menu +- 074db3b419: UX improvement for the Moderation Console Context bar for viewing the reported messages. The Report reason is now displayed in the reported messages context bar. + The Moderation Action Modal confirmation description is updated to be more clear and concise. +- 357a3a50fa: feat: high-contrast theme +- 7070f00b05: feat: return all broken password policies at once +- ead7c7bef2: Fixed read receipts not getting deleted after corresponding message is deleted +- 1041d4d361: Added option to select between two script engine options for the integrations +- ad08c26b46: Introduced upsells for the engagement dashboard and device management admin sidebar items in CE workspaces. Additionally, restructured the admin sidebar items to enhance organization. +- 93d4912e17: fix: missing params on updateOwnBasicInfo endpoint +- ee3815fce4: feat: add ChangePassword field to Account/Security +- 1000b9b317: Fixed the issue of apps icon uneven alignment in case of missing icons inside message composer toolbar & message toolbar menu. + +### Patch Changes + +- 6d453f71ac: Translation files are requested multiple times +- cada29b6ce: fix: Managers allowed to make deactivated agent's available +- 470c29d7e9: Fixed an issue causing `queue time` to be calculated from current time when a room was closed without being served. + Now: + - For served rooms: queue time = servedBy time - queuedAt + - For not served, but open rooms = now - queuedAt + - For not served and closed rooms = closedAt - queuedAt +- ea8998602b: fix: Performance issue on `Messages.countByType` aggregation caused by unindexed property on messages collection +- f634601d90: Bump @rocket.chat/meteor version. +- f46c1f7b70: Bump @rocket.chat/meteor version. +- 6963cc2d00: Bump @rocket.chat/meteor version. +- 7cc15ac814: Bump @rocket.chat/meteor version. +- 40c5277197: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- a08006c9f0: feat: add sections to room header and user infos menus with menuV2 +- 203304782f: Fixed `overrideDestinationChannelEnabled` treated as a required param in `integrations.create` and `integration.update` endpoints +- 9edca67b9b: feat(apps): `ActionManagerBusyState` component for apps `ui.interaction` +- ec60dbe8f5: Fixed custom translations not being displayed +- 6fa30ddcd1: Hide Reset TOTP option if 2FA is disabled +- ff7e181464: Added ability to freeze or completely disable integration scripts through envvars +- 4ce8ea89a8: fix: custom emoji upload with FileSystem method +- 87570d0fb7: New filters to the Rooms Table at `Workspace > Rooms` +- 8a59855fcf: When setting a room as read-only, do not allow previously unmuted users to send messages. +- c73f5373b8: fix: finnish translation +- f5a886a144: fixed an issue where 2fa was not working after an OAuth redirect +- 459c8574ed: Fixed issue with custom OAuth services' settings not being be fully removed +- 42644a6e44: fix: Prevent `RoomProvider.useEffect` from subscribing to room-data stream multiple times +- 9bdbc9b086: load sounds right before playing them +- 6154979119: Fix users being created without the `roles` field +- 6bcdd88531: Fixed CAS login after popup closes +- 839789c988: Fix moment timestamps language change +- f0025d4d92: Fixed message fetching method in LivechatBridge for Apps +- 9c957b9d9a: Fix pruning messages in a room results in an incorrect message counter +- 583a3149fe: fix: rejected conference calls continue to ring +- b59fd5d7fb: User information crashing for some locales +- 4349443629: Fix performance issue on Engagement Dashboard aggregation +- 614a9b8fc8: Show correct date for last day time +- 69447e1864: Added ability to disable private app installation via envvar (DISABLE_PRIVATE_APP_INSTALLATION) +- 52a1aa94eb: improve: System messages for omni-visitor abandonment feature +- 7dffec2e2f: chore: Add danger variant to apps action button menus +- f0c8867bb9: Disabled call to tags enterprise endpoint when on community license +- 5e89694bfa: Fixes SAML full name updates not being mirrored to DM rooms. +- d6f0c6afe2: Fixed Importer Progress Bar progress indicator +- 177506ea91: Make user default role setting public +- 3fb2124166: Fixed misleading of 'total' in team members list inside Channel +- 5cee21468e: Fix spotlight search does not find rooms with special or non-latin characters +- cf59c8abe3: Fix engagement dashboard not showing data +- dfb9a075b3: fixed wrong user status displayed during mentioning a user in a channel +- 1fbbb6241a: Don't allow to report self messages +- 53e0c346e2: fixed scrollbar over content in Federated Room List +- 5321e87363: Fix seat counter including bots users +- 7137a193a7: feat: Add flag to disable teams mention via troubleshoot page +- 59e6fe3d2a: fixed layout changing from embedded view when navigating +- 3245a0a318: Fix LinkedIn OAuth broken +- 45a8943ed4: Removed old/deprecated Rocket.Chat Federation card from Info page +- 6eea189ec8: Fix the code that was setting email URL to an invalid value when SMTP was not set +- f5a886a144: fixed an issue where oauth login was not working with some providers +- ba24f3c21f: Fixed `default` field not being returned from the `setDefault` endpoints when setting to false +- a79f61461d: Fixed an issue where timeout for http requests in Apps-Engine bridges was too short +- 51b988b3df: Fix importer filters not working +- 5d857f462c: fix: stop blinking "Room not found" before dm creation +- db26f8a8ee: fixed an issue with the positioning of the message menu +- aaefe865a7: fix: agent role being removed upon user deactivation +- 306a5830c3: Fix `mention-here` and `mention-all` permissions not being honored +- 761cad4382: Fix CORS headers not being set for assets +- 9e5718002a: Fixed Slackbridge was not handling correctly received events from Slack anymore. Events: (Send, edit, delete, react meassages) +- 54ef89c9a7: fix: show requested filters only on requested apps view +- 1589279b79: Fix users not able to login after block time perdiod has passed +- 880ab5689c: Fixed selected departments not being displayed due to pagination +- a81bad24e0: Fixed Apps-Engine event `IPostUserCreated` execution +- 7a4fdf41f8: Fix validation in app status call that allowed Enterprise apps to be enabled in invalid environments +- e28f8d95f0: Fixed inviter not informed when inviting member to room via `/invite` slashcommand +- d47d2021ac: Fixed "teams" icon not being displayed on spotlight sidebar search +- 93d5a5ceb8: fix: User timezone not being respected on Current Chat's filter +- f556518fa1: Change SAU aggregation to consider only sessions from few days ago instead of the whole past. + + This is particularly important for large workspaces in case the cron job did not run for some time, in that case the amount of sessions would accumulate and the aggregation would take a long time to run. + +- b747f3d3bc: Fixed unable to create admin user using ADMIN\_\* environment variables +- 2cf2643399: Fixed failing user data exports +- 61a106fbf2: Increase cron job check delay to 1 min from 5s. + + This reduces MongoDB requests introduced on 6.3. + +- ace35997a6: chore: Increase cache time from 5s to 10s on `getUnits` helpers. This should reduce the number of DB calls made by this method to fetch the unit limitations for a user. +- f5a886a144: fixed an issue on oauth login that caused missing emails to be detected as changed data +- 61128364d6: Fixes a problem where the calculated time for considering the visitor abandonment was the first message from the visitor and not the visitor's reply to the agent. +- 9496f1eb97: Deprecate `livechat:getOverviewData` and `livechat:getAgentOverviewData` methods and create API endpoints `livechat/analytics/overview` and `livechat/analytics/agent-overview` to fetch analytics data +- 01dec055a0: Fixed Accounts profile form name change was not working +- e4837a15ed: Fixed user mentioning when prepending the username with `>` +- d45365436e: Use group filter when set to LDAP sync process +- c536a4a237: fix: Missing padding on Omnichannel contacts Contextualbar loading state +- 87e4a4aa56: Fixes a problem that allowed users to send empty spaces as comment to bypass the "comment required" setting +- 69a5213afc: Fixed an issue where a mailer error was being sent to customers using offline message's form on Omnichannel instead of the translated one +- b8f3d5014f: Fixed the login page language switcher, now the component has a new look, is reactive and the language selection becomes concrete upon login in. Also changed the default language of the login page to be the browser language. +- 22cf158c43: fixed the unread messages mark not showing +- 72a34a02f7: fixed the video recorder window not closing after permission is denied. +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [1246a21648] +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [f9a748526d] +- Updated dependencies [5cee21468e] +- Updated dependencies [dc1d8ce92e] +- Updated dependencies [2db32f0d4a] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [aaefe865a7] +- Updated dependencies [074db3b419] +- Updated dependencies [357a3a50fa] +- Updated dependencies [f556518fa1] +- Updated dependencies [d9a150000d] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [1041d4d361] +- Updated dependencies [61a106fbf2] +- Updated dependencies [61128364d6] +- Updated dependencies [9496f1eb97] +- Updated dependencies [dce4a829fa] +- Updated dependencies [d45365436e] +- Updated dependencies [b8f3d5014f] +- Updated dependencies [93d4912e17] +- Updated dependencies [ee3815fce4] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/rest-typings@6.4.0 + - @rocket.chat/fuselage-ui-kit@2.0.0 + - @rocket.chat/model-typings@0.1.0 + - @rocket.chat/core-services@0.2.0 + - @rocket.chat/ui-client@2.0.0 + - @rocket.chat/ui-contexts@2.0.0 + - @rocket.chat/ui-theming@0.1.0 + - @rocket.chat/presence@0.0.15 + - @rocket.chat/tools@0.1.0 + - @rocket.chat/cron@0.0.11 + - @rocket.chat/i18n@0.0.2 + - @rocket.chat/web-ui-registration@2.0.0 + - @rocket.chat/api-client@0.1.9 + - @rocket.chat/omnichannel-services@0.0.15 + - @rocket.chat/pdf-worker@0.0.15 + - @rocket.chat/gazzodown@2.0.0 + - @rocket.chat/models@0.0.15 + - @rocket.chat/ui-video-conf@2.0.0 + - @rocket.chat/base64@1.0.12 + - @rocket.chat/instance-status@0.0.15 + - @rocket.chat/random@1.2.1 + - @rocket.chat/sha256@1.0.9 + - @rocket.chat/ui-composer@0.0.1 + +## 6.4.0-rc.5 + +### Minor Changes + +- 1041d4d361: Added option to select between two script engine options for the integrations + +### Patch Changes + +- Bump @rocket.chat/meteor version. +- ec60dbe8f5: Fixed custom translations not being displayed +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/rest-typings@6.4.0-rc.5 + - @rocket.chat/tools@0.1.0-rc.0 + - @rocket.chat/api-client@0.1.9-rc.5 + - @rocket.chat/omnichannel-services@0.0.15-rc.5 + - @rocket.chat/pdf-worker@0.0.15-rc.5 + - @rocket.chat/presence@0.0.15-rc.5 + - @rocket.chat/core-services@0.2.0-rc.5 + - @rocket.chat/cron@0.0.11-rc.5 + - @rocket.chat/gazzodown@2.0.0-rc.5 + - @rocket.chat/model-typings@0.1.0-rc.5 + - @rocket.chat/ui-contexts@2.0.0-rc.5 + - @rocket.chat/fuselage-ui-kit@2.0.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + - @rocket.chat/ui-theming@0.1.0-rc.0 + - @rocket.chat/ui-client@2.0.0-rc.5 + - @rocket.chat/ui-video-conf@2.0.0-rc.5 + - @rocket.chat/web-ui-registration@2.0.0-rc.5 + - @rocket.chat/instance-status@0.0.15-rc.5 + ## 6.4.0-rc.4 ### Patch Changes @@ -266,6 +477,31 @@ - @rocket.chat/sha256@1.0.9 - @rocket.chat/ui-composer@0.0.1 +## 6.3.8 + +### Patch Changes + +- ff8e9d9f54: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. + - @rocket.chat/core-typings@6.3.8 + - @rocket.chat/rest-typings@6.3.8 + - @rocket.chat/api-client@0.1.8 + - @rocket.chat/omnichannel-services@0.0.14 + - @rocket.chat/pdf-worker@0.0.14 + - @rocket.chat/presence@0.0.14 + - @rocket.chat/core-services@0.1.8 + - @rocket.chat/cron@0.0.10 + - @rocket.chat/gazzodown@1.0.8 + - @rocket.chat/model-typings@0.0.14 + - @rocket.chat/ui-contexts@1.0.8 + - @rocket.chat/fuselage-ui-kit@1.0.8 + - @rocket.chat/models@0.0.14 + - @rocket.chat/ui-theming@0.0.1 + - @rocket.chat/ui-client@1.0.8 + - @rocket.chat/ui-video-conf@1.0.8 + - @rocket.chat/web-ui-registration@1.0.8 + - @rocket.chat/instance-status@0.0.14 + ## 6.3.7 ### Patch Changes diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index 4a7aec073442..8e0541b8040b 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -670,7 +670,14 @@ async function createChannelValidator(params: { async function createChannel( userId: string, - params: { name?: string; members?: string[]; customFields?: Record; extraData?: Record; readOnly?: boolean }, + params: { + name?: string; + members?: string[]; + customFields?: Record; + extraData?: Record; + readOnly?: boolean; + excludeSelf?: boolean; + }, ): Promise<{ channel: IRoom }> { const readOnly = typeof params.readOnly !== 'undefined' ? params.readOnly : false; const id = await createChannelMethod( @@ -680,6 +687,7 @@ async function createChannel( readOnly, params.customFields, params.extraData, + params.excludeSelf, ); return { diff --git a/apps/meteor/app/api/server/v1/cloud.ts b/apps/meteor/app/api/server/v1/cloud.ts index 55b3f8588275..d904d40d84ff 100644 --- a/apps/meteor/app/api/server/v1/cloud.ts +++ b/apps/meteor/app/api/server/v1/cloud.ts @@ -71,6 +71,10 @@ API.v1.addRoute( return API.v1.unauthorized(); } + if (process.env.NODE_ENV === 'development') { + return API.v1.success({ offline: true }); + } + return API.v1.success({ offline: !(await registerPreIntentWorkspaceWizard()) }); }, }, diff --git a/apps/meteor/app/api/server/v1/groups.ts b/apps/meteor/app/api/server/v1/groups.ts index df54b683fda4..8f2999cee71e 100644 --- a/apps/meteor/app/api/server/v1/groups.ts +++ b/apps/meteor/app/api/server/v1/groups.ts @@ -1,4 +1,4 @@ -import { Team } from '@rocket.chat/core-services'; +import { Team, isMeteorError } from '@rocket.chat/core-services'; import type { IIntegration, IUser, IRoom, RoomType } from '@rocket.chat/core-typings'; import { Integrations, Messages, Rooms, Subscriptions, Uploads, Users } from '@rocket.chat/models'; import { check, Match } from 'meteor/check'; @@ -26,29 +26,7 @@ import { getLoggedInUser } from '../helpers/getLoggedInUser'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { getUserFromParams, getUserListFromParams } from '../helpers/getUserFromParams'; -// Returns the private group subscription IF found otherwise it will return the failure of why it didn't. Check the `statusCode` property -async function findPrivateGroupByIdOrName({ - params, - checkedArchived = true, - userId, -}: { - params: - | { - roomId?: string; - } - | { - roomName?: string; - }; - userId: string; - checkedArchived?: boolean; -}): Promise<{ - rid: string; - open: boolean; - ro: boolean; - t: string; - name: string; - broadcast: boolean; -}> { +async function getRoomFromParams(params: { roomId?: string } | { roomName?: string }): Promise { if ( (!('roomId' in params) && !('roomName' in params)) || ('roomId' in params && !(params as { roomId?: string }).roomId && 'roomName' in params && !(params as { roomName?: string }).roomName) @@ -68,17 +46,48 @@ async function findPrivateGroupByIdOrName({ broadcast: 1, }, }; - let room: IRoom | null = null; - if ('roomId' in params) { - room = await Rooms.findOneById(params.roomId || '', roomOptions); - } else if ('roomName' in params) { - room = await Rooms.findOneByName(params.roomName || '', roomOptions); - } + + const room = await (() => { + if ('roomId' in params) { + return Rooms.findOneById(params.roomId || '', roomOptions); + } + if ('roomName' in params) { + return Rooms.findOneByName(params.roomName || '', roomOptions); + } + })(); if (!room || room.t !== 'p') { throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); } + return room; +} + +// Returns the private group subscription IF found otherwise it will return the failure of why it didn't. Check the `statusCode` property +async function findPrivateGroupByIdOrName({ + params, + checkedArchived = true, + userId, +}: { + params: + | { + roomId?: string; + } + | { + roomName?: string; + }; + userId: string; + checkedArchived?: boolean; +}): Promise<{ + rid: string; + open: boolean; + ro: boolean; + t: string; + name: string; + broadcast: boolean; +}> { + const room = await getRoomFromParams(params); + const user = await Users.findOneById(userId, { projections: { username: 1 } }); if (!room || !user || !(await canAccessRoomAsync(room, user))) { @@ -302,10 +311,6 @@ API.v1.addRoute( { authRequired: true }, { async post() { - if (!(await hasPermissionAsync(this.userId, 'create-p'))) { - return API.v1.unauthorized(); - } - if (!this.bodyParams.name) { return API.v1.failure('Body param "name" is required'); } @@ -323,24 +328,32 @@ API.v1.addRoute( const readOnly = typeof this.bodyParams.readOnly !== 'undefined' ? this.bodyParams.readOnly : false; - const result = await createPrivateGroupMethod( - this.userId, - this.bodyParams.name, - this.bodyParams.members ? this.bodyParams.members : [], - readOnly, - this.bodyParams.customFields, - this.bodyParams.extraData, - ); - - const room = await Rooms.findOneById(result.rid, { projection: API.v1.defaultFieldsToExclude }); + try { + const result = await createPrivateGroupMethod( + this.user, + this.bodyParams.name, + this.bodyParams.members ? this.bodyParams.members : [], + readOnly, + this.bodyParams.customFields, + this.bodyParams.extraData, + this.bodyParams.excludeSelf ?? false, + ); + + const room = await Rooms.findOneById(result.rid, { projection: API.v1.defaultFieldsToExclude }); + if (!room) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } - if (!room) { - throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + return API.v1.success({ + group: await composeRoomWithLastMessage(room, this.userId), + }); + } catch (error: unknown) { + if (isMeteorError(error) && error.reason === 'error-not-allowed') { + return API.v1.unauthorized(); + } } - return API.v1.success({ - group: await composeRoomWithLastMessage(room, this.userId), - }); + return API.v1.internalError(); }, }, ); @@ -581,17 +594,14 @@ API.v1.addRoute( { authRequired: true }, { async post() { - const findResult = await findPrivateGroupByIdOrName({ - params: this.bodyParams, - userId: this.userId, - }); + const room = await getRoomFromParams(this.bodyParams); const user = await getUserFromParams(this.bodyParams); if (!user?.username) { return API.v1.failure('Invalid user'); } - await removeUserFromRoomMethod(this.userId, { rid: findResult.rid, username: user.username }); + await removeUserFromRoomMethod(this.userId, { rid: room._id, username: user.username }); return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index dec4da6bf87b..ae5a79719cce 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -1,13 +1,14 @@ import crypto from 'crypto'; import type { IUser } from '@rocket.chat/core-typings'; -import { Users } from '@rocket.chat/models'; +import { Settings, Users } from '@rocket.chat/models'; import { isShieldSvgProps, isSpotlightProps, isDirectoryProps, isMethodCallProps, isMethodCallAnonProps, + isFingerprintProps, isMeteorCall, validateParamsPwGetPolicyRest, } from '@rocket.chat/rest-typings'; @@ -16,6 +17,7 @@ import EJSON from 'ejson'; import { check } from 'meteor/check'; import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; +import { v4 as uuidv4 } from 'uuid'; import { i18n } from '../../../../server/lib/i18n'; import { SystemLogger } from '../../../../server/lib/logger/system'; @@ -643,3 +645,75 @@ API.v1.addRoute( }, }, ); + +/** + * @openapi + * /api/v1/fingerprint: + * post: + * description: Update Fingerprint definition as a new workspace or update of configuration + * security: + * $ref: '#/security/authenticated' + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * setDeploymentAs: + * type: string + * example: | + * { + * "setDeploymentAs": "new-workspace" + * } + * responses: + * 200: + * description: Workspace successfully configured + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiSuccessV1' + * default: + * description: Unexpected error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiFailureV1' + */ +API.v1.addRoute( + 'fingerprint', + { + authRequired: true, + validateParams: isFingerprintProps, + }, + { + async post() { + check(this.bodyParams, { + setDeploymentAs: String, + }); + + if (this.bodyParams.setDeploymentAs === 'new-workspace') { + await Promise.all([ + Settings.resetValueById('uniqueID', process.env.DEPLOYMENT_ID || uuidv4()), + // Settings.resetValueById('Cloud_Url'), + Settings.resetValueById('Cloud_Service_Agree_PrivacyTerms'), + Settings.resetValueById('Cloud_Workspace_Id'), + Settings.resetValueById('Cloud_Workspace_Name'), + Settings.resetValueById('Cloud_Workspace_Client_Id'), + Settings.resetValueById('Cloud_Workspace_Client_Secret'), + Settings.resetValueById('Cloud_Workspace_Client_Secret_Expires_At'), + Settings.resetValueById('Cloud_Workspace_Registration_Client_Uri'), + Settings.resetValueById('Cloud_Workspace_PublicKey'), + Settings.resetValueById('Cloud_Workspace_License'), + Settings.resetValueById('Cloud_Workspace_Had_Trial'), + Settings.resetValueById('Cloud_Workspace_Access_Token'), + Settings.resetValueById('Cloud_Workspace_Access_Token_Expires_At', new Date(0)), + Settings.resetValueById('Cloud_Workspace_Registration_State'), + ]); + } + + await Settings.updateValueById('Deployment_FingerPrint_Verified', true); + + return API.v1.success({}); + }, + }, +); diff --git a/apps/meteor/app/api/server/v1/voip/omnichannel.ts b/apps/meteor/app/api/server/v1/voip/omnichannel.ts index 6ffd0005c764..e1ee82d72478 100644 --- a/apps/meteor/app/api/server/v1/voip/omnichannel.ts +++ b/apps/meteor/app/api/server/v1/voip/omnichannel.ts @@ -78,7 +78,6 @@ API.v1.addRoute( } try { - logger.debug(`Setting extension ${extension} for agent with id ${user._id}`); await Users.setExtension(user._id, extension); return API.v1.success(); } catch (e) { @@ -146,7 +145,6 @@ API.v1.addRoute( return API.v1.notFound(); } if (!user.extension) { - logger.debug(`User ${user._id} is not associated with any extension. Skipping`); return API.v1.success(); } diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 71f7387e1aa5..70802f280095 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -44,7 +44,7 @@ export class AppLivechatBridge extends LivechatBridge { throw new Error('Invalid token for livechat message'); } - const msg = await Livechat.sendMessage({ + const msg = await LivechatTyped.sendMessage({ guest: this.orch.getConverters()?.get('visitors').convertAppVisitor(message.visitor), message: await this.orch.getConverters()?.get('messages').convertAppMessage(message), agent: undefined, @@ -74,7 +74,8 @@ export class AppLivechatBridge extends LivechatBridge { message: await this.orch.getConverters()?.get('messages').convertAppMessage(message), }; - await Livechat.updateMessage(data); + // @ts-expect-error IVisitor vs ILivechatVisitor :( + await LivechatTyped.updateMessage(data); } protected async createRoom(visitor: IVisitor, agent: IUser, appId: string, extraParams?: IExtraRoomParams): Promise { @@ -208,7 +209,7 @@ export class AppLivechatBridge extends LivechatBridge { userId = transferredTo._id; } - return Livechat.transfer( + return LivechatTyped.transfer( await this.orch.getConverters()?.get('rooms').convertAppRoom(currentRoom), this.orch.getConverters()?.get('visitors').convertAppVisitor(visitor), { userId, departmentId, transferredBy, transferredTo }, @@ -223,7 +224,7 @@ export class AppLivechatBridge extends LivechatBridge { } return Promise.all( - (await LivechatVisitors.find(query).toArray()).map( + (await LivechatVisitors.findEnabled(query).toArray()).map( async (visitor) => visitor && this.orch.getConverters()?.get('visitors').convertVisitor(visitor), ), ); @@ -288,7 +289,7 @@ export class AppLivechatBridge extends LivechatBridge { throw new Error('Could not get the message converter to process livechat room messages'); } - const livechatMessages = await Livechat.getRoomMessages({ rid: roomId }); + const livechatMessages = await LivechatTyped.getRoomMessages({ rid: roomId }); return Promise.all(livechatMessages.map((message) => messageConverter.convertMessage(message) as Promise)); } diff --git a/apps/meteor/app/apps/server/bridges/messages.ts b/apps/meteor/app/apps/server/bridges/messages.ts index d75a0c244674..e4d09018176d 100644 --- a/apps/meteor/app/apps/server/bridges/messages.ts +++ b/apps/meteor/app/apps/server/bridges/messages.ts @@ -103,7 +103,11 @@ export class AppMessageBridge extends MessageBridge { protected async typing({ scope, id, username, isTyping }: ITypingDescriptor): Promise { switch (scope) { case 'room': - notifications.notifyRoom(id, 'typing', username!, isTyping); + if (!username) { + throw new Error('Invalid username'); + } + + notifications.notifyRoom(id, 'user-activity', username, isTyping ? ['user-typing'] : []); return; default: throw new Error('Unrecognized typing scope provided'); diff --git a/apps/meteor/app/apps/server/bridges/rooms.ts b/apps/meteor/app/apps/server/bridges/rooms.ts index 481292d61790..91b0049513f0 100644 --- a/apps/meteor/app/apps/server/bridges/rooms.ts +++ b/apps/meteor/app/apps/server/bridges/rooms.ts @@ -55,7 +55,11 @@ export class AppRoomBridge extends RoomBridge { } private async createPrivateGroup(userId: string, room: ICoreRoom, members: string[]): Promise { - return (await createPrivateGroupMethod(userId, room.name || '', members, room.ro, room.customFields, this.prepareExtraData(room))).rid; + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('Invalid user'); + } + return (await createPrivateGroupMethod(user, room.name || '', members, room.ro, room.customFields, this.prepareExtraData(room))).rid; } protected async getById(roomId: string, appId: string): Promise { diff --git a/apps/meteor/app/apps/server/bridges/uiInteraction.ts b/apps/meteor/app/apps/server/bridges/uiInteraction.ts index b51c3be8ae3b..8e94f66e9617 100644 --- a/apps/meteor/app/apps/server/bridges/uiInteraction.ts +++ b/apps/meteor/app/apps/server/bridges/uiInteraction.ts @@ -1,11 +1,12 @@ import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import type { IUser } from '@rocket.chat/apps-engine/definition/users'; -import { UiInteractionBridge as UiIntBridge } from '@rocket.chat/apps-engine/server/bridges/UiInteractionBridge'; +import { UiInteractionBridge as AppsEngineUiInteractionBridge } from '@rocket.chat/apps-engine/server/bridges/UiInteractionBridge'; import { api } from '@rocket.chat/core-services'; +import type { UiKit } from '@rocket.chat/core-typings'; import type { AppServerOrchestrator } from '../../../../ee/server/apps/orchestrator'; -export class UiInteractionBridge extends UiIntBridge { +export class UiInteractionBridge extends AppsEngineUiInteractionBridge { constructor(private readonly orch: AppServerOrchestrator) { super(); } @@ -19,6 +20,6 @@ export class UiInteractionBridge extends UiIntBridge { throw new Error('Invalid app provided'); } - void api.broadcast('notify.uiInteraction', user.id, interaction); + void api.broadcast('notify.uiInteraction', user.id, interaction as UiKit.ServerInteraction); } } diff --git a/apps/meteor/app/apps/server/converters/rooms.js b/apps/meteor/app/apps/server/converters/rooms.js index ae38feff5eff..905534212836 100644 --- a/apps/meteor/app/apps/server/converters/rooms.js +++ b/apps/meteor/app/apps/server/converters/rooms.js @@ -37,7 +37,7 @@ export class AppRoomsConverter { let v; if (room.visitor) { - const visitor = await LivechatVisitors.findOneById(room.visitor.id); + const visitor = await LivechatVisitors.findOneEnabledById(room.visitor.id); const { lastMessageTs, phone } = room.visitorChannelInfo; diff --git a/apps/meteor/app/apps/server/converters/visitors.js b/apps/meteor/app/apps/server/converters/visitors.js index ba288c96d7b8..a9f5d450efad 100644 --- a/apps/meteor/app/apps/server/converters/visitors.js +++ b/apps/meteor/app/apps/server/converters/visitors.js @@ -9,7 +9,7 @@ export class AppVisitorsConverter { } async convertById(id) { - const visitor = await LivechatVisitors.findOneById(id); + const visitor = await LivechatVisitors.findOneEnabledById(id); return this.convertVisitor(visitor); } diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index fc917028c33f..7b5f1594e5c3 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -10,6 +10,8 @@ export const permissions = [ { _id: 'add-user-to-joined-room', roles: ['admin', 'owner', 'moderator'] }, { _id: 'add-user-to-any-c-room', roles: ['admin'] }, { _id: 'add-user-to-any-p-room', roles: [] }, + { _id: 'kick-user-from-any-c-room', roles: ['admin'] }, + { _id: 'kick-user-from-any-p-room', roles: [] }, { _id: 'api-bypass-rate-limit', roles: ['admin', 'bot', 'app'] }, { _id: 'archive-room', roles: ['admin', 'owner'] }, { _id: 'assign-admin-role', roles: ['admin'] }, diff --git a/apps/meteor/app/autotranslate/server/methods/saveSettings.ts b/apps/meteor/app/autotranslate/server/methods/saveSettings.ts index 1ba5bcdfcd76..e396d78887a9 100644 --- a/apps/meteor/app/autotranslate/server/methods/saveSettings.ts +++ b/apps/meteor/app/autotranslate/server/methods/saveSettings.ts @@ -1,4 +1,4 @@ -import { Subscriptions } from '@rocket.chat/models'; +import { Subscriptions, Rooms } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -46,6 +46,13 @@ Meteor.methods({ switch (field) { case 'autoTranslate': + const room = await Rooms.findE2ERoomById(rid, { projection: { _id: 1 } }); + if (room && value === '1') { + throw new Meteor.Error('error-e2e-enabled', 'Enabling auto-translation in E2E encrypted rooms is not allowed', { + method: 'saveAutoTranslateSettings', + }); + } + await Subscriptions.updateAutoTranslateById(subscription._id, value === '1'); if (!subscription.autoTranslateLanguage && options.defaultLanguage) { await Subscriptions.updateAutoTranslateLanguageById(subscription._id, options.defaultLanguage); diff --git a/apps/meteor/app/cas/server/cas_server.js b/apps/meteor/app/cas/server/cas_server.js index 25d3b9fdd698..60880c77d4f4 100644 --- a/apps/meteor/app/cas/server/cas_server.js +++ b/apps/meteor/app/cas/server/cas_server.js @@ -257,7 +257,7 @@ Accounts.registerLoginHandler('cas', async (options) => { if (roomName) { let room = await Rooms.findOneByNameAndType(roomName, 'c'); if (!room) { - room = await createRoom('c', roomName, user.username); + room = await createRoom('c', roomName, user); } } } diff --git a/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts b/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts index dc57307b1c4c..ed07540ba2b0 100644 --- a/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts +++ b/apps/meteor/app/channel-settings/server/functions/saveRoomEncrypted.ts @@ -1,7 +1,7 @@ import { Message } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; import { isRegisterUser } from '@rocket.chat/core-typings'; -import { Rooms } from '@rocket.chat/models'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { UpdateResult } from 'mongodb'; @@ -25,5 +25,9 @@ export const saveRoomEncrypted = async function (rid: string, encrypted: boolean await Message.saveSystemMessage(type, rid, user.username, user); } + + if (encrypted) { + await Subscriptions.disableAutoTranslateByRoomId(rid); + } return update; }; diff --git a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts index d65897b72094..ea94db8d17a1 100644 --- a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts +++ b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts @@ -1,50 +1,60 @@ -import type { SettingValue } from '@rocket.chat/core-typings'; import { Statistics, Users } from '@rocket.chat/models'; import { settings } from '../../../settings/server'; import { statistics } from '../../../statistics/server'; +import { Info } from '../../../utils/rocketchat.info'; import { LICENSE_VERSION } from '../license'; -type WorkspaceRegistrationData = { +export type WorkspaceRegistrationData = { uniqueId: string; - workspaceId: SettingValue; - address: SettingValue; + deploymentFingerprintHash: string; + deploymentFingerprintVerified: boolean; + workspaceId: string; + address: string; contactName: string; contactEmail: T; seats: number; - allowMarketing: SettingValue; - accountName: SettingValue; - organizationType: unknown; - industry: unknown; - orgSize: unknown; - country: unknown; - language: unknown; - agreePrivacyTerms: SettingValue; - website: SettingValue; - siteName: SettingValue; + + organizationType: string; + industry: string; + orgSize: string; + country: string; + language: string; + allowMarketing: string; + accountName: string; + agreePrivacyTerms: string; + website: string; + siteName: string; workspaceType: unknown; deploymentMethod: string; deploymentPlatform: string; - version: unknown; + version: string; licenseVersion: number; enterpriseReady: boolean; setupComplete: boolean; connectionDisable: boolean; - npsEnabled: SettingValue; + npsEnabled: string; + // TODO: Evaluate naming + MAC: number; + // activeContactsBillingMonth: number; + // activeContactsYesterday: number; + statsToken?: string; }; export async function buildWorkspaceRegistrationData(contactEmail: T): Promise> { const stats = (await Statistics.findLast()) || (await statistics.get()); - const address = settings.get('Site_Url'); - const siteName = settings.get('Site_Name'); - const workspaceId = settings.get('Cloud_Workspace_Id'); - const allowMarketing = settings.get('Allow_Marketing_Emails'); - const accountName = settings.get('Organization_Name'); - const website = settings.get('Website'); - const npsEnabled = settings.get('NPS_survey_enabled'); - const agreePrivacyTerms = settings.get('Cloud_Service_Agree_PrivacyTerms'); - const setupWizardState = settings.get('Show_Setup_Wizard'); + const address = settings.get('Site_Url'); + const siteName = settings.get('Site_Name'); + const workspaceId = settings.get('Cloud_Workspace_Id'); + const allowMarketing = settings.get('Allow_Marketing_Emails'); + const accountName = settings.get('Organization_Name'); + const website = settings.get('Website'); + const npsEnabled = settings.get('NPS_survey_enabled'); + const agreePrivacyTerms = settings.get('Cloud_Service_Agree_PrivacyTerms'); + const setupWizardState = settings.get('Show_Setup_Wizard'); + const deploymentFingerprintHash = settings.get('Deployment_FingerPrint_Hash'); + const deploymentFingerprintVerified = settings.get('Deployment_FingerPrint_Verified'); const firstUser = await Users.getOldest({ projection: { name: 1, emails: 1 } }); const contactName = firstUser?.name || ''; @@ -54,6 +64,8 @@ export async function buildWorkspaceRegistrationData { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/oauth/clients`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + const payload = await response.json(); + + if (!payload) { + return undefined; + } + + return payload; +}; + export async function connectWorkspace(token: string) { - // shouldn't get here due to checking this on the method - // but this is just to double check if (!token) { - return new Error('Invalid token; the registration token is required.'); + throw new CloudWorkspaceConnectionError('Invalid registration token'); } - const redirectUri = getRedirectUri(); + try { + const redirectUri = getRedirectUri(); - const regInfo = { - email: settings.get('Organization_Email'), - client_name: settings.get('Site_Name'), - redirect_uris: [redirectUri], - }; + const body = { + email: settings.get('Organization_Email'), + client_name: settings.get('Site_Name'), + redirect_uris: [redirectUri], + }; - const cloudUrl = settings.get('Cloud_Url'); - let result; - try { - const request = await fetch(`${cloudUrl}/api/oauth/clients`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - }, - body: regInfo, - }); + const payload = await fetchRegistrationDataPayload({ token, body }); - if (!request.ok) { - throw new Error((await request.json()).error); + if (!payload) { + return false; } - result = await request.json(); - } catch (err: any) { + await saveRegistrationData(payload); + + return true; + } catch (err) { SystemLogger.error({ msg: 'Failed to Connect with Rocket.Chat Cloud', url: '/api/oauth/clients', @@ -45,12 +76,4 @@ export async function connectWorkspace(token: string) { return false; } - - if (!result) { - return false; - } - - await saveRegistrationData(result); - - return true; } diff --git a/apps/meteor/app/cloud/server/functions/finishOAuthAuthorization.ts b/apps/meteor/app/cloud/server/functions/finishOAuthAuthorization.ts index 780aa5c67a99..61b3a77966e7 100644 --- a/apps/meteor/app/cloud/server/functions/finishOAuthAuthorization.ts +++ b/apps/meteor/app/cloud/server/functions/finishOAuthAuthorization.ts @@ -14,15 +14,15 @@ export async function finishOAuthAuthorization(code: string, state: string) { }); } - const cloudUrl = settings.get('Cloud_Url'); const clientId = settings.get('Cloud_Workspace_Client_Id'); const clientSecret = settings.get('Cloud_Workspace_Client_Secret'); const scope = userScopes.join(' '); - let result; + let payload; try { - const request = await fetch(`${cloudUrl}/api/oauth/token`, { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, params: new URLSearchParams({ @@ -35,11 +35,11 @@ export async function finishOAuthAuthorization(code: string, state: string) { }), }); - if (!request.ok) { - throw new Error((await request.json()).error); + if (!response.ok) { + throw new Error((await response.json()).error); } - result = await request.json(); + payload = await response.json(); } catch (err) { SystemLogger.error({ msg: 'Failed to finish OAuth authorization with Rocket.Chat Cloud', @@ -51,7 +51,7 @@ export async function finishOAuthAuthorization(code: string, state: string) { } const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + result.expires_in); + expiresAt.setSeconds(expiresAt.getSeconds() + payload.expires_in); const uid = Meteor.userId(); if (!uid) { @@ -65,11 +65,11 @@ export async function finishOAuthAuthorization(code: string, state: string) { { $set: { 'services.cloud': { - accessToken: result.access_token, + accessToken: payload.access_token, expiresAt, - scope: result.scope, - tokenType: result.token_type, - refreshToken: result.refresh_token, + scope: payload.scope, + tokenType: payload.token_type, + refreshToken: payload.refresh_token, }, }, }, diff --git a/apps/meteor/app/cloud/server/functions/getConfirmationPoll.ts b/apps/meteor/app/cloud/server/functions/getConfirmationPoll.ts index 4a35c9834ba5..2c5d9dec77dc 100644 --- a/apps/meteor/app/cloud/server/functions/getConfirmationPoll.ts +++ b/apps/meteor/app/cloud/server/functions/getConfirmationPoll.ts @@ -5,16 +5,16 @@ import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings } from '../../../settings/server'; export async function getConfirmationPoll(deviceCode: string): Promise { - const cloudUrl = settings.get('Cloud_Url'); - - let result; + let payload; try { - const request = await fetch(`${cloudUrl}/api/v2/register/workspace/poll`, { params: { token: deviceCode } }); - if (!request.ok) { - throw new Error((await request.json()).error); + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace/poll`, { params: { token: deviceCode } }); + + if (!response.ok) { + throw new Error((await response.json()).error); } - result = await request.json(); + payload = await response.json(); } catch (err: any) { SystemLogger.error({ msg: 'Failed to get confirmation poll from Rocket.Chat Cloud', @@ -25,9 +25,9 @@ export async function getConfirmationPoll(deviceCode: string): Promise('Cloud_Url'); // eslint-disable-next-line @typescript-eslint/naming-convention const client_secret = settings.get('Cloud_Workspace_Client_Secret'); const redirectUri = getRedirectUri(); - let authTokenResult; + let payload; try { const body = new URLSearchParams(); body.append('client_id', client_id); @@ -40,12 +39,13 @@ export async function getWorkspaceAccessTokenWithScope(scope = '') { body.append('grant_type', 'client_credentials'); body.append('redirect_uri', redirectUri); - const result = await fetch(`${cloudUrl}/api/oauth/token`, { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/oauth/token`, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, method: 'POST', body, }); - authTokenResult = await result.json(); + payload = await response.json(); } catch (err: any) { SystemLogger.error({ msg: 'Failed to get Workspace AccessToken from Rocket.Chat Cloud', @@ -64,10 +64,10 @@ export async function getWorkspaceAccessTokenWithScope(scope = '') { } const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + authTokenResult.expires_in); + expiresAt.setSeconds(expiresAt.getSeconds() + payload.expires_in); tokenResponse.expiresAt = expiresAt; - tokenResponse.token = authTokenResult.access_token; + tokenResponse.token = payload.access_token; return tokenResponse; } diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts index 6be18f86d466..f9f0cfadc669 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceLicense.ts @@ -1,56 +1,94 @@ +import type { Cloud, Serialized } from '@rocket.chat/core-typings'; import { Settings } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { v, compile } from 'suretype'; import { callbacks } from '../../../../lib/callbacks'; +import { CloudWorkspaceConnectionError } from '../../../../lib/errors/CloudWorkspaceConnectionError'; +import { CloudWorkspaceLicenseError } from '../../../../lib/errors/CloudWorkspaceLicenseError'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings } from '../../../settings/server'; import { LICENSE_VERSION } from '../license'; -import { generateWorkspaceBearerHttpHeaderOrThrow } from './getWorkspaceAccessToken'; -import { handleResponse } from './supportedVersionsToken/supportedVersionsToken'; +import { getWorkspaceAccessToken } from './getWorkspaceAccessToken'; -export async function getWorkspaceLicense() { - const token = await generateWorkspaceBearerHttpHeaderOrThrow(); +const workspaceLicensePayloadSchema = v.object({ + version: v.number().required(), + address: v.string().required(), + license: v.string().required(), + updatedAt: v.string().format('date-time').required(), + modules: v.string().required(), + expireAt: v.string().format('date-time').required(), +}); - const currentLicense = await Settings.findOne('Cloud_Workspace_License'); +const assertWorkspaceLicensePayload = compile(workspaceLicensePayloadSchema); + +const fetchCloudWorkspaceLicensePayload = async ({ token }: { token: string }): Promise> => { + const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); + const response = await fetch(`${workspaceRegistrationClientUri}/license`, { + headers: { + Authorization: `Bearer ${token}`, + }, + params: { + version: LICENSE_VERSION, + }, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + const payload = await response.json(); - // TODO: check if this is the correct way to handle this - // If there is no license, in theory, it should be a new workspace non registered - // in this case the `generateWorkspaceBearerHttpHeaderOrThrow` show throw an error before - // so in theory, this should never happen + assertWorkspaceLicensePayload(payload); + + return payload; +}; + +export async function getWorkspaceLicense(): Promise<{ updated: boolean; license: string }> { + const currentLicense = await Settings.findOne('Cloud_Workspace_License'); + // it should never happen, since even if the license is not found, it will return an empty settings if (!currentLicense?._updatedAt) { - throw new Error('Failed to retrieve current license'); + throw new CloudWorkspaceLicenseError('Failed to retrieve current license'); } - const request = await handleResponse( - fetch(`${settings.get('Cloud_Workspace_Registration_Client_Uri')}/license`, { - headers: { - ...token, - }, - params: { - version: LICENSE_VERSION, - }, - }), - ); - - if (!request.success) { + const fromCurrentLicense = async () => { + const license = currentLicense?.value as string | undefined; + if (license) { + await callbacks.run('workspaceLicenseChanged', license); + } + + return { updated: false, license: license ?? '' }; + }; + + try { + const token = await getWorkspaceAccessToken(); + if (!token) { + return fromCurrentLicense(); + } + + const payload = await fetchCloudWorkspaceLicensePayload({ token }); + + if (currentLicense.value && Date.parse(payload.updatedAt) <= currentLicense._updatedAt.getTime()) { + return fromCurrentLicense(); + } + + await Settings.updateValueById('Cloud_Workspace_License', payload.license); + + await callbacks.run('workspaceLicenseChanged', payload.license); + + return { updated: true, license: payload.license }; + } catch (err) { SystemLogger.error({ msg: 'Failed to update license from Rocket.Chat Cloud', url: '/license', - err: request.error, + err, }); - if (currentLicense.value) { - return callbacks.run('workspaceLicenseChanged', currentLicense.value); - } - return; - } - const remoteLicense = request.result as any; - - if (remoteLicense.updatedAt <= currentLicense._updatedAt) { - return callbacks.run('workspaceLicenseChanged', currentLicense.value); + return fromCurrentLicense(); } - - await Settings.updateValueById('Cloud_Workspace_License', remoteLicense.license); - - await callbacks.run('workspaceLicenseChanged', remoteLicense.license); } diff --git a/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts b/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts index 2a04aa54cfe7..ce415d2aa983 100644 --- a/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts +++ b/apps/meteor/app/cloud/server/functions/registerPreIntentWorkspaceWizard.ts @@ -15,16 +15,16 @@ export async function registerPreIntentWorkspaceWizard(): Promise { } const regInfo = await buildWorkspaceRegistrationData(email); - const cloudUrl = settings.get('Cloud_Url'); try { - const request = await fetch(`${cloudUrl}/api/v2/register/workspace/pre-intent`, { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace/pre-intent`, { + method: 'POST', body: regInfo, timeout: 10 * 1000, - method: 'POST', }); - if (!request.ok) { - throw new Error((await request.json()).error); + if (!response.ok) { + throw new Error((await response.json()).error); } return true; diff --git a/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts b/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts index 7f7c78a137e0..5f5df80d0d3d 100644 --- a/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts +++ b/apps/meteor/app/cloud/server/functions/startRegisterWorkspace.ts @@ -19,22 +19,21 @@ export async function startRegisterWorkspace(resend = false) { const regInfo = await buildWorkspaceRegistrationData(undefined); - const cloudUrl = settings.get('Cloud_Url'); - - let result; + let payload; try { - const request = await fetch(`${cloudUrl}/api/v2/register/workspace`, { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace`, { method: 'POST', body: regInfo, params: { resend, }, }); - if (!request.ok) { - throw new Error((await request.json()).error); + if (!response.ok) { + throw new Error((await response.json()).error); } - result = await request.json(); + payload = await response.json(); } catch (err: any) { SystemLogger.error({ msg: 'Failed to register with Rocket.Chat Cloud', @@ -44,11 +43,11 @@ export async function startRegisterWorkspace(resend = false) { return false; } - if (!result) { + if (!payload) { return false; } - await Settings.updateValueById('Cloud_Workspace_Id', result.id); + await Settings.updateValueById('Cloud_Workspace_Id', payload.id); return true; } diff --git a/apps/meteor/app/cloud/server/functions/startRegisterWorkspaceSetupWizard.ts b/apps/meteor/app/cloud/server/functions/startRegisterWorkspaceSetupWizard.ts index 3afe84c409ec..382478db61c7 100644 --- a/apps/meteor/app/cloud/server/functions/startRegisterWorkspaceSetupWizard.ts +++ b/apps/meteor/app/cloud/server/functions/startRegisterWorkspaceSetupWizard.ts @@ -7,22 +7,22 @@ import { buildWorkspaceRegistrationData } from './buildRegistrationData'; export async function startRegisterWorkspaceSetupWizard(resend = false, email: string): Promise { const regInfo = await buildWorkspaceRegistrationData(email); - const cloudUrl = settings.get('Cloud_Url'); - let result; + let payload; try { - const request = await fetch(`${cloudUrl}/api/v2/register/workspace/intent`, { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v2/register/workspace/intent`, { body: regInfo, method: 'POST', params: { resent: resend, }, }); - if (!request.ok) { - throw new Error((await request.json()).error); + if (!response.ok) { + throw new Error((await response.json()).error); } - result = await request.json(); + payload = await response.json(); } catch (err: any) { SystemLogger.error({ msg: 'Failed to register workspace intent with Rocket.Chat Cloud', @@ -33,9 +33,9 @@ export async function startRegisterWorkspaceSetupWizard(resend = false, email: s throw err; } - if (!result) { + if (!payload) { throw new Error('Failed to fetch registration intent endpoint'); } - return result; + return payload; } diff --git a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts index 3d79ed436e51..f4c662362f5c 100644 --- a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts +++ b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts @@ -1,15 +1,22 @@ import type { SettingValue } from '@rocket.chat/core-typings'; import { License } from '@rocket.chat/license'; import { Settings } from '@rocket.chat/models'; -import type { SupportedVersions } from '@rocket.chat/server-cloud-communication'; +import type { SignedSupportedVersions, SupportedVersions } from '@rocket.chat/server-cloud-communication'; import type { Response } from '@rocket.chat/server-fetch'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { SystemLogger } from '../../../../../server/lib/logger/system'; import { settings } from '../../../../settings/server'; +import { buildVersionUpdateMessage } from '../../../../version-check/server/functions/buildVersionUpdateMessage'; import { generateWorkspaceBearerHttpHeader } from '../getWorkspaceAccessToken'; import { supportedVersionsChooseLatest } from './supportedVersionsChooseLatest'; +declare module '@rocket.chat/license' { + interface ILicenseV3 { + supportedVersions?: SignedSupportedVersions; + } +} + /** HELPERS */ export const wrapPromise = ( @@ -77,7 +84,9 @@ const cacheValueInSettings = ( ); }; -/** CODE */ +const releaseEndpoint = process.env.OVERWRITE_INTERNAL_RELEASE_URL?.trim() + ? process.env.OVERWRITE_INTERNAL_RELEASE_URL.trim() + : 'https://releases.rocket.chat/v2/server/supportedVersions'; const getSupportedVersionsFromCloud = async () => { if (process.env.CLOUD_SUPPORTED_VERSIONS_TOKEN) { @@ -91,7 +100,7 @@ const getSupportedVersionsFromCloud = async () => { const headers = await generateWorkspaceBearerHttpHeader(); const response = await handleResponse( - fetch('https://releases.rocket.chat/v2/server/supportedVersions', { + fetch(releaseEndpoint, { headers, }), ); @@ -99,7 +108,7 @@ const getSupportedVersionsFromCloud = async () => { if (!response.success) { SystemLogger.error({ msg: 'Failed to communicate with Rocket.Chat Cloud', - url: 'https://releases.rocket.chat/v2/server/supportedVersions', + url: releaseEndpoint, err: response.error, }); } @@ -115,9 +124,16 @@ const getSupportedVersionsToken = async () => { * return the token */ - const [versionsFromLicense, response] = await Promise.all([License.supportedVersions(), getSupportedVersionsFromCloud()]); + const [versionsFromLicense, response] = await Promise.all([License.getLicense(), getSupportedVersionsFromCloud()]); + + const supportedVersions = await supportedVersionsChooseLatest( + versionsFromLicense?.supportedVersions, + (response.success && response.result) || undefined, + ); + + await buildVersionUpdateMessage(supportedVersions?.versions); - return (await supportedVersionsChooseLatest(versionsFromLicense, (response.success && response.result) || undefined))?.signed; + return supportedVersions?.signed; }; export const getCachedSupportedVersionsToken = cacheValueInSettings('Cloud_Workspace_Supported_Versions_Token', getSupportedVersionsToken); diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/announcementSync.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/announcementSync.ts new file mode 100644 index 000000000000..f3885c1e95c2 --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/announcementSync.ts @@ -0,0 +1,116 @@ +import { type Cloud, type Serialized } from '@rocket.chat/core-typings'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { v, compile } from 'suretype'; + +import { CloudWorkspaceAccessError } from '../../../../../lib/errors/CloudWorkspaceAccessError'; +import { CloudWorkspaceConnectionError } from '../../../../../lib/errors/CloudWorkspaceConnectionError'; +import { CloudWorkspaceRegistrationError } from '../../../../../lib/errors/CloudWorkspaceRegistrationError'; +import { SystemLogger } from '../../../../../server/lib/logger/system'; +import { settings } from '../../../../settings/server'; +import { buildWorkspaceRegistrationData } from '../buildRegistrationData'; +import { getWorkspaceAccessToken } from '../getWorkspaceAccessToken'; +import { retrieveRegistrationStatus } from '../retrieveRegistrationStatus'; +import { handleAnnouncementsOnWorkspaceSync, handleNpsOnWorkspaceSync } from './handleCommsSync'; +import { legacySyncWorkspace } from './legacySyncWorkspace'; + +const workspaceCommPayloadSchema = v.object({ + workspaceId: v.string().required(), + publicKey: v.string(), + nps: v.object({ + id: v.string().required(), + startAt: v.string().format('date-time').required(), + expireAt: v.string().format('date-time').required(), + }), + announcements: v.object({ + create: v.array( + v.object({ + _id: v.string().required(), + _updatedAt: v.string().format('date-time').required(), + selector: v.object({ + roles: v.array(v.string()), + }), + platform: v.array(v.string().enum('web', 'mobile')).required(), + expireAt: v.string().format('date-time').required(), + startAt: v.string().format('date-time').required(), + createdBy: v.string().enum('cloud', 'system').required(), + createdAt: v.string().format('date-time').required(), + dictionary: v.object({}).additional(v.object({}).additional(v.string())), + view: v.any(), + surface: v.string().enum('banner', 'modal').required(), + }), + ), + delete: v.array(v.string()), + }), +}); + +const assertWorkspaceCommPayload = compile(workspaceCommPayloadSchema); + +const fetchCloudAnnouncementsSync = async ({ + token, + data, +}: { + token: string; + data: Cloud.WorkspaceSyncRequestPayload; +}): Promise> => { + const cloudUrl = settings.get('Cloud_Url'); + const response = await fetch(`${cloudUrl}/api/v3/comms/workspace`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: data, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + const payload = await response.json(); + + assertWorkspaceCommPayload(payload); + return payload; +}; + +export async function announcementSync() { + try { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (!workspaceRegistered) { + throw new CloudWorkspaceRegistrationError('Workspace is not registered'); + } + + const token = await getWorkspaceAccessToken(true); + if (!token) { + throw new CloudWorkspaceAccessError('Workspace does not have a valid access token'); + } + + const workspaceRegistrationData = await buildWorkspaceRegistrationData(undefined); + + const { nps, announcements } = await fetchCloudAnnouncementsSync({ + token, + data: workspaceRegistrationData, + }); + + if (nps) { + await handleNpsOnWorkspaceSync(nps); + } + + if (announcements) { + await handleAnnouncementsOnWorkspaceSync(announcements); + } + + return true; + } catch (err) { + SystemLogger.error({ + msg: 'Failed to sync with Rocket.Chat Cloud', + url: '/comms/workspace', + err, + }); + } + + await legacySyncWorkspace(); +} diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts new file mode 100644 index 000000000000..c8b07f8826cf --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/handleCommsSync.ts @@ -0,0 +1,65 @@ +import { NPS, Banner } from '@rocket.chat/core-services'; +import { type Cloud, type Serialized } from '@rocket.chat/core-typings'; +import { CloudAnnouncements } from '@rocket.chat/models'; + +import { getAndCreateNpsSurvey } from '../../../../../server/services/nps/getAndCreateNpsSurvey'; + +export const handleNpsOnWorkspaceSync = async (nps: Exclude['nps'], undefined>) => { + const { id: npsId, expireAt } = nps; + + const startAt = new Date(nps.startAt); + + await NPS.create({ + npsId, + startAt, + expireAt: new Date(expireAt), + createdBy: { + _id: 'rocket.cat', + username: 'rocket.cat', + }, + }); + + const now = new Date(); + + if (startAt.getFullYear() === now.getFullYear() && startAt.getMonth() === now.getMonth() && startAt.getDate() === now.getDate()) { + await getAndCreateNpsSurvey(npsId); + } +}; + +export const handleBannerOnWorkspaceSync = async (banners: Exclude['banners'], undefined>) => { + for await (const banner of banners) { + const { createdAt, expireAt, startAt, inactivedAt, _updatedAt, ...rest } = banner; + + await Banner.create({ + ...rest, + createdAt: new Date(createdAt), + expireAt: new Date(expireAt), + startAt: new Date(startAt), + ...(inactivedAt && { inactivedAt: new Date(inactivedAt) }), + }); + } +}; + +const deserializeAnnouncement = (announcement: Serialized): Cloud.Announcement => ({ + ...announcement, + _updatedAt: new Date(announcement._updatedAt), + expireAt: new Date(announcement.expireAt), + startAt: new Date(announcement.startAt), + createdAt: new Date(announcement.createdAt), +}); + +export const handleAnnouncementsOnWorkspaceSync = async ( + announcements: Exclude['announcements'], undefined>, +) => { + const { create, delete: deleteIds } = announcements; + + if (deleteIds) { + await CloudAnnouncements.deleteMany({ _id: { $in: deleteIds } }); + } + + for await (const announcement of create.map(deserializeAnnouncement)) { + const { _id, ...rest } = announcement; + + await CloudAnnouncements.updateOne({ _id }, { $set: rest }, { upsert: true }); + } +}; diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts index 48d5afa9dbc5..bdd898b510f7 100644 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts @@ -1,16 +1,18 @@ +import { SystemLogger } from '../../../../../server/lib/logger/system'; import { CloudWorkspaceAccessTokenError } from '../getWorkspaceAccessToken'; -import { getWorkspaceLicense } from '../getWorkspaceLicense'; import { getCachedSupportedVersionsToken } from '../supportedVersionsToken/supportedVersionsToken'; +import { announcementSync } from './announcementSync'; import { syncCloudData } from './syncCloudData'; export async function syncWorkspace() { try { await syncCloudData(); - await getWorkspaceLicense(); - } catch (error) { - if (error instanceof CloudWorkspaceAccessTokenError) { + await announcementSync(); + } catch (err) { + if (err instanceof CloudWorkspaceAccessTokenError) { // TODO: Remove License if there is no access token } + SystemLogger.error({ msg: 'Error during workspace sync', err }); } await getCachedSupportedVersionsToken.reset(); diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts new file mode 100644 index 000000000000..d5f86fad8409 --- /dev/null +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/legacySyncWorkspace.ts @@ -0,0 +1,182 @@ +import { type Cloud, type Serialized } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { v, compile } from 'suretype'; + +import { CloudWorkspaceAccessError } from '../../../../../lib/errors/CloudWorkspaceAccessError'; +import { CloudWorkspaceConnectionError } from '../../../../../lib/errors/CloudWorkspaceConnectionError'; +import { CloudWorkspaceRegistrationError } from '../../../../../lib/errors/CloudWorkspaceRegistrationError'; +import { SystemLogger } from '../../../../../server/lib/logger/system'; +import { settings } from '../../../../settings/server'; +import type { WorkspaceRegistrationData } from '../buildRegistrationData'; +import { buildWorkspaceRegistrationData } from '../buildRegistrationData'; +import { getWorkspaceAccessToken } from '../getWorkspaceAccessToken'; +import { getWorkspaceLicense } from '../getWorkspaceLicense'; +import { retrieveRegistrationStatus } from '../retrieveRegistrationStatus'; +import { handleBannerOnWorkspaceSync, handleNpsOnWorkspaceSync } from './handleCommsSync'; + +const workspaceClientPayloadSchema = v.object({ + workspaceId: v.string().required(), + publicKey: v.string(), + trial: v.object({ + trialing: v.boolean().required(), + trialID: v.string().required(), + endDate: v.string().format('date-time').required(), + marketing: v + .object({ + utmContent: v.string().required(), + utmMedium: v.string().required(), + utmSource: v.string().required(), + utmCampaign: v.string().required(), + }) + .required(), + DowngradesToPlan: v + .object({ + id: v.string().required(), + }) + .required(), + trialRequested: v.boolean().required(), + }), + nps: v.object({ + id: v.string().required(), + startAt: v.string().format('date-time').required(), + expireAt: v.string().format('date-time').required(), + }), + banners: v.array( + v.object({ + _id: v.string().required(), + _updatedAt: v.string().format('date-time').required(), + platform: v.array(v.string()).required(), + expireAt: v.string().format('date-time').required(), + startAt: v.string().format('date-time').required(), + roles: v.array(v.string()), + createdBy: v.object({ + _id: v.string().required(), + username: v.string(), + }), + createdAt: v.string().format('date-time').required(), + view: v.any(), + active: v.boolean(), + inactivedAt: v.string().format('date-time'), + snapshot: v.string(), + }), + ), + announcements: v.object({ + create: v.array( + v.object({ + _id: v.string().required(), + _updatedAt: v.string().format('date-time').required(), + selector: v.object({ + roles: v.array(v.string()), + }), + platform: v.array(v.string().enum('web', 'mobile')).required(), + expireAt: v.string().format('date-time').required(), + startAt: v.string().format('date-time').required(), + createdBy: v.string().enum('cloud', 'system').required(), + createdAt: v.string().format('date-time').required(), + dictionary: v.object({}).additional(v.object({}).additional(v.string())), + view: v.any(), + surface: v.string().enum('banner', 'modal').required(), + }), + ), + delete: v.array(v.string()), + }), +}); + +const assertWorkspaceClientPayload = compile(workspaceClientPayloadSchema); + +/** @deprecated */ +const fetchWorkspaceClientPayload = async ({ + token, + workspaceRegistrationData, +}: { + token: string; + workspaceRegistrationData: WorkspaceRegistrationData; +}): Promise | undefined> => { + const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); + const response = await fetch(`${workspaceRegistrationClientUri}/client`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: workspaceRegistrationData, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } + } + + const payload = await response.json(); + + if (!payload) { + return undefined; + } + + if (!assertWorkspaceClientPayload(payload)) { + throw new CloudWorkspaceConnectionError('Invalid response from Rocket.Chat Cloud'); + } + + return payload; +}; + +/** @deprecated */ +const consumeWorkspaceSyncPayload = async (result: Serialized) => { + if (result.publicKey) { + await Settings.updateValueById('Cloud_Workspace_PublicKey', result.publicKey); + } + + if (result.trial?.trialID) { + await Settings.updateValueById('Cloud_Workspace_Had_Trial', true); + } + + // add banners + if (result.banners) { + await handleBannerOnWorkspaceSync(result.banners); + } + + if (result.nps) { + await handleNpsOnWorkspaceSync(result.nps); + } +}; + +/** @deprecated */ +export async function legacySyncWorkspace() { + try { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (!workspaceRegistered) { + throw new CloudWorkspaceRegistrationError('Workspace is not registered'); + } + + const token = await getWorkspaceAccessToken(true); + if (!token) { + throw new CloudWorkspaceAccessError('Workspace does not have a valid access token'); + } + + const workspaceRegistrationData = await buildWorkspaceRegistrationData(undefined); + + const payload = await fetchWorkspaceClientPayload({ token, workspaceRegistrationData }); + + if (!payload) { + return true; + } + + await consumeWorkspaceSyncPayload(payload); + + return true; + } catch (err) { + SystemLogger.error({ + msg: 'Failed to sync with Rocket.Chat Cloud', + url: '/client', + err, + }); + + return false; + } finally { + await getWorkspaceLicense(); + } +} diff --git a/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts b/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts index 0dc56f31c5da..5f529a4892ec 100644 --- a/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts +++ b/apps/meteor/app/cloud/server/functions/syncWorkspace/syncCloudData.ts @@ -1,85 +1,87 @@ -import { NPS, Banner } from '@rocket.chat/core-services'; -import { Settings } from '@rocket.chat/models'; +import type { Cloud, Serialized } from '@rocket.chat/core-typings'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { v, compile } from 'suretype'; +import { callbacks } from '../../../../../lib/callbacks'; +import { CloudWorkspaceAccessError } from '../../../../../lib/errors/CloudWorkspaceAccessError'; +import { CloudWorkspaceConnectionError } from '../../../../../lib/errors/CloudWorkspaceConnectionError'; +import { CloudWorkspaceRegistrationError } from '../../../../../lib/errors/CloudWorkspaceRegistrationError'; import { SystemLogger } from '../../../../../server/lib/logger/system'; -import { getAndCreateNpsSurvey } from '../../../../../server/services/nps/getAndCreateNpsSurvey'; import { settings } from '../../../../settings/server'; import { buildWorkspaceRegistrationData } from '../buildRegistrationData'; -import { generateWorkspaceBearerHttpHeaderOrThrow } from '../getWorkspaceAccessToken'; -import { handleResponse } from '../supportedVersionsToken/supportedVersionsToken'; - -export async function syncCloudData() { - const info = await buildWorkspaceRegistrationData(undefined); - - const token = await generateWorkspaceBearerHttpHeaderOrThrow(true); - - const request = await handleResponse( - fetch(`${settings.get('Cloud_Workspace_Registration_Client_Uri')}/client`, { - headers: { - ...token, - }, - body: info, - method: 'POST', - }), - ); - - if (!request.success) { - return SystemLogger.error({ - msg: 'Failed to sync with Rocket.Chat Cloud', - url: '/client', - err: request.error, - }); - } - - const data = request.result as any; - if (!data) { - return true; +import { getWorkspaceAccessToken } from '../getWorkspaceAccessToken'; +import { retrieveRegistrationStatus } from '../retrieveRegistrationStatus'; +import { legacySyncWorkspace } from './legacySyncWorkspace'; + +const workspaceSyncPayloadSchema = v.object({ + workspaceId: v.string().required(), + publicKey: v.string(), + license: v.string().required(), +}); + +const assertWorkspaceSyncPayload = compile(workspaceSyncPayloadSchema); + +const fetchWorkspaceSyncPayload = async ({ + token, + data, +}: { + token: string; + data: Cloud.WorkspaceSyncRequestPayload; +}): Promise> => { + const workspaceRegistrationClientUri = settings.get('Cloud_Workspace_Registration_Client_Uri'); + const response = await fetch(`${workspaceRegistrationClientUri}/sync`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: data, + }); + + if (!response.ok) { + try { + const { error } = await response.json(); + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${error}`); + } catch (error) { + throw new CloudWorkspaceConnectionError(`Failed to connect to Rocket.Chat Cloud: ${response.statusText}`); + } } - if (data.publicKey) { - await Settings.updateValueById('Cloud_Workspace_PublicKey', data.publicKey); - } + const payload = await response.json(); - if (data.trial?.trialId) { - await Settings.updateValueById('Cloud_Workspace_Had_Trial', true); - } + assertWorkspaceSyncPayload(payload); - if (data.nps) { - const { id: npsId, expireAt } = data.nps; + return payload; +}; - const startAt = new Date(data.nps.startAt); +export async function syncCloudData() { + try { + const { workspaceRegistered } = await retrieveRegistrationStatus(); + if (!workspaceRegistered) { + throw new CloudWorkspaceRegistrationError('Workspace is not registered'); + } - await NPS.create({ - npsId, - startAt, - expireAt: new Date(expireAt), - createdBy: { - _id: 'rocket.cat', - username: 'rocket.cat', - }, - }); + const token = await getWorkspaceAccessToken(true); + if (!token) { + throw new CloudWorkspaceAccessError('Workspace does not have a valid access token'); + } - const now = new Date(); + const workspaceRegistrationData = await buildWorkspaceRegistrationData(undefined); - if (startAt.getFullYear() === now.getFullYear() && startAt.getMonth() === now.getMonth() && startAt.getDate() === now.getDate()) { - await getAndCreateNpsSurvey(npsId); - } - } + const { license } = await fetchWorkspaceSyncPayload({ + token, + data: workspaceRegistrationData, + }); - // add banners - if (data.banners) { - for await (const banner of data.banners) { - const { createdAt, expireAt, startAt } = banner; + await callbacks.run('workspaceLicenseChanged', license); - await Banner.create({ - ...banner, - createdAt: new Date(createdAt), - expireAt: new Date(expireAt), - startAt: new Date(startAt), - }); - } + return true; + } catch (err) { + SystemLogger.error({ + msg: 'Failed to sync with Rocket.Chat Cloud', + url: '/sync', + err, + }); } - return true; + await legacySyncWorkspace(); } diff --git a/apps/meteor/app/cloud/server/functions/userLogout.ts b/apps/meteor/app/cloud/server/functions/userLogout.ts index 7dd4aa094535..386137ced604 100644 --- a/apps/meteor/app/cloud/server/functions/userLogout.ts +++ b/apps/meteor/app/cloud/server/functions/userLogout.ts @@ -26,10 +26,11 @@ export async function userLogout(userId: string): Promise { return ''; } - const cloudUrl = settings.get('Cloud_Url'); const clientSecret = settings.get('Cloud_Workspace_Client_Secret'); const { refreshToken } = user.services.cloud; + + const cloudUrl = settings.get('Cloud_Url'); await fetch(`${cloudUrl}/api/oauth/revoke`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, diff --git a/apps/meteor/app/cors/server/cors.ts b/apps/meteor/app/cors/server/cors.ts index 03a42e45a17b..cb6fa94273a2 100644 --- a/apps/meteor/app/cors/server/cors.ts +++ b/apps/meteor/app/cors/server/cors.ts @@ -1,4 +1,6 @@ +import { createHash } from 'crypto'; import type http from 'http'; +import type { UrlWithParsedQuery } from 'url'; import url from 'url'; import { Logger } from '@rocket.chat/logger'; @@ -77,6 +79,19 @@ WebApp.rawConnectHandlers.use((_req: http.IncomingMessage, res: http.ServerRespo }); const _staticFilesMiddleware = WebAppInternals.staticFilesMiddleware; +declare module 'meteor/webapp' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace WebApp { + function categorizeRequest( + req: http.IncomingMessage, + ): { arch: string; path: string; url: UrlWithParsedQuery } & Record; + } +} + +// These routes already handle cache control on their own +const cacheControlledRoutes: Array = ['/assets', '/custom-sounds', '/emoji-custom', '/avatar', '/file-upload'].map( + (route) => new RegExp(`^${route}`, 'i'), +); // @ts-expect-error - accessing internal property of webapp WebAppInternals.staticFilesMiddleware = function ( @@ -86,6 +101,32 @@ WebAppInternals.staticFilesMiddleware = function ( next: NextFunction, ) { res.setHeader('Access-Control-Allow-Origin', '*'); + const { arch, path, url } = WebApp.categorizeRequest(req); + + if (Meteor.isProduction && !cacheControlledRoutes.some((regexp) => regexp.test(path))) { + res.setHeader('Cache-Control', 'public, max-age=31536000'); + } + + // Prevent meteor_runtime_config.js to load from a different expected hash possibly causing + // a cache of the file for the wrong hash and start a client loop due to the mismatch + // of the hashes of ui versions which would be checked against a websocket response + if (path === '/meteor_runtime_config.js') { + const program = WebApp.clientPrograms[arch] as (typeof WebApp.clientPrograms)[string] & { + meteorRuntimeConfigHash?: string; + meteorRuntimeConfig: string; + }; + + if (!program?.meteorRuntimeConfigHash) { + program.meteorRuntimeConfigHash = createHash('sha1') + .update(JSON.stringify(encodeURIComponent(program.meteorRuntimeConfig))) + .digest('hex'); + } + + if (program.meteorRuntimeConfigHash !== url.query.hash) { + res.writeHead(404); + return res.end(); + } + } return _staticFilesMiddleware(staticFiles, req, res, next); }; diff --git a/apps/meteor/app/custom-sounds/client/index.ts b/apps/meteor/app/custom-sounds/client/index.ts index d1154824c78c..95992988ccfb 100644 --- a/apps/meteor/app/custom-sounds/client/index.ts +++ b/apps/meteor/app/custom-sounds/client/index.ts @@ -1,21 +1 @@ -import { Meteor } from 'meteor/meteor'; - -import { Notifications } from '../../notifications/client'; -import { CachedCollectionManager } from '../../ui-cached-collection/client'; -import { CustomSounds } from './lib/CustomSounds'; - -Meteor.startup(() => { - CachedCollectionManager.onLogin(() => { - Notifications.onAll('public-info', ([key, data]) => { - switch (key) { - case 'updateCustomSound': - CustomSounds.update(data[0].soundData); - break; - case 'deleteCustomSound': - CustomSounds.remove(data[0].soundData); - break; - } - }); - }); -}); export { CustomSounds } from './lib/CustomSounds'; diff --git a/apps/meteor/app/custom-sounds/client/lib/CustomSounds.ts b/apps/meteor/app/custom-sounds/client/lib/CustomSounds.ts index a4f59136a1f9..f925caf7f809 100644 --- a/apps/meteor/app/custom-sounds/client/lib/CustomSounds.ts +++ b/apps/meteor/app/custom-sounds/client/lib/CustomSounds.ts @@ -1,34 +1,36 @@ import type { ICustomSound } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; -import { CachedCollectionManager } from '../../../ui-cached-collection/client'; import { getURL } from '../../../utils/client'; import { sdk } from '../../../utils/client/lib/SDKClient'; const getCustomSoundId = (soundId: ICustomSound['_id']) => `custom-sound-${soundId}`; +const getAssetUrl = (asset: string, params?: Record) => getURL(asset, params, undefined, true); const defaultSounds = [ - { _id: 'chime', name: 'Chime', extension: 'mp3', src: getURL('sounds/chime.mp3') }, - { _id: 'door', name: 'Door', extension: 'mp3', src: getURL('sounds/door.mp3') }, - { _id: 'beep', name: 'Beep', extension: 'mp3', src: getURL('sounds/beep.mp3') }, - { _id: 'chelle', name: 'Chelle', extension: 'mp3', src: getURL('sounds/chelle.mp3') }, - { _id: 'ding', name: 'Ding', extension: 'mp3', src: getURL('sounds/ding.mp3') }, - { _id: 'droplet', name: 'Droplet', extension: 'mp3', src: getURL('sounds/droplet.mp3') }, - { _id: 'highbell', name: 'Highbell', extension: 'mp3', src: getURL('sounds/highbell.mp3') }, - { _id: 'seasons', name: 'Seasons', extension: 'mp3', src: getURL('sounds/seasons.mp3') }, - { _id: 'telephone', name: 'Telephone', extension: 'mp3', src: getURL('sounds/telephone.mp3') }, - { _id: 'outbound-call-ringing', name: 'Outbound Call Ringing', extension: 'mp3', src: getURL('sounds/outbound-call-ringing.mp3') }, - { _id: 'call-ended', name: 'Call Ended', extension: 'mp3', src: getURL('sounds/call-ended.mp3') }, - { _id: 'dialtone', name: 'Dialtone', extension: 'mp3', src: getURL('sounds/dialtone.mp3') }, - { _id: 'ringtone', name: 'Ringtone', extension: 'mp3', src: getURL('sounds/ringtone.mp3') }, + { _id: 'chime', name: 'Chime', extension: 'mp3', src: getAssetUrl('sounds/chime.mp3') }, + { _id: 'door', name: 'Door', extension: 'mp3', src: getAssetUrl('sounds/door.mp3') }, + { _id: 'beep', name: 'Beep', extension: 'mp3', src: getAssetUrl('sounds/beep.mp3') }, + { _id: 'chelle', name: 'Chelle', extension: 'mp3', src: getAssetUrl('sounds/chelle.mp3') }, + { _id: 'ding', name: 'Ding', extension: 'mp3', src: getAssetUrl('sounds/ding.mp3') }, + { _id: 'droplet', name: 'Droplet', extension: 'mp3', src: getAssetUrl('sounds/droplet.mp3') }, + { _id: 'highbell', name: 'Highbell', extension: 'mp3', src: getAssetUrl('sounds/highbell.mp3') }, + { _id: 'seasons', name: 'Seasons', extension: 'mp3', src: getAssetUrl('sounds/seasons.mp3') }, + { _id: 'telephone', name: 'Telephone', extension: 'mp3', src: getAssetUrl('sounds/telephone.mp3') }, + { _id: 'outbound-call-ringing', name: 'Outbound Call Ringing', extension: 'mp3', src: getAssetUrl('sounds/outbound-call-ringing.mp3') }, + { _id: 'call-ended', name: 'Call Ended', extension: 'mp3', src: getAssetUrl('sounds/call-ended.mp3') }, + { _id: 'dialtone', name: 'Dialtone', extension: 'mp3', src: getAssetUrl('sounds/dialtone.mp3') }, + { _id: 'ringtone', name: 'Ringtone', extension: 'mp3', src: getAssetUrl('sounds/ringtone.mp3') }, ]; class CustomSoundsClass { list: ReactiveVar>; + initialFetchDone: boolean; + constructor() { this.list = new ReactiveVar({}); + this.initialFetchDone = false; defaultSounds.forEach((sound) => this.add(sound)); } @@ -85,7 +87,7 @@ class CustomSoundsClass { } getURL(sound: ICustomSound) { - return getURL(`/custom-sounds/${sound._id}.${sound.extension}?_dc=${sound.random || 0}`); + return getAssetUrl(`/custom-sounds/${sound._id}.${sound.extension}`, { _dc: sound.random || 0 }); } getList() { @@ -129,15 +131,17 @@ class CustomSoundsClass { return audio && audio.duration > 0 && !audio.paused; }; -} - -export const CustomSounds = new CustomSoundsClass(); -Meteor.startup(() => - CachedCollectionManager.onLogin(async () => { + fetchCustomSoundList = async () => { + if (this.initialFetchDone) { + return; + } const result = await sdk.call('listCustomSounds'); for (const sound of result) { - CustomSounds.add(sound); + this.add(sound); } - }), -); + this.initialFetchDone = true; + }; +} + +export const CustomSounds = new CustomSoundsClass(); diff --git a/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts b/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts index 5eb2ef38e5b7..3ad61c4c42f0 100644 --- a/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts +++ b/apps/meteor/app/discussion/client/createDiscussionMessageAction.ts @@ -59,7 +59,7 @@ Meteor.startup(() => { return false; } - return uid !== user._id ? hasPermission('start-discussion-other-user') : hasPermission('start-discussion'); + return uid !== user._id ? hasPermission('start-discussion-other-user', room._id) : hasPermission('start-discussion', room._id); }, order: 1, group: 'menu', diff --git a/apps/meteor/app/discussion/server/methods/createDiscussion.ts b/apps/meteor/app/discussion/server/methods/createDiscussion.ts index ce5c09947a60..c3869f8ff963 100644 --- a/apps/meteor/app/discussion/server/methods/createDiscussion.ts +++ b/apps/meteor/app/discussion/server/methods/createDiscussion.ts @@ -156,7 +156,7 @@ const create = async ({ const discussion = await createRoom( type, name, - user.username as string, + user, [...new Set(invitedUsers)].filter(Boolean), false, false, @@ -221,7 +221,7 @@ export const createDiscussion = async ( }); } - if (!(await hasAtLeastOnePermissionAsync(userId, ['start-discussion', 'start-discussion-other-user']))) { + if (!(await hasAtLeastOnePermissionAsync(userId, ['start-discussion', 'start-discussion-other-user'], prid))) { throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' }); } const user = await Users.findOneById(userId); diff --git a/apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.js b/apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.js deleted file mode 100644 index 2da490942fdc..000000000000 --- a/apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.js +++ /dev/null @@ -1,3 +0,0 @@ -import { settings } from '../../../settings/server'; - -export const getFederationDiscoveryMethod = () => settings.get('FEDERATION_Discovery_Method'); diff --git a/apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.ts b/apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.ts new file mode 100644 index 000000000000..b8ea8c4f6ce6 --- /dev/null +++ b/apps/meteor/app/federation/server/lib/getFederationDiscoveryMethod.ts @@ -0,0 +1,3 @@ +import { settings } from '../../../settings/server'; + +export const getFederationDiscoveryMethod = () => settings.get('FEDERATION_Discovery_Method'); diff --git a/apps/meteor/app/federation/server/lib/getFederationDomain.js b/apps/meteor/app/federation/server/lib/getFederationDomain.js deleted file mode 100644 index c5e67629db75..000000000000 --- a/apps/meteor/app/federation/server/lib/getFederationDomain.js +++ /dev/null @@ -1,3 +0,0 @@ -import { settings } from '../../../settings/server'; - -export const getFederationDomain = () => settings.get('FEDERATION_Domain').replace('@', ''); diff --git a/apps/meteor/app/federation/server/lib/getFederationDomain.ts b/apps/meteor/app/federation/server/lib/getFederationDomain.ts new file mode 100644 index 000000000000..80f683743f2d --- /dev/null +++ b/apps/meteor/app/federation/server/lib/getFederationDomain.ts @@ -0,0 +1,3 @@ +import { settings } from '../../../settings/server'; + +export const getFederationDomain = () => settings.get('FEDERATION_Domain').replace('@', ''); diff --git a/apps/meteor/app/federation/server/lib/isFederationEnabled.js b/apps/meteor/app/federation/server/lib/isFederationEnabled.js deleted file mode 100644 index 9e46d3004ace..000000000000 --- a/apps/meteor/app/federation/server/lib/isFederationEnabled.js +++ /dev/null @@ -1,3 +0,0 @@ -import { settings } from '../../../settings/server'; - -export const isFederationEnabled = () => settings.get('FEDERATION_Enabled'); diff --git a/apps/meteor/app/federation/server/lib/isFederationEnabled.ts b/apps/meteor/app/federation/server/lib/isFederationEnabled.ts new file mode 100644 index 000000000000..e3edb818e602 --- /dev/null +++ b/apps/meteor/app/federation/server/lib/isFederationEnabled.ts @@ -0,0 +1,3 @@ +import { settings } from '../../../settings/server'; + +export const isFederationEnabled = () => settings.get('FEDERATION_Enabled'); diff --git a/apps/meteor/app/federation/server/lib/logger.js b/apps/meteor/app/federation/server/lib/logger.ts similarity index 100% rename from apps/meteor/app/federation/server/lib/logger.js rename to apps/meteor/app/federation/server/lib/logger.ts diff --git a/apps/meteor/app/file-upload/server/config/AmazonS3.ts b/apps/meteor/app/file-upload/server/config/AmazonS3.ts index b97ff60d86d6..567e5e5d71eb 100644 --- a/apps/meteor/app/file-upload/server/config/AmazonS3.ts +++ b/apps/meteor/app/file-upload/server/config/AmazonS3.ts @@ -32,12 +32,15 @@ const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, const copy: FileUploadClass['copy'] = async function (this: FileUploadClass, file, out) { const fileUrl = await this.store.getRedirectURL(file); - if (fileUrl) { - const request = /^https:/.test(fileUrl) ? https : http; - request.get(fileUrl, (fileRes) => fileRes.pipe(out)); - } else { + if (!fileUrl) { out.end(); + return; } + + const request = /^https:/.test(fileUrl) ? https : http; + return new Promise((resolve) => { + request.get(fileUrl, (fileRes) => fileRes.pipe(out).on('finish', () => resolve())); + }); }; const AmazonS3Uploads = new FileUploadClass({ diff --git a/apps/meteor/app/file-upload/server/config/GoogleStorage.ts b/apps/meteor/app/file-upload/server/config/GoogleStorage.ts index 124bad4365a0..41eb4350b876 100644 --- a/apps/meteor/app/file-upload/server/config/GoogleStorage.ts +++ b/apps/meteor/app/file-upload/server/config/GoogleStorage.ts @@ -32,12 +32,15 @@ const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, const copy: FileUploadClass['copy'] = async function (this: FileUploadClass, file, out) { const fileUrl = await this.store.getRedirectURL(file, false); - if (fileUrl) { - const request = /^https:/.test(fileUrl) ? https : http; - request.get(fileUrl, (fileRes) => fileRes.pipe(out)); - } else { + if (!fileUrl) { out.end(); + return; } + + const request = /^https:/.test(fileUrl) ? https : http; + return new Promise((resolve) => { + request.get(fileUrl, (fileRes) => fileRes.pipe(out).on('finish', () => resolve())); + }); }; const GoogleCloudStorageUploads = new FileUploadClass({ diff --git a/apps/meteor/app/file-upload/server/config/Webdav.ts b/apps/meteor/app/file-upload/server/config/Webdav.ts index fb8c1ca82ca4..901c74e9c149 100644 --- a/apps/meteor/app/file-upload/server/config/Webdav.ts +++ b/apps/meteor/app/file-upload/server/config/Webdav.ts @@ -19,7 +19,9 @@ const get: FileUploadClass['get'] = async function (this: FileUploadClass, file, }; const copy: FileUploadClass['copy'] = async function (this: FileUploadClass, file, out) { - (await this.store.getReadStream(file._id, file)).pipe(out); + return new Promise(async (resolve) => { + (await this.store.getReadStream(file._id, file)).pipe(out).on('finish', () => resolve()); + }); }; const WebdavUploads = new FileUploadClass({ diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 8f929a17fe34..e512e5d09bfe 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -562,7 +562,32 @@ export const FileUpload = { ) { res.setHeader('Content-Disposition', `${forceDownload ? 'attachment' : 'inline'}; filename="${encodeURI(fileName)}"`); - request.get(fileUrl, (fileRes) => fileRes.pipe(res)); + request.get(fileUrl, (fileRes) => { + if (fileRes.statusCode !== 200) { + res.setHeader('x-rc-proxyfile-status', String(fileRes.statusCode)); + res.setHeader('content-length', 0); + res.writeHead(500); + res.end(); + return; + } + + // eslint-disable-next-line prettier/prettier + const headersToProxy = [ + 'age', + 'cache-control', + 'content-length', + 'content-type', + 'date', + 'expired', + 'last-modified', + ]; + + headersToProxy.forEach((header) => { + fileRes.headers[header] && res.setHeader(header, String(fileRes.headers[header])); + }); + + fileRes.pipe(res); + }); }, generateJWTToFileUrls({ rid, userId, fileId }: { rid: string; userId: string; fileId: string }) { diff --git a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts index b9f0807b6112..d6b69faf75fa 100644 --- a/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts +++ b/apps/meteor/app/file-upload/ufs/AmazonS3/server.ts @@ -80,7 +80,7 @@ class AmazonS3Store extends UploadFS.Store { ResponseContentDisposition: `${forceDownload ? 'attachment' : 'inline'}; filename="${encodeURI(file.name || '')}"`, }; - return s3.getSignedUrl('getObject', params); + return s3.getSignedUrlPromise('getObject', params); }; /** diff --git a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts index f241879cdc67..1b596d625d9b 100644 --- a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts +++ b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts @@ -1034,7 +1034,11 @@ export class ImportDataConverter { return; } if (roomData.t === 'p') { - roomInfo = await createPrivateGroupMethod(startedByUserId, roomData.name, members, false, {}, {}, true); + const user = await Users.findOneById(startedByUserId); + if (!user) { + throw new Error('importer-channel-invalid-creator'); + } + roomInfo = await createPrivateGroupMethod(user, roomData.name, members, false, {}, {}, true); } else { roomInfo = await createChannelMethod(startedByUserId, roomData.name, members, false, {}, {}, true); } diff --git a/apps/meteor/app/irc/server/irc-bridge/peerHandlers/joinedChannel.js b/apps/meteor/app/irc/server/irc-bridge/peerHandlers/joinedChannel.js index 0968eacc5340..bb5053ffdd71 100644 --- a/apps/meteor/app/irc/server/irc-bridge/peerHandlers/joinedChannel.js +++ b/apps/meteor/app/irc/server/irc-bridge/peerHandlers/joinedChannel.js @@ -16,7 +16,7 @@ export default async function handleJoinedChannel(args) { let room = await Rooms.findOneByName(args.roomName); if (!room) { - const createdRoom = await createRoom('c', args.roomName, user.username, []); + const createdRoom = await createRoom('c', args.roomName, user, []); room = await Rooms.findOne({ _id: createdRoom.rid }); this.log(`${user.username} created room ${args.roomName}`); diff --git a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts index ad632a3b7dfc..835f59419ad5 100644 --- a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts +++ b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts @@ -12,7 +12,7 @@ export const addUserToDefaultChannels = async function (user: IUser, silenced?: }).toArray(); for await (const room of defaultRooms) { if (!(await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1 } }))) { - const autoTranslateConfig = await getSubscriptionAutotranslateDefaultConfig(user); + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(user); // Add a subscription to this user await Subscriptions.createWithRoomAndUser(room, user, { ts: new Date(), diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 41000cda2038..4e29576cf3bb 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -71,7 +71,7 @@ export const addUserToRoom = async function ( await callbacks.run('beforeJoinRoom', userToBeAdded, room); } - const autoTranslateConfig = await getSubscriptionAutotranslateDefaultConfig(userToBeAdded); + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); await Subscriptions.createWithRoomAndUser(room, userToBeAdded as IUser, { ts: now, diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 30cf2a593700..312451f54845 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -10,7 +10,6 @@ import { Apps } from '../../../../ee/server/apps/orchestrator'; import { callbacks } from '../../../../lib/callbacks'; import { beforeCreateRoomCallback } from '../../../../lib/callbacks/beforeCreateRoomCallback'; import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; -import { addUserRolesAsync } from '../../../../server/lib/roles/addUserRoles'; import { getValidRoomName } from '../../../utils/server/lib/getValidRoomName'; import { createDirectRoom } from './createDirectRoom'; @@ -21,10 +20,90 @@ const isValidName = (name: unknown): name is string => { const onlyUsernames = (members: unknown): members is string[] => Array.isArray(members) && members.every((member) => typeof member === 'string'); +async function createUsersSubscriptions({ + room, + shouldBeHandledByFederation, + members, + now, + owner, + options, +}: { + room: IRoom; + shouldBeHandledByFederation: boolean; + members: string[]; + now: Date; + owner: IUser; + options?: ICreateRoomParams['options']; +}) { + if (shouldBeHandledByFederation) { + const extra: Partial = options?.subscriptionExtra || {}; + extra.open = true; + extra.ls = now; + + if (room.prid) { + extra.prid = room.prid; + } + + await Subscriptions.createWithRoomAndUser(room, owner, extra); + + return; + } + + const subs = []; + + const memberIds = []; + + const membersCursor = Users.findUsersByUsernames>(members, { + projection: { 'username': 1, 'settings.preferences': 1, 'federated': 1, 'roles': 1 }, + }); + + for await (const member of membersCursor) { + try { + await callbacks.run('federation.beforeAddUserToARoom', { user: member, inviter: owner }, room); + await callbacks.run('beforeAddedToRoom', { user: member, inviter: owner }); + } catch (error) { + continue; + } + + memberIds.push(member._id); + + const extra: Partial = options?.subscriptionExtra || {}; + + extra.open = true; + + if (room.prid) { + extra.prid = room.prid; + } + + if (member.username === owner.username) { + extra.ls = now; + extra.roles = ['owner']; + } + + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(member); + + subs.push({ + user: member, + extraData: { + ...extra, + ...autoTranslateConfig, + }, + }); + } + + if (!['d', 'l'].includes(room.t)) { + await Users.addRoomByUserIds(memberIds, room._id); + } + + await Subscriptions.createWithRoomAndManyUsers(room, subs); + + await Rooms.incUsersCountById(room._id, subs.length); +} + export const createRoom = async ( type: T, name: T extends 'd' ? undefined : string, - ownerUsername: string | undefined, + owner: T extends 'd' ? IUser | undefined : IUser, members: T extends 'd' ? IUser[] : string[] = [], excludeSelf?: boolean, readOnly?: boolean, @@ -47,7 +126,7 @@ export const createRoom = async ( // options, }); if (type === 'd') { - return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || ownerUsername }); + return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?.username }); } if (!onlyUsernames(members)) { @@ -63,15 +142,13 @@ export const createRoom = async ( }); } - if (!ownerUsername) { + if (!owner) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: 'RocketChat.createRoom', }); } - const owner = await Users.findOneByUsernameIgnoringCase(ownerUsername, { projection: { username: 1, name: 1 } }); - - if (!ownerUsername || !owner) { + if (!owner?.username) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: 'RocketChat.createRoom', }); @@ -140,53 +217,12 @@ export const createRoom = async ( if (type === 'c') { await callbacks.run('beforeCreateChannel', owner, roomProps); } - const room = await Rooms.createWithFullRoomData(roomProps); - const shouldBeHandledByFederation = room.federated === true || ownerUsername.includes(':'); - if (shouldBeHandledByFederation) { - const extra: Partial = options?.subscriptionExtra || {}; - extra.open = true; - extra.ls = now; - if (room.prid) { - extra.prid = room.prid; - } - - await Subscriptions.createWithRoomAndUser(room, owner, extra); - } else { - for await (const username of [...new Set(members)]) { - const member = await Users.findOneByUsername(username, { - projection: { 'username': 1, 'settings.preferences': 1, 'federated': 1, 'roles': 1 }, - }); - if (!member) { - continue; - } - - try { - await callbacks.run('federation.beforeAddUserToARoom', { user: member, inviter: owner }, room); - await callbacks.run('beforeAddedToRoom', { user: member, inviter: owner }); - } catch (error) { - continue; - } - - const extra: Partial = options?.subscriptionExtra || {}; - - extra.open = true; - - if (room.prid) { - extra.prid = room.prid; - } - - if (username === owner.username) { - extra.ls = now; - } - - const autoTranslateConfig = await getSubscriptionAutotranslateDefaultConfig(member); + const room = await Rooms.createWithFullRoomData(roomProps); - await Subscriptions.createWithRoomAndUser(room, member, { ...extra, ...autoTranslateConfig }); - } - } + const shouldBeHandledByFederation = room.federated === true || owner.username.includes(':'); - await addUserRolesAsync(owner._id, ['owner'], room._id); + await createUsersSubscriptions({ room, members, now, owner, options, shouldBeHandledByFederation }); if (type === 'c') { if (room.teamId) { @@ -195,7 +231,7 @@ export const createRoom = async ( await Message.saveSystemMessage('user-added-room-to-team', team.roomId, room.name || '', owner); } } - await callbacks.run('afterCreateChannel', owner, room); + callbacks.runAsync('afterCreateChannel', owner, room); } else if (type === 'p') { callbacks.runAsync('afterCreatePrivateGroup', owner, room); } diff --git a/apps/meteor/app/lib/server/functions/processWebhookMessage.js b/apps/meteor/app/lib/server/functions/processWebhookMessage.ts similarity index 69% rename from apps/meteor/app/lib/server/functions/processWebhookMessage.js rename to apps/meteor/app/lib/server/functions/processWebhookMessage.ts index 1e5c34b287bb..4dafc6211015 100644 --- a/apps/meteor/app/lib/server/functions/processWebhookMessage.js +++ b/apps/meteor/app/lib/server/functions/processWebhookMessage.ts @@ -1,15 +1,47 @@ +import type { IMessage, IUser, RequiredField, MessageAttachment } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import _ from 'underscore'; +import { ensureArray } from '../../../../lib/utils/arrayUtils'; import { trim } from '../../../../lib/utils/stringUtils'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { validateRoomMessagePermissionsAsync } from '../../../authorization/server/functions/canSendMessage'; import { getRoomByNameOrIdWithOptionToJoin } from './getRoomByNameOrIdWithOptionToJoin'; import { sendMessage } from './sendMessage'; -export const processWebhookMessage = async function (messageObj, user, defaultValues = { channel: '', alias: '', avatar: '', emoji: '' }) { +type Payload = { + channel?: string | string[]; + roomId?: string | string[]; + text?: IMessage['msg']; + msg?: IMessage['msg']; // overridden if text is present + username?: IMessage['alias']; + alias?: IMessage['alias']; // overridden if username is present + icon_emoji?: IMessage['emoji']; + emoji?: IMessage['emoji']; // overridden if icon_emoji is present + icon_url?: IMessage['avatar']; + avatar?: IMessage['avatar']; // overridden if icon_url is present + attachments?: IMessage['attachments']; + parseUrls?: boolean; + bot?: IMessage['bot']; + groupable?: IMessage['groupable']; + tmid?: IMessage['tmid']; +}; + +type DefaultValues = { + channel: string | string[]; + alias: string; + avatar: string; + emoji: string; +}; + +export const processWebhookMessage = async function ( + messageObj: Payload, + user: IUser & { username: RequiredField }, + defaultValues: DefaultValues = { channel: '', alias: '', avatar: '', emoji: '' }, +) { const sentData = []; - const channels = [].concat(messageObj.channel || messageObj.roomId || defaultValues.channel); + + const channels: Array = [...new Set(ensureArray(messageObj.channel || messageObj.roomId || defaultValues.channel))]; for await (const channel of channels) { const channelType = channel[0]; @@ -69,7 +101,7 @@ export const processWebhookMessage = async function (messageObj, user, defaultVa messageObj.attachments = undefined; } - const message = { + const message: Partial & { parseUrls?: boolean } = { alias: messageObj.username || messageObj.alias || defaultValues.alias, msg: trim(messageObj.text || messageObj.msg || ''), attachments: messageObj.attachments || [], @@ -91,7 +123,7 @@ export const processWebhookMessage = async function (messageObj, user, defaultVa if (Array.isArray(message.attachments)) { for (let i = 0; i < message.attachments.length; i++) { - const attachment = message.attachments[i]; + const attachment = message.attachments[i] as MessageAttachment & { msg?: string }; if (attachment.msg) { attachment.text = trim(attachment.msg); delete attachment.msg; diff --git a/apps/meteor/app/lib/server/functions/saveUser.js b/apps/meteor/app/lib/server/functions/saveUser.js index 42438be4ab7a..46bef4c7d1aa 100644 --- a/apps/meteor/app/lib/server/functions/saveUser.js +++ b/apps/meteor/app/lib/server/functions/saveUser.js @@ -366,6 +366,7 @@ export const saveUser = async function (userId, userData) { _id: userData._id, username: userData.username, name: userData.name, + updateUsernameInBackground: true, })) ) { throw new Meteor.Error('error-could-not-save-identity', 'Could not save user identity', { diff --git a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts index 2eb360e150c6..34ca0ca246db 100644 --- a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts +++ b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts @@ -1,5 +1,7 @@ +import type { IUser } from '@rocket.chat/core-typings'; import { Messages, VideoConference, LivechatDepartmentAgents, Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { SystemLogger } from '../../../../server/lib/logger/system'; import { FileUpload } from '../../../file-upload/server'; import { _setRealName } from './setRealName'; import { _setUsername } from './setUsername'; @@ -11,7 +13,17 @@ import { validateName } from './validateName'; * @param {object} changes changes to the user */ -export async function saveUserIdentity({ _id, name: rawName, username: rawUsername }: { _id: string; name?: string; username?: string }) { +export async function saveUserIdentity({ + _id, + name: rawName, + username: rawUsername, + updateUsernameInBackground = false, +}: { + _id: string; + name?: string; + username?: string; + updateUsernameInBackground?: boolean; // TODO: remove this +}) { if (!_id) { return false; } @@ -48,46 +60,91 @@ export async function saveUserIdentity({ _id, name: rawName, username: rawUserna // if coming from old username, update all references if (previousUsername) { - if (usernameChanged && typeof rawUsername !== 'undefined') { - const fileStore = FileUpload.getStore('Avatars'); - const previousFile = await fileStore.model.findOneByName(previousUsername); - const file = await fileStore.model.findOneByName(username); - if (file) { - await fileStore.model.deleteFile(file._id); - } - if (previousFile) { - await fileStore.model.updateFileNameById(previousFile._id, username); - } - - await Messages.updateAllUsernamesByUserId(user._id, username); - await Messages.updateUsernameOfEditByUserId(user._id, username); - - const cursor = Messages.findByMention(previousUsername); - for await (const msg of cursor) { - const updatedMsg = msg.msg.replace(new RegExp(`@${previousUsername}`, 'ig'), `@${username}`); - await Messages.updateUsernameAndMessageOfMentionByIdAndOldUsername(msg._id, previousUsername, username, updatedMsg); - } - - await Rooms.replaceUsername(previousUsername, username); - await Rooms.replaceMutedUsername(previousUsername, username); - await Rooms.replaceUsernameOfUserByUserId(user._id, username); - await Subscriptions.setUserUsernameByUserId(user._id, username); - - await LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username); + const handleUpdateParams = { + username, + previousUsername, + rawUsername, + usernameChanged, + user, + name, + previousName, + rawName, + nameChanged, + }; + if (updateUsernameInBackground) { + setImmediate(async () => { + try { + await updateUsernameReferences(handleUpdateParams); + } catch (err) { + SystemLogger.error(err); + } + }); + } else { + await updateUsernameReferences(handleUpdateParams); } + } + + return true; +} - // update other references if either the name or username has changed - if (usernameChanged || nameChanged) { - // update name and fname of 1-on-1 direct messages - await Subscriptions.updateDirectNameAndFnameByName(previousUsername, rawUsername && username, rawName && name); +async function updateUsernameReferences({ + username, + previousUsername, + rawUsername, + usernameChanged, + user, + name, + previousName, + rawName, + nameChanged, +}: { + username: string; + previousUsername: string; + rawUsername?: string; + usernameChanged: boolean; + user: IUser; + name: string; + previousName: string | undefined; + rawName?: string; + nameChanged: boolean; +}): Promise { + if (usernameChanged && typeof rawUsername !== 'undefined') { + const fileStore = FileUpload.getStore('Avatars'); + const previousFile = await fileStore.model.findOneByName(previousUsername); + const file = await fileStore.model.findOneByName(username); + if (file) { + await fileStore.model.deleteFile(file._id); + } + if (previousFile) { + await fileStore.model.updateFileNameById(previousFile._id, username); + } - // update name and fname of group direct messages - await updateGroupDMsName(user); + await Messages.updateAllUsernamesByUserId(user._id, username); + await Messages.updateUsernameOfEditByUserId(user._id, username); - // update name and username of users on video conferences - await VideoConference.updateUserReferences(user._id, username || previousUsername, name || previousName); + const cursor = Messages.findByMention(previousUsername); + for await (const msg of cursor) { + const updatedMsg = msg.msg.replace(new RegExp(`@${previousUsername}`, 'ig'), `@${username}`); + await Messages.updateUsernameAndMessageOfMentionByIdAndOldUsername(msg._id, previousUsername, username, updatedMsg); } + + await Rooms.replaceUsername(previousUsername, username); + await Rooms.replaceMutedUsername(previousUsername, username); + await Rooms.replaceUsernameOfUserByUserId(user._id, username); + await Subscriptions.setUserUsernameByUserId(user._id, username); + + await LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username); } - return true; + // update other references if either the name or username has changed + if (usernameChanged || nameChanged) { + // update name and fname of 1-on-1 direct messages + await Subscriptions.updateDirectNameAndFnameByName(previousUsername, rawUsername && username, rawName && name); + + // update name and fname of group direct messages + await updateGroupDMsName(user); + + // update name and username of users on video conferences + await VideoConference.updateUserReferences(user._id, username || previousUsername, name || previousName); + } } diff --git a/apps/meteor/app/lib/server/methods/createChannel.ts b/apps/meteor/app/lib/server/methods/createChannel.ts index ff8182cec8c9..98cea517bed4 100644 --- a/apps/meteor/app/lib/server/methods/createChannel.ts +++ b/apps/meteor/app/lib/server/methods/createChannel.ts @@ -35,8 +35,7 @@ export const createChannelMethod = async ( throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'createChannel' }); } - const user = await Users.findOneById(userId, { projection: { username: 1 } }); - + const user = await Users.findOneById(userId); if (!user?.username) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'createChannel' }); } @@ -44,7 +43,7 @@ export const createChannelMethod = async ( if (!(await hasPermissionAsync(userId, 'create-c'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'createChannel' }); } - return createRoom('c', name, user.username, members, excludeSelf, readOnly, { + return createRoom('c', name, user, members, excludeSelf, readOnly, { customFields, ...extraData, }); diff --git a/apps/meteor/app/lib/server/methods/createPrivateGroup.ts b/apps/meteor/app/lib/server/methods/createPrivateGroup.ts index 65298949a345..75097b5c89b8 100644 --- a/apps/meteor/app/lib/server/methods/createPrivateGroup.ts +++ b/apps/meteor/app/lib/server/methods/createPrivateGroup.ts @@ -1,4 +1,4 @@ -import type { ICreatedRoom } from '@rocket.chat/core-typings'; +import type { ICreatedRoom, IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Match, check } from 'meteor/check'; @@ -21,7 +21,7 @@ declare module '@rocket.chat/ui-contexts' { } export const createPrivateGroupMethod = async ( - userId: string, + user: IUser, name: string, members: string[], readOnly = false, @@ -35,23 +35,12 @@ export const createPrivateGroupMethod = async ( > => { check(name, String); check(members, Match.Optional([String])); - if (!userId) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: 'createPrivateGroup', - }); - } - const user = await Users.findOneById(userId, { projection: { username: 1 } }); - if (!user) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: 'createPrivateGroup', - }); - } - if (!(await hasPermissionAsync(userId, 'create-p'))) { + if (!(await hasPermissionAsync(user._id, 'create-p'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'createPrivateGroup' }); } - return createRoom('p', name, user.username, members, excludeSelf, readOnly, { + return createRoom('p', name, user, members, excludeSelf, readOnly, { customFields, ...extraData, }); @@ -67,6 +56,13 @@ Meteor.methods({ }); } - return createPrivateGroupMethod(uid, name, members, readOnly, customFields, extraData); + const user = await Users.findOneById(uid); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'createPrivateGroup', + }); + } + + return createPrivateGroupMethod(user, name, members, readOnly, customFields, extraData); }, }); diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index ebdcdfd43d9b..e12ebc2d47e9 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -82,7 +82,7 @@ export async function executeSendMessage(uid: IUser['_id'], message: AtLeast({ - sendMessage(message, previewUrls) { + async sendMessage(message, previewUrls) { check(message, Object); const uid = Meteor.userId(); @@ -118,7 +118,7 @@ Meteor.methods({ } try { - return executeSendMessage(uid, message, previewUrls); + return await executeSendMessage(uid, message, previewUrls); } catch (error: any) { if ((error.error || error.message) === 'error-not-allowed') { throw new Meteor.Error(error.error || error.message, error.reason, { diff --git a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts index 28d09958535a..906ace402bb9 100644 --- a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts +++ b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts @@ -8,18 +8,34 @@ import { LivechatInquiry } from '../../collections/LivechatInquiry'; const departments = new Set(); const events = { - added: (inquiry: ILivechatInquiryRecord) => { - departments.has(inquiry.department) && LivechatInquiry.insert({ ...inquiry, alert: true, _updatedAt: new Date(inquiry._updatedAt) }); + added: async (inquiry: ILivechatInquiryRecord) => { + if (!departments.has(inquiry.department)) { + return; + } + + LivechatInquiry.insert({ ...inquiry, alert: true, _updatedAt: new Date(inquiry._updatedAt) }); + await invalidateRoomQueries(inquiry.rid); }, changed: async (inquiry: ILivechatInquiryRecord) => { if (inquiry.status !== 'queued' || (inquiry.department && !departments.has(inquiry.department))) { - return LivechatInquiry.remove(inquiry._id); + return removeInquiry(inquiry); } LivechatInquiry.upsert({ _id: inquiry._id }, { ...inquiry, alert: true, _updatedAt: new Date(inquiry._updatedAt) }); - await queryClient.invalidateQueries(['/v1/rooms.info', inquiry.rid]); + await invalidateRoomQueries(inquiry.rid); }, - removed: (inquiry: ILivechatInquiryRecord) => LivechatInquiry.remove(inquiry._id), + removed: (inquiry: ILivechatInquiryRecord) => removeInquiry(inquiry), +}; + +const invalidateRoomQueries = async (rid: string) => { + await queryClient.invalidateQueries(['rooms', { reference: rid, type: 'l' }]); + await queryClient.removeQueries(['rooms', rid]); + await queryClient.removeQueries(['/v1/rooms.info', rid]); +}; + +const removeInquiry = async (inquiry: ILivechatInquiryRecord) => { + await LivechatInquiry.remove(inquiry._id); + return queryClient.invalidateQueries(['rooms', { reference: inquiry.rid, type: 'l' }]); }; const getInquiriesFromAPI = async () => { diff --git a/apps/meteor/app/livechat/client/views/app/business-hours/BusinessHours.ts b/apps/meteor/app/livechat/client/views/app/business-hours/BusinessHours.ts index 0935ac7554e4..3c723cc46257 100644 --- a/apps/meteor/app/livechat/client/views/app/business-hours/BusinessHours.ts +++ b/apps/meteor/app/livechat/client/views/app/business-hours/BusinessHours.ts @@ -29,10 +29,6 @@ class BusinessHoursManager { showBackButton(): boolean { return this.behavior.showBackButton(); } - - showTimezoneTemplate(): boolean { - return this.behavior.showTimezoneTemplate(); - } } export const businessHourManager = new BusinessHoursManager(new SingleBusinessHourBehavior()); diff --git a/apps/meteor/app/livechat/client/views/app/business-hours/IBusinessHourBehavior.ts b/apps/meteor/app/livechat/client/views/app/business-hours/IBusinessHourBehavior.ts index 10a51fe25e25..1ba6e4a56907 100644 --- a/apps/meteor/app/livechat/client/views/app/business-hours/IBusinessHourBehavior.ts +++ b/apps/meteor/app/livechat/client/views/app/business-hours/IBusinessHourBehavior.ts @@ -4,5 +4,4 @@ export interface IBusinessHourBehavior { getView(): string; showCustomTemplate(businessHourData: ILivechatBusinessHour): boolean; showBackButton(): boolean; - showTimezoneTemplate(): boolean; } diff --git a/apps/meteor/app/livechat/client/views/app/business-hours/Single.ts b/apps/meteor/app/livechat/client/views/app/business-hours/Single.ts index 1ce09a63d79a..9b343264ca54 100644 --- a/apps/meteor/app/livechat/client/views/app/business-hours/Single.ts +++ b/apps/meteor/app/livechat/client/views/app/business-hours/Single.ts @@ -12,8 +12,4 @@ export class SingleBusinessHourBehavior implements IBusinessHourBehavior { showBackButton(): boolean { return false; } - - showTimezoneTemplate(): boolean { - return false; - } } diff --git a/apps/meteor/app/livechat/imports/server/rest/dashboards.ts b/apps/meteor/app/livechat/imports/server/rest/dashboards.ts index 8951854bff84..8dfdefc7e917 100644 --- a/apps/meteor/app/livechat/imports/server/rest/dashboards.ts +++ b/apps/meteor/app/livechat/imports/server/rest/dashboards.ts @@ -3,15 +3,15 @@ import { isGETDashboardTotalizerParams, isGETDashboardsAgentStatusParams } from import { API } from '../../../../api/server'; import { - findAllChatsStatusAsync, - getProductivityMetricsAsync, - getConversationsMetricsAsync, - findAllChatMetricsByAgentAsync, - findAllAgentsStatusAsync, - findAllChatMetricsByDepartmentAsync, - findAllResponseTimeMetricsAsync, - getAgentsProductivityMetricsAsync, - getChatsMetricsAsync, + getProductivityMetricsAsyncCached, + getConversationsMetricsAsyncCached, + getAgentsProductivityMetricsAsyncCached, + getChatsMetricsAsyncCached, + findAllChatsStatusAsyncCached, + findAllChatMetricsByAgentAsyncCached, + findAllAgentsStatusAsyncCached, + findAllChatMetricsByDepartmentAsyncCached, + findAllResponseTimeMetricsAsyncCached, } from '../../../server/lib/analytics/dashboards'; API.v1.addRoute( @@ -41,7 +41,7 @@ API.v1.addRoute( return API.v1.failure('User not found'); } - const totalizers = await getConversationsMetricsAsync({ start: startDate, end: endDate, departmentId, user }); + const totalizers = await getConversationsMetricsAsyncCached({ start: startDate, end: endDate, departmentId, user }); return API.v1.success(totalizers); }, }, @@ -70,7 +70,7 @@ API.v1.addRoute( return API.v1.failure('User not found'); } - const totalizers = await getAgentsProductivityMetricsAsync({ start: startDate, end: endDate, departmentId, user }); + const totalizers = await getAgentsProductivityMetricsAsyncCached({ start: startDate, end: endDate, departmentId, user }); return API.v1.success(totalizers); }, }, @@ -94,7 +94,7 @@ API.v1.addRoute( } const endDate = new Date(end); - const totalizers = await getChatsMetricsAsync({ start: startDate, end: endDate, departmentId }); + const totalizers = await getChatsMetricsAsyncCached({ start: startDate, end: endDate, departmentId }); return API.v1.success(totalizers); }, }, @@ -123,7 +123,7 @@ API.v1.addRoute( return API.v1.failure('User not found'); } - const totalizers = await getProductivityMetricsAsync({ start: startDate, end: endDate, departmentId, user }); + const totalizers = await getProductivityMetricsAsyncCached({ start: startDate, end: endDate, departmentId, user }); return API.v1.success(totalizers); }, @@ -148,7 +148,7 @@ API.v1.addRoute( } const endDate = new Date(end); - const result = await findAllChatsStatusAsync({ start: startDate, end: endDate, departmentId }); + const result = await findAllChatsStatusAsyncCached({ start: startDate, end: endDate, departmentId }); return API.v1.success(result); }, @@ -172,7 +172,7 @@ API.v1.addRoute( return API.v1.failure('The "end" query parameter must be a valid date.'); } const endDate = new Date(end); - const result = (await findAllChatMetricsByAgentAsync({ start: startDate, end: endDate, departmentId })) as { + const result = (await findAllChatMetricsByAgentAsyncCached({ start: startDate, end: endDate, departmentId })) as { [k: string]: { open: number; closed: number; onhold: number }; }; @@ -188,7 +188,7 @@ API.v1.addRoute( async get() { const { departmentId } = this.queryParams; - const result = await findAllAgentsStatusAsync({ departmentId }); + const result = await findAllAgentsStatusAsyncCached({ departmentId }); return API.v1.success(result); }, @@ -213,7 +213,7 @@ API.v1.addRoute( } const endDate = new Date(end); - const result = (await findAllChatMetricsByDepartmentAsync({ start: startDate, end: endDate, departmentId })) as { + const result = (await findAllChatMetricsByDepartmentAsyncCached({ start: startDate, end: endDate, departmentId })) as { [k: string]: { open: number; closed: number }; }; @@ -240,7 +240,7 @@ API.v1.addRoute( } const endDate = new Date(end); - const result = await findAllResponseTimeMetricsAsync({ start: startDate, end: endDate, departmentId }); + const result = await findAllResponseTimeMetricsAsyncCached({ start: startDate, end: endDate, departmentId }); return API.v1.success(result); }, diff --git a/apps/meteor/app/livechat/imports/server/rest/departments.ts b/apps/meteor/app/livechat/imports/server/rest/departments.ts index 6540b67d79aa..095baefaa294 100644 --- a/apps/meteor/app/livechat/imports/server/rest/departments.ts +++ b/apps/meteor/app/livechat/imports/server/rest/departments.ts @@ -17,6 +17,7 @@ import { } from '../../../server/api/lib/departments'; import { DepartmentHelper } from '../../../server/lib/Departments'; import { Livechat } from '../../../server/lib/Livechat'; +import { Livechat as LivechatTs } from '../../../server/lib/LivechatTyped'; API.v1.addRoute( 'livechat/department', @@ -192,7 +193,7 @@ API.v1.addRoute( }, { async post() { - await Livechat.archiveDepartment(this.urlParams._id); + await LivechatTs.archiveDepartment(this.urlParams._id); return API.v1.success(); }, @@ -207,11 +208,8 @@ API.v1.addRoute( }, { async post() { - if (await Livechat.unarchiveDepartment(this.urlParams._id)) { - return API.v1.success(); - } - - return API.v1.failure(); + await LivechatTs.unarchiveDepartment(this.urlParams._id); + return API.v1.success(); }, }, ); diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.js b/apps/meteor/app/livechat/imports/server/rest/sms.js index 6521c0f662dd..9d2bee133784 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.js +++ b/apps/meteor/app/livechat/imports/server/rest/sms.js @@ -56,7 +56,7 @@ const defineVisitor = async (smsNumber, targetDepartment) => { } const id = await LivechatTyped.registerGuest(data); - return LivechatVisitors.findOneById(id); + return LivechatVisitors.findOneEnabledById(id); }; const normalizeLocationSharing = (payload) => { @@ -182,7 +182,7 @@ API.v1.addRoute('livechat/sms-incoming/:service', { }; try { - const msg = SMSService.response.call(this, await Livechat.sendMessage(sendMessage)); + const msg = SMSService.response.call(this, await LivechatTyped.sendMessage(sendMessage)); setImmediate(async () => { if (sms.extra) { if (sms.extra.fromCountry) { diff --git a/apps/meteor/app/livechat/imports/server/rest/triggers.ts b/apps/meteor/app/livechat/imports/server/rest/triggers.ts index a12d4a988281..a7660827b0ce 100644 --- a/apps/meteor/app/livechat/imports/server/rest/triggers.ts +++ b/apps/meteor/app/livechat/imports/server/rest/triggers.ts @@ -3,7 +3,7 @@ import { isGETLivechatTriggersParams, isPOSTLivechatTriggersParams } from '@rock import { API } from '../../../../api/server'; import { getPaginationItems } from '../../../../api/server/helpers/getPaginationItems'; -import { findTriggers, findTriggerById } from '../../../server/api/lib/triggers'; +import { findTriggers, findTriggerById, deleteTrigger } from '../../../server/api/lib/triggers'; API.v1.addRoute( 'livechat/triggers', @@ -57,5 +57,12 @@ API.v1.addRoute( trigger, }); }, + async delete() { + await deleteTrigger({ + triggerId: this.urlParams._id, + }); + + return API.v1.success(); + }, }, ); diff --git a/apps/meteor/app/livechat/imports/server/rest/users.ts b/apps/meteor/app/livechat/imports/server/rest/users.ts index 680bdc7e80d2..196970583249 100644 --- a/apps/meteor/app/livechat/imports/server/rest/users.ts +++ b/apps/meteor/app/livechat/imports/server/rest/users.ts @@ -9,16 +9,15 @@ import { hasAtLeastOnePermissionAsync } from '../../../../authorization/server/f import { findAgents, findManagers } from '../../../server/api/lib/users'; import { Livechat } from '../../../server/lib/Livechat'; +const emptyStringArray: string[] = []; + API.v1.addRoute( 'livechat/users/:type', { authRequired: true, permissionsRequired: { - GET: { - permissions: ['manage-livechat-agents'], - operation: 'hasAll', - }, - POST: { permissions: ['view-livechat-manager'], operation: 'hasAll' }, + 'POST': ['view-livechat-manager'], + '*': emptyStringArray, }, validateParams: { GET: isLivechatUsersManagerGETProps, @@ -39,9 +38,13 @@ API.v1.addRoute( return API.v1.unauthorized(); } + const { onlyAvailable, excludeId, showIdleAgents } = this.queryParams; return API.v1.success( await findAgents({ text, + onlyAvailable, + excludeId, + showIdleAgents, pagination: { offset, count, diff --git a/apps/meteor/app/livechat/server/api/lib/livechat.ts b/apps/meteor/app/livechat/server/api/lib/livechat.ts index 7bb608090557..2b72065345d6 100644 --- a/apps/meteor/app/livechat/server/api/lib/livechat.ts +++ b/apps/meteor/app/livechat/server/api/lib/livechat.ts @@ -13,7 +13,6 @@ import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../../lib/callbacks'; import { i18n } from '../../../../../server/lib/i18n'; import { normalizeAgent } from '../../lib/Helper'; -import { Livechat } from '../../lib/Livechat'; import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; export function online(department: string, skipSettingCheck = false, skipFallbackCheck = false): Promise { @@ -139,7 +138,7 @@ export function normalizeHttpHeaderData(headers: Record> { // Putting this ugly conversion while we type the livechat service - const initSettings = (await Livechat.getInitSettings()) as unknown as Record; + const initSettings = await LivechatTyped.getInitSettings(); const triggers = await findTriggers(); const departments = await findDepartments(businessUnit); const sound = `${Meteor.absoluteUrl()}sounds/chime.mp3`; diff --git a/apps/meteor/app/livechat/server/api/lib/triggers.ts b/apps/meteor/app/livechat/server/api/lib/triggers.ts index dbb6f8a6633a..4cbafcb0dc73 100644 --- a/apps/meteor/app/livechat/server/api/lib/triggers.ts +++ b/apps/meteor/app/livechat/server/api/lib/triggers.ts @@ -29,3 +29,7 @@ export async function findTriggers({ export async function findTriggerById({ triggerId }: { triggerId: string }): Promise { return LivechatTrigger.findOneById(triggerId); } + +export async function deleteTrigger({ triggerId }: { triggerId: string }): Promise { + await LivechatTrigger.removeById(triggerId); +} diff --git a/apps/meteor/app/livechat/server/api/lib/users.ts b/apps/meteor/app/livechat/server/api/lib/users.ts index 948cde0f8bfd..49ac5682a6c0 100644 --- a/apps/meteor/app/livechat/server/api/lib/users.ts +++ b/apps/meteor/app/livechat/server/api/lib/users.ts @@ -1,6 +1,7 @@ import type { ILivechatAgent, IRole } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; +import type { FilterOperators } from 'mongodb'; /** * @param {IRole['_id']} role the role id @@ -10,18 +11,39 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; async function findUsers({ role, text, + onlyAvailable = false, + excludeId, + showIdleAgents = true, pagination: { offset, count, sort }, }: { role: IRole['_id']; text?: string; + onlyAvailable?: boolean; + excludeId?: string; + showIdleAgents?: boolean; pagination: { offset: number; count: number; sort: any }; }): Promise<{ users: ILivechatAgent[]; count: number; offset: number; total: number }> { - const query = {}; + const query: FilterOperators = {}; + const orConditions: FilterOperators['$or'] = []; if (text) { const filterReg = new RegExp(escapeRegExp(text), 'i'); - Object.assign(query, { - $or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }], - }); + orConditions.push({ $or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }] }); + } + + if (onlyAvailable) { + query.statusLivechat = 'available'; + } + + if (excludeId) { + query._id = { $ne: excludeId }; + } + + if (!showIdleAgents) { + orConditions.push({ $or: [{ status: { $exists: true, $ne: 'offline' }, roles: { $ne: 'bot' } }, { roles: 'bot' }] }); + } + + if (orConditions.length) { + query.$and = orConditions; } const [ @@ -52,14 +74,23 @@ async function findUsers({ } export async function findAgents({ text, + onlyAvailable = false, + excludeId, + showIdleAgents = true, pagination: { offset, count, sort }, }: { text?: string; + onlyAvailable: boolean; + excludeId?: string; + showIdleAgents?: boolean; pagination: { offset: number; count: number; sort: any }; }): Promise> { return findUsers({ role: 'livechat-agent', text, + onlyAvailable, + excludeId, + showIdleAgents, pagination: { offset, count, diff --git a/apps/meteor/app/livechat/server/api/lib/visitors.ts b/apps/meteor/app/livechat/server/api/lib/visitors.ts index e559aecc892e..0abed5197d78 100644 --- a/apps/meteor/app/livechat/server/api/lib/visitors.ts +++ b/apps/meteor/app/livechat/server/api/lib/visitors.ts @@ -6,7 +6,7 @@ import { callbacks } from '../../../../../lib/callbacks'; import { canAccessRoomAsync } from '../../../../authorization/server/functions/canAccessRoom'; export async function findVisitorInfo({ visitorId }: { visitorId: IVisitor['_id'] }) { - const visitor = await LivechatVisitors.findOneById(visitorId); + const visitor = await LivechatVisitors.findOneEnabledById(visitorId); if (!visitor) { throw new Error('visitor-not-found'); } diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 517acf33f137..57c1d117f1b0 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -33,7 +33,7 @@ API.v1.addRoute( contactId: String, }); - const contact = await LivechatVisitors.findOneById(this.queryParams.contactId); + const contact = await LivechatVisitors.findOneEnabledById(this.queryParams.contactId); return API.v1.success({ contact }); }, diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index 2b6f4c00af53..1dcf54e403a6 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -17,7 +17,6 @@ import { isWidget } from '../../../../api/server/helpers/isWidget'; import { loadMessageHistory } from '../../../../lib/server/functions/loadMessageHistory'; import { settings } from '../../../../settings/server'; import { normalizeMessageFileUpload } from '../../../../utils/server/functions/normalizeMessageFileUpload'; -import { Livechat } from '../../lib/Livechat'; import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; import { findGuest, findRoom, normalizeHttpHeaderData } from '../lib/livechat'; @@ -67,7 +66,7 @@ API.v1.addRoute( }, }; - const result = await Livechat.sendMessage(sendMessage); + const result = await LivechatTyped.sendMessage(sendMessage); if (result) { const message = await Messages.findOneById(_id); if (!message) { @@ -134,9 +133,9 @@ API.v1.addRoute( throw new Error('invalid-message'); } - const result = await Livechat.updateMessage({ + const result = await LivechatTyped.updateMessage({ guest, - message: { _id: msg._id, msg: this.bodyParams.msg }, + message: { _id: msg._id, msg: this.bodyParams.msg, rid: msg.rid }, }); if (!result) { return API.v1.failure(); @@ -176,7 +175,7 @@ API.v1.addRoute( throw new Error('invalid-message'); } - const result = await Livechat.deleteMessage({ guest, message }); + const result = await LivechatTyped.deleteMessage({ guest, message }); if (result) { return API.v1.success({ message: { @@ -269,13 +268,18 @@ API.v1.addRoute( guest.connectionData = normalizeHttpHeaderData(this.request.headers); const visitorId = await LivechatTyped.registerGuest(guest); - visitor = await LivechatVisitors.findOneById(visitorId); + visitor = await LivechatVisitors.findOneEnabledById(visitorId); + } + + const guest = visitor; + if (!guest) { + throw new Error('error-invalid-token'); } const sentMessages = await Promise.all( this.bodyParams.messages.map(async (message: { msg: string }): Promise<{ username: string; msg: string; ts: number }> => { const sendMessage = { - guest: visitor, + guest, message: { _id: Random.id(), rid, @@ -288,8 +292,8 @@ API.v1.addRoute( }, }, }; - // @ts-expect-error -- Typings on sendMessage are wrong - const sentMessage = await Livechat.sendMessage(sendMessage); + + const sentMessage = await LivechatTyped.sendMessage(sendMessage); return { username: sentMessage.u.username, msg: sentMessage.msg, diff --git a/apps/meteor/app/livechat/server/api/v1/offlineMessage.ts b/apps/meteor/app/livechat/server/api/v1/offlineMessage.ts index b01e60d2265f..6acd6ab98ea1 100644 --- a/apps/meteor/app/livechat/server/api/v1/offlineMessage.ts +++ b/apps/meteor/app/livechat/server/api/v1/offlineMessage.ts @@ -2,7 +2,7 @@ import { isPOSTLivechatOfflineMessageParams } from '@rocket.chat/rest-typings'; import { i18n } from '../../../../../server/lib/i18n'; import { API } from '../../../../api/server'; -import { Livechat } from '../../lib/Livechat'; +import { Livechat } from '../../lib/LivechatTyped'; API.v1.addRoute( 'livechat/offline.message', diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 0fe60248bfba..b4779ce9e7be 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -1,3 +1,4 @@ +import { Omnichannel } from '@rocket.chat/core-services'; import type { ILivechatAgent, IOmnichannelRoom, 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'; @@ -18,7 +19,7 @@ import { callbacks } from '../../../../../lib/callbacks'; import { i18n } from '../../../../../server/lib/i18n'; import { API } from '../../../../api/server'; import { isWidget } from '../../../../api/server/helpers/isWidget'; -import { canAccessRoomAsync } from '../../../../authorization/server'; +import { canAccessRoomAsync, roomAccessAttributes } from '../../../../authorization/server'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { addUserToRoom } from '../../../../lib/server/functions/addUserToRoom'; import { settings as rcSettings } from '../../../../settings/server'; @@ -251,7 +252,7 @@ API.v1.addRoute( const { _id, username, name } = guest; const transferredBy = normalizeTransferredByData({ _id, username, name, userType: 'visitor' }, room); - if (!(await Livechat.transfer(room, guest, { roomId: rid, departmentId: department, transferredBy }))) { + if (!(await LivechatTyped.transfer(room, guest, { departmentId: department, transferredBy }))) { return API.v1.failure(); } @@ -312,10 +313,10 @@ API.v1.addRoute( { authRequired: true, permissionsRequired: ['view-l-room', 'transfer-livechat-guest'], validateParams: isLiveChatRoomForwardProps }, { async post() { - const transferData: typeof this.bodyParams & { - transferredBy?: unknown; + const transferData = this.bodyParams as typeof this.bodyParams & { + transferredBy: TransferByData; transferredTo?: { _id: string; username?: string; name?: string }; - } = this.bodyParams; + }; const room = await LivechatRooms.findOneById(this.bodyParams.roomId); if (!room || room.t !== 'l') { @@ -326,7 +327,15 @@ API.v1.addRoute( throw new Error('This_conversation_is_already_closed'); } - const guest = await LivechatVisitors.findOneById(room.v?._id); + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + + const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); + if (!guest) { + throw new Error('error-invalid-visitor'); + } + const transferedBy = this.user satisfies TransferByData; transferData.transferredBy = normalizeTransferredByData(transferedBy, room); if (transferData.userId) { @@ -340,7 +349,7 @@ API.v1.addRoute( } } - const chatForwardedResult = await Livechat.transfer(room, guest, transferData); + const chatForwardedResult = await LivechatTyped.transfer(room, guest, transferData); if (!chatForwardedResult) { throw new Error('error-forwarding-chat'); } @@ -352,7 +361,12 @@ API.v1.addRoute( API.v1.addRoute( 'livechat/room.visitor', - { authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isPUTLivechatRoomVisitorParams, deprecationVersion: '7.0.0' }, + { + authRequired: true, + permissionsRequired: ['change-livechat-room-visitor'], + validateParams: isPUTLivechatRoomVisitorParams, + deprecationVersion: '7.0.0', + }, { async put() { // This endpoint is deprecated and will be removed in future versions. @@ -363,7 +377,7 @@ API.v1.addRoute( throw new Error('invalid-visitor'); } - const room = await LivechatRooms.findOneById(rid, { _id: 1, v: 1 }); // TODO: check _id + const room = await LivechatRooms.findOneById(rid, { projection: { ...roomAccessAttributes, _id: 1, t: 1, v: 1 } }); // TODO: check _id if (!room) { throw new Error('invalid-room'); } @@ -373,7 +387,7 @@ API.v1.addRoute( throw new Error('invalid-room-visitor'); } - const roomAfterChange = await Livechat.changeRoomVisitor(this.userId, rid, visitor); + const roomAfterChange = await LivechatTyped.changeRoomVisitor(this.userId, room, visitor); if (!roomAfterChange) { return API.v1.failure(); @@ -403,6 +417,10 @@ API.v1.addRoute( throw new Error('error-invalid-room'); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + if (!(await canAccessRoomAsync(room, user))) { throw new Error('error-not-allowed'); } diff --git a/apps/meteor/app/livechat/server/api/v1/statistics.ts b/apps/meteor/app/livechat/server/api/v1/statistics.ts index 078f366bb484..2cf30474a7d8 100644 --- a/apps/meteor/app/livechat/server/api/v1/statistics.ts +++ b/apps/meteor/app/livechat/server/api/v1/statistics.ts @@ -3,7 +3,7 @@ import { isLivechatAnalyticsAgentOverviewProps, isLivechatAnalyticsOverviewProps import { API } from '../../../../api/server'; import { settings } from '../../../../settings/server'; -import { Livechat } from '../../lib/Livechat'; +import { getAgentOverviewDataCached, getAnalyticsOverviewDataCached } from '../../lib/AnalyticsTyped'; API.v1.addRoute( 'livechat/analytics/agent-overview', @@ -22,7 +22,7 @@ API.v1.addRoute( const user = await Users.findOneById(this.userId, { projection: { _id: 1, utcOffset: 1 } }); return API.v1.success( - await Livechat.Analytics.getAgentOverviewData({ + await getAgentOverviewDataCached({ departmentId, utcOffset: user?.utcOffset || 0, daterange: { from, to }, @@ -52,7 +52,7 @@ API.v1.addRoute( const language = user?.language || settings.get('Language') || 'en'; return API.v1.success( - await Livechat.Analytics.getAnalyticsOverviewData({ + await getAnalyticsOverviewDataCached({ departmentId, utcOffset: user?.utcOffset || 0, daterange: { from, to }, diff --git a/apps/meteor/app/livechat/server/api/v1/transcript.ts b/apps/meteor/app/livechat/server/api/v1/transcript.ts index 3eaa91c37c7c..c32ecaff3415 100644 --- a/apps/meteor/app/livechat/server/api/v1/transcript.ts +++ b/apps/meteor/app/livechat/server/api/v1/transcript.ts @@ -1,3 +1,4 @@ +import { Omnichannel } from '@rocket.chat/core-services'; import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms, Users } from '@rocket.chat/models'; import { isPOSTLivechatTranscriptParams, isPOSTLivechatTranscriptRequestParams } from '@rocket.chat/rest-typings'; @@ -34,8 +35,8 @@ API.v1.addRoute( { async delete() { const { rid } = this.urlParams; - const room = await LivechatRooms.findOneById>(rid, { - projection: { open: 1, transcriptRequest: 1 }, + const room = await LivechatRooms.findOneById>(rid, { + projection: { open: 1, transcriptRequest: 1, v: 1 }, }); if (!room?.open) { @@ -45,6 +46,10 @@ API.v1.addRoute( throw new Error('error-transcript-not-requested'); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + await LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid); return API.v1.success(); diff --git a/apps/meteor/app/livechat/server/api/v1/videoCall.ts b/apps/meteor/app/livechat/server/api/v1/videoCall.ts index 5ce0ddc4ca37..94df06ba418c 100644 --- a/apps/meteor/app/livechat/server/api/v1/videoCall.ts +++ b/apps/meteor/app/livechat/server/api/v1/videoCall.ts @@ -1,4 +1,5 @@ -import { Message } from '@rocket.chat/core-services'; +import { Message, Omnichannel } from '@rocket.chat/core-services'; +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { Messages, Settings, Rooms } from '@rocket.chat/models'; import { isGETWebRTCCall, isPUTWebRTCCallId } from '@rocket.chat/rest-typings'; @@ -6,7 +7,7 @@ import { i18n } from '../../../../../server/lib/i18n'; import { API } from '../../../../api/server'; import { canSendMessageAsync } from '../../../../authorization/server/functions/canSendMessage'; import { settings as rcSettings } from '../../../../settings/server'; -import { Livechat } from '../../lib/Livechat'; +import { Livechat } from '../../lib/LivechatTyped'; import { settings } from '../lib/livechat'; API.v1.addRoute( @@ -27,6 +28,10 @@ API.v1.addRoute( throw new Error('invalid-room'); } + if (!(await Omnichannel.isWithinMACLimit(room as IOmnichannelRoom))) { + throw new Error('error-mac-limit-reached'); + } + const webrtcCallingAllowed = rcSettings.get('WebRTC_Enabled') === true && rcSettings.get('Omnichannel_call_provider') === 'WebRTC'; if (!webrtcCallingAllowed) { throw new Error('webRTC calling not enabled'); @@ -79,6 +84,10 @@ API.v1.addRoute( throw new Error('invalid-room'); } + if (!(await Omnichannel.isWithinMACLimit(room as IOmnichannelRoom))) { + throw new Error('error-mac-limit-reached'); + } + const call = await Messages.findOneById(callId); if (!call || call.t !== 'livechat_webrtc_video_call') { throw new Error('invalid-callId'); diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index 012b412639ea..6488d34eab7a 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -45,7 +45,7 @@ API.v1.addRoute('livechat/visitor', { const visitorId = await LivechatTyped.registerGuest(guest); - let visitor = await VisitorsRaw.findOneById(visitorId, {}); + let visitor = await VisitorsRaw.findOneEnabledById(visitorId, {}); if (visitor) { const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); // If it's updating an existing visitor, it must also update the roomInfo @@ -65,7 +65,7 @@ API.v1.addRoute('livechat/visitor', { } } - visitor = await VisitorsRaw.findOneById(visitorId, {}); + visitor = await VisitorsRaw.findOneEnabledById(visitorId, {}); } if (!visitor) { @@ -121,8 +121,8 @@ API.v1.addRoute('livechat/visitor/:token', { } const { _id } = visitor; - const result = await Livechat.removeGuest(_id); - if (!result) { + const result = await LivechatTyped.removeGuest(_id); + if (!result.modifiedCount) { throw new Meteor.Error('error-removing-visitor', 'An error ocurred while deleting visitor'); } @@ -174,7 +174,7 @@ API.v1.addRoute('livechat/visitor.callStatus', { if (!guest) { throw new Meteor.Error('invalid-token'); } - await Livechat.updateCallStatus(callId, rid, callStatus, guest); + await LivechatTyped.updateCallStatus(callId, rid, callStatus, guest); return API.v1.success({ token, callStatus }); }, }); diff --git a/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts b/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts index 52ccd0441e24..c541e5f7b2c3 100644 --- a/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts +++ b/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts @@ -7,7 +7,6 @@ import moment from 'moment'; import { closeBusinessHour } from '../../../../ee/app/livechat-enterprise/server/business-hour/Helper'; import { callbacks } from '../../../../lib/callbacks'; import { settings } from '../../../settings/server'; -import { businessHourLogger } from '../lib/logger'; import type { IBusinessHourBehavior, IBusinessHourType } from './AbstractBusinessHour'; export class BusinessHourManager { @@ -27,7 +26,6 @@ export class BusinessHourManager { async startManager(): Promise { await this.createCronJobsForWorkHours(); - businessHourLogger.debug('Cron jobs created, setting up callbacks'); this.setupCallbacks(); await this.cleanupDisabledDepartmentReferences(); await this.behavior.onStartBusinessHours(); diff --git a/apps/meteor/app/livechat/server/business-hour/Helper.ts b/apps/meteor/app/livechat/server/business-hour/Helper.ts index e61bb1621765..e96ccb4c7b89 100644 --- a/apps/meteor/app/livechat/server/business-hour/Helper.ts +++ b/apps/meteor/app/livechat/server/business-hour/Helper.ts @@ -59,7 +59,6 @@ export const openBusinessHourDefault = async (): Promise => { await Users.makeAgentsWithinBusinessHourAvailable(); } await Users.updateLivechatStatusBasedOnBusinessHours(); - businessHourLogger.debug('Done opening default business hours'); }; export const createDefaultBusinessHourIfNotExists = async (): Promise => { diff --git a/apps/meteor/app/livechat/server/business-hour/Single.ts b/apps/meteor/app/livechat/server/business-hour/Single.ts index d899f2717376..5d2730dba9a1 100644 --- a/apps/meteor/app/livechat/server/business-hour/Single.ts +++ b/apps/meteor/app/livechat/server/business-hour/Single.ts @@ -8,7 +8,6 @@ import { filterBusinessHoursThatMustBeOpened, openBusinessHourDefault } from './ export class SingleBusinessHourBehavior extends AbstractBusinessHourBehavior implements IBusinessHourBehavior { async openBusinessHoursByDayAndHour(): Promise { - businessHourLogger.debug('opening single business hour'); return openBusinessHourDefault(); } @@ -23,7 +22,6 @@ export class SingleBusinessHourBehavior extends AbstractBusinessHourBehavior imp } async onStartBusinessHours(): Promise { - businessHourLogger.debug('Starting Single Business Hours'); return openBusinessHourDefault(); } diff --git a/apps/meteor/app/livechat/server/hooks/afterUserActions.ts b/apps/meteor/app/livechat/server/hooks/afterUserActions.ts index 30900481c4e2..0419f1d02a1d 100644 --- a/apps/meteor/app/livechat/server/hooks/afterUserActions.ts +++ b/apps/meteor/app/livechat/server/hooks/afterUserActions.ts @@ -3,7 +3,6 @@ import { Users } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { Livechat } from '../lib/Livechat'; -import { callbackLogger } from '../lib/logger'; type IAfterSaveUserProps = { user: IUser; @@ -34,17 +33,12 @@ const handleAgentCreated = async (user: IUser) => { const handleDeactivateUser = async (user: IUser) => { if (wasAgent(user)) { - callbackLogger.debug({ - msg: 'Removing agent extension & making agent unavailable', - userId: user._id, - }); await Users.makeAgentUnavailableAndUnsetExtension(user._id); } }; const handleActivateUser = async (user: IUser) => { if (isAgent(user)) { - callbackLogger.debug('Adding agent', user._id); await Livechat.addAgent(user.username); } }; diff --git a/apps/meteor/app/livechat/server/hooks/checkMAC.ts b/apps/meteor/app/livechat/server/hooks/checkMAC.ts new file mode 100644 index 000000000000..4d0789252b50 --- /dev/null +++ b/apps/meteor/app/livechat/server/hooks/checkMAC.ts @@ -0,0 +1,30 @@ +import { Omnichannel } from '@rocket.chat/core-services'; +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { isEditedMessage } from '@rocket.chat/core-typings'; + +import { callbacks } from '../../../../lib/callbacks'; + +callbacks.add('beforeSaveMessage', async (message, room) => { + if (!room || room.t !== 'l') { + return message; + } + + if (isEditedMessage(message)) { + return message; + } + + if (message.token) { + return message; + } + + if (message.t) { + return message; + } + + const canSendMessage = await Omnichannel.isWithinMACLimit(room as IOmnichannelRoom); + if (!canSendMessage) { + throw new Error('error-mac-limit-reached'); + } + + return message; +}); diff --git a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts index 5ebf924e7334..ad68fcf5ce5c 100644 --- a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts +++ b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts @@ -1,6 +1,7 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { isOmnichannelRoom, isEditedMessage } from '@rocket.chat/core-typings'; -import { LivechatRooms } from '@rocket.chat/models'; +import { LivechatRooms, LivechatVisitors } from '@rocket.chat/models'; +import moment from 'moment'; import { callbacks } from '../../../../lib/callbacks'; @@ -26,6 +27,19 @@ callbacks.add( return message; } + // Return YYYY-MM from moment + const monthYear = moment().format('YYYY-MM'); + const isVisitorActive = await LivechatVisitors.isVisitorActiveOnPeriod(room.v._id, monthYear); + if (!isVisitorActive) { + await LivechatVisitors.markVisitorActiveForPeriod(room.v._id, monthYear); + } + + await LivechatRooms.markVisitorActiveForPeriod(room._id, monthYear); + + if (room.responseBy) { + await LivechatRooms.setAgentLastMessageTs(room._id); + } + // check if room is yet awaiting for response from visitor if (!room.waitingResponse) { // case where agent sends second message or any subsequent message in a room before visitor responds to the first message diff --git a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts index ec584ec001d6..e92e6b4d940b 100644 --- a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts +++ b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts @@ -3,7 +3,6 @@ import { LivechatRooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { normalizeMessageFileUpload } from '../../../utils/server/functions/normalizeMessageFileUpload'; -import { callbackLogger } from '../lib/logger'; callbacks.add( 'afterSaveMessage', @@ -13,7 +12,6 @@ callbacks.add( return message; } - callbackLogger.debug(`Calculating Omnichannel metrics for room ${room._id}`); // skips this callback if the message was edited if (!message || isEditedMessage(message)) { return message; @@ -43,7 +41,6 @@ callbacks.add( const isResponseTotal = room.metrics?.response?.total; if (agentLastReply === room.ts) { - callbackLogger.debug('Calculating: first message from agent'); // first response const firstResponseDate = now; const firstResponseTime = (now.getTime() - new Date(visitorLastQuery).getTime()) / 1000; @@ -66,7 +63,6 @@ callbacks.add( reactionTime, }; } else if (visitorLastQuery > agentLastReply) { - callbackLogger.debug('Calculating: visitor sent a message after agent'); // response, not first const responseTime = (now.getTime() - new Date(visitorLastQuery).getTime()) / 1000; const avgResponseTime = diff --git a/apps/meteor/app/livechat/server/hooks/saveContactLastChat.ts b/apps/meteor/app/livechat/server/hooks/saveContactLastChat.ts index 7b4d9b89f14c..6f42a910417d 100644 --- a/apps/meteor/app/livechat/server/hooks/saveContactLastChat.ts +++ b/apps/meteor/app/livechat/server/hooks/saveContactLastChat.ts @@ -1,7 +1,7 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { callbacks } from '../../../../lib/callbacks'; -import { Livechat } from '../lib/Livechat'; +import { Livechat } from '../lib/LivechatTyped'; callbacks.add( 'livechat.newRoom', diff --git a/apps/meteor/app/livechat/server/index.ts b/apps/meteor/app/livechat/server/index.ts index 7d9167966953..b6f4e98af6db 100644 --- a/apps/meteor/app/livechat/server/index.ts +++ b/apps/meteor/app/livechat/server/index.ts @@ -16,6 +16,7 @@ import './hooks/saveContactLastChat'; import './hooks/saveLastMessageToInquiry'; import './hooks/afterUserActions'; import './hooks/afterAgentRemoved'; +import './hooks/checkMAC'; import './methods/addAgent'; import './methods/addManager'; import './methods/changeLivechatStatus'; diff --git a/apps/meteor/app/livechat/server/lib/Analytics.js b/apps/meteor/app/livechat/server/lib/Analytics.js index 5f6e3469501e..bff3dd1d25d7 100644 --- a/apps/meteor/app/livechat/server/lib/Analytics.js +++ b/apps/meteor/app/livechat/server/lib/Analytics.js @@ -36,872 +36,866 @@ async function* hourIterator(day) { } } -export const Analytics = { - async getAgentOverviewData(options) { - const { departmentId, utcOffset, daterange: { from: fDate, to: tDate } = {}, chartOptions: { name } = {} } = options; - const timezone = getTimezone({ utcOffset }); - const from = moment.tz(fDate, 'YYYY-MM-DD', timezone).startOf('day').utc(); - const to = moment.tz(tDate, 'YYYY-MM-DD', timezone).endOf('day').utc(); - - logger.debug(`getAgentOverviewData[${name}] -> Using timezone ${timezone} with date range ${from} - ${to}`); - - if (!(moment(from).isValid() && moment(to).isValid())) { - logger.error('livechat:getAgentOverviewData => Invalid dates'); - return; - } - - if (!this.AgentOverviewData[name]) { - logger.error(`Method RocketChat.Livechat.Analytics.AgentOverviewData.${name} does NOT exist`); - return; - } +const OverviewData = { + /** + * + * @param {Map} map + * + * @return {String} + */ + getKeyHavingMaxValue(map, def) { + let maxValue = 0; + let maxKey = def; // default + + map.forEach((value, key) => { + if (value > maxValue) { + maxValue = value; + maxKey = key; + } + }); - const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); - return this.AgentOverviewData[name](from, to, departmentId, extraQuery); + return maxKey; }, - async getAnalyticsChartData(options) { - const { - utcOffset, - departmentId, - daterange: { from: fDate, to: tDate } = {}, - chartOptions: { name: chartLabel }, - chartOptions: { name } = {}, - } = options; - - // Check if function exists, prevent server error in case property altered - if (!this.ChartData[name]) { - logger.error(`Method RocketChat.Livechat.Analytics.ChartData.${name} does NOT exist`); - return; - } + /** + * + * @param {Date} from + * @param {Date} to + * + * @returns {Array[Object]} + */ + async Conversations(from, to, departmentId, timezone, t = (v) => v, extraQuery) { + // TODO: most calls to db here can be done in one single call instead of one per day/hour + let totalConversations = 0; // Total conversations + let openConversations = 0; // open conversations + let totalMessages = 0; // total msgs + const totalMessagesOnWeekday = new Map(); // total messages on weekdays i.e Monday, Tuesday... + const totalMessagesInHour = new Map(); // total messages in hour 0, 1, ... 23 of weekday + const days = to.diff(from, 'days') + 1; // total days + + const summarize = + (m) => + ({ metrics, msgs, onHold = false }) => { + if (metrics && !metrics.chatDuration && !onHold) { + openConversations++; + } + totalMessages += msgs; - const timezone = getTimezone({ utcOffset }); - const from = moment.tz(fDate, 'YYYY-MM-DD', timezone).startOf('day').utc(); - const to = moment.tz(tDate, 'YYYY-MM-DD', timezone).endOf('day').utc(); - const isSameDay = from.diff(to, 'days') === 0; + const weekday = m.format('dddd'); // @string: Monday, Tuesday ... + totalMessagesOnWeekday.set(weekday, totalMessagesOnWeekday.has(weekday) ? totalMessagesOnWeekday.get(weekday) + msgs : msgs); + }; - logger.debug(`getAnalyticsChartData[${name}] -> Using timezone ${timezone} with date range ${from} - ${to}`); + const m = moment.tz(from, timezone).startOf('day').utc(); + // eslint-disable-next-line no-unused-vars + for await (const _ of Array(days).fill(0)) { + const clonedDate = m.clone(); + const date = { + gte: clonedDate, + lt: m.add(1, 'days'), + }; + // eslint-disable-next-line no-await-in-loop + const result = await LivechatRooms.getAnalyticsBetweenDate(date, { departmentId }, extraQuery).toArray(); + totalConversations += result.length; - if (!(moment(from).isValid() && moment(to).isValid())) { - logger.error('livechat:getAnalyticsChartData => Invalid dates'); - return; + result.forEach(summarize(clonedDate)); } - const data = { - chartLabel, - dataLabels: [], - dataPoints: [], - }; - - const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); - if (isSameDay) { - // data for single day - const m = moment(from); - for await (const currentHour of Array.from({ length: HOURS_IN_DAY }, (_, i) => i)) { - const hour = m.add(currentHour ? 1 : 0, 'hour').format('H'); - const label = { - from: moment.utc().set({ hour }).tz(timezone).format('hA'), - to: moment.utc().set({ hour }).add(1, 'hour').tz(timezone).format('hA'), - }; - data.dataLabels.push(`${label.from}-${label.to}`); + const busiestDay = this.getKeyHavingMaxValue(totalMessagesOnWeekday, '-'); // returns key with max value - const date = { - gte: m, - lt: moment(m).add(1, 'hours'), - }; - - data.dataPoints.push(await this.ChartData[name](date, departmentId, extraQuery)); + // TODO: this code assumes the busiest day is the same every week, which may not be true + // This means that for periods larger than 1 week, the busiest hour won't be the "busiest hour" + // on the period, but the busiest hour on the busiest day. (sorry for busiest excess) + // iterate through all busiestDay in given date-range and find busiest hour + for await (const m of weekIterator(from, to, timezone)) { + if (m < from) { + continue; } - } else { - for await (const m of dayIterator(from, to)) { - data.dataLabels.push(m.format('M/D')); + for await (const h of hourIterator(m)) { const date = { - gte: m, - lt: moment(m).add(1, 'days'), + gte: h.clone(), + lt: h.add(1, 'hours'), }; - - data.dataPoints.push(await this.ChartData[name](date, departmentId, extraQuery)); + (await LivechatRooms.getAnalyticsBetweenDate(date, { departmentId }, extraQuery).toArray()).forEach(({ msgs }) => { + const dayHour = h.format('H'); // @int : 0, 1, ... 23 + totalMessagesInHour.set(dayHour, totalMessagesInHour.has(dayHour) ? totalMessagesInHour.get(dayHour) + msgs : msgs); + }); } } - return data; + const utcBusiestHour = this.getKeyHavingMaxValue(totalMessagesInHour, -1); + const busiestHour = { + to: utcBusiestHour >= 0 ? moment.utc().set({ hour: utcBusiestHour }).tz(timezone).format('hA') : '-', + from: utcBusiestHour >= 0 ? moment.utc().set({ hour: utcBusiestHour }).subtract(1, 'hour').tz(timezone).format('hA') : '', + }; + const onHoldConversations = await LivechatRooms.getOnHoldConversationsBetweenDate(from, to, departmentId, extraQuery); + + return [ + { + title: 'Total_conversations', + value: totalConversations, + }, + { + title: 'Open_conversations', + value: openConversations, + }, + { + title: 'On_Hold_conversations', + value: onHoldConversations, + }, + { + title: 'Total_messages', + value: totalMessages, + }, + { + title: 'Busiest_day', + value: t(busiestDay), + }, + { + title: 'Conversations_per_day', + value: (totalConversations / days).toFixed(2), + }, + { + title: 'Busiest_time', + value: `${busiestHour.from}${busiestHour.to ? `- ${busiestHour.to}` : ''}`, + }, + ]; }, - async getAnalyticsOverviewData(options) { - const { departmentId, utcOffset = 0, language, daterange: { from: fDate, to: tDate } = {}, analyticsOptions: { name } = {} } = options; - const timezone = getTimezone({ utcOffset }); - const from = moment.tz(fDate, 'YYYY-MM-DD', timezone).startOf('day').utc(); - const to = moment.tz(tDate, 'YYYY-MM-DD', timezone).endOf('day').utc(); + /** + * + * @param {Date} from + * @param {Date} to + * + * @returns {Array[Object]} + */ + async Productivity(from, to, departmentId, extraQuery) { + let avgResponseTime = 0; + let firstResponseTime = 0; + let avgReactionTime = 0; + let count = 0; + + const date = { + gte: from, + lt: to.add(1, 'days'), + }; - logger.debug(`getAnalyticsOverviewData[${name}] -> Using timezone ${timezone} with date range ${from} - ${to}`); + await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { + if (metrics && metrics.response && metrics.reaction) { + avgResponseTime += metrics.response.avg; + firstResponseTime += metrics.response.ft; + avgReactionTime += metrics.reaction.ft; + count++; + } + }); - if (!(moment(from).isValid() && moment(to).isValid())) { - logger.error('livechat:getAnalyticsOverviewData => Invalid dates'); - return; + if (count) { + avgResponseTime /= count; + firstResponseTime /= count; + avgReactionTime /= count; } - if (!this.OverviewData[name]) { - logger.error(`Method RocketChat.Livechat.Analytics.OverviewData.${name} does NOT exist`); - return; - } + const data = [ + { + title: 'Avg_response_time', + value: secondsToHHMMSS(avgResponseTime.toFixed(2)), + }, + { + title: 'Avg_first_response_time', + value: secondsToHHMMSS(firstResponseTime.toFixed(2)), + }, + { + title: 'Avg_reaction_time', + value: secondsToHHMMSS(avgReactionTime.toFixed(2)), + }, + ]; - const t = (s) => i18n.t(s, { lng: language }); + return data; + }, +}; - const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); - return this.OverviewData[name](from, to, departmentId, timezone, t, extraQuery); +const ChartData = { + /** + * + * @param {Object} date {gte: {Date}, lt: {Date}} + * + * @returns {Integer} + */ + Total_conversations(date, departmentId, extraQuery) { + return LivechatRooms.getTotalConversationsBetweenDate('l', date, { departmentId }, extraQuery); }, - ChartData: { - /** - * - * @param {Object} date {gte: {Date}, lt: {Date}} - * - * @returns {Integer} - */ - Total_conversations(date, departmentId, extraQuery) { - return LivechatRooms.getTotalConversationsBetweenDate('l', date, { departmentId }, extraQuery); - }, - - async Avg_chat_duration(date, departmentId, extraQuery) { - let total = 0; - let count = 0; - - await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { - if (metrics && metrics.chatDuration) { - total += metrics.chatDuration; - count++; - } - }); + async Avg_chat_duration(date, departmentId, extraQuery) { + let total = 0; + let count = 0; - const avgCD = count ? total / count : 0; - return Math.round(avgCD * 100) / 100; - }, - - async Total_messages(date, departmentId, extraQuery) { - let total = 0; - - // we don't want to count visitor messages - const extraFilter = { $lte: ['$token', null] }; - const allConversations = await LivechatRooms.getAnalyticsMetricsBetweenDateWithMessages( - 'l', - date, - { departmentId }, - extraFilter, - extraQuery, - ).toArray(); - allConversations.map(({ msgs }) => { - if (msgs) { - total += msgs; - } - return null; - }); + await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { + if (metrics && metrics.chatDuration) { + total += metrics.chatDuration; + count++; + } + }); - return total; - }, - - /** - * - * @param {Object} date {gte: {Date}, lt: {Date}} - * - * @returns {Double} - */ - async Avg_first_response_time(date, departmentId, extraQuery) { - let frt = 0; - let count = 0; - await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { - if (metrics && metrics.response && metrics.response.ft) { - frt += metrics.response.ft; - count++; - } - }); + const avgCD = count ? total / count : 0; + return Math.round(avgCD * 100) / 100; + }, - const avgFrt = count ? frt / count : 0; - return Math.round(avgFrt * 100) / 100; - }, - - /** - * - * @param {Object} date {gte: {Date}, lt: {Date}} - * - * @returns {Double} - */ - async Best_first_response_time(date, departmentId, extraQuery) { - let maxFrt; - - await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { - if (metrics && metrics.response && metrics.response.ft) { - maxFrt = maxFrt ? Math.min(maxFrt, metrics.response.ft) : metrics.response.ft; - } - }); + async Total_messages(date, departmentId, extraQuery) { + let total = 0; + + // we don't want to count visitor messages + const extraFilter = { $lte: ['$token', null] }; + const allConversations = await LivechatRooms.getAnalyticsMetricsBetweenDateWithMessages( + 'l', + date, + { departmentId }, + extraFilter, + extraQuery, + ).toArray(); + allConversations.map(({ msgs }) => { + if (msgs) { + total += msgs; + } + return null; + }); - if (!maxFrt) { - maxFrt = 0; + return total; + }, + + /** + * + * @param {Object} date {gte: {Date}, lt: {Date}} + * + * @returns {Double} + */ + async Avg_first_response_time(date, departmentId, extraQuery) { + let frt = 0; + let count = 0; + await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { + if (metrics && metrics.response && metrics.response.ft) { + frt += metrics.response.ft; + count++; } + }); - return Math.round(maxFrt * 100) / 100; - }, - - /** - * - * @param {Object} date {gte: {Date}, lt: {Date}} - * - * @returns {Double} - */ - async Avg_response_time(date, departmentId, extraQuery) { - let art = 0; - let count = 0; - await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { - if (metrics && metrics.response && metrics.response.avg) { - art += metrics.response.avg; - count++; - } - }); + const avgFrt = count ? frt / count : 0; + return Math.round(avgFrt * 100) / 100; + }, - const avgArt = count ? art / count : 0; - - return Math.round(avgArt * 100) / 100; - }, - - /** - * - * @param {Object} date {gte: {Date}, lt: {Date}} - * - * @returns {Double} - */ - async Avg_reaction_time(date, departmentId, extraQuery) { - let arnt = 0; - let count = 0; - await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { - if (metrics && metrics.reaction && metrics.reaction.ft) { - arnt += metrics.reaction.ft; - count++; - } - }); + /** + * + * @param {Object} date {gte: {Date}, lt: {Date}} + * + * @returns {Double} + */ + async Best_first_response_time(date, departmentId, extraQuery) { + let maxFrt; + + await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { + if (metrics && metrics.response && metrics.response.ft) { + maxFrt = maxFrt ? Math.min(maxFrt, metrics.response.ft) : metrics.response.ft; + } + }); - const avgArnt = count ? arnt / count : 0; + if (!maxFrt) { + maxFrt = 0; + } - return Math.round(avgArnt * 100) / 100; - }, + return Math.round(maxFrt * 100) / 100; }, - OverviewData: { - /** - * - * @param {Map} map - * - * @return {String} - */ - getKeyHavingMaxValue(map, def) { - let maxValue = 0; - let maxKey = def; // default - - map.forEach((value, key) => { - if (value > maxValue) { - maxValue = value; - maxKey = key; - } - }); + /** + * + * @param {Object} date {gte: {Date}, lt: {Date}} + * + * @returns {Double} + */ + async Avg_response_time(date, departmentId, extraQuery) { + let art = 0; + let count = 0; + await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { + if (metrics && metrics.response && metrics.response.avg) { + art += metrics.response.avg; + count++; + } + }); - return maxKey; - }, - - /** - * - * @param {Date} from - * @param {Date} to - * - * @returns {Array[Object]} - */ - async Conversations(from, to, departmentId, timezone, t = (v) => v, extraQuery) { - // TODO: most calls to db here can be done in one single call instead of one per day/hour - let totalConversations = 0; // Total conversations - let openConversations = 0; // open conversations - let totalMessages = 0; // total msgs - const totalMessagesOnWeekday = new Map(); // total messages on weekdays i.e Monday, Tuesday... - const totalMessagesInHour = new Map(); // total messages in hour 0, 1, ... 23 of weekday - const days = to.diff(from, 'days') + 1; // total days - - const summarize = - (m) => - ({ metrics, msgs, onHold = false }) => { - if (metrics && !metrics.chatDuration && !onHold) { - openConversations++; - } - totalMessages += msgs; - - const weekday = m.format('dddd'); // @string: Monday, Tuesday ... - totalMessagesOnWeekday.set(weekday, totalMessagesOnWeekday.has(weekday) ? totalMessagesOnWeekday.get(weekday) + msgs : msgs); - }; + const avgArt = count ? art / count : 0; - const m = moment.tz(from, timezone).startOf('day').utc(); - // eslint-disable-next-line no-unused-vars - for await (const _ of Array(days).fill(0)) { - const clonedDate = m.clone(); - const date = { - gte: clonedDate, - lt: m.add(1, 'days'), - }; - // eslint-disable-next-line no-await-in-loop - const result = await LivechatRooms.getAnalyticsBetweenDate(date, { departmentId }, extraQuery).toArray(); - totalConversations += result.length; + return Math.round(avgArt * 100) / 100; + }, - result.forEach(summarize(clonedDate)); + /** + * + * @param {Object} date {gte: {Date}, lt: {Date}} + * + * @returns {Double} + */ + async Avg_reaction_time(date, departmentId, extraQuery) { + let arnt = 0; + let count = 0; + await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { + if (metrics && metrics.reaction && metrics.reaction.ft) { + arnt += metrics.reaction.ft; + count++; } + }); - const busiestDay = this.getKeyHavingMaxValue(totalMessagesOnWeekday, '-'); // returns key with max value + const avgArnt = count ? arnt / count : 0; - // TODO: this code assumes the busiest day is the same every week, which may not be true - // This means that for periods larger than 1 week, the busiest hour won't be the "busiest hour" - // on the period, but the busiest hour on the busiest day. (sorry for busiest excess) - // iterate through all busiestDay in given date-range and find busiest hour - for await (const m of weekIterator(from, to, timezone)) { - if (m < from) { - continue; - } + return Math.round(avgArnt * 100) / 100; + }, +}; - for await (const h of hourIterator(m)) { - const date = { - gte: h.clone(), - lt: h.add(1, 'hours'), - }; - (await LivechatRooms.getAnalyticsBetweenDate(date, { departmentId }, extraQuery).toArray()).forEach(({ msgs }) => { - const dayHour = h.format('H'); // @int : 0, 1, ... 23 - totalMessagesInHour.set(dayHour, totalMessagesInHour.has(dayHour) ? totalMessagesInHour.get(dayHour) + msgs : msgs); - }); - } +const AgentOverviewData = { + /** + * do operation equivalent to map[key] += value + * + */ + updateMap(map, key, value) { + map.set(key, map.has(key) ? map.get(key) + value : value); + }, + + /** + * Sort array of objects by value property of object + * @param {Array(Object)} data + * @param {Boolean} [inv=false] reverse sort + */ + sortByValue(data, inv = false) { + data.sort((a, b) => { + // sort array + if (parseFloat(a.value) > parseFloat(b.value)) { + return inv ? -1 : 1; // if inv, reverse sort + } + if (parseFloat(a.value) < parseFloat(b.value)) { + return inv ? 1 : -1; } + return 0; + }); + }, - const utcBusiestHour = this.getKeyHavingMaxValue(totalMessagesInHour, -1); - const busiestHour = { - to: utcBusiestHour >= 0 ? moment.utc().set({ hour: utcBusiestHour }).tz(timezone).format('hA') : '-', - from: utcBusiestHour >= 0 ? moment.utc().set({ hour: utcBusiestHour }).subtract(1, 'hour').tz(timezone).format('hA') : '', - }; - const onHoldConversations = await LivechatRooms.getOnHoldConversationsBetweenDate(from, to, departmentId, extraQuery); + /** + * + * @param {Date} from + * @param {Date} to + * + * @returns {Array(Object), Array(Object)} + */ + async Total_conversations(from, to, departmentId, extraQuery) { + let total = 0; + const agentConversations = new Map(); // stores total conversations for each agent + const date = { + gte: from, + lt: to.add(1, 'days'), + }; - return [ - { - title: 'Total_conversations', - value: totalConversations, - }, - { - title: 'Open_conversations', - value: openConversations, - }, - { - title: 'On_Hold_conversations', - value: onHoldConversations, - }, + const data = { + head: [ { - title: 'Total_messages', - value: totalMessages, + name: 'Agent', }, { - title: 'Busiest_day', - value: t(busiestDay), + name: '%_of_conversations', }, + ], + data: [], + }; + + const allConversations = await LivechatRooms.getAnalyticsMetricsBetweenDateWithMessages( + 'l', + date, + { + departmentId, + }, + {}, + extraQuery, + ).toArray(); + allConversations.map((room) => { + if (room.servedBy) { + this.updateMap(agentConversations, room.servedBy.username, 1); + total++; + } + return null; + }); + + agentConversations.forEach((value, key) => { + // calculate percentage + const percentage = ((value / total) * 100).toFixed(2); + + data.data.push({ + name: key, + value: percentage, + }); + }); + + this.sortByValue(data.data, true); // reverse sort array + + data.data.forEach((value) => { + value.value = `${value.value}%`; + }); + + return data; + }, + + /** + * + * @param {Date} from + * @param {Date} to + * + * @returns {Array(Object), Array(Object)} + */ + async Avg_chat_duration(from, to, departmentId, extraQuery) { + const agentChatDurations = new Map(); // stores total conversations for each agent + const date = { + gte: from, + lt: to.add(1, 'days'), + }; + + const data = { + head: [ { - title: 'Conversations_per_day', - value: (totalConversations / days).toFixed(2), + name: 'Agent', }, { - title: 'Busiest_time', - value: `${busiestHour.from}${busiestHour.to ? `- ${busiestHour.to}` : ''}`, + name: 'Avg_chat_duration', }, - ]; - }, - - /** - * - * @param {Date} from - * @param {Date} to - * - * @returns {Array[Object]} - */ - async Productivity(from, to, departmentId, extraQuery) { - let avgResponseTime = 0; - let firstResponseTime = 0; - let avgReactionTime = 0; - let count = 0; - - const date = { - gte: from, - lt: to.add(1, 'days'), - }; + ], + data: [], + }; - await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => { - if (metrics && metrics.response && metrics.reaction) { - avgResponseTime += metrics.response.avg; - firstResponseTime += metrics.response.ft; - avgReactionTime += metrics.reaction.ft; - count++; + await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { + if (servedBy && metrics && metrics.chatDuration) { + if (agentChatDurations.has(servedBy.username)) { + agentChatDurations.set(servedBy.username, { + chatDuration: agentChatDurations.get(servedBy.username).chatDuration + metrics.chatDuration, + total: agentChatDurations.get(servedBy.username).total + 1, + }); + } else { + agentChatDurations.set(servedBy.username, { + chatDuration: metrics.chatDuration, + total: 1, + }); } + } + }); + + agentChatDurations.forEach((obj, key) => { + // calculate percentage + const avg = (obj.chatDuration / obj.total).toFixed(2); + + data.data.push({ + name: key, + value: avg, }); + }); - if (count) { - avgResponseTime /= count; - firstResponseTime /= count; - avgReactionTime /= count; - } + this.sortByValue(data.data, true); // reverse sort array - const data = [ - { - title: 'Avg_response_time', - value: secondsToHHMMSS(avgResponseTime.toFixed(2)), - }, + data.data.forEach((obj) => { + obj.value = secondsToHHMMSS(obj.value); + }); + + return data; + }, + + /** + * + * @param {Date} from + * @param {Date} to + * + * @returns {Array(Object), Array(Object)} + */ + async Total_messages(from, to, departmentId, extraQuery) { + const agentMessages = new Map(); // stores total conversations for each agent + const date = { + gte: from, + lt: to.add(1, 'days'), + }; + + const data = { + head: [ { - title: 'Avg_first_response_time', - value: secondsToHHMMSS(firstResponseTime.toFixed(2)), + name: 'Agent', }, { - title: 'Avg_reaction_time', - value: secondsToHHMMSS(avgReactionTime.toFixed(2)), + name: 'Total_messages', }, - ]; - - return data; - }, - }, + ], + data: [], + }; - AgentOverviewData: { - /** - * do operation equivalent to map[key] += value - * - */ - updateMap(map, key, value) { - map.set(key, map.has(key) ? map.get(key) + value : value); - }, - - /** - * Sort array of objects by value property of object - * @param {Array(Object)} data - * @param {Boolean} [inv=false] reverse sort - */ - sortByValue(data, inv = false) { - data.sort((a, b) => { - // sort array - if (parseFloat(a.value) > parseFloat(b.value)) { - return inv ? -1 : 1; // if inv, reverse sort - } - if (parseFloat(a.value) < parseFloat(b.value)) { - return inv ? 1 : -1; - } - return 0; + // we don't want to count visitor messages + const extraFilter = { $lte: ['$token', null] }; + const allConversations = await LivechatRooms.getAnalyticsMetricsBetweenDateWithMessages( + 'l', + date, + { departmentId }, + extraFilter, + extraQuery, + ).toArray(); + allConversations.map(({ servedBy, msgs }) => { + if (servedBy) { + this.updateMap(agentMessages, servedBy.username, msgs); + } + return null; + }); + + agentMessages.forEach((value, key) => { + // calculate percentage + data.data.push({ + name: key, + value, }); - }, - - /** - * - * @param {Date} from - * @param {Date} to - * - * @returns {Array(Object), Array(Object)} - */ - async Total_conversations(from, to, departmentId, extraQuery) { - let total = 0; - const agentConversations = new Map(); // stores total conversations for each agent - const date = { - gte: from, - lt: to.add(1, 'days'), - }; + }); - const data = { - head: [ - { - name: 'Agent', - }, - { - name: '%_of_conversations', - }, - ], - data: [], - }; + this.sortByValue(data.data, true); // reverse sort array - const allConversations = await LivechatRooms.getAnalyticsMetricsBetweenDateWithMessages( - 'l', - date, + return data; + }, + + /** + * + * @param {Date} from + * @param {Date} to + * + * @returns {Array(Object), Array(Object)} + */ + async Avg_first_response_time(from, to, departmentId, extraQuery) { + const agentAvgRespTime = new Map(); // stores avg response time for each agent + const date = { + gte: from, + lt: to.add(1, 'days'), + }; + + const data = { + head: [ + { + name: 'Agent', + }, { - departmentId, + name: 'Avg_first_response_time', }, - {}, - extraQuery, - ).toArray(); - allConversations.map((room) => { - if (room.servedBy) { - this.updateMap(agentConversations, room.servedBy.username, 1); - total++; + ], + data: [], + }; + + await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { + if (servedBy && metrics && metrics.response && metrics.response.ft) { + if (agentAvgRespTime.has(servedBy.username)) { + agentAvgRespTime.set(servedBy.username, { + frt: agentAvgRespTime.get(servedBy.username).frt + metrics.response.ft, + total: agentAvgRespTime.get(servedBy.username).total + 1, + }); + } else { + agentAvgRespTime.set(servedBy.username, { + frt: metrics.response.ft, + total: 1, + }); } - return null; - }); + } + }); - agentConversations.forEach((value, key) => { - // calculate percentage - const percentage = ((value / total) * 100).toFixed(2); + agentAvgRespTime.forEach((obj, key) => { + // calculate avg + const avg = obj.frt / obj.total; - data.data.push({ - name: key, - value: percentage, - }); + data.data.push({ + name: key, + value: avg.toFixed(2), }); + }); - this.sortByValue(data.data, true); // reverse sort array + this.sortByValue(data.data, false); // sort array - data.data.forEach((value) => { - value.value = `${value.value}%`; - }); + data.data.forEach((obj) => { + obj.value = secondsToHHMMSS(obj.value); + }); - return data; - }, - - /** - * - * @param {Date} from - * @param {Date} to - * - * @returns {Array(Object), Array(Object)} - */ - async Avg_chat_duration(from, to, departmentId, extraQuery) { - const agentChatDurations = new Map(); // stores total conversations for each agent - const date = { - gte: from, - lt: to.add(1, 'days'), - }; + return data; + }, - const data = { - head: [ - { - name: 'Agent', - }, - { - name: 'Avg_chat_duration', - }, - ], - data: [], - }; + /** + * + * @param {Date} from + * @param {Date} to + * + * @returns {Array(Object), Array(Object)} + */ + async Best_first_response_time(from, to, departmentId, extraQuery) { + const agentFirstRespTime = new Map(); // stores avg response time for each agent + const date = { + gte: from, + lt: to.add(1, 'days'), + }; - await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { - if (servedBy && metrics && metrics.chatDuration) { - if (agentChatDurations.has(servedBy.username)) { - agentChatDurations.set(servedBy.username, { - chatDuration: agentChatDurations.get(servedBy.username).chatDuration + metrics.chatDuration, - total: agentChatDurations.get(servedBy.username).total + 1, - }); - } else { - agentChatDurations.set(servedBy.username, { - chatDuration: metrics.chatDuration, - total: 1, - }); - } - } - }); + const data = { + head: [ + { + name: 'Agent', + }, + { + name: 'Best_first_response_time', + }, + ], + data: [], + }; - agentChatDurations.forEach((obj, key) => { - // calculate percentage - const avg = (obj.chatDuration / obj.total).toFixed(2); + await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { + if (servedBy && metrics && metrics.response && metrics.response.ft) { + if (agentFirstRespTime.has(servedBy.username)) { + agentFirstRespTime.set(servedBy.username, Math.min(agentFirstRespTime.get(servedBy.username), metrics.response.ft)); + } else { + agentFirstRespTime.set(servedBy.username, metrics.response.ft); + } + } + }); - data.data.push({ - name: key, - value: avg, - }); + agentFirstRespTime.forEach((value, key) => { + // calculate avg + data.data.push({ + name: key, + value: value.toFixed(2), }); + }); - this.sortByValue(data.data, true); // reverse sort array + this.sortByValue(data.data, false); // sort array - data.data.forEach((obj) => { - obj.value = secondsToHHMMSS(obj.value); - }); + data.data.forEach((obj) => { + obj.value = secondsToHHMMSS(obj.value); + }); - return data; - }, - - /** - * - * @param {Date} from - * @param {Date} to - * - * @returns {Array(Object), Array(Object)} - */ - async Total_messages(from, to, departmentId, extraQuery) { - const agentMessages = new Map(); // stores total conversations for each agent - const date = { - gte: from, - lt: to.add(1, 'days'), - }; + return data; + }, - const data = { - head: [ - { - name: 'Agent', - }, - { - name: 'Total_messages', - }, - ], - data: [], - }; + /** + * + * @param {Date} from + * @param {Date} to + * + * @returns {Array(Object), Array(Object)} + */ + async Avg_response_time(from, to, departmentId, extraQuery) { + const agentAvgRespTime = new Map(); // stores avg response time for each agent + const date = { + gte: from, + lt: to.add(1, 'days'), + }; + + const data = { + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_response_time', + }, + ], + data: [], + }; - // we don't want to count visitor messages - const extraFilter = { $lte: ['$token', null] }; - const allConversations = await LivechatRooms.getAnalyticsMetricsBetweenDateWithMessages( - 'l', - date, - { departmentId }, - extraFilter, - extraQuery, - ).toArray(); - allConversations.map(({ servedBy, msgs }) => { - if (servedBy) { - this.updateMap(agentMessages, servedBy.username, msgs); + await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { + if (servedBy && metrics && metrics.response && metrics.response.avg) { + if (agentAvgRespTime.has(servedBy.username)) { + agentAvgRespTime.set(servedBy.username, { + avg: agentAvgRespTime.get(servedBy.username).avg + metrics.response.avg, + total: agentAvgRespTime.get(servedBy.username).total + 1, + }); + } else { + agentAvgRespTime.set(servedBy.username, { + avg: metrics.response.avg, + total: 1, + }); } - return null; - }); + } + }); - agentMessages.forEach((value, key) => { - // calculate percentage - data.data.push({ - name: key, - value, - }); + agentAvgRespTime.forEach((obj, key) => { + // calculate avg + const avg = obj.avg / obj.total; + + data.data.push({ + name: key, + value: avg.toFixed(2), }); + }); - this.sortByValue(data.data, true); // reverse sort array + this.sortByValue(data.data, false); // sort array - return data; - }, + data.data.forEach((obj) => { + obj.value = secondsToHHMMSS(obj.value); + }); - /** - * - * @param {Date} from - * @param {Date} to - * - * @returns {Array(Object), Array(Object)} - */ - async Avg_first_response_time(from, to, departmentId, extraQuery) { - const agentAvgRespTime = new Map(); // stores avg response time for each agent - const date = { - gte: from, - lt: to.add(1, 'days'), - }; + return data; + }, - const data = { - head: [ - { - name: 'Agent', - }, - { - name: 'Avg_first_response_time', - }, - ], - data: [], - }; + /** + * + * @param {Date} from + * @param {Date} to + * + * @returns {Array(Object), Array(Object)} + */ + async Avg_reaction_time(from, to, departmentId, extraQuery) { + const agentAvgReactionTime = new Map(); // stores avg reaction time for each agent + const date = { + gte: from, + lt: to.add(1, 'days'), + }; - await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { - if (servedBy && metrics && metrics.response && metrics.response.ft) { - if (agentAvgRespTime.has(servedBy.username)) { - agentAvgRespTime.set(servedBy.username, { - frt: agentAvgRespTime.get(servedBy.username).frt + metrics.response.ft, - total: agentAvgRespTime.get(servedBy.username).total + 1, - }); - } else { - agentAvgRespTime.set(servedBy.username, { - frt: metrics.response.ft, - total: 1, - }); - } + const data = { + head: [ + { + name: 'Agent', + }, + { + name: 'Avg_reaction_time', + }, + ], + data: [], + }; + + await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { + if (servedBy && metrics && metrics.reaction && metrics.reaction.ft) { + if (agentAvgReactionTime.has(servedBy.username)) { + agentAvgReactionTime.set(servedBy.username, { + frt: agentAvgReactionTime.get(servedBy.username).frt + metrics.reaction.ft, + total: agentAvgReactionTime.get(servedBy.username).total + 1, + }); + } else { + agentAvgReactionTime.set(servedBy.username, { + frt: metrics.reaction.ft, + total: 1, + }); } - }); + } + }); - agentAvgRespTime.forEach((obj, key) => { - // calculate avg - const avg = obj.frt / obj.total; + agentAvgReactionTime.forEach((obj, key) => { + // calculate avg + const avg = obj.frt / obj.total; - data.data.push({ - name: key, - value: avg.toFixed(2), - }); + data.data.push({ + name: key, + value: avg.toFixed(2), }); + }); - this.sortByValue(data.data, false); // sort array + this.sortByValue(data.data, false); // sort array - data.data.forEach((obj) => { - obj.value = secondsToHHMMSS(obj.value); - }); + data.data.forEach((obj) => { + obj.value = secondsToHHMMSS(obj.value); + }); - return data; - }, - - /** - * - * @param {Date} from - * @param {Date} to - * - * @returns {Array(Object), Array(Object)} - */ - async Best_first_response_time(from, to, departmentId, extraQuery) { - const agentFirstRespTime = new Map(); // stores avg response time for each agent - const date = { - gte: from, - lt: to.add(1, 'days'), - }; + return data; + }, +}; - const data = { - head: [ - { - name: 'Agent', - }, - { - name: 'Best_first_response_time', - }, - ], - data: [], - }; +export const Analytics = { + async getAgentOverviewData(options) { + const { departmentId, utcOffset, daterange: { from: fDate, to: tDate } = {}, chartOptions: { name } = {} } = options; + const timezone = getTimezone({ utcOffset }); + const from = moment.tz(fDate, 'YYYY-MM-DD', timezone).startOf('day').utc(); + const to = moment.tz(tDate, 'YYYY-MM-DD', timezone).endOf('day').utc(); - await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { - if (servedBy && metrics && metrics.response && metrics.response.ft) { - if (agentFirstRespTime.has(servedBy.username)) { - agentFirstRespTime.set(servedBy.username, Math.min(agentFirstRespTime.get(servedBy.username), metrics.response.ft)); - } else { - agentFirstRespTime.set(servedBy.username, metrics.response.ft); - } - } - }); + if (!(moment(from).isValid() && moment(to).isValid())) { + logger.error('livechat:getAgentOverviewData => Invalid dates'); + return; + } - agentFirstRespTime.forEach((value, key) => { - // calculate avg - data.data.push({ - name: key, - value: value.toFixed(2), - }); - }); + if (!AgentOverviewData[name]) { + logger.error(`Method RocketChat.Livechat.Analytics.AgentOverviewData.${name} does NOT exist`); + return; + } - this.sortByValue(data.data, false); // sort array + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); + return AgentOverviewData[name](from, to, departmentId, extraQuery); + }, - data.data.forEach((obj) => { - obj.value = secondsToHHMMSS(obj.value); - }); + async getAnalyticsChartData(options) { + const { + utcOffset, + departmentId, + daterange: { from: fDate, to: tDate } = {}, + chartOptions: { name: chartLabel }, + chartOptions: { name } = {}, + } = options; - return data; - }, - - /** - * - * @param {Date} from - * @param {Date} to - * - * @returns {Array(Object), Array(Object)} - */ - async Avg_response_time(from, to, departmentId, extraQuery) { - const agentAvgRespTime = new Map(); // stores avg response time for each agent - const date = { - gte: from, - lt: to.add(1, 'days'), - }; + // Check if function exists, prevent server error in case property altered + if (!ChartData[name]) { + logger.error(`Method RocketChat.Livechat.Analytics.ChartData.${name} does NOT exist`); + return; + } - const data = { - head: [ - { - name: 'Agent', - }, - { - name: 'Avg_response_time', - }, - ], - data: [], - }; + const timezone = getTimezone({ utcOffset }); + const from = moment.tz(fDate, 'YYYY-MM-DD', timezone).startOf('day').utc(); + const to = moment.tz(tDate, 'YYYY-MM-DD', timezone).endOf('day').utc(); + const isSameDay = from.diff(to, 'days') === 0; - await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { - if (servedBy && metrics && metrics.response && metrics.response.avg) { - if (agentAvgRespTime.has(servedBy.username)) { - agentAvgRespTime.set(servedBy.username, { - avg: agentAvgRespTime.get(servedBy.username).avg + metrics.response.avg, - total: agentAvgRespTime.get(servedBy.username).total + 1, - }); - } else { - agentAvgRespTime.set(servedBy.username, { - avg: metrics.response.avg, - total: 1, - }); - } - } - }); + if (!(moment(from).isValid() && moment(to).isValid())) { + logger.error('livechat:getAnalyticsChartData => Invalid dates'); + return; + } - agentAvgRespTime.forEach((obj, key) => { - // calculate avg - const avg = obj.avg / obj.total; + const data = { + chartLabel, + dataLabels: [], + dataPoints: [], + }; - data.data.push({ - name: key, - value: avg.toFixed(2), - }); - }); + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); + if (isSameDay) { + // data for single day + const m = moment(from); + for await (const currentHour of Array.from({ length: HOURS_IN_DAY }, (_, i) => i)) { + const hour = m.add(currentHour ? 1 : 0, 'hour').format('H'); + const label = { + from: moment.utc().set({ hour }).tz(timezone).format('hA'), + to: moment.utc().set({ hour }).add(1, 'hour').tz(timezone).format('hA'), + }; + data.dataLabels.push(`${label.from}-${label.to}`); - this.sortByValue(data.data, false); // sort array + const date = { + gte: m, + lt: moment(m).add(1, 'hours'), + }; - data.data.forEach((obj) => { - obj.value = secondsToHHMMSS(obj.value); - }); + data.dataPoints.push(await ChartData[name](date, departmentId, extraQuery)); + } + } else { + for await (const m of dayIterator(from, to)) { + data.dataLabels.push(m.format('M/D')); - return data; - }, - - /** - * - * @param {Date} from - * @param {Date} to - * - * @returns {Array(Object), Array(Object)} - */ - async Avg_reaction_time(from, to, departmentId, extraQuery) { - const agentAvgReactionTime = new Map(); // stores avg reaction time for each agent - const date = { - gte: from, - lt: to.add(1, 'days'), - }; + const date = { + gte: m, + lt: moment(m).add(1, 'days'), + }; - const data = { - head: [ - { - name: 'Agent', - }, - { - name: 'Avg_reaction_time', - }, - ], - data: [], - }; + data.dataPoints.push(await ChartData[name](date, departmentId, extraQuery)); + } + } - await LivechatRooms.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { - if (servedBy && metrics && metrics.reaction && metrics.reaction.ft) { - if (agentAvgReactionTime.has(servedBy.username)) { - agentAvgReactionTime.set(servedBy.username, { - frt: agentAvgReactionTime.get(servedBy.username).frt + metrics.reaction.ft, - total: agentAvgReactionTime.get(servedBy.username).total + 1, - }); - } else { - agentAvgReactionTime.set(servedBy.username, { - frt: metrics.reaction.ft, - total: 1, - }); - } - } - }); + return data; + }, - agentAvgReactionTime.forEach((obj, key) => { - // calculate avg - const avg = obj.frt / obj.total; + async getAnalyticsOverviewData(options) { + const { departmentId, utcOffset = 0, language, daterange: { from: fDate, to: tDate } = {}, analyticsOptions: { name } = {} } = options; + const timezone = getTimezone({ utcOffset }); + const from = moment.tz(fDate, 'YYYY-MM-DD', timezone).startOf('day').utc(); + const to = moment.tz(tDate, 'YYYY-MM-DD', timezone).endOf('day').utc(); - data.data.push({ - name: key, - value: avg.toFixed(2), - }); - }); + if (!(moment(from).isValid() && moment(to).isValid())) { + logger.error('livechat:getAnalyticsOverviewData => Invalid dates'); + return; + } - this.sortByValue(data.data, false); // sort array + if (!OverviewData[name]) { + logger.error(`Method RocketChat.Livechat.Analytics.OverviewData.${name} does NOT exist`); + return; + } - data.data.forEach((obj) => { - obj.value = secondsToHHMMSS(obj.value); - }); + const t = (s) => i18n.t(s, { lng: language }); - return data; - }, + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); + return OverviewData[name](from, to, departmentId, timezone, t, extraQuery); }, }; diff --git a/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts b/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts new file mode 100644 index 000000000000..20717d6e02dd --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts @@ -0,0 +1,12 @@ +import mem from 'mem'; + +import { Analytics } from './Analytics'; + +export const getAgentOverviewDataCached = mem(Analytics.getAgentOverviewData, { maxAge: 60000, cacheKey: JSON.stringify }); +// Agent overview data on realtime is cached for 5 seconds +// while the data on the overview page is cached for 1 minute +export const getAnalyticsOverviewDataCached = mem(Analytics.getAnalyticsOverviewData, { maxAge: 60000, cacheKey: JSON.stringify }); +export const getAnalyticsOverviewDataCachedForRealtime = mem(Analytics.getAnalyticsOverviewData, { + maxAge: 5000, + cacheKey: JSON.stringify, +}); diff --git a/apps/meteor/app/livechat/server/lib/Departments.ts b/apps/meteor/app/livechat/server/lib/Departments.ts index 0dd48a328fd1..f17015e52e79 100644 --- a/apps/meteor/app/livechat/server/lib/Departments.ts +++ b/apps/meteor/app/livechat/server/lib/Departments.ts @@ -12,7 +12,6 @@ class DepartmentHelperClass { const department = await LivechatDepartment.findOneById(departmentId); if (!department) { - this.logger.debug(`Department not found: ${departmentId}`); throw new Error('error-department-not-found'); } @@ -20,10 +19,8 @@ class DepartmentHelperClass { const ret = await LivechatDepartment.removeById(_id); if (ret.acknowledged !== true) { - this.logger.error(`Department record not removed: ${_id}. Result from db: ${ret}`); throw new Error('error-failed-to-delete-department'); } - this.logger.debug(`Department record removed: ${_id}`); const agentsIds: string[] = await LivechatDepartmentAgents.findAgentsByDepartmentId>( department._id, @@ -47,8 +44,6 @@ class DepartmentHelperClass { } }); - this.logger.debug(`Post-department-removal actions completed: ${_id}. Notifying callbacks with department and agentsIds`); - setImmediate(() => { void callbacks.run('livechat.afterRemoveDepartment', { department, agentsIds }); }); diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 75722e709b17..4acbdf5090ad 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -402,6 +402,9 @@ export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: T logger.debug(`Forwarding room ${room._id} to agent ${transferData.userId}`); const { userId: agentId, clientAction } = transferData; + if (!agentId) { + throw new Error('error-invalid-agent'); + } const user = await Users.findOneOnlineAgentById(agentId); if (!user) { logger.debug(`Agent ${agentId} is offline. Cannot forward`); @@ -434,7 +437,7 @@ export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: T return false; } - await Livechat.saveTransferHistory(room, transferData); + await LivechatTyped.saveTransferHistory(room, transferData); const { servedBy } = roomTaken; if (servedBy) { @@ -534,7 +537,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi logger.debug( `Routing algorithm doesn't support auto assignment (using ${RoutingManager.methodName}). Chat will be on department queue`, ); - await Livechat.saveTransferHistory(room, transferData); + await LivechatTyped.saveTransferHistory(room, transferData); return RoutingManager.unassignAgent(inquiry, departmentId); } @@ -570,7 +573,7 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi } } - await Livechat.saveTransferHistory(room, transferData); + await LivechatTyped.saveTransferHistory(room, transferData); if (oldServedBy) { // if chat is queued then we don't ignore the new servedBy agent bcs at this // point the chat is not assigned to him/her and it is still in the queue diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index 52138740e295..2e1a77ca7114 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -1,24 +1,16 @@ // Note: Please don't add any new methods to this file, since its still in js and we are migrating to ts // Please add new methods to LivechatTyped.ts - -import dns from 'dns'; -import util from 'util'; - -import { Message, VideoConf, api } from '@rocket.chat/core-services'; +import { Message, Omnichannel } from '@rocket.chat/core-services'; import { Logger } from '@rocket.chat/logger'; import { LivechatVisitors, LivechatCustomField, - Settings, LivechatRooms, LivechatInquiry, Subscriptions, - Messages, LivechatDepartment as LivechatDepartmentRaw, - LivechatDepartmentAgents, Rooms, Users, - ReadReceipts, } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -30,86 +22,21 @@ import { trim } from '../../../../lib/utils/stringUtils'; import { i18n } from '../../../../server/lib/i18n'; import { addUserRolesAsync } from '../../../../server/lib/roles/addUserRoles'; import { removeUserFromRolesAsync } from '../../../../server/lib/roles/removeUserFromRoles'; -import { canAccessRoomAsync, roomAccessAttributes } from '../../../authorization/server'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { FileUpload } from '../../../file-upload/server'; -import { deleteMessage } from '../../../lib/server/functions/deleteMessage'; -import { sendMessage } from '../../../lib/server/functions/sendMessage'; -import { updateMessage } from '../../../lib/server/functions/updateMessage'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; import { Analytics } from './Analytics'; -import { normalizeTransferredByData, parseAgentCustomFields, updateDepartmentAgents } from './Helper'; -import { Livechat as LivechatTyped } from './LivechatTyped'; +import { parseAgentCustomFields, updateDepartmentAgents } from './Helper'; import { RoutingManager } from './RoutingManager'; const logger = new Logger('Livechat'); -const dnsResolveMx = util.promisify(dns.resolveMx); - export const Livechat = { Analytics, logger, - async sendMessage({ guest, message, roomInfo, agent }) { - const { room, newRoom } = await LivechatTyped.getRoom(guest, message, roomInfo, agent); - if (guest.name) { - message.alias = guest.name; - } - return Object.assign(await sendMessage(guest, message, room), { - newRoom, - showConnecting: this.showConnecting(), - }); - }, - - async updateMessage({ guest, message }) { - check(message, Match.ObjectIncluding({ _id: String })); - - const originalMessage = await Messages.findOneById(message._id); - if (!originalMessage || !originalMessage._id) { - return; - } - - const editAllowed = settings.get('Message_AllowEditing'); - const editOwn = originalMessage.u && originalMessage.u._id === guest._id; - - if (!editAllowed || !editOwn) { - throw new Meteor.Error('error-action-not-allowed', 'Message editing not allowed', { - method: 'livechatUpdateMessage', - }); - } - - await updateMessage(message, guest); - - return true; - }, - - async deleteMessage({ guest, message }) { - Livechat.logger.debug(`Attempting to delete a message by visitor ${guest._id}`); - check(message, Match.ObjectIncluding({ _id: String })); - - const msg = await Messages.findOneById(message._id); - if (!msg || !msg._id) { - return; - } - - const deleteAllowed = settings.get('Message_AllowDeleting'); - const editOwn = msg.u && msg.u._id === guest._id; - - if (!deleteAllowed || !editOwn) { - Livechat.logger.debug('Cannot delete message: not allowed'); - throw new Meteor.Error('error-action-not-allowed', 'Message deleting not allowed', { - method: 'livechatDeleteMessage', - }); - } - - await deleteMessage(message, guest); - - return true; - }, - async saveGuest(guestData, userId) { const { _id, name, email, phone, livechatData = {} } = guestData; Livechat.logger.debug(`Saving data for visitor ${_id}`); @@ -189,50 +116,6 @@ export const Livechat = { return 0; }, - async getInitSettings() { - const rcSettings = {}; - - await Settings.findNotHiddenPublic([ - 'Livechat_title', - 'Livechat_title_color', - 'Livechat_enable_message_character_limit', - 'Livechat_message_character_limit', - 'Message_MaxAllowedSize', - 'Livechat_enabled', - 'Livechat_registration_form', - 'Livechat_allow_switching_departments', - 'Livechat_offline_title', - 'Livechat_offline_title_color', - 'Livechat_offline_message', - 'Livechat_offline_success_message', - 'Livechat_offline_form_unavailable', - 'Livechat_display_offline_form', - 'Omnichannel_call_provider', - 'Language', - 'Livechat_enable_transcript', - 'Livechat_transcript_message', - 'Livechat_fileupload_enabled', - 'FileUpload_Enabled', - 'Livechat_conversation_finished_message', - 'Livechat_conversation_finished_text', - 'Livechat_name_field_registration_form', - 'Livechat_email_field_registration_form', - 'Livechat_registration_form_message', - 'Livechat_force_accept_data_processing_consent', - 'Livechat_data_processing_consent_text', - 'Livechat_show_agent_info', - 'Livechat_clear_local_storage_when_chat_ended', - ]).forEach((setting) => { - rcSettings[setting._id] = setting.value; - }); - - rcSettings.Livechat_history_monitor_type = settings.get('Livechat_history_monitor_type'); - - rcSettings.Livechat_Show_Connecting = this.showConnecting(); - - return rcSettings; - }, - async saveRoomInfo(roomData, guestData, userId) { Livechat.logger.debug(`Saving room information on room ${roomData._id}`); const { livechatData = {} } = roomData; @@ -281,35 +164,6 @@ export const Livechat = { } }, - async closeOpenChats(userId, comment) { - Livechat.logger.debug(`Closing open chats for user ${userId}`); - const user = await Users.findOneById(userId); - - const extraQuery = await callbacks.run('livechat.applyDepartmentRestrictions', {}); - const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery); - const promises = []; - await openChats.forEach((room) => { - promises.push(LivechatTyped.closeRoom({ user, room, comment })); - }); - - await Promise.all(promises); - }, - - async forwardOpenChats(userId) { - Livechat.logger.debug(`Transferring open chats for user ${userId}`); - for await (const room of LivechatRooms.findOpenByAgent(userId)) { - const guest = await LivechatVisitors.findOneById(room.v._id); - const user = await Users.findOneById(userId); - const { _id, username, name } = user; - const transferredBy = normalizeTransferredByData({ _id, username, name }, room); - await this.transfer(room, guest, { - roomId: room._id, - transferredBy, - departmentId: guest.department, - }); - } - }, - async savePageHistory(token, roomId, pageInfo) { Livechat.logger.debug(`Saving page movement history for visitor with token ${token}`); if (pageInfo.change !== settings.get('Livechat_history_monitor_type')) { @@ -339,130 +193,8 @@ export const Livechat = { return Message.saveSystemMessage('livechat_navigation_history', roomId, `${pageTitle} - ${pageUrl}`, user, extraData); }, - async saveTransferHistory(room, transferData) { - Livechat.logger.debug(`Saving transfer history for room ${room._id}`); - const { departmentId: previousDepartment } = room; - const { department: nextDepartment, transferredBy, transferredTo, scope, comment } = transferData; - - check( - transferredBy, - Match.ObjectIncluding({ - _id: String, - username: String, - name: Match.Maybe(String), - type: String, - }), - ); - - const { _id, username } = transferredBy; - const scopeData = scope || (nextDepartment ? 'department' : 'agent'); - Livechat.logger.debug(`Storing new chat transfer of ${room._id} [Transfered by: ${_id} to ${scopeData}]`); - - const transfer = { - transferData: { - transferredBy, - 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, - }, - groupable: false, - }; - - Object.assign(transferMessage, transfer); - - await sendMessage(transferredBy, transferMessage, room); - }, - - async transfer(room, guest, transferData) { - Livechat.logger.debug(`Transfering room ${room._id} [Transfered by: ${transferData?.transferredBy?._id}]`); - if (room.onHold) { - Livechat.logger.debug('Cannot transfer. Room is on hold'); - throw new Error('error-room-onHold'); - } - - if (transferData.departmentId) { - transferData.department = await LivechatDepartmentRaw.findOneById(transferData.departmentId, { - projection: { name: 1 }, - }); - Livechat.logger.debug(`Transfering room ${room._id} to department ${transferData.department?._id}`); - } - - return RoutingManager.transferRoom(room, guest, transferData); - }, - - async returnRoomAsInquiry(rid, departmentId, overrideTransferData = {}) { - Livechat.logger.debug(`Transfering room ${rid} to ${departmentId ? 'department' : ''} queue`); - const room = await LivechatRooms.findOneById(rid); - if (!room) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - if (!room.open) { - throw new Meteor.Error('room-closed', 'Room closed', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - if (room.onHold) { - throw new Meteor.Error('error-room-onHold', 'Room On Hold', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - if (!room.servedBy) { - return false; - } - - const user = await Users.findOneById(room.servedBy._id); - if (!user || !user._id) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - // find inquiry corresponding to room - const inquiry = await LivechatInquiry.findOne({ rid }); - if (!inquiry) { - return false; - } - - const transferredBy = normalizeTransferredByData(user, room); - Livechat.logger.debug(`Transfering room ${room._id} by user ${transferredBy._id}`); - const transferData = { roomId: rid, scope: 'queue', departmentId, transferredBy, ...overrideTransferData }; - try { - await this.saveTransferHistory(room, transferData); - await RoutingManager.unassignAgent(inquiry, departmentId); - } catch (e) { - this.logger.error(e); - throw new Meteor.Error('error-returning-inquiry', 'Error returning inquiry to the queue', { - method: 'livechat:returnRoomAsInquiry', - }); - } - - callbacks.runAsync('livechat:afterReturnRoomAsInquiry', { room }); - - return true; - }, - async getLivechatRoomGuestInfo(room) { - const visitor = await LivechatVisitors.findOneById(room.v._id); + const visitor = await LivechatVisitors.findOneEnabledById(room.v._id); const agent = await Users.findOneById(room.servedBy && room.servedBy._id); const ua = new UAParser(); @@ -603,59 +335,12 @@ export const Livechat = { return removeUserFromRolesAsync(user._id, ['livechat-manager']); }, - async removeGuest(_id) { - check(_id, String); - const guest = await LivechatVisitors.findOneById(_id, { projection: { _id: 1 } }); - if (!guest) { - throw new Meteor.Error('error-invalid-guest', 'Invalid guest', { - method: 'livechat:removeGuest', - }); - } - - await this.cleanGuestHistory(_id); - return LivechatVisitors.removeById(_id); - }, - async setUserStatusLivechat(userId, status) { const user = await Users.setLivechatStatus(userId, status); callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status }); return user; }, - async setUserStatusLivechatIf(userId, status, condition, fields) { - const user = await Users.setLivechatStatusIf(userId, status, condition, fields); - callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status }); - return user; - }, - - async cleanGuestHistory(_id) { - const guest = await LivechatVisitors.findOneById(_id); - if (!guest) { - throw new Meteor.Error('error-invalid-guest', 'Invalid guest', { - method: 'livechat:cleanGuestHistory', - }); - } - - const { token } = guest; - check(token, String); - - const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); - const cursor = LivechatRooms.findByVisitorToken(token, extraQuery); - for await (const room of cursor) { - await Promise.all([ - FileUpload.removeFilesByRoomId(room._id), - Messages.removeByRoomId(room._id), - ReadReceipts.removeByRoomId(room._id), - ]); - } - - await Promise.all([ - Subscriptions.removeByVisitorToken(token), - LivechatRooms.removeByVisitorToken(token), - LivechatInquiry.removeByVisitorToken(token), - ]); - }, - async saveDepartmentAgents(_id, departmentAgents) { check(_id, String); check(departmentAgents, { @@ -687,75 +372,6 @@ export const Livechat = { return updateDepartmentAgents(_id, departmentAgents, department.enabled); }, - /* - * @deprecated - Use the equivalent from DepartmentHelpers class - */ - async removeDepartment(_id) { - check(_id, String); - - const departmentRemovalEnabled = settings.get('Omnichannel_enable_department_removal'); - - if (!departmentRemovalEnabled) { - throw new Meteor.Error('department-removal-disabled', 'Department removal is disabled', { - method: 'livechat:removeDepartment', - }); - } - - const department = await LivechatDepartmentRaw.findOneById(_id, { projection: { _id: 1 } }); - - if (!department) { - throw new Meteor.Error('department-not-found', 'Department not found', { - method: 'livechat:removeDepartment', - }); - } - const ret = (await LivechatDepartmentRaw.removeById(_id)).deletedCount; - const agentsIds = (await LivechatDepartmentAgents.findByDepartmentId(_id, { projection: { agentId: 1 } }).toArray()).map( - (agent) => agent.agentId, - ); - await LivechatDepartmentAgents.removeByDepartmentId(_id); - await LivechatDepartmentRaw.unsetFallbackDepartmentByDepartmentId(_id); - if (ret) { - setImmediate(() => { - callbacks.run('livechat.afterRemoveDepartment', { department, agentsIds }); - }); - } - return ret; - }, - - async unarchiveDepartment(_id) { - check(_id, String); - - const department = await LivechatDepartmentRaw.findOneById(_id, { projection: { _id: 1 } }); - - if (!department) { - throw new Meteor.Error('department-not-found', 'Department not found', { - method: 'livechat:removeDepartment', - }); - } - - // TODO: these kind of actions should be on events instead of here - await LivechatDepartmentAgents.enableAgentsByDepartmentId(_id); - return LivechatDepartmentRaw.unarchiveDepartment(_id); - }, - - async archiveDepartment(_id) { - check(_id, String); - - const department = await LivechatDepartmentRaw.findOneById(_id, { projection: { _id: 1 } }); - - if (!department) { - throw new Meteor.Error('department-not-found', 'Department not found', { - method: 'livechat:removeDepartment', - }); - } - - await LivechatDepartmentAgents.disableAgentsByDepartmentId(_id); - await LivechatDepartmentRaw.archiveDepartment(_id); - - this.logger.debug({ msg: 'Running livechat.afterDepartmentArchived callback for department:', departmentId: _id }); - await callbacks.run('livechat.afterDepartmentArchived', department); - }, - showConnecting() { const { showConnecting } = RoutingManager.getConfig(); return showConnecting; @@ -771,28 +387,6 @@ export const Livechat = { }); }, - async getRoomMessages({ rid }) { - check(rid, String); - - const room = await Rooms.findOneById(rid, { projection: { t: 1 } }); - if (room?.t !== 'l') { - throw new Meteor.Error('invalid-room'); - } - - const ignoredMessageTypes = [ - 'livechat_navigation_history', - 'livechat_transcript_history', - 'command', - 'livechat-close', - 'livechat-started', - 'livechat_video_call', - ]; - - return Messages.findVisibleByRoomIdNotContainingTypes(rid, ignoredMessageTypes, { - sort: { ts: 1 }, - }).toArray(); - }, - async requestTranscript({ rid, email, subject, user }) { check(rid, String); check(email, String); @@ -817,6 +411,10 @@ export const Livechat = { throw new Meteor.Error('error-transcript-already-requested', 'Transcript already requested'); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + const { _id, username, name, utcOffset } = user; const transcriptRequest = { requestedAt: new Date(), @@ -839,77 +437,6 @@ export const Livechat = { await LivechatRooms.updateVisitorStatus(token, status); }, - async sendOfflineMessage(data = {}) { - if (!settings.get('Livechat_display_offline_form')) { - throw new Error('error-offline-form-disabled'); - } - - const { message, name, email, department, host } = data; - const emailMessage = `${message}`.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1
$2'); - - let html = '

New livechat message

'; - if (host && host !== '') { - html = html.concat(`

Sent from: ${host}

`); - } - html = html.concat(` -

Visitor name: ${name}

-

Visitor email: ${email}

-

Message:
${emailMessage}

`); - - let fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); - - if (fromEmail) { - fromEmail = fromEmail[0]; - } else { - fromEmail = settings.get('From_Email'); - } - - if (settings.get('Livechat_validate_offline_email')) { - const emailDomain = email.substr(email.lastIndexOf('@') + 1); - - try { - await dnsResolveMx(emailDomain); - } catch (e) { - throw new Meteor.Error('error-invalid-email-address', 'Invalid email address', { - method: 'livechat:sendOfflineMessage', - }); - } - } - - // TODO Block offline form if Livechat_offline_email is undefined - // (it does not make sense to have an offline form that does nothing) - // `this.sendEmail` will throw an error if the email is invalid - // thus this breaks livechat, since the "to" email is invalid, and that returns an [invalid email] error to the livechat client - let emailTo = settings.get('Livechat_offline_email'); - if (department && department !== '') { - const dep = await LivechatDepartmentRaw.findOneByIdOrName(department); - emailTo = dep.email || emailTo; - } - - const from = `${name} - ${email} <${fromEmail}>`; - const replyTo = `${name} <${email}>`; - const subject = `Livechat offline message from ${name}: ${`${emailMessage}`.substring(0, 20)}`; - await this.sendEmail(from, emailTo, replyTo, subject, html); - - setImmediate(() => { - callbacks.run('livechat.offlineMessage', data); - }); - }, - - async notifyAgentStatusChanged(userId, status) { - callbacks.runAsync('livechat.agentStatusChanged', { userId, status }); - if (!settings.get('Livechat_show_agent_info')) { - return; - } - - await LivechatRooms.findOpenByAgent(userId).forEach((room) => { - void api.broadcast('omnichannel.room', room._id, { - type: 'agentStatus', - status, - }); - }); - }, - async allowAgentChangeServiceStatus(statusLivechat, agentId) { if (statusLivechat !== 'available') { return true; @@ -917,56 +444,4 @@ export const Livechat = { return businessHourManager.allowAgentChangeServiceStatus(agentId); }, - - notifyRoomVisitorChange(roomId, visitor) { - void api.broadcast('omnichannel.room', roomId, { - type: 'visitorData', - visitor, - }); - }, - - async changeRoomVisitor(userId, roomId, visitor) { - const user = await Users.findOneById(userId); - if (!user) { - throw new Error('error-user-not-found'); - } - - if (!(await hasPermissionAsync(userId, 'change-livechat-room-visitor'))) { - throw new Error('error-not-authorized'); - } - - const room = await LivechatRooms.findOneById(roomId, { ...roomAccessAttributes, _id: 1, t: 1 }); - - if (!room) { - throw new Meteor.Error('invalid-room'); - } - - if (!(await canAccessRoomAsync(room, user))) { - throw new Error('error-not-allowed'); - } - - await LivechatRooms.changeVisitorByRoomId(room._id, visitor); - - Livechat.notifyRoomVisitorChange(room._id, visitor); - - return LivechatRooms.findOneById(roomId); - }, - async updateLastChat(contactId, lastChat) { - const updateUser = { - $set: { - lastChat, - }, - }; - await LivechatVisitors.updateById(contactId, updateUser); - }, - async updateCallStatus(callId, rid, status, user) { - await Rooms.setCallStatus(rid, status); - if (status === 'ended' || status === 'declined') { - if (await VideoConf.declineLivechatCall(callId)) { - return; - } - - return updateMessage({ _id: callId, msg: status, actionLinks: [], webRtcCallEndTs: new Date() }, user); - } - }, }; diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 1c60a257d319..a23fd5ef7702 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -1,4 +1,7 @@ -import { Message } from '@rocket.chat/core-services'; +import dns from 'dns'; +import * as util from 'util'; + +import { Message, VideoConf, api, Omnichannel } from '@rocket.chat/core-services'; import type { IOmnichannelRoom, IOmnichannelRoomClosingInfo, @@ -10,6 +13,11 @@ import type { ILivechatAgent, IMessage, ILivechatDepartment, + AtLeast, + TransferData, + MessageAttachment, + IMessageInbox, + ILivechatAgentStatus, } from '@rocket.chat/core-typings'; import { UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger, type MainLogger } from '@rocket.chat/logger'; @@ -23,22 +31,28 @@ import { Users, LivechatDepartmentAgents, ReadReceipts, + Rooms, + Settings, } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import moment from 'moment-timezone'; -import type { FindCursor, UpdateFilter } from 'mongodb'; +import type { Filter, FindCursor, UpdateFilter } from 'mongodb'; import { Apps, AppEvents } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { i18n } from '../../../../server/lib/i18n'; +import { canAccessRoomAsync } from '../../../authorization/server'; import { hasRoleAsync } from '../../../authorization/server/functions/hasRole'; +import { FileUpload } from '../../../file-upload/server'; +import { deleteMessage } from '../../../lib/server/functions/deleteMessage'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; +import { updateMessage } from '../../../lib/server/functions/updateMessage'; 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 { updateDepartmentAgents, validateEmail } from './Helper'; +import { updateDepartmentAgents, validateEmail, normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; import { RoutingManager } from './RoutingManager'; @@ -72,6 +86,50 @@ export type CloseRoomParamsByVisitor = { export type CloseRoomParams = CloseRoomParamsByUser | CloseRoomParamsByVisitor; +type OfflineMessageData = { + message: string; + name: string; + email: string; + department?: string; + host?: string; +}; + +export interface ILivechatMessage { + token: string; + _id: string; + rid: string; + msg: string; + file?: { + _id: string; + name?: string; + type?: string; + size?: number; + description?: string; + identify?: { size: { width: number; height: number } }; + format?: string; + }; + files?: { + _id: string; + name?: string; + type?: string; + size?: number; + description?: string; + identify?: { size: { width: number; height: number } }; + format?: string; + }[]; + attachments?: MessageAttachment[]; + alias?: string; + groupable?: boolean; + blocks?: IMessage['blocks']; + email?: IMessageInbox['email']; +} + +type AKeyOf = { + [K in keyof T]?: T[K]; +}; + +const dnsResolveMx = util.promisify(dns.resolveMx); + class LivechatClass { logger: Logger; @@ -305,7 +363,7 @@ class LivechatClass { !(await LivechatDepartment.findOneById>(guest.department, { projection: { _id: 1 } })) ) { await LivechatVisitors.removeDepartmentById(guest._id); - const tmpGuest = await LivechatVisitors.findOneById(guest._id); + const tmpGuest = await LivechatVisitors.findOneEnabledById(guest._id); if (tmpGuest) { guest = tmpGuest; } @@ -463,6 +521,10 @@ class LivechatClass { throw new Error('error-invalid-room'); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + const showAgentInfo = settings.get('Livechat_show_agent_info'); const closingMessage = await Messages.findLivechatClosingMessage(rid, { projection: { ts: 1 } }); const ignoredMessageTypes: MessageTypesValues[] = [ @@ -738,12 +800,15 @@ class LivechatClass { attempts = 10, ) { if (!attempts) { + Livechat.logger.error({ msg: 'Omnichannel webhook call failed. Max attempts reached' }); return; } const timeout = settings.get('Livechat_http_timeout'); const secretToken = settings.get('Livechat_secret_token'); + const webhookUrl = settings.get('Livechat_webhookUrl'); try { - const result = await fetch(settings.get('Livechat_webhookUrl'), { + Livechat.webhookLogger.debug({ msg: 'Sending webhook request', postData }); + const result = await fetch(webhookUrl, { method: 'POST', headers: { ...(secretToken && { 'X-RocketChat-Livechat-Token': secretToken }), @@ -754,17 +819,20 @@ class LivechatClass { if (result.status === 200) { metrics.totalLivechatWebhooksSuccess.inc(); - } else { - metrics.totalLivechatWebhooksFailures.inc(); + return result; } - return result; + + metrics.totalLivechatWebhooksFailures.inc(); + throw new Error(await result.text()); } catch (err) { - Livechat.webhookLogger.error({ msg: `Response error on ${11 - attempts} try ->`, err }); + const retryAfter = timeout * 4; + Livechat.webhookLogger.error({ msg: `Error response on ${11 - attempts} try ->`, err }); // try 10 times after 20 seconds each - attempts - 1 && Livechat.webhookLogger.warn(`Will try again in ${(timeout / 1000) * 4} seconds ...`); + attempts - 1 && + Livechat.webhookLogger.warn({ msg: `Webhook call failed. Retrying`, newAttemptAfterSeconds: retryAfter / 1000, webhookUrl }); setTimeout(async () => { await Livechat.sendRequest(postData, attempts - 1); - }, timeout * 4); + }, retryAfter); } } @@ -808,6 +876,468 @@ class LivechatClass { return true; } + + async updateCallStatus(callId: string, rid: string, status: 'ended' | 'declined', user: IUser | ILivechatVisitor) { + await Rooms.setCallStatus(rid, status); + if (status === 'ended' || status === 'declined') { + if (await VideoConf.declineLivechatCall(callId)) { + return; + } + + return updateMessage({ _id: callId, msg: status, actionLinks: [], webRtcCallEndTs: new Date(), rid }, user as unknown as IUser); + } + } + + async updateLastChat(contactId: string, lastChat: Required) { + const updateUser = { + $set: { + lastChat, + }, + }; + await LivechatVisitors.updateById(contactId, updateUser); + } + + notifyRoomVisitorChange(roomId: string, visitor: ILivechatVisitor) { + void api.broadcast('omnichannel.room', roomId, { + type: 'visitorData', + visitor, + }); + } + + async changeRoomVisitor(userId: string, room: IOmnichannelRoom, visitor: ILivechatVisitor) { + const user = await Users.findOneById(userId, { projection: { _id: 1 } }); + if (!user) { + throw new Error('error-user-not-found'); + } + + if (!(await canAccessRoomAsync(room, user))) { + throw new Error('error-not-allowed'); + } + + await LivechatRooms.changeVisitorByRoomId(room._id, visitor); + + this.notifyRoomVisitorChange(room._id, visitor); + + return LivechatRooms.findOneById(room._id); + } + + async notifyAgentStatusChanged(userId: string, status?: UserStatus) { + if (!status) { + return; + } + + void callbacks.runAsync('livechat.agentStatusChanged', { userId, status }); + if (!settings.get('Livechat_show_agent_info')) { + return; + } + + await LivechatRooms.findOpenByAgent(userId).forEach((room) => { + void api.broadcast('omnichannel.room', room._id, { + type: 'agentStatus', + status, + }); + }); + } + + async getRoomMessages({ rid }: { rid: string }) { + const room = await Rooms.findOneById(rid, { projection: { t: 1 } }); + if (room?.t !== 'l') { + throw new Meteor.Error('invalid-room'); + } + + const ignoredMessageTypes: MessageTypesValues[] = [ + 'livechat_navigation_history', + 'livechat_transcript_history', + 'command', + 'livechat-close', + 'livechat-started', + 'livechat_video_call', + ]; + + return Messages.findVisibleByRoomIdNotContainingTypes(rid, ignoredMessageTypes, { + sort: { ts: 1 }, + }).toArray(); + } + + async archiveDepartment(_id: string) { + const department = await LivechatDepartment.findOneById(_id, { projection: { _id: 1 } }); + + if (!department) { + throw new Error('department-not-found'); + } + + await Promise.all([LivechatDepartmentAgents.disableAgentsByDepartmentId(_id), LivechatDepartment.archiveDepartment(_id)]); + + await callbacks.run('livechat.afterDepartmentArchived', department); + } + + async unarchiveDepartment(_id: string) { + const department = await LivechatDepartment.findOneById(_id, { projection: { _id: 1 } }); + + if (!department) { + throw new Meteor.Error('department-not-found'); + } + + // TODO: these kind of actions should be on events instead of here + await Promise.all([LivechatDepartmentAgents.enableAgentsByDepartmentId(_id), LivechatDepartment.unarchiveDepartment(_id)]); + return true; + } + + async updateMessage({ guest, message }: { guest: ILivechatVisitor; message: AtLeast }) { + check(message, Match.ObjectIncluding({ _id: String })); + + const originalMessage = await Messages.findOneById>(message._id, { projection: { u: 1 } }); + if (!originalMessage?._id) { + return; + } + + const editAllowed = settings.get('Message_AllowEditing'); + const editOwn = originalMessage.u && originalMessage.u._id === guest._id; + + if (!editAllowed || !editOwn) { + throw new Error('error-action-not-allowed'); + } + + // TODO: Apps sends an `any` object and apparently we just check for _id being present + // while updateMessage expects AtLeast + await updateMessage(message, guest as unknown as IUser); + + return true; + } + + async closeOpenChats(userId: string, comment?: string) { + this.logger.debug(`Closing open chats for user ${userId}`); + const user = await Users.findOneById(userId); + + const extraQuery = await callbacks.run('livechat.applyDepartmentRestrictions', {}, { userId }); + const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery); + const promises: Promise[] = []; + await openChats.forEach((room) => { + promises.push(this.closeRoom({ user, room, comment })); + }); + + await Promise.all(promises); + } + + async transfer(room: IOmnichannelRoom, guest: ILivechatVisitor, transferData: TransferData) { + this.logger.debug(`Transfering room ${room._id} [Transfered by: ${transferData?.transferredBy?._id}]`); + if (room.onHold) { + throw new Error('error-room-onHold'); + } + + if (transferData.departmentId) { + const department = await LivechatDepartment.findOneById(transferData.departmentId, { + projection: { name: 1 }, + }); + if (!department) { + throw new Error('error-invalid-department'); + } + + transferData.department = department; + this.logger.debug(`Transfering room ${room._id} to department ${transferData.department?._id}`); + } + + return RoutingManager.transferRoom(room, guest, transferData); + } + + async forwardOpenChats(userId: string) { + this.logger.debug(`Transferring open chats for user ${userId}`); + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('error-invalid-user'); + } + + const { _id, username, name } = user; + for await (const room of LivechatRooms.findOpenByAgent(userId)) { + const guest = await LivechatVisitors.findOneEnabledById(room.v._id); + if (!guest) { + continue; + } + + const transferredBy = normalizeTransferredByData({ _id, username, name }, room); + await this.transfer(room, guest, { + transferredBy, + departmentId: guest.department, + }); + } + } + + showConnecting() { + return RoutingManager.getConfig()?.showConnecting || false; + } + + async getInitSettings() { + const rcSettings: Record = {}; + + await Settings.findNotHiddenPublic([ + 'Livechat_title', + 'Livechat_title_color', + 'Livechat_enable_message_character_limit', + 'Livechat_message_character_limit', + 'Message_MaxAllowedSize', + 'Livechat_enabled', + 'Livechat_registration_form', + 'Livechat_allow_switching_departments', + 'Livechat_offline_title', + 'Livechat_offline_title_color', + 'Livechat_offline_message', + 'Livechat_offline_success_message', + 'Livechat_offline_form_unavailable', + 'Livechat_display_offline_form', + 'Omnichannel_call_provider', + 'Language', + 'Livechat_enable_transcript', + 'Livechat_transcript_message', + 'Livechat_fileupload_enabled', + 'FileUpload_Enabled', + 'Livechat_conversation_finished_message', + 'Livechat_conversation_finished_text', + 'Livechat_name_field_registration_form', + 'Livechat_email_field_registration_form', + 'Livechat_registration_form_message', + 'Livechat_force_accept_data_processing_consent', + 'Livechat_data_processing_consent_text', + 'Livechat_show_agent_info', + 'Livechat_clear_local_storage_when_chat_ended', + ]).forEach((setting) => { + rcSettings[setting._id] = setting.value; + }); + + rcSettings.Livechat_history_monitor_type = settings.get('Livechat_history_monitor_type'); + + rcSettings.Livechat_Show_Connecting = this.showConnecting(); + + return rcSettings; + } + + async sendOfflineMessage(data: OfflineMessageData) { + if (!settings.get('Livechat_display_offline_form')) { + throw new Error('error-offline-form-disabled'); + } + + const { message, name, email, department, host } = data; + + if (!email) { + throw new Error('error-invalid-email'); + } + + const emailMessage = `${message}`.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1
$2'); + + let html = '

New livechat message

'; + if (host && host !== '') { + html = html.concat(`

Sent from: ${host}

`); + } + html = html.concat(` +

Visitor name: ${name}

+

Visitor email: ${email}

+

Message:
${emailMessage}

`); + + const fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); + + let from: string; + if (fromEmail) { + from = fromEmail[0]; + } else { + from = settings.get('From_Email'); + } + + if (settings.get('Livechat_validate_offline_email')) { + const emailDomain = email.substr(email.lastIndexOf('@') + 1); + + try { + await dnsResolveMx(emailDomain); + } catch (e) { + throw new Meteor.Error('error-invalid-email-address'); + } + } + + // TODO Block offline form if Livechat_offline_email is undefined + // (it does not make sense to have an offline form that does nothing) + // `this.sendEmail` will throw an error if the email is invalid + // thus this breaks livechat, since the "to" email is invalid, and that returns an [invalid email] error to the livechat client + let emailTo = settings.get('Livechat_offline_email'); + if (department && department !== '') { + const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { email: 1 } }); + if (dep) { + emailTo = dep.email || emailTo; + } + } + + const fromText = `${name} - ${email} <${from}>`; + const replyTo = `${name} <${email}>`; + const subject = `Livechat offline message from ${name}: ${`${emailMessage}`.substring(0, 20)}`; + await this.sendEmail(fromText, emailTo, replyTo, subject, html); + + setImmediate(() => { + void callbacks.run('livechat.offlineMessage', data); + }); + } + + async sendMessage({ + guest, + message, + roomInfo, + agent, + }: { + guest: ILivechatVisitor; + message: ILivechatMessage; + roomInfo: { + source?: IOmnichannelRoom['source']; + [key: string]: unknown; + }; + agent?: SelectedAgent; + }) { + const { room, newRoom } = await this.getRoom(guest, message, roomInfo, agent); + if (guest.name) { + message.alias = guest.name; + } + return Object.assign(await sendMessage(guest, message, room), { + newRoom, + showConnecting: this.showConnecting(), + }); + } + + async removeGuest(_id: string) { + const guest = await LivechatVisitors.findOneEnabledById(_id, { projection: { _id: 1, token: 1 } }); + if (!guest) { + throw new Error('error-invalid-guest'); + } + + await this.cleanGuestHistory(guest); + return LivechatVisitors.disableById(_id); + } + + async cleanGuestHistory(guest: ILivechatVisitor) { + const { token } = guest; + + // This shouldn't be possible, but just in case + if (!token) { + throw new Error('error-invalid-guest'); + } + + const cursor = LivechatRooms.findByVisitorToken(token); + for await (const room of cursor) { + await Promise.all([ + FileUpload.removeFilesByRoomId(room._id), + Messages.removeByRoomId(room._id), + ReadReceipts.removeByRoomId(room._id), + ]); + } + + await Promise.all([ + Subscriptions.removeByVisitorToken(token), + LivechatRooms.removeByVisitorToken(token), + LivechatInquiry.removeByVisitorToken(token), + ]); + } + + async deleteMessage({ guest, message }: { guest: ILivechatVisitor; message: IMessage }) { + const deleteAllowed = settings.get('Message_AllowDeleting'); + const editOwn = message.u && message.u._id === guest._id; + + if (!deleteAllowed || !editOwn) { + throw new Error('error-action-not-allowed'); + } + + await deleteMessage(message, guest as unknown as IUser); + + return true; + } + + async setUserStatusLivechatIf(userId: string, status: ILivechatAgentStatus, condition?: Filter, fields?: AKeyOf) { + const user = await Users.setLivechatStatusIf(userId, status, condition, fields); + callbacks.runAsync('livechat.setUserStatusLivechat', { userId, status }); + return user; + } + + async returnRoomAsInquiry(room: IOmnichannelRoom, departmentId?: string, overrideTransferData: any = {}) { + this.logger.debug({ msg: `Transfering room to ${departmentId ? 'department' : ''} queue`, room }); + if (!room.open) { + throw new Meteor.Error('room-closed'); + } + + if (room.onHold) { + throw new Meteor.Error('error-room-onHold'); + } + + if (!room.servedBy) { + return false; + } + + const user = await Users.findOneById(room.servedBy._id); + if (!user?._id) { + throw new Meteor.Error('error-invalid-user'); + } + + // find inquiry corresponding to room + const inquiry = await LivechatInquiry.findOne({ rid: room._id }); + if (!inquiry) { + return false; + } + + const transferredBy = normalizeTransferredByData(user, room); + this.logger.debug(`Transfering room ${room._id} by user ${transferredBy._id}`); + const transferData = { roomId: room._id, scope: 'queue', departmentId, transferredBy, ...overrideTransferData }; + try { + await this.saveTransferHistory(room, transferData); + await RoutingManager.unassignAgent(inquiry, departmentId); + } catch (e) { + this.logger.error(e); + throw new Meteor.Error('error-returning-inquiry'); + } + + callbacks.runAsync('livechat:afterReturnRoomAsInquiry', { room }); + + return true; + } + + async saveTransferHistory(room: IOmnichannelRoom, transferData: TransferData) { + const { departmentId: previousDepartment } = room; + const { department: nextDepartment, transferredBy, transferredTo, scope, comment } = transferData; + + check( + transferredBy, + Match.ObjectIncluding({ + _id: String, + username: String, + name: Match.Maybe(String), + type: String, + }), + ); + + const { _id, username } = transferredBy; + 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, + 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, + }, + groupable: false, + }; + + Object.assign(transferMessage, transfer); + + await sendMessage(transferredBy, transferMessage, room); + } } export const Livechat = new LivechatClass(); diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index 597f38b71ec0..fffeae4d2f29 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -1,3 +1,4 @@ +import { Omnichannel } from '@rocket.chat/core-services'; import type { ILivechatInquiryRecord, ILivechatVisitor, IMessage, IOmnichannelRoom, SelectedAgent } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models'; @@ -20,10 +21,17 @@ export const queueInquiry = async (inquiry: ILivechatInquiryRecord, defaultAgent logger.debug(`Delegating inquiry with id ${inquiry._id} to agent ${defaultAgent?.username}`); await callbacks.run('livechat.beforeRouteChat', inquiry, inquiryAgent); + const room = await LivechatRooms.findOneById(inquiry.rid, { projection: { v: 1 } }); + if (!room || !(await Omnichannel.isWithinMACLimit(room))) { + logger.error({ msg: 'MAC limit reached, not routing inquiry', inquiry }); + // We'll queue these inquiries so when new license is applied, they just start rolling again + // Minimizing disruption + await saveQueueInquiry(inquiry); + return; + } const dbInquiry = await LivechatInquiry.findOneById(inquiry._id); if (!dbInquiry) { - logger.error(`Inquiry with id ${inquiry._id} not found`); throw new Error('inquiry-not-found'); } @@ -68,7 +76,6 @@ export const QueueManager: queueManager = { ); if (!(await checkServiceStatus({ guest, agent }))) { - logger.debug(`Cannot create room for visitor ${guest._id}. No online agents`); throw new Meteor.Error('no-agent-online', 'Sorry, no online agents'); } @@ -96,8 +103,6 @@ export const QueueManager: queueManager = { throw new Error('inquiry-not-found'); } - logger.debug(`Generated inquiry for visitor ${guest._id} with id ${inquiry._id} [Not queued]`); - await LivechatRooms.updateRoomCount(); await queueInquiry(inquiry, agent); @@ -114,7 +119,6 @@ export const QueueManager: queueManager = { async unarchiveRoom(archivedRoom) { if (!archivedRoom) { - logger.error('No room to unarchive'); throw new Error('no-room-to-unarchive'); } @@ -145,17 +149,13 @@ export const QueueManager: queueManager = { await LivechatRooms.unarchiveOneById(rid); const room = await LivechatRooms.findOneById(rid); if (!room) { - logger.debug(`Room with id ${rid} not found`); throw new Error('room-not-found'); } const inquiry = await LivechatInquiry.findOneById(await createLivechatInquiry({ rid, name, guest, message, extraData: { source } })); if (!inquiry) { - logger.error(`Inquiry for visitor ${guest._id} not found`); throw new Error('inquiry-not-found'); } - logger.debug(`Generated inquiry for visitor ${v._id} with id ${inquiry._id} [Not queued]`); - await queueInquiry(inquiry, defaultAgent); logger.debug(`Inquiry ${inquiry._id} queued`); diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index 0e975ca06763..e9c173d86913 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -74,7 +74,7 @@ export const RoutingManager: Routing = { }, async setMethodNameAndStartQueue(name) { - logger.debug(`Changing default routing method from ${this.methodName} to ${name}`); + logger.info(`Changing default routing method from ${this.methodName} to ${name}`); if (!this.methods[name]) { logger.warn(`Cannot change routing method to ${name}. Selected Routing method does not exists. Defaulting to Manual_Selection`); this.methodName = 'Manual_Selection'; @@ -87,7 +87,6 @@ export const RoutingManager: Routing = { // eslint-disable-next-line @typescript-eslint/naming-convention registerMethod(name, Method) { - logger.debug(`Registering new routing method with name ${name}`); this.methods[name] = new Method(); }, @@ -157,6 +156,11 @@ export const RoutingManager: Routing = { await Promise.all([Message.saveSystemMessage('command', rid, 'connected', user), Message.saveSystemMessage('uj', rid, '', user)]); } + if (!room) { + logger.debug(`Cannot assign agent to inquiry ${inquiry._id}: Room not found`); + throw new Meteor.Error('error-room-not-found', 'Room not found'); + } + await dispatchAgentDelegated(rid, agent.agentId); logger.debug(`Agent ${agent.agentId} assigned to inquriy ${inquiry._id}. Instances notified`); @@ -174,6 +178,10 @@ export const RoutingManager: Routing = { return false; } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + if (departmentId && departmentId !== department) { logger.debug(`Switching department for inquiry ${inquiry._id} [Current: ${department} | Next: ${departmentId}]`); await updateChatDepartment({ @@ -188,7 +196,6 @@ export const RoutingManager: Routing = { const { servedBy } = room; if (servedBy) { - logger.debug(`Unassigning current agent for inquiry ${inquiry._id}`); await LivechatRooms.removeAgentByRoomId(rid); await this.removeAllRoomSubscriptions(room); await dispatchAgentDelegated(rid); @@ -254,7 +261,7 @@ export const RoutingManager: Routing = { await LivechatInquiry.takeInquiry(_id); const inq = await this.assignAgent(inquiry as InquiryWithAgentInfo, agent); - logger.debug(`Inquiry ${inquiry._id} taken by agent ${agent.agentId}`); + logger.info(`Inquiry ${inquiry._id} taken by agent ${agent.agentId}`); callbacks.runAsync('livechat.afterTakeInquiry', inq, agent); @@ -262,6 +269,10 @@ export const RoutingManager: Routing = { }, async transferRoom(room, guest, transferData) { + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + logger.debug(`Transfering room ${room._id} by ${transferData.transferredBy._id}`); if (transferData.departmentId) { logger.debug(`Transfering room ${room._id} to department ${transferData.departmentId}`); @@ -278,7 +289,6 @@ export const RoutingManager: Routing = { }, async delegateAgent(agent, inquiry) { - logger.debug(`Delegating Inquiry ${inquiry._id}`); const defaultAgent = await callbacks.run('livechat.beforeDelegateAgent', agent, { department: inquiry?.department, }); diff --git a/apps/meteor/app/livechat/server/lib/analytics/dashboards.ts b/apps/meteor/app/livechat/server/lib/analytics/dashboards.ts index cb7ecbf079f2..6788d01c4441 100644 --- a/apps/meteor/app/livechat/server/lib/analytics/dashboards.ts +++ b/apps/meteor/app/livechat/server/lib/analytics/dashboards.ts @@ -1,9 +1,11 @@ import type { IUser } from '@rocket.chat/core-typings'; import { LivechatRooms, Users, LivechatVisitors, LivechatAgentActivity } from '@rocket.chat/models'; +import mem from 'mem'; import moment from 'moment'; import { secondsToHHMMSS } from '../../../../../lib/utils/secondsToHHMMSS'; import { settings } from '../../../../settings/server'; +import { getAnalyticsOverviewDataCachedForRealtime } from '../AnalyticsTyped'; import { Livechat } from '../Livechat'; import { findPercentageOfAbandonedRoomsAsync, @@ -13,15 +15,7 @@ import { findAllAverageServiceTimeAsync, } from './departments'; -export const findAllChatsStatusAsync = async ({ - start, - end, - departmentId = undefined, -}: { - start: Date; - end: Date; - departmentId?: string; -}) => { +const findAllChatsStatusAsync = async ({ start, end, departmentId = undefined }: { start: Date; end: Date; departmentId?: string }) => { if (!start || !end) { throw new Error('"start" and "end" must be provided'); } @@ -33,7 +27,7 @@ export const findAllChatsStatusAsync = async ({ }; }; -export const getProductivityMetricsAsync = async ({ +const getProductivityMetricsAsync = async ({ start, end, departmentId = undefined, @@ -78,7 +72,7 @@ export const getProductivityMetricsAsync = async ({ }; }; -export const getAgentsProductivityMetricsAsync = async ({ +const getAgentsProductivityMetricsAsync = async ({ start, end, departmentId = undefined, @@ -148,7 +142,7 @@ export const getAgentsProductivityMetricsAsync = async ({ }; }; -export const getChatsMetricsAsync = async ({ start, end, departmentId = undefined }: { start: Date; end: Date; departmentId?: string }) => { +const getChatsMetricsAsync = async ({ start, end, departmentId = undefined }: { start: Date; end: Date; departmentId?: string }) => { if (!start || !end) { throw new Error('"start" and "end" must be provided'); } @@ -223,7 +217,7 @@ export const getChatsMetricsAsync = async ({ start, end, departmentId = undefine }; }; -export const getConversationsMetricsAsync = async ({ +const getConversationsMetricsAsync = async ({ start, end, departmentId, @@ -237,7 +231,7 @@ export const getConversationsMetricsAsync = async ({ if (!start || !end) { throw new Error('"start" and "end" must be provided'); } - const totalizers = await Livechat.Analytics.getAnalyticsOverviewData({ + const totalizers = await getAnalyticsOverviewDataCachedForRealtime({ daterange: { from: start, to: end, @@ -263,7 +257,7 @@ export const getConversationsMetricsAsync = async ({ }; }; -export const findAllChatMetricsByAgentAsync = async ({ +const findAllChatMetricsByAgentAsync = async ({ start, end, departmentId = undefined, @@ -311,10 +305,10 @@ export const findAllChatMetricsByAgentAsync = async ({ return result; }; -export const findAllAgentsStatusAsync = async ({ departmentId = undefined }: { departmentId?: string }) => +const findAllAgentsStatusAsync = async ({ departmentId = undefined }: { departmentId?: string }) => (await Users.countAllAgentsStatus({ departmentId }))[0]; -export const findAllChatMetricsByDepartmentAsync = async ({ +const findAllChatMetricsByDepartmentAsync = async ({ start, end, departmentId = undefined, @@ -349,7 +343,7 @@ export const findAllChatMetricsByDepartmentAsync = async ({ return result; }; -export const findAllResponseTimeMetricsAsync = async ({ +const findAllResponseTimeMetricsAsync = async ({ start, end, departmentId = undefined, @@ -380,3 +374,16 @@ export const findAllResponseTimeMetricsAsync = async ({ }, }; }; + +export const getConversationsMetricsAsyncCached = mem(getConversationsMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const getAgentsProductivityMetricsAsyncCached = mem(getAgentsProductivityMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const getChatsMetricsAsyncCached = mem(getChatsMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const getProductivityMetricsAsyncCached = mem(getProductivityMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const findAllChatsStatusAsyncCached = mem(findAllChatsStatusAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const findAllChatMetricsByAgentAsyncCached = mem(findAllChatMetricsByAgentAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const findAllAgentsStatusAsyncCached = mem(findAllAgentsStatusAsync, { maxAge: 5000, cacheKey: JSON.stringify }); +export const findAllChatMetricsByDepartmentAsyncCached = mem(findAllChatMetricsByDepartmentAsync, { + maxAge: 5000, + cacheKey: JSON.stringify, +}); +export const findAllResponseTimeMetricsAsyncCached = mem(findAllResponseTimeMetricsAsync, { maxAge: 5000, cacheKey: JSON.stringify }); diff --git a/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts b/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts index bbce5d16efb4..5ddd25e90bd2 100644 --- a/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts +++ b/apps/meteor/app/livechat/server/lib/stream/agentStatus.ts @@ -1,7 +1,7 @@ import { Logger } from '@rocket.chat/logger'; import { settings } from '../../../../settings/server'; -import { Livechat } from '../Livechat'; +import { Livechat } from '../LivechatTyped'; const logger = new Logger('AgentStatusWatcher'); diff --git a/apps/meteor/app/livechat/server/methods/getAgentOverviewData.ts b/apps/meteor/app/livechat/server/methods/getAgentOverviewData.ts index 94fae239b74c..9cd5de75a0f3 100644 --- a/apps/meteor/app/livechat/server/methods/getAgentOverviewData.ts +++ b/apps/meteor/app/livechat/server/methods/getAgentOverviewData.ts @@ -18,9 +18,7 @@ declare module '@rocket.chat/ui-contexts' { Meteor.methods({ async 'livechat:getAgentOverviewData'(options) { - methodDeprecationLogger.warn( - 'The method "livechat:getAgentOverviewData" is deprecated and will be removed after version v7.0.0. Use "livechat/analytics/agent-overview" instead.', - ); + methodDeprecationLogger.method('livechat:getAgentOverviewData', '7.0.0', ' Use "livechat/analytics/agent-overview" instead.'); const uid = Meteor.userId(); if (!uid || !(await hasPermissionAsync(uid, 'view-livechat-manager'))) { diff --git a/apps/meteor/app/livechat/server/methods/getAnalyticsOverviewData.ts b/apps/meteor/app/livechat/server/methods/getAnalyticsOverviewData.ts index 48313f1ce67c..76b7f276d671 100644 --- a/apps/meteor/app/livechat/server/methods/getAnalyticsOverviewData.ts +++ b/apps/meteor/app/livechat/server/methods/getAnalyticsOverviewData.ts @@ -3,6 +3,7 @@ import type { ServerMethods, TranslationKey } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { settings } from '../../../settings/server'; import { Livechat } from '../lib/Livechat'; @@ -18,6 +19,7 @@ declare module '@rocket.chat/ui-contexts' { Meteor.methods({ async 'livechat:getAnalyticsOverviewData'(options) { + methodDeprecationLogger.method('livechat:getAnalyticsOverviewData', '7.0.0', ' Use "livechat/analytics/overview" instead.'); const uid = Meteor.userId(); if (!uid || !(await hasPermissionAsync(uid, 'view-livechat-manager'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { diff --git a/apps/meteor/app/livechat/server/methods/removeTrigger.ts b/apps/meteor/app/livechat/server/methods/removeTrigger.ts index c403dcd3edac..69f3a4a2d80c 100644 --- a/apps/meteor/app/livechat/server/methods/removeTrigger.ts +++ b/apps/meteor/app/livechat/server/methods/removeTrigger.ts @@ -4,6 +4,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,6 +15,8 @@ declare module '@rocket.chat/ui-contexts' { Meteor.methods({ async 'livechat:removeTrigger'(triggerId) { + methodDeprecationLogger.method('livechat:removeTrigger', '7.0.0'); + const uid = Meteor.userId(); if (!uid || !(await hasPermissionAsync(uid, 'view-livechat-manager'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { diff --git a/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts b/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts index 57a2b0afa3d5..38b58b9d2d42 100644 --- a/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts +++ b/apps/meteor/app/livechat/server/methods/returnAsInquiry.ts @@ -1,10 +1,11 @@ +import { Omnichannel } from '@rocket.chat/core-services'; import type { ILivechatDepartment, IRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { Livechat } from '../lib/Livechat'; +import { Livechat } from '../lib/LivechatTyped'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -29,10 +30,14 @@ Meteor.methods({ }); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Meteor.Error('error-mac-limit-reached', 'MAC limit reached', { method: 'livechat:returnAsInquiry' }); + } + if (!room.open) { throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:returnAsInquiry' }); } - return Livechat.returnRoomAsInquiry(rid, departmentId); + return Livechat.returnRoomAsInquiry(room, departmentId); }, }); diff --git a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts index c7d412ea4a06..516a9bc5081f 100644 --- a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts +++ b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts @@ -1,36 +1,12 @@ import { OmnichannelSourceType } from '@rocket.chat/core-typings'; -import type { MessageAttachment } from '@rocket.chat/core-typings'; import { LivechatVisitors } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { settings } from '../../../settings/server'; -import { Livechat } from '../lib/Livechat'; - -interface ILivechatMessage { - token: string; - _id: string; - rid: string; - msg: string; - file?: { - _id: string; - name?: string; - type?: string; - size?: number; - description?: string; - identify?: { size: { width: number; height: number } }; - }; - files?: { - _id: string; - name?: string; - type?: string; - size?: number; - description?: string; - identify?: { size: { width: number; height: number } }; - }[]; - attachments?: MessageAttachment[]; -} +import { Livechat } from '../lib/LivechatTyped'; +import type { ILivechatMessage } from '../lib/LivechatTyped'; interface ILivechatMessageAgent { agentId: string; diff --git a/apps/meteor/app/livechat/server/methods/sendOfflineMessage.ts b/apps/meteor/app/livechat/server/methods/sendOfflineMessage.ts index 9a475de5e32d..c3b5537f31be 100644 --- a/apps/meteor/app/livechat/server/methods/sendOfflineMessage.ts +++ b/apps/meteor/app/livechat/server/methods/sendOfflineMessage.ts @@ -4,7 +4,7 @@ import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; -import { Livechat } from '../lib/Livechat'; +import { Livechat } from '../lib/LivechatTyped'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/apps/meteor/app/livechat/server/methods/setDepartmentForVisitor.ts b/apps/meteor/app/livechat/server/methods/setDepartmentForVisitor.ts index 61e6b21267da..a14933ed8d47 100644 --- a/apps/meteor/app/livechat/server/methods/setDepartmentForVisitor.ts +++ b/apps/meteor/app/livechat/server/methods/setDepartmentForVisitor.ts @@ -5,7 +5,7 @@ import { Meteor } from 'meteor/meteor'; import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { normalizeTransferredByData } from '../lib/Helper'; -import { Livechat } from '../lib/Livechat'; +import { Livechat } from '../lib/LivechatTyped'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/apps/meteor/app/livechat/server/methods/takeInquiry.ts b/apps/meteor/app/livechat/server/methods/takeInquiry.ts index 17007d7da8c2..3433b4a33ae8 100644 --- a/apps/meteor/app/livechat/server/methods/takeInquiry.ts +++ b/apps/meteor/app/livechat/server/methods/takeInquiry.ts @@ -1,4 +1,5 @@ -import { LivechatInquiry, Users } from '@rocket.chat/models'; +import { Omnichannel } from '@rocket.chat/core-services'; +import { LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; @@ -48,6 +49,11 @@ export const takeInquiry = async ( }); } + const room = await LivechatRooms.findOneById(inquiry.rid); + if (!room || !(await Omnichannel.isWithinMACLimit(room))) { + throw new Error('error-mac-limit-reached'); + } + const agent = { agentId: user._id, username: user.username, diff --git a/apps/meteor/app/livechat/server/methods/transfer.ts b/apps/meteor/app/livechat/server/methods/transfer.ts index 2dc796fc6c94..64a32c24638c 100644 --- a/apps/meteor/app/livechat/server/methods/transfer.ts +++ b/apps/meteor/app/livechat/server/methods/transfer.ts @@ -1,3 +1,4 @@ +import { Omnichannel } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; import { LivechatVisitors, LivechatRooms, Subscriptions, Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; @@ -7,7 +8,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { normalizeTransferredByData } from '../lib/Helper'; -import { Livechat } from '../lib/Livechat'; +import { Livechat } from '../lib/LivechatTyped'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -49,6 +50,10 @@ Meteor.methods({ throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:transfer' }); } + if (!(await Omnichannel.isWithinMACLimit(room))) { + throw new Meteor.Error('error-mac-limit-reached', 'MAC limit reached', { method: 'livechat:transfer' }); + } + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, uid, { projection: { _id: 1 }, }); @@ -58,7 +63,11 @@ Meteor.methods({ }); } - const guest = await LivechatVisitors.findOneById(room.v?._id); + const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); + + if (!guest) { + throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor', { method: 'livechat:transfer' }); + } const user = await Meteor.userAsync(); diff --git a/apps/meteor/app/livechat/server/sendMessageBySMS.ts b/apps/meteor/app/livechat/server/sendMessageBySMS.ts index ea220b24d149..2557fcdeb83d 100644 --- a/apps/meteor/app/livechat/server/sendMessageBySMS.ts +++ b/apps/meteor/app/livechat/server/sendMessageBySMS.ts @@ -10,33 +10,27 @@ import { callbackLogger } from './lib/logger'; callbacks.add( 'afterSaveMessage', async (message, room) => { - callbackLogger.debug('Attempting to send SMS message'); // skips this callback if the message was edited if (isEditedMessage(message)) { - callbackLogger.debug('Message was edited, skipping SMS send'); return message; } if (!settings.get('SMS_Enabled')) { - callbackLogger.debug('SMS is not enabled, skipping SMS send'); return message; } // only send the sms by SMS if it is a livechat room with SMS set to true if (!(isOmnichannelRoom(room) && room.sms && room.v && room.v.token)) { - callbackLogger.debug('Room is not a livechat room, skipping SMS send'); return message; } // if the message has a token, it was sent from the visitor, so ignore it if (message.token) { - callbackLogger.debug('Message was sent from the visitor, skipping SMS send'); return message; } // if the message has a type means it is a special message (like the closing comment), so skips if (message.t) { - callbackLogger.debug('Message is a special message, skipping SMS send'); return message; } @@ -52,8 +46,9 @@ callbacks.add( const { location } = message; extraData = Object.assign({}, extraData, { location }); } + const service = settings.get('SMS_Service'); - const SMSService = await OmnichannelIntegration.getSmsService(settings.get('SMS_Service')); + const SMSService = await OmnichannelIntegration.getSmsService(service); if (!SMSService) { callbackLogger.debug('SMS Service is not configured, skipping SMS send'); @@ -63,14 +58,12 @@ callbacks.add( const visitor = await LivechatVisitors.getVisitorByToken(room.v.token, { projection: { phone: 1 } }); if (!visitor?.phone || visitor.phone.length === 0) { - callbackLogger.debug('Visitor does not have a phone number, skipping SMS send'); return message; } try { - callbackLogger.debug(`Message will be sent to ${visitor.phone[0].phoneNumber} through service ${settings.get('SMS_Service')}`); await SMSService.send(room.sms.from, visitor.phone[0].phoneNumber, message.msg, extraData); - callbackLogger.debug(`SMS message sent to ${visitor.phone[0].phoneNumber}`); + callbackLogger.debug(`SMS message sent to ${visitor.phone[0].phoneNumber} via ${service}`); } catch (e) { callbackLogger.error(e); } diff --git a/apps/meteor/app/livechat/server/startup.ts b/apps/meteor/app/livechat/server/startup.ts index f24f88975b22..c8487f742b3a 100644 --- a/apps/meteor/app/livechat/server/startup.ts +++ b/apps/meteor/app/livechat/server/startup.ts @@ -1,5 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; -import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { ILivechatAgentStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; @@ -13,6 +13,7 @@ import { settings } from '../../settings/server'; import { businessHourManager } from './business-hour'; import { createDefaultBusinessHourIfNotExists } from './business-hour/Helper'; import { Livechat } from './lib/Livechat'; +import { Livechat as LivechatTyped } from './lib/LivechatTyped'; import { RoutingManager } from './lib/RoutingManager'; import { LivechatAgentActivityMonitor } from './statistics/LivechatAgentActivityMonitor'; import './roomAccessValidator.internalService'; @@ -62,14 +63,12 @@ Meteor.startup(async () => { await createDefaultBusinessHourIfNotExists(); settings.watch('Livechat_enable_business_hours', async (value) => { - Livechat.logger.debug(`Changing business hour type to ${value}`); + Livechat.logger.info(`Changing business hour type to ${value}`); if (value) { await businessHourManager.startManager(); - Livechat.logger.debug(`Business hour manager started`); return; } await businessHourManager.stopManager(); - Livechat.logger.debug(`Business hour manager stopped`); }); settings.watch('Livechat_Routing_Method', (value) => { @@ -81,6 +80,11 @@ Meteor.startup(async () => { ({ user }: { user: IUser }) => user?.roles?.includes('livechat-agent') && !user?.roles?.includes('bot') && - void Livechat.setUserStatusLivechatIf(user._id, 'not-available', {}, { livechatStatusSystemModified: true }).catch(), + void LivechatTyped.setUserStatusLivechatIf( + user._id, + ILivechatAgentStatus.NOT_AVAILABLE, + {}, + { livechatStatusSystemModified: true }, + ).catch(), ); }); diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts index 06c3014a8a56..f62ab71f2302 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts @@ -480,7 +480,6 @@ export class SAML { continue; } - const room = await Rooms.findOneByNameAndType(roomName, 'c', {}); const privRoom = await Rooms.findOneByNameAndType(roomName, 'p', {}); if (privRoom && includePrivateChannelsInUpdate === true) { @@ -488,6 +487,7 @@ export class SAML { continue; } + const room = await Rooms.findOneByNameAndType(roomName, 'c', {}); if (room) { await addUserToRoom(room._id, user); continue; @@ -496,7 +496,7 @@ export class SAML { if (!room && !privRoom) { // If the user doesn't have an username yet, we can't create new rooms for them if (user.username) { - await createRoom('c', roomName, user.username); + await createRoom('c', roomName, user); } } } diff --git a/apps/meteor/app/oembed/server/providers.ts b/apps/meteor/app/oembed/server/providers.ts index e80e456c679b..d2d0f85d19ce 100644 --- a/apps/meteor/app/oembed/server/providers.ts +++ b/apps/meteor/app/oembed/server/providers.ts @@ -1,9 +1,5 @@ -import QueryString from 'querystring'; -import URL from 'url'; - -import type { OEmbedMeta, OEmbedUrlContent, ParsedUrl, OEmbedProvider } from '@rocket.chat/core-typings'; +import type { OEmbedMeta, OEmbedUrlContent, OEmbedProvider } from '@rocket.chat/core-typings'; import { camelCase } from 'change-case'; -import _ from 'underscore'; import { callbacks } from '../../../lib/callbacks'; import { SystemLogger } from '../../../server/lib/logger/system'; @@ -16,10 +12,10 @@ class Providers { } static getConsumerUrl(provider: OEmbedProvider, url: string): string { - const urlObj = new URL.URL(provider.endPoint); + const urlObj = new URL(provider.endPoint); urlObj.searchParams.set('url', url); - return URL.format(urlObj); + return urlObj.toString(); } registerProvider(provider: OEmbedProvider): number { @@ -95,25 +91,20 @@ providers.registerProvider({ callbacks.add( 'oembed:beforeGetUrlContent', (data) => { - if (data.parsedUrl != null) { - const url = URL.format(data.parsedUrl); - const provider = providers.getProviderForUrl(url); - if (provider != null) { - const consumerUrl = Providers.getConsumerUrl(provider, url); - - const parsedConsumerUrl = URL.parse(consumerUrl, true); - _.extend(data.parsedUrl, parsedConsumerUrl); - - data.urlObj.port = parsedConsumerUrl.port; - data.urlObj.hostname = parsedConsumerUrl.hostname; - data.urlObj.pathname = parsedConsumerUrl.pathname; - data.urlObj.query = parsedConsumerUrl.query; - - delete data.urlObj.search; - delete data.urlObj.host; - } + if (!data.urlObj) { + return data; } - return data; + + const url = data.urlObj.toString(); + const provider = providers.getProviderForUrl(url); + + if (!provider) { + return data; + } + + const consumerUrl = Providers.getConsumerUrl(provider, url); + + return { ...data, urlObj: new URL(consumerUrl) }; }, callbacks.priority.MEDIUM, 'oembed-providers-before', @@ -123,13 +114,11 @@ const cleanupOembed = (data: { url: string; meta: OEmbedMeta; headers: { [k: string]: string }; - parsedUrl: ParsedUrl; content: OEmbedUrlContent; }): { url: string; meta: Omit; headers: { [k: string]: string }; - parsedUrl: ParsedUrl; content: OEmbedUrlContent; } => { if (!data?.meta) { @@ -148,24 +137,17 @@ const cleanupOembed = (data: { callbacks.add( 'oembed:afterParseContent', (data) => { - if (!data?.url || !data.content?.body || !data.parsedUrl?.query) { + if (!data?.url || !data.content?.body) { return cleanupOembed(data); } - const queryString = typeof data.parsedUrl.query === 'string' ? QueryString.parse(data.parsedUrl.query) : data.parsedUrl.query; - - if (!queryString.url) { - return cleanupOembed(data); - } + const provider = providers.getProviderForUrl(data.url); - const { url: originalUrl } = data; - const provider = providers.getProviderForUrl(originalUrl); if (!provider) { return cleanupOembed(data); } - const { url } = queryString; - data.meta.oembedUrl = url; + data.meta.oembedUrl = data.url; try { const metas = JSON.parse(data.content.body); diff --git a/apps/meteor/app/oembed/server/server.ts b/apps/meteor/app/oembed/server/server.ts index 256722cdd3d4..79de0402043f 100644 --- a/apps/meteor/app/oembed/server/server.ts +++ b/apps/meteor/app/oembed/server/server.ts @@ -1,6 +1,3 @@ -import querystring from 'querystring'; -import URL from 'url'; - import type { OEmbedUrlContentResult, OEmbedUrlWithMetadata, IMessage, MessageAttachment, OEmbedMeta } from '@rocket.chat/core-typings'; import { isOEmbedUrlContentResult, isOEmbedUrlWithMetadata } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; @@ -11,7 +8,6 @@ import he from 'he'; import iconv from 'iconv-lite'; import ipRangeCheck from 'ip-range-check'; import jschardet from 'jschardet'; -import _ from 'underscore'; import { callbacks } from '../../../lib/callbacks'; import { isURL } from '../../../lib/utils/isURL'; @@ -62,14 +58,7 @@ const toUtf8 = function (contentType: string, body: Buffer): string { return iconv.decode(body, getCharset(contentType, body)); }; -const getUrlContent = async function (urlObjStr: string | URL.UrlWithStringQuery, redirectCount = 5): Promise { - let urlObj: URL.UrlWithStringQuery; - if (typeof urlObjStr === 'string') { - urlObj = URL.parse(urlObjStr); - } else { - urlObj = urlObjStr; - } - +const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise => { const portsProtocol = new Map( Object.entries({ 80: 'http:', @@ -78,34 +67,28 @@ const getUrlContent = async function (urlObjStr: string | URL.UrlWithStringQuery }), ); - const parsedUrl = _.pick(urlObj, ['host', 'hash', 'pathname', 'protocol', 'port', 'query', 'search', 'hostname']); const ignoredHosts = settings.get('API_EmbedIgnoredHosts').replace(/\s/g, '').split(',') || []; - if (parsedUrl.hostname && (ignoredHosts.includes(parsedUrl.hostname) || ipRangeCheck(parsedUrl.hostname, ignoredHosts))) { + if (urlObj.hostname && (ignoredHosts.includes(urlObj.hostname) || ipRangeCheck(urlObj.hostname, ignoredHosts))) { throw new Error('invalid host'); } const safePorts = settings.get('API_EmbedSafePorts').replace(/\s/g, '').split(',') || []; - if (safePorts.length > 0 && parsedUrl.port && !safePorts.includes(parsedUrl.port)) { + // checks if the URL port is in the safe ports list + if (safePorts.length > 0 && urlObj.port && !safePorts.includes(urlObj.port)) { throw new Error('invalid/unsafe port'); } - if (safePorts.length > 0 && !parsedUrl.port && !safePorts.some((port) => portsProtocol.get(port) === parsedUrl.protocol)) { + // if port is not detected, use protocol to verify instead + if (safePorts.length > 0 && !urlObj.port && !safePorts.some((port) => portsProtocol.get(port) === urlObj.protocol)) { throw new Error('invalid/unsafe port'); } const data = await callbacks.run('oembed:beforeGetUrlContent', { urlObj, - parsedUrl, }); - /* This prop is neither passed or returned by the callback, so I'll just comment it for now - if (data.attachments != null) { - return data; - } */ - - const url = URL.format(data.urlObj); - + const url = data.urlObj.toString(); const sizeLimit = 250000; log.debug(`Fetching ${url} following redirects ${redirectCount} times`); @@ -137,10 +120,10 @@ const getUrlContent = async function (urlObjStr: string | URL.UrlWithStringQuery log.debug('Obtained response from server with length of', totalSize); const buffer = Buffer.concat(chunks); + return { headers: Object.fromEntries(response.headers), body: toUtf8(response.headers.get('content-type') || 'text/plain', buffer), - parsedUrl, statusCode: response.status, }; }; @@ -150,19 +133,13 @@ const getUrlMeta = async function ( withFragment?: boolean, ): Promise { log.debug('Obtaining metadata for URL', url); - const urlObj = URL.parse(url); - if (withFragment != null) { - const queryStringObj = querystring.parse(urlObj.query || ''); - queryStringObj._escaped_fragment_ = ''; - urlObj.query = querystring.stringify(queryStringObj); - let path = urlObj.pathname; - if (urlObj.query != null) { - path += `?${urlObj.query}`; - urlObj.search = `?${urlObj.query}`; - } - urlObj.path = path; + const urlObj = new URL(url); + + if (withFragment) { + urlObj.searchParams.set('_escaped_fragment_', ''); } - log.debug('Fetching url content', urlObj.path); + + log.debug('Fetching url content', urlObj.toString()); let content: OEmbedUrlContentResult | undefined; try { content = await getUrlContent(urlObj, 5); @@ -174,7 +151,7 @@ const getUrlMeta = async function ( return; } - if (content.attachments != null) { + if (content.attachments) { return content; } @@ -221,7 +198,6 @@ const getUrlMeta = async function ( url, meta: metas, headers, - parsedUrl: content.parsedUrl, content, }); }; @@ -233,38 +209,25 @@ const getUrlMetaWithCache = async function ( log.debug('Getting oembed metadata for', url); const cache = await OEmbedCache.findOneById(url); - if (cache != null) { + if (cache) { log.debug('Found oembed metadata in cache for', url); return cache.data; } + const data = await getUrlMeta(url, withFragment); - if (data != null) { - try { - log.debug('Saving oembed metadata in cache for', url); - await OEmbedCache.createWithIdAndData(url, data); - } catch (_error) { - log.error({ msg: 'OEmbed duplicated record', url }); - } - return data; - } -}; -const hasOnlyContentLength = (obj: any): obj is { contentLength: string } => 'contentLength' in obj && Object.keys(obj).length === 1; -const hasOnlyContentType = (obj: any): obj is { contentType: string } => 'contentType' in obj && Object.keys(obj).length === 1; -const hasContentLengthAndContentType = (obj: any): obj is { contentLength: string; contentType: string } => - 'contentLength' in obj && 'contentType' in obj && Object.keys(obj).length === 2; - -const getRelevantHeaders = function (headersObj: { - [key: string]: string; -}): { contentLength: string } | { contentType: string } | { contentLength: string; contentType: string } | void { - const headers = { - ...(headersObj.contentLength && { contentLength: headersObj.contentLength }), - ...(headersObj.contentType && { contentType: headersObj.contentType }), - }; + if (!data) { + return; + } - if (hasOnlyContentLength(headers) || hasOnlyContentType(headers) || hasContentLengthAndContentType(headers)) { - return headers; + try { + log.debug('Saving oembed metadata in cache for', url); + await OEmbedCache.createWithIdAndData(url, data); + } catch (_error) { + log.error({ msg: 'OEmbed duplicated record', url }); } + + return data; }; const getRelevantMetaTags = function (metaObj: OEmbedMeta): Record | void { @@ -286,57 +249,71 @@ const insertMaxWidthInOembedHtml = (oembedHtml?: string): string | undefined => const rocketUrlParser = async function (message: IMessage): Promise { log.debug('Parsing message URLs'); - if (Array.isArray(message.urls)) { - log.debug('URLs found', message.urls.length); - - if ( - (message.attachments && message.attachments.length > 0) || - message.urls.filter((item) => !item.url.includes(settings.get('Site_Url'))).length > MAX_EXTERNAL_URL_PREVIEWS - ) { - log.debug('All URL ignored'); - return message; + + if (!Array.isArray(message.urls)) { + return message; + } + + log.debug('URLs found', message.urls.length); + + if ( + (message.attachments && message.attachments.length > 0) || + message.urls.filter((item) => !item.url.includes(settings.get('Site_Url'))).length > MAX_EXTERNAL_URL_PREVIEWS + ) { + log.debug('All URL ignored'); + return message; + } + + const attachments: MessageAttachment[] = []; + + let changed = false; + for await (const item of message.urls) { + if (item.ignoreParse === true) { + log.debug('URL ignored', item.url); + continue; } - const attachments: MessageAttachment[] = []; + if (!isURL(item.url)) { + continue; + } - let changed = false; - for await (const item of message.urls) { - if (item.ignoreParse === true) { - log.debug('URL ignored', item.url); - continue; - } - if (!isURL(item.url)) { - continue; - } - const data = await getUrlMetaWithCache(item.url); - if (data != null) { - if (isOEmbedUrlContentResult(data) && data.attachments) { - attachments.push(...data.attachments); - break; - } - if (isOEmbedUrlWithMetadata(data) && data.meta != null) { - item.meta = getRelevantMetaTags(data.meta) || {}; - if (item.meta?.oembedHtml) { - item.meta.oembedHtml = insertMaxWidthInOembedHtml(item.meta.oembedHtml) || ''; - } - } - if (data.headers != null) { - const headers = getRelevantHeaders(data.headers); - if (headers) { - item.headers = headers; - } - } - item.parsedUrl = data.parsedUrl; - changed = true; + const data = await getUrlMetaWithCache(item.url); + + if (!data) { + continue; + } + + if (isOEmbedUrlContentResult(data) && data.attachments) { + attachments.push(...data.attachments); + break; + } + + if (isOEmbedUrlWithMetadata(data) && data.meta) { + item.meta = getRelevantMetaTags(data.meta) || {}; + if (item.meta?.oembedHtml) { + item.meta.oembedHtml = insertMaxWidthInOembedHtml(item.meta.oembedHtml) || ''; } } - if (attachments.length > 0) { - await Messages.setMessageAttachments(message._id, attachments); + + if (data.headers?.contentLength) { + item.headers = { ...item.headers, contentLength: data.headers.contentLength }; } - if (changed === true) { - await Messages.setUrlsById(message._id, message.urls); + + if (data.headers?.contentType) { + item.headers = { ...item.headers, contentType: data.headers.contentType }; } + + changed = true; } + + if (attachments.length) { + await Messages.setMessageAttachments(message._id, attachments); + } + + if (changed === true) { + await Messages.setUrlsById(message._id, message.urls); + } + return message; }; diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.js b/apps/meteor/app/slackbridge/server/RocketAdapter.js index d0ef8157137d..f76c33fa1f81 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.js +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.js @@ -295,7 +295,7 @@ export default class RocketAdapter { try { const isPrivate = slackChannel.is_private; - const rocketChannel = await createRoom(isPrivate ? 'p' : 'c', slackChannel.name, rocketUserCreator.username, rocketUsers); + const rocketChannel = await createRoom(isPrivate ? 'p' : 'c', slackChannel.name, rocketUserCreator, rocketUsers); slackChannel.rocketId = rocketChannel.rid; } catch (e) { if (!hasRetried) { diff --git a/apps/meteor/app/slackbridge/server/SlackAPI.js b/apps/meteor/app/slackbridge/server/SlackAPI.js index 63774024dc7e..540aa3b91160 100644 --- a/apps/meteor/app/slackbridge/server/SlackAPI.js +++ b/apps/meteor/app/slackbridge/server/SlackAPI.js @@ -1,15 +1,17 @@ import { serverFetch as fetch } from '@rocket.chat/server-fetch'; export class SlackAPI { - constructor(apiToken) { - this.apiToken = apiToken; + constructor(apiOrBotToken) { + this.token = apiOrBotToken; } async getChannels(cursor = null) { let channels = []; const request = await fetch('https://slack.com/api/conversations.list', { + headers: { + Authorization: `Bearer ${this.token}`, + }, params: { - token: this.apiToken, types: 'public_channel', exclude_archived: true, limit: 1000, @@ -32,8 +34,10 @@ export class SlackAPI { async getGroups(cursor = null) { let groups = []; const request = await fetch('https://slack.com/api/conversations.list', { + headers: { + Authorization: `Bearer ${this.token}`, + }, params: { - token: this.apiToken, types: 'private_channel', exclude_archived: true, limit: 1000, @@ -55,8 +59,10 @@ export class SlackAPI { async getRoomInfo(roomId) { const request = await fetch(`https://slack.com/api/conversations.info`, { + headers: { + Authorization: `Bearer ${this.token}`, + }, params: { - token: this.apiToken, channel: roomId, include_num_members: true, }, @@ -73,8 +79,10 @@ export class SlackAPI { for (let index = 0; index < num_members; index += MAX_MEMBERS_PER_CALL) { // eslint-disable-next-line no-await-in-loop const request = await fetch('https://slack.com/api/conversations.members', { + headers: { + Authorization: `Bearer ${this.token}`, + }, params: { - token: this.apiToken, channel: channelId, limit: MAX_MEMBERS_PER_CALL, ...(currentCursor && { cursor: currentCursor }), @@ -95,6 +103,9 @@ export class SlackAPI { async react(data) { const request = await fetch('https://slack.com/api/reactions.add', { + headers: { + Authorization: `Bearer ${this.token}`, + }, method: 'POST', params: data, }); @@ -104,6 +115,9 @@ export class SlackAPI { async removeReaction(data) { const request = await fetch('https://slack.com/api/reactions.remove', { + headers: { + Authorization: `Bearer ${this.token}`, + }, method: 'POST', params: data, }); @@ -113,6 +127,9 @@ export class SlackAPI { async removeMessage(data) { const request = await fetch('https://slack.com/api/chat.delete', { + headers: { + Authorization: `Bearer ${this.token}`, + }, method: 'POST', params: data, }); @@ -122,6 +139,9 @@ export class SlackAPI { async sendMessage(data) { const request = await fetch('https://slack.com/api/chat.postMessage', { + headers: { + Authorization: `Bearer ${this.token}`, + }, method: 'POST', params: data, }); @@ -130,6 +150,9 @@ export class SlackAPI { async updateMessage(data) { const request = await fetch('https://slack.com/api/chat.update', { + headers: { + Authorization: `Bearer ${this.token}`, + }, method: 'POST', params: data, }); @@ -137,12 +160,12 @@ export class SlackAPI { return response && request.status === 200 && response && request.ok; } - async getHistory(family, options) { - const request = await fetch(`https://slack.com/api/${family}.history`, { - params: { - token: this.apiToken, - ...options, + async getHistory(options) { + const request = await fetch(`https://slack.com/api/conversations.history`, { + headers: { + Authorization: `Bearer ${this.token}`, }, + params: options, }); const response = await request.json(); return response; @@ -150,8 +173,10 @@ export class SlackAPI { async getPins(channelId) { const request = await fetch('https://slack.com/api/pins.list', { + headers: { + Authorization: `Bearer ${this.token}`, + }, params: { - token: this.apiToken, channel: channelId, }, }); @@ -161,12 +186,38 @@ export class SlackAPI { async getUser(userId) { const request = await fetch('https://slack.com/api/users.info', { + headers: { + Authorization: `Bearer ${this.token}`, + }, params: { - token: this.apiToken, user: userId, }, }); const response = await request.json(); return response && response && request.status === 200 && request.ok && response.user; } + + static async verifyToken(token) { + const request = await fetch('https://slack.com/api/auth.test', { + headers: { + Authorization: `Bearer ${token}`, + }, + method: 'POST', + }); + const response = await request.json(); + return response && response && request.status === 200 && request.ok && response.ok; + } + + static async verifyAppCredentials({ botToken, appToken }) { + const request = await fetch('https://slack.com/api/apps.connections.open', { + headers: { + Authorization: `Bearer ${appToken}`, + }, + method: 'POST', + }); + const response = await request.json(); + const isAppTokenOk = response && response && request.status === 200 && request.ok && response.ok; + const isBotTokenOk = await this.verifyToken(botToken); + return isAppTokenOk && isBotTokenOk; + } } diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.js b/apps/meteor/app/slackbridge/server/SlackAdapter.js index d5379c082507..0fb4ee8a7125 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.js +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.js @@ -4,6 +4,7 @@ import url from 'url'; import { Message } from '@rocket.chat/core-services'; import { Messages, Rooms, Users, ReadReceipts } from '@rocket.chat/models'; +import { App as SlackApp } from '@slack/bolt'; import { RTMClient } from '@slack/rtm-api'; import { Meteor } from 'meteor/meteor'; @@ -28,6 +29,8 @@ export default class SlackAdapter { this.slackBridge = slackBridge; this.rtm = {}; // slack-client Real Time Messaging API this.apiToken = {}; // Slack API Token passed in via Connect + this.slackApp = {}; + this.appCredential = {}; // On Slack, a rocket integration bot will be added to slack channels, this is the list of those channels, key is Rocket Ch ID this.slackChannelRocketBotMembershipMap = new Map(); // Key=RocketChannelID, Value=SlackChannel this.rocket = {}; @@ -37,48 +40,96 @@ export default class SlackAdapter { this.slackAPI = {}; } + async connect({ apiToken, appCredential }) { + try { + const connectResult = await (appCredential ? this.connectApp(appCredential) : this.connectLegacy(apiToken)); + + if (connectResult) { + slackLogger.info('Connected to Slack'); + slackLogger.debug('Slack connection result: ', connectResult); + Meteor.startup(async () => { + try { + await this.populateMembershipChannelMap(); // If run outside of Meteor.startup, HTTP is not defined + } catch (err) { + slackLogger.error({ msg: 'Error attempting to connect to Slack', err }); + if (err.data.error === 'invalid_auth') { + slackLogger.error('The provided token is invalid'); + } + this.slackBridge.disconnect(); + } + }); + } + } catch (err) { + slackLogger.error({ msg: 'Error attempting to connect to Slack', err }); + this.slackBridge.disconnect(); + } + } + + /** + * Connect to the remote Slack server using the passed in app credential and register for Slack events. + * @typedef {Object} AppCredential + * @property {string} botToken + * @property {string} appToken + * @property {string} signingSecret + * @param {AppCredential} appCredential + */ + async connectApp(appCredential) { + this.appCredential = appCredential; + + // Invalid app credentials causes unhandled errors + if (!(await SlackAPI.verifyAppCredentials(appCredential))) { + throw new Error('Invalid app credentials (botToken or appToken) for the slack app'); + } + this.slackAPI = new SlackAPI(this.appCredential.botToken); + + this.slackApp = new SlackApp({ + appToken: this.appCredential.appToken, + signingSecret: this.appCredential.signingSecret, + token: this.appCredential.botToken, + socketMode: true, + }); + + this.registerForEvents(); + + const connectionResult = await this.slackApp.start(); + + return connectionResult; + } + /** - * Connect to the remote Slack server using the passed in token API and register for Slack events + * Connect to the remote Slack server using the passed in token API and register for Slack events. * @param apiToken + * @deprecated */ - async connect(apiToken) { + async connectLegacy(apiToken) { this.apiToken = apiToken; + // Invalid apiToken causes unhandled errors + if (!(await SlackAPI.verifyToken(apiToken))) { + throw new Error('Invalid ApiToken for the slack legacy bot integration'); + } + if (RTMClient != null) { RTMClient.disconnect; } this.slackAPI = new SlackAPI(this.apiToken); this.rtm = new RTMClient(this.apiToken); - await this.rtm - .start() - .then((res) => slackLogger.debug('Connecting to slack', res)) - .catch((err) => { - slackLogger.error({ msg: 'Error attempting to connect to Slack', err }); - if (err.data.error === 'invalid_auth') { - throw new Error('The provided token is invalid'); - } - throw new Error(err); - }); + this.registerForEventsLegacy(); - this.registerForEvents(); + const connectionResult = await this.rtm.start(); - Meteor.startup(async () => { - try { - await this.populateMembershipChannelMap(); // If run outside of Meteor.startup, HTTP is not defined - } catch (err) { - slackLogger.error({ msg: 'Error attempting to connect to Slack', err }); - this.slackBridge.disconnect(); - } - }); + return connectionResult; } /** * Unregister for slack events and disconnect from Slack */ - disconnect() { + async disconnect() { if (this.rtm.connected && this.rtm.disconnect) { - this.rtm.disconnect(); + await this.rtm.disconnect(); + } else if (this.slackApp.stop) { + await this.slackApp.stop(); } } @@ -87,6 +138,119 @@ export default class SlackAdapter { } registerForEvents() { + /** + * message: { + * "client_msg_id": "caab144d-41e7-47cc-87fa-af5d50c02784", + * "type": "message", + * "text": "heyyyyy", + * "user": "U060WD4QW81", + * "ts": "1697054782.214569", + * "blocks": [], + * "team": "T060383CUDV", + * "channel": "C060HSLQPCN", + * "event_ts": "1697054782.214569", + * "channel_type": "channel" + * } + */ + this.slackApp.message(async ({ message }) => { + slackLogger.debug('OnSlackEvent-MESSAGE: ', message); + if (message) { + try { + await this.onMessage(message); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onMessage', err }); + } + } + }); + + /** + * Event fired when a message is reacted in a channel or group app is added in + * event: { + * "type": "reaction_added", + * "user": "U060WD4QW81", + * "reaction": "telephone_receiver", + * "item": { + * "type": "message", + * "channel": "C06196XMUMN", + * "ts": "1697037020.309679" + * }, + * "item_user": "U060WD4QW81", + * "event_ts": "1697037219.001600" + * } + */ + this.slackApp.event('reaction_added', async ({ event }) => { + slackLogger.debug('OnSlackEvent-REACTION_ADDED: ', event); + try { + slackLogger.error({ event }); + await this.onReactionAdded(event); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onReactionAdded', err }); + } + }); + + /** + * Event fired when a reaction is removed from a message in a channel or group app is added in. + * event: { + * "type": "reaction_removed", + * "user": "U060WD4QW81", + * "reaction": "raised_hands", + * "item": { + * "type": "message", + * "channel": "C06196XMUMN", + * "ts": "1697028997.057629" + * }, + * "item_user": "U060WD4QW81", + * "event_ts": "1697029220.000600" + * } + */ + this.slackApp.event('reaction_removed', async ({ event }) => { + slackLogger.debug('OnSlackEvent-REACTION_REMOVED: ', event); + try { + await this.onReactionRemoved(event); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onReactionRemoved', err }); + } + }); + + /** + * Event fired when a members joins a channel + * event: { + * "type": "member_joined_channel", + * "user": "U06039U8WK1", + * "channel": "C060HT033E2", + * "channel_type": "C", + * "team": "T060383CUDV", + * "inviter": "U060WD4QW81", + * "event_ts": "1697042377.000800" + * } + */ + this.slackApp.event('member_joined_channel', async ({ event, context }) => { + slackLogger.debug('OnSlackEvent-CHANNEL_LEFT: ', event); + try { + await this.processMemberJoinChannel(event, context); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onChannelLeft', err }); + } + }); + + this.slackApp.event('channel_left', async ({ event }) => { + slackLogger.debug('OnSlackEvent-CHANNEL_LEFT: ', event); + try { + this.onChannelLeft(event); + } catch (err) { + slackLogger.error({ msg: 'Unhandled error onChannelLeft', err }); + } + }); + + this.slackApp.error((error) => { + slackLogger.error({ msg: 'Error on SlackApp', error }); + }); + } + + /** + * @deprecated + */ + registerForEventsLegacy() { slackLogger.debug('Register for events'); this.rtm.on('authenticated', () => { slackLogger.info('Connected to Slack'); @@ -586,7 +750,6 @@ export default class SlackAdapter { async postReactionAdded(reaction, slackChannel, slackTS) { if (reaction && slackChannel && slackTS) { const data = { - token: this.apiToken, name: reaction, channel: slackChannel, timestamp: slackTS, @@ -606,7 +769,6 @@ export default class SlackAdapter { async postReactionRemove(reaction, slackChannel, slackTS) { if (reaction && slackChannel && slackTS) { const data = { - token: this.apiToken, name: reaction, channel: slackChannel, timestamp: slackTS, @@ -626,7 +788,6 @@ export default class SlackAdapter { if (slackChannel != null) { const data = { - token: this.apiToken, ts: this.getTimeStamp(rocketMessage), channel: this.getSlackChannel(rocketMessage.rid).id, as_user: true, @@ -681,7 +842,6 @@ export default class SlackAdapter { iconUrl = Meteor.absoluteUrl().replace(/\/$/, '') + iconUrl; } const data = { - token: this.apiToken, text: rocketMessage.msg, channel: slackChannel.id, username: rocketMessage.u && rocketMessage.u.username, @@ -722,7 +882,6 @@ export default class SlackAdapter { async postMessageUpdate(slackChannel, rocketMessage) { if (slackChannel && slackChannel.id) { const data = { - token: this.apiToken, ts: this.getTimeStamp(rocketMessage), channel: slackChannel.id, text: rocketMessage.msg, @@ -736,6 +895,18 @@ export default class SlackAdapter { } } + async processMemberJoinChannel(event, context) { + slackLogger.debug('Member join channel', event.channel); + const rocketCh = await this.rocket.getChannel({ channel: event.channel }); + if (rocketCh != null) { + this.addSlackChannel(rocketCh._id, event.channel); + if (context?.botUserId !== event?.user) { + const rocketChatUser = await this.rocket.getUser(event.user); + await addUserToRoom(rocketCh._id, rocketChatUser); + } + } + } + async processChannelJoin(slackMessage) { slackLogger.debug('Channel join', slackMessage.channel.id); const rocketCh = await this.rocket.addChannel(slackMessage.channel); @@ -1142,9 +1313,9 @@ export default class SlackAdapter { }); } - async importFromHistory(family, options) { + async importFromHistory(options) { slackLogger.debug('Importing messages history'); - const data = await this.slackAPI.getHistory(family, options); + const data = await this.slackAPI.getHistory(options); if (Array.isArray(data.messages) && data.messages.length) { let latest = 0; for await (const message of data.messages.reverse()) { @@ -1245,13 +1416,14 @@ export default class SlackAdapter { await this.copyChannelInfo(rid, this.getSlackChannel(rid)); slackLogger.debug('Importing messages from Slack to Rocket.Chat', this.getSlackChannel(rid), rid); - let results = await this.importFromHistory(this.getSlackChannel(rid).family, { + + let results = await this.importFromHistory({ channel: this.getSlackChannel(rid).id, oldest: 1, }); while (results && results.has_more) { // eslint-disable-next-line no-await-in-loop - results = await this.importFromHistory(this.getSlackChannel(rid).family, { + results = await this.importFromHistory({ channel: this.getSlackChannel(rid).id, oldest: results.ts, }); diff --git a/apps/meteor/app/slackbridge/server/slackbridge.js b/apps/meteor/app/slackbridge/server/slackbridge.js index 3198b750145f..b5983e7fff58 100644 --- a/apps/meteor/app/slackbridge/server/slackbridge.js +++ b/apps/meteor/app/slackbridge/server/slackbridge.js @@ -1,3 +1,5 @@ +import { debounce } from 'lodash'; + import { settings } from '../../settings/server'; import RocketAdapter from './RocketAdapter.js'; import SlackAdapter from './SlackAdapter.js'; @@ -8,6 +10,8 @@ import { classLogger, connLogger } from './logger'; */ class SlackBridgeClass { constructor() { + this.isEnabled = false; + this.isLegacyRTM = true; this.slackAdapters = []; this.rocket = new RocketAdapter(this); this.reactionsMap = new Map(); // Sync object between rocket and slack @@ -17,6 +21,9 @@ class SlackBridgeClass { // Settings that we cache versus looking up at runtime this.apiTokens = false; + this.botTokens = false; + this.appTokens = false; + this.signingSecrets = false; this.aliasFormat = ''; this.excludeBotnames = ''; this.isReactionsEnabled = true; @@ -29,16 +36,43 @@ class SlackBridgeClass { this.slackAdapters = []; this.rocket.clearSlackAdapters(); - const tokenList = this.apiTokens.split('\n'); + if (this.isLegacyRTM) { + const tokenList = this.apiTokens.split('\n'); + + tokenList.forEach((apiToken) => { + const slack = new SlackAdapter(this); + slack.setRocket(this.rocket); + this.rocket.addSlack(slack); + this.slackAdapters.push(slack); - tokenList.forEach((apiToken) => { - const slack = new SlackAdapter(this); - slack.setRocket(this.rocket); - this.rocket.addSlack(slack); - this.slackAdapters.push(slack); + slack.connect({ apiToken }).catch((err) => connLogger.error('error connecting to slack', err)); + }); + } else { + const botTokenList = this.botTokens.split('\n'); // Bot token list + const appTokenList = this.appTokens.split('\n'); // App token list + const signingSecretList = this.signingSecrets.split('\n'); // Signing secret list + + // Check if the number of tokens are the same + if (botTokenList.length !== appTokenList.length || botTokenList.length !== signingSecretList.length) { + connLogger.error('error connecting to slack: number of tokens are not the same'); + return; + } - slack.connect(apiToken).catch((err) => connLogger.error('error connecting to slack', err)); - }); + const appCredentials = botTokenList.map((botToken, i) => ({ + botToken, + appToken: appTokenList[i], + signingSecret: signingSecretList[i], + })); + + appCredentials.forEach((appCredential) => { + const slack = new SlackAdapter(this); + slack.setRocket(this.rocket); + this.rocket.addSlack(slack); + this.slackAdapters.push(slack); + + slack.connect({ appCredential }).catch((err) => connLogger.error('error connecting to slack', err)); + }); + } if (settings.get('SlackBridge_Out_Enabled')) { this.rocket.connect(); @@ -49,27 +83,76 @@ class SlackBridgeClass { } } - disconnect() { - if (this.connected === true) { - this.rocket.disconnect(); - this.slackAdapters.forEach((slack) => { - slack.disconnect(); - }); - this.slackAdapters = []; - this.connected = false; - connLogger.info('Disabled'); + async reconnect() { + await this.disconnect(); + // connect if either apiTokens or appCredentials are set + if (this.isLegacyRTM && this.apiTokens) { + this.connect(); + } else if (!this.isLegacyRTM && this.botTokens && this.appTokens && this.signingSecrets) { + this.connect(); + } + } + + debouncedReconnectIfEnabled = debounce(() => { + if (this.isEnabled) { + this.reconnect(); + } + }, 500); + + async disconnect() { + try { + if (this.connected === true) { + await this.rocket.disconnect(); + await Promise.all(this.slackAdapters.map((slack) => slack.disconnect())); + this.slackAdapters = []; + this.connected = false; + connLogger.info('Slack Bridge Disconnected'); + } + } catch (error) { + connLogger.error('An error occurred during disconnection', error); } } processSettings() { + // Check if legacy realtime api is enabled + settings.watch('SlackBridge_UseLegacy', (value) => { + if (value !== this.isLegacyRTM) { + this.isLegacyRTM = value; + this.debouncedReconnectIfEnabled(); + } + classLogger.debug('Setting: SlackBridge_UseLegacy', value); + }); + + // Slack installtion Bot token + settings.watch('SlackBridge_BotToken', (value) => { + if (value !== this.botTokens) { + this.botTokens = value; + this.debouncedReconnectIfEnabled(); + } + classLogger.debug('Setting: SlackBridge_BotToken', value); + }); + // Slack installtion App token + settings.watch('SlackBridge_AppToken', (value) => { + if (value !== this.appTokens) { + this.appTokens = value; + this.debouncedReconnectIfEnabled(); + } + classLogger.debug('Setting: SlackBridge_AppToken', value); + }); + // Slack installtion Signing token + settings.watch('SlackBridge_SigningSecret', (value) => { + if (value !== this.signingSecrets) { + this.signingSecrets = value; + this.debouncedReconnectIfEnabled(); + } + classLogger.debug('Setting: SlackBridge_SigningSecret', value); + }); + // Slack installation API token settings.watch('SlackBridge_APIToken', (value) => { if (value !== this.apiTokens) { this.apiTokens = value; - if (this.connected) { - this.disconnect(); - this.connect(); - } + this.debouncedReconnectIfEnabled(); } classLogger.debug('Setting: SlackBridge_APIToken', value); @@ -95,10 +178,13 @@ class SlackBridgeClass { // Is this entire SlackBridge enabled settings.watch('SlackBridge_Enabled', (value) => { - if (value && this.apiTokens) { - this.connect(); - } else { - this.disconnect(); + if (this.isEnabled !== value) { + this.isEnabled = value; + if (this.isEnabled) { + this.debouncedReconnectIfEnabled(); + } else { + this.disconnect(); + } } classLogger.debug('Setting: SlackBridge_Enabled', value); }); diff --git a/apps/meteor/app/slashcommands-create/server/server.ts b/apps/meteor/app/slashcommands-create/server/server.ts index a3c70f012fa1..104d50c56926 100644 --- a/apps/meteor/app/slashcommands-create/server/server.ts +++ b/apps/meteor/app/slashcommands-create/server/server.ts @@ -1,6 +1,6 @@ import { api } from '@rocket.chat/core-services'; import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; -import { Rooms } from '@rocket.chat/models'; +import { Rooms, Users } from '@rocket.chat/models'; import { i18n } from '../../../server/lib/i18n'; import { createChannelMethod } from '../../lib/server/methods/createChannel'; @@ -50,7 +50,11 @@ slashCommands.add({ } if (getParams(params).indexOf('private') > -1) { - await createPrivateGroupMethod(userId, channelStr, []); + const user = await Users.findOneById(userId); + if (!user) { + return; + } + await createPrivateGroupMethod(user, channelStr, []); return; } diff --git a/apps/meteor/app/slashcommands-inviteall/server/server.ts b/apps/meteor/app/slashcommands-inviteall/server/server.ts index 9917775aca06..5376bd6ae64b 100644 --- a/apps/meteor/app/slashcommands-inviteall/server/server.ts +++ b/apps/meteor/app/slashcommands-inviteall/server/server.ts @@ -37,6 +37,9 @@ function inviteAll(type: T): SlashCommand['callback'] { } const user = await Users.findOneById(userId); + if (!user) { + return; + } const lng = user?.language || settings.get('Language') || 'en'; const baseChannel = type === 'to' ? await Rooms.findOneById(message.rid) : await Rooms.findOneByName(channel); @@ -69,7 +72,7 @@ function inviteAll(type: T): SlashCommand['callback'] { const users = (await cursor.toArray()).map((s: ISubscription) => s.u.username).filter(isTruthy); if (!targetChannel && ['c', 'p'].indexOf(baseChannel.t) > -1) { - baseChannel.t === 'c' ? await createChannelMethod(userId, channel, users) : await createPrivateGroupMethod(userId, channel, users); + baseChannel.t === 'c' ? await createChannelMethod(userId, channel, users) : await createPrivateGroupMethod(user, channel, users); void api.broadcast('notify.ephemeralMessage', userId, message.rid, { msg: i18n.t('Channel_created', { postProcess: 'sprintf', diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index 54470a209196..9e63c18506ac 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -1,7 +1,7 @@ import { log } from 'console'; import os from 'os'; -import { Analytics, Team, VideoConf } from '@rocket.chat/core-services'; +import { Analytics, Team, VideoConf, Presence } from '@rocket.chat/core-services'; import type { IRoom, IStats } from '@rocket.chat/core-typings'; import { UserStatus } from '@rocket.chat/core-typings'; import { @@ -24,8 +24,10 @@ import { LivechatCustomField, Subscriptions, Users, + LivechatRooms, } from '@rocket.chat/models'; import { MongoInternals } from 'meteor/mongo'; +import moment from 'moment'; import { getStatistics as getEnterpriseStatistics } from '../../../../ee/app/license/server/getStatistics'; import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; @@ -41,8 +43,6 @@ import { getAppsStatistics } from './getAppsStatistics'; import { getImporterStatistics } from './getImporterStatistics'; import { getServicesStatistics } from './getServicesStatistics'; -const wizardFields = ['Organization_Type', 'Industry', 'Size', 'Country', 'Language', 'Server_Type', 'Register_Server']; - const getUserLanguages = async (totalUsers: number): Promise<{ [key: string]: number }> => { const result = await Users.getUserLanguages(); @@ -70,17 +70,29 @@ export const statistics = { const statistics = {} as IStats; const statsPms = []; + const fetchWizardSettingValue = async (settingName: string): Promise => { + return ((await Settings.findOne(settingName))?.value as T | undefined) ?? undefined; + }; + // Setup Wizard - statistics.wizard = {}; - await Promise.all( - wizardFields.map(async (field) => { - const record = await Settings.findOne(field); - if (record) { - const wizardField = field.replace(/_/g, '').replace(field[0], field[0].toLowerCase()); - statistics.wizard[wizardField] = record.value; - } - }), - ); + const [organizationType, industry, size, country, language, serverType, registerServer] = await Promise.all([ + fetchWizardSettingValue('Organization_Type'), + fetchWizardSettingValue('Industry'), + fetchWizardSettingValue('Size'), + fetchWizardSettingValue('Country'), + fetchWizardSettingValue('Language'), + fetchWizardSettingValue('Server_Type'), + fetchWizardSettingValue('Register_Server'), + ]); + statistics.wizard = { + organizationType, + industry, + size, + country, + language, + serverType, + registerServer, + }; // Version const uniqueID = await Settings.findOne('uniqueID'); @@ -89,6 +101,9 @@ export const statistics = { statistics.installedAt = uniqueID.createdAt.toISOString(); } + statistics.deploymentFingerprintHash = settings.get('Deployment_FingerPrint_Hash'); + statistics.deploymentFingerprintVerified = settings.get('Deployment_FingerPrint_Verified'); + if (Info) { statistics.version = Info.version; statistics.tag = Info.tag; @@ -259,6 +274,36 @@ export const statistics = { }), ); + const defaultValue = { contactsCount: 0, conversationsCount: 0, sources: [] }; + const billablePeriod = moment.utc().format('YYYY-MM'); + statsPms.push( + LivechatRooms.getMACStatisticsForPeriod(billablePeriod).then(([result]) => { + statistics.omnichannelContactsBySource = result || defaultValue; + }), + ); + + const monthAgo = moment.utc().subtract(30, 'days').toDate(); + const today = moment.utc().toDate(); + statsPms.push( + LivechatRooms.getMACStatisticsBetweenDates(monthAgo, today).then(([result]) => { + statistics.uniqueContactsOfLastMonth = result || defaultValue; + }), + ); + + const weekAgo = moment.utc().subtract(7, 'days').toDate(); + statsPms.push( + LivechatRooms.getMACStatisticsBetweenDates(weekAgo, today).then(([result]) => { + statistics.uniqueContactsOfLastWeek = result || defaultValue; + }), + ); + + const yesterday = moment.utc().subtract(1, 'days').toDate(); + statsPms.push( + LivechatRooms.getMACStatisticsBetweenDates(yesterday, today).then(([result]) => { + statistics.uniqueContactsOfYesterday = result || defaultValue; + }), + ); + // Message statistics statistics.totalChannelMessages = (await Rooms.findByType('c', { projection: { msgs: 1 } }).toArray()).reduce( function _countChannelMessages(num: number, room: IRoom) { @@ -507,6 +552,15 @@ export const statistics = { statistics.totalWebRTCCalls = settings.get('WebRTC_Calls_Count'); statistics.uncaughtExceptionsCount = settings.get('Uncaught_Exceptions_Count'); + const defaultGateway = (await Settings.findOneById('Push_gateway', { projection: { packageValue: 1 } }))?.packageValue; + + // one bit for each of the following: + const pushEnabled = settings.get('Push_enable') ? 1 : 0; + const pushGatewayEnabled = settings.get('Push_enable_gateway') ? 2 : 0; + const pushGatewayChanged = settings.get('Push_gateway') !== defaultGateway ? 4 : 0; + + statistics.push = pushEnabled | pushGatewayEnabled | pushGatewayChanged; + const defaultHomeTitle = (await Settings.findOneById('Layout_Home_Title'))?.packageValue; statistics.homeTitleChanged = settings.get('Layout_Home_Title') !== defaultHomeTitle; @@ -525,6 +579,11 @@ export const statistics = { const defaultLoggedInCustomScript = (await Settings.findOneById('Custom_Script_Logged_In'))?.packageValue; statistics.loggedInCustomScriptChanged = settings.get('Custom_Script_Logged_In') !== defaultLoggedInCustomScript; + statistics.dailyPeakConnections = await Presence.getPeakConnections(true); + + const peak = await Statistics.findMonthlyPeakConnections(); + statistics.maxMonthlyPeakConnections = Math.max(statistics.dailyPeakConnections, peak?.dailyPeakConnections || 0); + statistics.matrixFederation = await getMatrixFederationStatistics(); // Omnichannel call stats @@ -539,7 +598,9 @@ export const statistics = { async save(): Promise { const rcStatistics = await statistics.get(); rcStatistics.createdAt = new Date(); - await Statistics.insertOne(rcStatistics); + const { insertedId } = await Statistics.insertOne(rcStatistics); + rcStatistics._id = insertedId; + return rcStatistics; }, }; diff --git a/apps/meteor/app/theme/client/imports/general/base.css b/apps/meteor/app/theme/client/imports/general/base.css index 311327fb6f73..d1f4b8d11fb6 100644 --- a/apps/meteor/app/theme/client/imports/general/base.css +++ b/apps/meteor/app/theme/client/imports/general/base.css @@ -52,12 +52,6 @@ body { a { cursor: pointer; - text-decoration: none; - - &:hover, - &:active { - text-decoration: none; - } } button { diff --git a/apps/meteor/app/ui-master/server/index.js b/apps/meteor/app/ui-master/server/index.js index f9e335451d74..2d4f3cc7de56 100644 --- a/apps/meteor/app/ui-master/server/index.js +++ b/apps/meteor/app/ui-master/server/index.js @@ -6,6 +6,7 @@ import { Tracker } from 'meteor/tracker'; import { withDebouncing } from '../../../lib/utils/highOrderFunctions'; import { settings } from '../../settings/server'; +import { getURL } from '../../utils/server/getURL'; import { applyHeadInjections, headInjections, injectIntoBody, injectIntoHead } from './inject'; import './scripts'; @@ -41,14 +42,14 @@ Meteor.startup(() => { settings.watch('Assets_SvgFavicon_Enable', (value) => { const standardFavicons = ` - - `; + + `; if (value) { injectIntoHead( 'Assets_SvgFavicon_Enable', `${standardFavicons} - `, + `, ); } else { injectIntoHead('Assets_SvgFavicon_Enable', standardFavicons); @@ -120,8 +121,6 @@ Meteor.startup(() => { })(__meteor_runtime_config__.ROOT_URL_PATH_PREFIX); injectIntoHead('base', ``); - - injectIntoHead('css-theme', ''); }); const renderDynamicCssList = withDebouncing({ wait: 500 })(async () => { diff --git a/apps/meteor/app/ui-master/server/inject.ts b/apps/meteor/app/ui-master/server/inject.ts index 78112bcee343..1e00a0e47433 100644 --- a/apps/meteor/app/ui-master/server/inject.ts +++ b/apps/meteor/app/ui-master/server/inject.ts @@ -32,7 +32,7 @@ const callback: NextHandleFunction = (req, res, next) => { return; } - const injection = headInjections.get(pathname.replace(/^\//, '')) as Injection | undefined; + const injection = headInjections.get(pathname.replace(/^\//, '').split('_')[0]) as Injection | undefined; if (!injection || typeof injection === 'string') { next(); @@ -76,27 +76,37 @@ export const injectIntoHead = (key: string, value: Injection): void => { }; export const addScript = (key: string, content: string): void => { + if (/_/.test(key)) { + throw new Error('inject.js > addScript - key cannot contain "_" (underscore)'); + } + if (!content.trim()) { - injectIntoHead(`${key}.js`, ''); + injectIntoHead(key, ''); return; } const currentHash = crypto.createHash('sha1').update(content).digest('hex'); - injectIntoHead(`${key}.js`, { + + injectIntoHead(key, { type: 'JS', - tag: ``, + tag: ``, content, }); }; export const addStyle = (key: string, content: string): void => { + if (/_/.test(key)) { + throw new Error('inject.js > addStyle - key cannot contain "_" (underscore)'); + } + if (!content.trim()) { - injectIntoHead(`${key}.css`, ''); + injectIntoHead(key, ''); return; } const currentHash = crypto.createHash('sha1').update(content).digest('hex'); - injectIntoHead(`${key}.css`, { + + injectIntoHead(key, { type: 'CSS', - tag: ``, + tag: ``, content, }); }; diff --git a/apps/meteor/app/ui-message/client/ActionManager.js b/apps/meteor/app/ui-message/client/ActionManager.js deleted file mode 100644 index ebe9d1aed093..000000000000 --- a/apps/meteor/app/ui-message/client/ActionManager.js +++ /dev/null @@ -1,261 +0,0 @@ -import { UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; -import { UIKitInteractionTypes } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; -import { Random } from '@rocket.chat/random'; -import { lazy } from 'react'; - -import * as banners from '../../../client/lib/banners'; -import { imperativeModal } from '../../../client/lib/imperativeModal'; -import { dispatchToastMessage } from '../../../client/lib/toast'; -import { router } from '../../../client/providers/RouterProvider'; -import { sdk } from '../../utils/client/lib/SDKClient'; -import { t } from '../../utils/lib/i18n'; - -const UiKitModal = lazy(() => import('../../../client/views/modal/uikit/UiKitModal')); - -export const events = new Emitter(); - -export const on = (...args) => { - events.on(...args); -}; - -export const off = (...args) => { - events.off(...args); -}; - -const TRIGGER_TIMEOUT = 5000; - -const TRIGGER_TIMEOUT_ERROR = 'TRIGGER_TIMEOUT_ERROR'; - -const triggersId = new Map(); - -const instances = new Map(); - -const invalidateTriggerId = (id) => { - const appId = triggersId.get(id); - triggersId.delete(id); - return appId; -}; - -export const generateTriggerId = (appId) => { - const triggerId = Random.id(); - triggersId.set(triggerId, appId); - setTimeout(invalidateTriggerId, TRIGGER_TIMEOUT, triggerId); - return triggerId; -}; - -export const handlePayloadUserInteraction = (type, { /* appId,*/ triggerId, ...data }) => { - if (!triggersId.has(triggerId)) { - return; - } - const appId = invalidateTriggerId(triggerId); - if (!appId) { - return; - } - - const { view } = data; - let { viewId } = data; - - if (view && view.id) { - viewId = view.id; - } - - if (!viewId) { - return; - } - - if ([UIKitInteractionTypes.ERRORS].includes(type)) { - events.emit(viewId, { - type, - triggerId, - viewId, - appId, - ...data, - }); - return UIKitInteractionTypes.ERRORS; - } - - if ( - [UIKitInteractionTypes.BANNER_UPDATE, UIKitInteractionTypes.MODAL_UPDATE, UIKitInteractionTypes.CONTEXTUAL_BAR_UPDATE].includes(type) - ) { - events.emit(viewId, { - type, - triggerId, - viewId, - appId, - ...data, - }); - return type; - } - - if ([UIKitInteractionTypes.MODAL_OPEN].includes(type)) { - const instance = imperativeModal.open({ - component: UiKitModal, - props: { - triggerId, - viewId, - appId, - ...data, - }, - }); - - instances.set(viewId, { - close() { - instance.close(); - instances.delete(viewId); - }, - }); - - return UIKitInteractionTypes.MODAL_OPEN; - } - - if ([UIKitInteractionTypes.CONTEXTUAL_BAR_OPEN].includes(type)) { - instances.set(viewId, { - payload: { - type, - triggerId, - appId, - viewId, - ...data, - }, - close() { - instances.delete(viewId); - }, - }); - - router.navigate({ - name: router.getRouteName(), - params: { - ...router.getRouteParameters(), - tab: 'app', - context: viewId, - }, - }); - - return UIKitInteractionTypes.CONTEXTUAL_BAR_OPEN; - } - - if ([UIKitInteractionTypes.BANNER_OPEN].includes(type)) { - banners.open(data); - instances.set(viewId, { - close() { - banners.closeById(viewId); - }, - }); - return UIKitInteractionTypes.BANNER_OPEN; - } - - if ([UIKitIncomingInteractionType.BANNER_CLOSE].includes(type)) { - const instance = instances.get(viewId); - - if (instance) { - instance.close(); - } - return UIKitIncomingInteractionType.BANNER_CLOSE; - } - - if ([UIKitIncomingInteractionType.CONTEXTUAL_BAR_CLOSE].includes(type)) { - const instance = instances.get(viewId); - - if (instance) { - instance.close(); - } - return UIKitIncomingInteractionType.CONTEXTUAL_BAR_CLOSE; - } - - return UIKitInteractionTypes.MODAL_ClOSE; -}; - -export const triggerAction = async ({ type, actionId, appId, rid, mid, viewId, container, tmid, ...rest }) => - new Promise(async (resolve, reject) => { - events.emit('busy', { busy: true }); - - const triggerId = generateTriggerId(appId); - - const payload = rest.payload || rest; - - setTimeout(reject, TRIGGER_TIMEOUT, [TRIGGER_TIMEOUT_ERROR, { triggerId, appId }]); - - const { type: interactionType, ...data } = await (async () => { - try { - return await sdk.rest.post(`/apps/ui.interaction/${appId}`, { - type, - actionId, - payload, - container, - mid, - rid, - tmid, - triggerId, - viewId, - }); - } catch (e) { - reject(e); - return {}; - } finally { - events.emit('busy', { busy: false }); - } - })(); - - return resolve(handlePayloadUserInteraction(interactionType, data)); - }); - -export const triggerBlockAction = (options) => triggerAction({ type: UIKitIncomingInteractionType.BLOCK, ...options }); - -export const triggerActionButtonAction = (options) => - triggerAction({ type: UIKitIncomingInteractionType.ACTION_BUTTON, ...options }).catch(async (reason) => { - if (Array.isArray(reason) && reason[0] === TRIGGER_TIMEOUT_ERROR) { - dispatchToastMessage({ - type: 'error', - message: t('UIKit_Interaction_Timeout'), - }); - } - }); - -export const triggerSubmitView = async ({ viewId, ...options }) => { - const close = () => { - const instance = instances.get(viewId); - - if (instance) { - instance.close(); - } - }; - - try { - const result = await triggerAction({ - type: UIKitIncomingInteractionType.VIEW_SUBMIT, - viewId, - ...options, - }); - if (!result || UIKitInteractionTypes.MODAL_CLOSE === result) { - close(); - } - } catch { - close(); - } -}; - -export const triggerCancel = async ({ view, ...options }) => { - const instance = instances.get(view.id); - try { - await triggerAction({ type: UIKitIncomingInteractionType.VIEW_CLOSED, view, ...options }); - } finally { - if (instance) { - instance.close(); - } - } -}; - -export const getUserInteractionPayloadByViewId = (viewId) => { - if (!viewId) { - throw new Error('No viewId provided when checking for `user interaction payload`'); - } - - const instance = instances.get(viewId); - - if (!instance) { - return {}; - } - - return instance.payload; -}; diff --git a/apps/meteor/app/ui-message/client/ActionManager.ts b/apps/meteor/app/ui-message/client/ActionManager.ts new file mode 100644 index 000000000000..14650c3e12a0 --- /dev/null +++ b/apps/meteor/app/ui-message/client/ActionManager.ts @@ -0,0 +1,237 @@ +import type { DistributiveOmit, UiKit } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import { Random } from '@rocket.chat/random'; +import type { ActionManagerContext, RouterContext } from '@rocket.chat/ui-contexts'; +import type { ContextType } from 'react'; +import { lazy } from 'react'; + +import * as banners from '../../../client/lib/banners'; +import { imperativeModal } from '../../../client/lib/imperativeModal'; +import { router } from '../../../client/providers/RouterProvider'; +import { sdk } from '../../utils/client/lib/SDKClient'; +import { UiKitTriggerTimeoutError } from './UiKitTriggerTimeoutError'; + +const UiKitModal = lazy(() => import('../../../client/views/modal/uikit/UiKitModal')); + +type ActionManagerType = Exclude, undefined>; + +export class ActionManager implements ActionManagerType { + protected static TRIGGER_TIMEOUT = 5000; + + protected static TRIGGER_TIMEOUT_ERROR = 'TRIGGER_TIMEOUT_ERROR'; + + protected events = new Emitter<{ busy: { busy: boolean }; [viewId: string]: any }>(); + + protected triggersId = new Map(); + + protected viewInstances = new Map< + string, + { + payload?: { + view: UiKit.ContextualBarView; + }; + close: () => void; + } + >(); + + public constructor(protected router: ContextType) {} + + protected invalidateTriggerId(id: string) { + const appId = this.triggersId.get(id); + this.triggersId.delete(id); + return appId; + } + + public on(viewId: string, listener: (data: any) => void): void; + + public on(eventName: 'busy', listener: ({ busy }: { busy: boolean }) => void): void; + + public on(eventName: string, listener: (data: any) => void) { + return this.events.on(eventName, listener); + } + + public off(viewId: string, listener: (data: any) => any): void; + + public off(eventName: 'busy', listener: ({ busy }: { busy: boolean }) => void): void; + + public off(eventName: string, listener: (data: any) => void) { + return this.events.off(eventName, listener); + } + + public generateTriggerId(appId: string | undefined) { + const triggerId = Random.id(); + this.triggersId.set(triggerId, appId); + setTimeout(() => this.invalidateTriggerId(triggerId), ActionManager.TRIGGER_TIMEOUT); + return triggerId; + } + + public async emitInteraction(appId: string, userInteraction: DistributiveOmit) { + this.events.emit('busy', { busy: true }); + + const triggerId = this.generateTriggerId(appId); + + let timeout: ReturnType | undefined; + + await Promise.race([ + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new UiKitTriggerTimeoutError('Timeout', { triggerId, appId })), ActionManager.TRIGGER_TIMEOUT); + }), + sdk.rest + .post(`/apps/ui.interaction/${appId}`, { + ...userInteraction, + triggerId, + }) + .then((interaction) => this.handleServerInteraction(interaction)), + ]).finally(() => { + if (timeout) clearTimeout(timeout); + this.events.emit('busy', { busy: false }); + }); + } + + public handleServerInteraction(interaction: UiKit.ServerInteraction) { + const { triggerId } = interaction; + + if (!this.triggersId.has(triggerId)) { + return; + } + + const appId = this.invalidateTriggerId(triggerId); + if (!appId) { + return; + } + + switch (interaction.type) { + case 'errors': { + const { type, triggerId, viewId, appId, errors } = interaction; + this.events.emit(interaction.viewId, { + type, + triggerId, + viewId, + appId, + errors, + }); + break; + } + + case 'modal.open': { + const { view } = interaction; + const instance = imperativeModal.open({ + component: UiKitModal, + props: { + key: view.id, + initialView: interaction.view, + }, + }); + + this.viewInstances.set(view.id, { + close: () => { + instance.close(); + this.viewInstances.delete(view.id); + }, + }); + break; + } + + case 'modal.update': + case 'contextual_bar.update': { + const { type, triggerId, appId, view } = interaction; + this.events.emit(view.id, { + type, + triggerId, + viewId: view.id, + appId, + view, + }); + break; + } + + case 'modal.close': { + break; + } + + case 'banner.open': { + const { type, triggerId, ...view } = interaction; + banners.open(view); + this.viewInstances.set(view.viewId, { + close: () => { + banners.closeById(view.viewId); + }, + }); + break; + } + + case 'banner.update': { + const { type, triggerId, appId, view } = interaction; + this.events.emit(view.viewId, { + type, + triggerId, + viewId: view.viewId, + appId, + view, + }); + break; + } + + case 'banner.close': { + const { viewId } = interaction; + this.viewInstances.get(viewId)?.close(); + + break; + } + + case 'contextual_bar.open': { + const { view } = interaction; + this.viewInstances.set(view.id, { + payload: { + view, + }, + close: () => { + this.viewInstances.delete(view.id); + }, + }); + + const routeName = this.router.getRouteName(); + const routeParams = this.router.getRouteParameters(); + + if (!routeName) { + break; + } + + this.router.navigate({ + name: routeName, + params: { + ...routeParams, + tab: 'app', + context: view.id, + }, + }); + break; + } + + case 'contextual_bar.close': { + const { view } = interaction; + this.viewInstances.get(view.id)?.close(); + break; + } + } + + return interaction.type; + } + + public getInteractionPayloadByViewId(viewId: UiKit.ContextualBarView['id']) { + if (!viewId) { + throw new Error('No viewId provided when checking for `user interaction payload`'); + } + + return this.viewInstances.get(viewId)?.payload; + } + + public disposeView(viewId: UiKit.ModalView['id'] | UiKit.BannerView['viewId'] | UiKit.ContextualBarView['id']) { + const instance = this.viewInstances.get(viewId); + instance?.close?.(); + this.viewInstances.delete(viewId); + } +} + +/** @deprecated consumer should use the context instead */ +export const actionManager = new ActionManager(router); diff --git a/apps/meteor/app/ui-message/client/UiKitTriggerTimeoutError.ts b/apps/meteor/app/ui-message/client/UiKitTriggerTimeoutError.ts new file mode 100644 index 000000000000..75b035d822a1 --- /dev/null +++ b/apps/meteor/app/ui-message/client/UiKitTriggerTimeoutError.ts @@ -0,0 +1,7 @@ +import { RocketChatError } from '../../../client/lib/errors/RocketChatError'; + +export class UiKitTriggerTimeoutError extends RocketChatError<'trigger-timeout'> { + constructor(message = 'Timeout', details: { triggerId: string; appId: string }) { + super('trigger-timeout', message, details); + } +} diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index 4609797e6bd2..a926f8540d27 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -48,13 +48,15 @@ export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string) text: string, { selection, + skipFocus, }: { selection?: | { readonly start?: number; readonly end?: number } | ((previous: { readonly start: number; readonly end: number }) => { readonly start?: number; readonly end?: number }); + skipFocus?: boolean; } = {}, ): void => { - focus(); + !skipFocus && focus(); const { selectionStart, selectionEnd } = input; const textAreaTxt = input.value; @@ -66,7 +68,7 @@ export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string) if (selection) { if (!document.execCommand?.('insertText', false, text)) { input.value = textAreaTxt.substring(0, selectionStart) + text + textAreaTxt.substring(selectionStart); - focus(); + !skipFocus && focus(); } input.setSelectionRange(selection.start ?? 0, selection.end ?? text.length); } @@ -78,7 +80,7 @@ export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string) triggerEvent(input, 'input'); triggerEvent(input, 'change'); - focus(); + !skipFocus && focus(); }; const insertText = (text: string): void => { @@ -260,7 +262,9 @@ export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string) const insertNewLine = (): void => insertText('\n'); - setText(Meteor._localStorage.getItem(storageID) ?? ''); + setText(Meteor._localStorage.getItem(storageID) ?? '', { + skipFocus: true, + }); // Gets the text that is connected to the cursor and replaces it with the given text const replaceText = (text: string, selection: { readonly start: number; readonly end: number }): void => { diff --git a/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts b/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts index 5618442ee6da..5807673188e5 100644 --- a/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts +++ b/apps/meteor/app/ui-utils/client/lib/messageActionDefault.ts @@ -29,7 +29,7 @@ Meteor.startup(async () => { id: 'reply-directly', icon: 'reply-directly', label: 'Reply_in_direct_message', - context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], + context: ['message', 'message-mobile', 'threads', 'federated'], role: 'link', type: 'communication', action(_, props) { @@ -122,7 +122,7 @@ Meteor.startup(async () => { icon: 'permalink', label: 'Copy_link', // classes: 'clipboard', - context: ['message', 'message-mobile', 'threads', 'federated'], + context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], type: 'duplication', async action(_, props) { try { @@ -208,7 +208,7 @@ Meteor.startup(async () => { id: 'delete-message', icon: 'trash', label: 'Delete', - context: ['message', 'message-mobile', 'threads', 'federated'], + context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], color: 'alert', type: 'management', async action(this: unknown, _, { message = messageArgs(this).msg, chat }) { @@ -236,7 +236,7 @@ Meteor.startup(async () => { id: 'report-message', icon: 'report', label: 'Report', - context: ['message', 'message-mobile', 'threads', 'federated'], + context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'], color: 'alert', type: 'management', action(this: unknown, _, { message = messageArgs(this).msg }) { @@ -264,7 +264,7 @@ Meteor.startup(async () => { id: 'reaction-list', icon: 'emoji', label: 'Reactions', - context: ['message', 'message-mobile', 'threads'], + context: ['message', 'message-mobile', 'threads', 'videoconf', 'videoconf-threads'], type: 'interaction', action(this: unknown, _, { message: { reactions = {} } = messageArgs(this).msg }) { imperativeModal.open({ diff --git a/apps/meteor/app/ui/client/lib/ChatMessages.ts b/apps/meteor/app/ui/client/lib/ChatMessages.ts index 4a4b04f11833..4563bae81d52 100644 --- a/apps/meteor/app/ui/client/lib/ChatMessages.ts +++ b/apps/meteor/app/ui/client/lib/ChatMessages.ts @@ -18,7 +18,7 @@ import { setHighlightMessage, clearHighlightMessage, } from '../../../../client/views/room/MessageList/providers/messageHighlightSubscription'; -import * as ActionManager from '../../../ui-message/client/ActionManager'; +import { actionManager } from '../../../ui-message/client/ActionManager'; import { UserAction } from './UserAction'; type DeepWritable = T extends (...args: any) => any @@ -150,7 +150,7 @@ export class ChatMessages implements ChatAPI { this.uid = params.uid; this.data = createDataAPI({ rid, tmid }); this.uploads = createUploadsAPI({ rid, tmid }); - this.ActionManager = ActionManager; + this.ActionManager = actionManager; const unimplemented = () => { throw new Error('Flow is not implemented'); diff --git a/apps/meteor/app/utils/client/getURL.ts b/apps/meteor/app/utils/client/getURL.ts index 91ef0989bd19..040b6dfa9dc2 100644 --- a/apps/meteor/app/utils/client/getURL.ts +++ b/apps/meteor/app/utils/client/getURL.ts @@ -1,13 +1,19 @@ import { settings } from '../../settings/client'; import { getURLWithoutSettings } from '../lib/getURL'; +import { Info } from '../rocketchat.info'; export const getURL = function ( path: string, // eslint-disable-next-line @typescript-eslint/naming-convention params: Record = {}, cloudDeepLinkUrl?: string, + cacheKey?: boolean, ): string { const cdnPrefix = settings.get('CDN_PREFIX') || ''; const siteUrl = settings.get('Site_Url') || ''; + if (cacheKey) { + path += `${path.includes('?') ? '&' : '?'}cacheKey=${Info.version}`; + } + return getURLWithoutSettings(path, params, cdnPrefix, siteUrl, cloudDeepLinkUrl); }; diff --git a/apps/meteor/app/utils/lib/getDefaultSubscriptionPref.ts b/apps/meteor/app/utils/lib/getDefaultSubscriptionPref.ts index a388548c18a8..adb4c2ab1ae9 100644 --- a/apps/meteor/app/utils/lib/getDefaultSubscriptionPref.ts +++ b/apps/meteor/app/utils/lib/getDefaultSubscriptionPref.ts @@ -1,4 +1,4 @@ -import type { ISubscription, IUser } from '@rocket.chat/core-typings'; +import type { AtLeast, ISubscription, IUser } from '@rocket.chat/core-typings'; /** * @type {(userPref: Pick) => { @@ -7,7 +7,7 @@ import type { ISubscription, IUser } from '@rocket.chat/core-typings'; * emailPrefOrigin: 'user'; * }} */ -export const getDefaultSubscriptionPref = (userPref: IUser) => { +export const getDefaultSubscriptionPref = (userPref: AtLeast) => { const subscription: Partial = {}; const { desktopNotifications, pushNotifications, emailNotificationMode, highlights } = userPref.settings?.preferences || {}; diff --git a/apps/meteor/app/utils/lib/i18n.ts b/apps/meteor/app/utils/lib/i18n.ts index 13d5c667709d..7fa491d965e8 100644 --- a/apps/meteor/app/utils/lib/i18n.ts +++ b/apps/meteor/app/utils/lib/i18n.ts @@ -7,7 +7,7 @@ export const i18n = i18next.use(sprintf); export const addSprinfToI18n = function (t: (typeof i18n)['t']) { return function (key: string, ...replaces: any): string { - if (replaces[0] === undefined || isObject(replaces[0])) { + if (replaces[0] === undefined || (isObject(replaces[0]) && !Array.isArray(replaces[0]))) { return t(key, ...replaces); } diff --git a/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts b/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts new file mode 100644 index 000000000000..8d159213d4e3 --- /dev/null +++ b/apps/meteor/app/version-check/server/functions/buildVersionUpdateMessage.ts @@ -0,0 +1,59 @@ +import { Settings } from '@rocket.chat/models'; +import semver from 'semver'; + +import { i18n } from '../../../../server/lib/i18n'; +import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins'; +import { settings } from '../../../settings/server'; +import { Info } from '../../../utils/rocketchat.info'; + +export const buildVersionUpdateMessage = async ( + versions: { + version: string; + security: boolean; + infoUrl: string; + }[] = [], +) => { + const lastCheckedVersion = settings.get('Update_LatestAvailableVersion'); + + if (!lastCheckedVersion) { + return; + } + + for await (const version of versions) { + if (semver.lte(version.version, lastCheckedVersion)) { + continue; + } + + if (semver.lte(version.version, Info.version)) { + continue; + } + + await Settings.updateValueById('Update_LatestAvailableVersion', version.version); + + await sendMessagesToAdmins({ + msgs: async ({ adminUser }) => [ + { + msg: `*${i18n.t('Update_your_RocketChat', { ...(adminUser.language && { lng: adminUser.language }) })}*\n${i18n.t( + 'New_version_available_(s)', + { + postProcess: 'sprintf', + sprintf: [version.version], + }, + )}\n${version.infoUrl}`, + }, + ], + banners: [ + { + id: `versionUpdate-${version.version}`.replace(/\./g, '_'), + priority: 10, + title: 'Update_your_RocketChat', + text: 'New_version_available_(s)', + textArguments: [version.version], + link: version.infoUrl, + modifiers: [], + }, + ], + }); + break; + } +}; diff --git a/apps/meteor/app/version-check/server/functions/checkVersionUpdate.ts b/apps/meteor/app/version-check/server/functions/checkVersionUpdate.ts index 16a3034c0bd3..ca616950a55b 100644 --- a/apps/meteor/app/version-check/server/functions/checkVersionUpdate.ts +++ b/apps/meteor/app/version-check/server/functions/checkVersionUpdate.ts @@ -1,14 +1,11 @@ import type { IUser } from '@rocket.chat/core-typings'; -import { Settings, Users } from '@rocket.chat/models'; -import semver from 'semver'; +import { Users } from '@rocket.chat/models'; import { i18n } from '../../../../server/lib/i18n'; import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins'; -import { settings } from '../../../settings/server'; -import { Info } from '../../../utils/rocketchat.info'; import logger from '../logger'; +import { buildVersionUpdateMessage } from './buildVersionUpdateMessage'; import { getNewUpdates } from './getNewUpdates'; -// import getNewUpdates from '../sampleUpdateData'; const getMessagesToSendToAdmins = async ( alerts: { @@ -42,67 +39,43 @@ const getMessagesToSendToAdmins = async ( } return msgs; }; - +/** + * @deprecated + */ export const checkVersionUpdate = async () => { logger.info('Checking for version updates'); const { versions, alerts } = await getNewUpdates(); - const lastCheckedVersion = settings.get('Update_LatestAvailableVersion'); - - for await (const version of versions) { - if (!lastCheckedVersion) { - break; - } - if (semver.lte(version.version, lastCheckedVersion)) { - continue; - } + await buildVersionUpdateMessage(versions); - if (semver.lte(version.version, Info.version)) { - continue; - } - - await Settings.updateValueById('Update_LatestAvailableVersion', version.version); - - await sendMessagesToAdmins({ - msgs: async ({ adminUser }) => [ - { - msg: `*${i18n.t('Update_your_RocketChat', { ...(adminUser.language && { lng: adminUser.language }) })}*\n${i18n.t( - 'New_version_available_(s)', - { - postProcess: 'sprintf', - sprintf: [version.version], - }, - )}\n${version.infoUrl}`, - }, - ], - banners: [ - { - id: `versionUpdate-${version.version}`.replace(/\./g, '_'), - priority: 10, - title: 'Update_your_RocketChat', - text: 'New_version_available_(s)', - textArguments: [version.version], - link: version.infoUrl, - modifiers: [], - }, - ], - }); - break; - } + await showAlertsFromCloud(alerts); +}; - if (alerts?.length) { - await sendMessagesToAdmins({ - msgs: async ({ adminUser }) => getMessagesToSendToAdmins(alerts, adminUser), - banners: alerts.map((alert) => ({ - id: `alert-${alert.id}`.replace(/\./g, '_'), - priority: 10, - title: alert.title, - text: alert.text, - textArguments: alert.textArguments, - modifiers: alert.modifiers, - link: alert.infoUrl, - })), - }); +const showAlertsFromCloud = async ( + alerts?: { + id: string; + priority: number; + title: string; + text: string; + textArguments?: string[]; + modifiers: string[]; + infoUrl: string; + }[], +) => { + if (!alerts?.length) { + return; } + return sendMessagesToAdmins({ + msgs: async ({ adminUser }) => getMessagesToSendToAdmins(alerts, adminUser), + banners: alerts.map((alert) => ({ + id: `alert-${alert.id}`.replace(/\./g, '_'), + priority: 10, + title: alert.title, + text: alert.text, + textArguments: alert.textArguments, + modifiers: alert.modifiers, + link: alert.infoUrl, + })), + }); }; diff --git a/apps/meteor/app/version-check/server/functions/getNewUpdates.ts b/apps/meteor/app/version-check/server/functions/getNewUpdates.ts index ac0c0e443453..926926253a6c 100644 --- a/apps/meteor/app/version-check/server/functions/getNewUpdates.ts +++ b/apps/meteor/app/version-check/server/functions/getNewUpdates.ts @@ -7,6 +7,8 @@ import { check, Match } from 'meteor/check'; import { getWorkspaceAccessToken } from '../../../cloud/server'; import { Info } from '../../../utils/rocketchat.info'; +/** @deprecated */ + export const getNewUpdates = async () => { try { const uniqueID = await Settings.findOne('uniqueID'); @@ -50,18 +52,16 @@ export const getNewUpdates = async () => { infoUrl: String, }), ], - alerts: [ - Match.Optional([ - Match.ObjectIncluding({ - id: String, - title: String, - text: String, - textArguments: [Match.Any], - modifiers: [String] as [StringConstructor], - infoUrl: String, - }), - ]), - ], + alerts: Match.Optional([ + Match.ObjectIncluding({ + id: String, + title: String, + text: String, + textArguments: [Match.Any], + modifiers: [String] as [StringConstructor], + infoUrl: String, + }), + ]), }), ); diff --git a/apps/meteor/client/UIKit/hooks/useUIKitHandleAction.tsx b/apps/meteor/client/UIKit/hooks/useUIKitHandleAction.tsx deleted file mode 100644 index 6a97f18a7936..000000000000 --- a/apps/meteor/client/UIKit/hooks/useUIKitHandleAction.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; -import type { UiKitPayload, UIKitActionEvent } from '@rocket.chat/core-typings'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; - -import { useUiKitActionManager } from '../../hooks/useUiKitActionManager'; - -const useUIKitHandleAction = (state: S): ((event: UIKitActionEvent) => Promise) => { - const actionManager = useUiKitActionManager(); - return useMutableCallback(async ({ blockId, value, appId, actionId }) => { - if (!appId) { - throw new Error('useUIKitHandleAction - invalid appId'); - } - return actionManager.triggerBlockAction({ - container: { - type: UIKitIncomingInteractionContainerType.VIEW, - id: state.viewId || state.appId, - }, - actionId, - appId, - value, - blockId, - }); - }); -}; - -export { useUIKitHandleAction }; diff --git a/apps/meteor/client/UIKit/hooks/useUIKitHandleClose.tsx b/apps/meteor/client/UIKit/hooks/useUIKitHandleClose.tsx deleted file mode 100644 index 672e1b311b5d..000000000000 --- a/apps/meteor/client/UIKit/hooks/useUIKitHandleClose.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { UIKitInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; -import type { UiKitPayload } from '@rocket.chat/core-typings'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; - -import { useUiKitActionManager } from '../../hooks/useUiKitActionManager'; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const emptyFn = (_error: any, _result: UIKitInteractionType | void): void => undefined; - -const useUIKitHandleClose = (state: S, fn = emptyFn): (() => Promise) => { - const actionManager = useUiKitActionManager(); - const dispatchToastMessage = useToastMessageDispatch(); - return useMutableCallback(() => - actionManager - .triggerCancel({ - appId: state.appId, - viewId: state.viewId, - view: { - ...state, - id: state.viewId, - }, - isCleared: true, - }) - .then((result) => fn(undefined, result)) - .catch((error) => { - dispatchToastMessage({ type: 'error', message: error }); - fn(error, undefined); - return Promise.reject(error); - }), - ); -}; - -export { useUIKitHandleClose }; diff --git a/apps/meteor/client/UIKit/hooks/useUIKitStateManager.tsx b/apps/meteor/client/UIKit/hooks/useUIKitStateManager.tsx deleted file mode 100644 index 26b329f2ea60..000000000000 --- a/apps/meteor/client/UIKit/hooks/useUIKitStateManager.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { UIKitUserInteractionResult, UiKitPayload } from '@rocket.chat/core-typings'; -import { isErrorType } from '@rocket.chat/core-typings'; -import { useSafely } from '@rocket.chat/fuselage-hooks'; -import { useEffect, useState } from 'react'; - -import { useUiKitActionManager } from '../../hooks/useUiKitActionManager'; - -const useUIKitStateManager = (initialState: S): S => { - const actionManager = useUiKitActionManager(); - const [state, setState] = useSafely(useState(initialState)); - - const { viewId } = state; - - useEffect(() => { - const handleUpdate = ({ ...data }: UIKitUserInteractionResult): void => { - if (isErrorType(data)) { - const { errors } = data; - setState((state) => ({ ...state, errors })); - return; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { type, ...rest } = data; - setState(rest as any); - }; - - actionManager.on(viewId, handleUpdate); - - return (): void => { - actionManager.off(viewId, handleUpdate); - }; - }, [setState, viewId]); - - return state; -}; - -export { useUIKitStateManager }; diff --git a/apps/meteor/client/hooks/useUiKitActionManager.ts b/apps/meteor/client/UIKit/hooks/useUiKitActionManager.ts similarity index 100% rename from apps/meteor/client/hooks/useUiKitActionManager.ts rename to apps/meteor/client/UIKit/hooks/useUiKitActionManager.ts diff --git a/apps/meteor/client/UIKit/hooks/useUiKitView.ts b/apps/meteor/client/UIKit/hooks/useUiKitView.ts new file mode 100644 index 000000000000..2d0d1512bc17 --- /dev/null +++ b/apps/meteor/client/UIKit/hooks/useUiKitView.ts @@ -0,0 +1,93 @@ +import type { UiKit } from '@rocket.chat/core-typings'; +import { useSafely } from '@rocket.chat/fuselage-hooks'; +import { extractInitialStateFromLayout } from '@rocket.chat/fuselage-ui-kit'; +import type { Dispatch } from 'react'; +import { useEffect, useMemo, useReducer, useState } from 'react'; + +import { useUiKitActionManager } from './useUiKitActionManager'; + +const reduceValues = ( + values: { [actionId: string]: { value: unknown; blockId?: string } }, + { actionId, payload }: { actionId: string; payload: { value: unknown; blockId?: string } }, +): { [actionId: string]: { value: unknown; blockId?: string } } => ({ + ...values, + [actionId]: payload, +}); + +const getViewId = (view: UiKit.View): string => { + if ('id' in view && typeof view.id === 'string') { + return view.id; + } + + if ('viewId' in view && typeof view.viewId === 'string') { + return view.viewId; + } + + throw new Error('Invalid view'); +}; + +const getViewFromInteraction = (interaction: UiKit.ServerInteraction): UiKit.View | undefined => { + if ('view' in interaction && typeof interaction.view === 'object') { + return interaction.view; + } + + if (interaction.type === 'banner.open') { + return interaction; + } + + return undefined; +}; + +type UseUiKitViewReturnType = { + view: TView; + errors?: { [field: string]: string }[]; + values: { [actionId: string]: { value: unknown; blockId?: string } }; + updateValues: Dispatch<{ actionId: string; payload: { value: unknown; blockId?: string } }>; + state: { + [blockId: string]: { + [key: string]: unknown; + }; + }; +}; + +export function useUiKitView(initialView: S): UseUiKitViewReturnType { + const [errors, setErrors] = useSafely(useState<{ [field: string]: string }[] | undefined>()); + const [values, updateValues] = useSafely(useReducer(reduceValues, initialView.blocks, extractInitialStateFromLayout)); + const [view, updateView] = useSafely(useState(initialView)); + const actionManager = useUiKitActionManager(); + + const state = useMemo(() => { + return Object.entries(values).reduce<{ [blockId: string]: { [actionId: string]: unknown } }>((obj, [key, payload]) => { + if (!payload?.blockId) { + return obj; + } + + const { blockId, value } = payload; + obj[blockId] = obj[blockId] || {}; + obj[blockId][key] = value; + + return obj; + }, {}); + }, [values]); + + const viewId = getViewId(view); + + useEffect(() => { + const handleUpdate = (interaction: UiKit.ServerInteraction): void => { + if (interaction.type === 'errors') { + setErrors(interaction.errors); + return; + } + + updateView((view) => ({ ...view, ...getViewFromInteraction(interaction) })); + }; + + actionManager.on(viewId, handleUpdate); + + return (): void => { + actionManager.off(viewId, handleUpdate); + }; + }, [actionManager, setErrors, updateView, viewId]); + + return { view, errors, values, updateValues, state }; +} diff --git a/apps/meteor/client/components/ActionManagerBusyState.tsx b/apps/meteor/client/components/ActionManagerBusyState.tsx index 0374254a7de9..033b200a2aa7 100644 --- a/apps/meteor/client/components/ActionManagerBusyState.tsx +++ b/apps/meteor/client/components/ActionManagerBusyState.tsx @@ -3,7 +3,7 @@ import { Box } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useEffect, useState } from 'react'; -import { useUiKitActionManager } from '../hooks/useUiKitActionManager'; +import { useUiKitActionManager } from '../UIKit/hooks/useUiKitActionManager'; const ActionManagerBusyState = () => { const t = useTranslation(); @@ -15,10 +15,12 @@ const ActionManagerBusyState = () => { return; } - actionManager.on('busy', ({ busy }: { busy: boolean }) => setBusy(busy)); + const handleBusyStateChange = ({ busy }: { busy: boolean }) => setBusy(busy); + + actionManager.on('busy', handleBusyStateChange); return () => { - actionManager.off('busy'); + actionManager.off('busy', handleBusyStateChange); }; }, [actionManager]); diff --git a/apps/meteor/client/components/AutoCompleteAgent.tsx b/apps/meteor/client/components/AutoCompleteAgent.tsx index b4e287bcc4ae..059ac4251cc8 100644 --- a/apps/meteor/client/components/AutoCompleteAgent.tsx +++ b/apps/meteor/client/components/AutoCompleteAgent.tsx @@ -11,18 +11,26 @@ type AutoCompleteAgentProps = { value: string; error?: string; placeholder?: string; - onChange: (value: string) => void; haveAll?: boolean; haveNoAgentsSelectedOption?: boolean; + excludeId?: string; + showIdleAgents?: boolean; + onlyAvailable?: boolean; + withTitle?: boolean; + onChange: (value: string) => void; }; const AutoCompleteAgent = ({ value, error, placeholder, - onChange, haveAll = false, haveNoAgentsSelectedOption = false, + excludeId, + showIdleAgents = true, + onlyAvailable = false, + withTitle = false, + onChange, }: AutoCompleteAgentProps): ReactElement => { const [agentsFilter, setAgentsFilter] = useState(''); @@ -30,26 +38,16 @@ const AutoCompleteAgent = ({ const { itemsList: AgentsList, loadMoreItems: loadMoreAgents } = useAgentsList( useMemo( - () => ({ text: debouncedAgentsFilter, haveAll, haveNoAgentsSelectedOption }), - [debouncedAgentsFilter, haveAll, haveNoAgentsSelectedOption], + () => ({ text: debouncedAgentsFilter, onlyAvailable, haveAll, haveNoAgentsSelectedOption, excludeId, showIdleAgents }), + [debouncedAgentsFilter, excludeId, haveAll, haveNoAgentsSelectedOption, onlyAvailable, showIdleAgents], ), ); const { phase: agentsPhase, itemCount: agentsTotal, items: agentsItems } = useRecordList(AgentsList); - const sortedByName = agentsItems.sort((a, b) => { - if (a.label > b.label) { - return 1; - } - if (a.label < b.label) { - return -1; - } - - return 0; - }); - return ( void} - options={sortedByName} + options={agentsItems} data-qa='autocomplete-agent' endReached={ agentsPhase === AsyncStatePhase.LOADING ? (): void => undefined : (start): void => loadMoreAgents(start, Math.min(50, agentsTotal)) diff --git a/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx b/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx index 50d53da351bc..ed1fe8b9576e 100644 --- a/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx +++ b/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx @@ -2,6 +2,7 @@ import { PaginatedMultiSelectFiltered } from '@rocket.chat/fuselage'; import type { PaginatedMultiSelectOption } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentProps } from 'react'; import React, { memo, useMemo, useState } from 'react'; import { useRecordList } from '../hooks/lists/useRecordList'; @@ -14,10 +15,10 @@ type AutoCompleteDepartmentMultipleProps = { onlyMyDepartments?: boolean; showArchived?: boolean; enabled?: boolean; -}; +} & Omit, 'options'>; const AutoCompleteDepartmentMultiple = ({ - value, + value = [], onlyMyDepartments = false, showArchived = false, enabled = false, @@ -37,6 +38,11 @@ const AutoCompleteDepartmentMultiple = ({ const { phase: departmentsPhase, items: departmentsItems, itemCount: departmentsTotal } = useRecordList(departmentsList); + const departmentOptions = useMemo(() => { + const pending = value.filter(({ value }) => !departmentsItems.find((dep) => dep.value === value)) || []; + return [...departmentsItems, ...pending]; + }, [departmentsItems, value]); + return ( ) => } + wrapperFunction={(props) => } > {t('Discussion_title')} - + + {t('Discussion_description')} - {t('Discussion_description')} - - - {t('Discussion_target_channel')} - + + {t('Discussion_target_channel')} + + {defaultParentRoom && ( } /> )} - {!defaultParentRoom && ( ( + rules={{ required: t('error-the-field-is-required', { field: t('Discussion_target_channel') }) }} + render={({ field: { name, onBlur, onChange, value } }) => ( )} /> )} - - {errors.parentRoom && {errors.parentRoom.message}} + + {errors.parentRoom && ( + + {errors.parentRoom.message} + + )} - - - {t('Encrypted')} - - ( - + + {t('Encrypted')} + + } /> - )} - /> + + - {t('Discussion_name')} - - } + + {t('Discussion_name')} + + + ( + } + /> + )} /> - - {errors.name && {errors.name.message}} + + {errors.name && ( + + {errors.name.message} + + )} - {t('Invite_Users')} - + {t('Invite_Users')} + ( - + render={({ field: { name, onChange, value, onBlur } }) => ( + )} /> - + - {t('Discussion_first_message_title')} - - - - {encrypted && {t('Discussion_first_message_disabled_due_to_e2e')}} + {t('Discussion_first_message_title')} + + ( + + )} + /> + + {encrypted && {t('Discussion_first_message_disabled_due_to_e2e')}} - diff --git a/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx b/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx index 0a2717a65552..6036f14049a4 100644 --- a/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx +++ b/apps/meteor/client/components/CreateDiscussion/DefaultParentRoomField.tsx @@ -1,38 +1,42 @@ import { Skeleton, TextInput, Callout } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; -import { AsyncStatePhase } from '../../hooks/useAsyncState'; -import { useEndpointData } from '../../hooks/useEndpointData'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; const DefaultParentRoomField = ({ defaultParentRoom }: { defaultParentRoom: string }): ReactElement => { const t = useTranslation(); - const { value, phase } = useEndpointData('/v1/rooms.info', { - params: useMemo( - () => ({ - roomId: defaultParentRoom, - }), - [defaultParentRoom], - ), + + const query = useMemo( + () => ({ + roomId: defaultParentRoom, + }), + [defaultParentRoom], + ); + + const roomsInfoEndpoint = useEndpoint('GET', '/v1/rooms.info'); + + const { data, isLoading, isError } = useQuery(['defaultParentRoomInfo', query], async () => roomsInfoEndpoint(query), { + refetchOnWindowFocus: false, }); - if (phase === AsyncStatePhase.LOADING) { + if (isLoading) { return ; } - if (!value || !value.room) { + if (!data?.room || isError) { return {t('Error')}; } return ( diff --git a/apps/meteor/client/components/FingerprintChangeModal.tsx b/apps/meteor/client/components/FingerprintChangeModal.tsx new file mode 100644 index 000000000000..db4c33654a92 --- /dev/null +++ b/apps/meteor/client/components/FingerprintChangeModal.tsx @@ -0,0 +1,44 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import GenericModal from './GenericModal'; + +type FingerprintChangeModalProps = { + onConfirm: () => void; + onCancel: () => void; + onClose: () => void; +}; + +const FingerprintChangeModal = ({ onConfirm, onCancel, onClose }: FingerprintChangeModalProps): ReactElement => { + const t = useTranslation(); + return ( + + + + + ); +}; + +export default FingerprintChangeModal; diff --git a/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx b/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx new file mode 100644 index 000000000000..77718de0f441 --- /dev/null +++ b/apps/meteor/client/components/FingerprintChangeModalConfirmation.tsx @@ -0,0 +1,47 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import GenericModal from './GenericModal'; + +type FingerprintChangeModalConfirmationProps = { + onConfirm: () => void; + onCancel: () => void; + newWorkspace: boolean; +}; + +const FingerprintChangeModalConfirmation = ({ + onConfirm, + onCancel, + newWorkspace, +}: FingerprintChangeModalConfirmationProps): ReactElement => { + const t = useTranslation(); + return ( + + + + + ); +}; + +export default FingerprintChangeModalConfirmation; diff --git a/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx b/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx index ec9f852c8a8b..513b31dd81fa 100644 --- a/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx +++ b/apps/meteor/client/components/GenericUpsellModal/GenericUpsellModal.tsx @@ -42,7 +42,7 @@ const GenericUpsellModal = ({ {icon && } - {tagline ?? t('Enterprise_capability')} + {tagline ?? t('Premium_capability')} {title} diff --git a/apps/meteor/client/components/InfoPanel/InfoPanelAction.tsx b/apps/meteor/client/components/InfoPanel/InfoPanelAction.tsx index bd2c7d100b53..0750003063a4 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanelAction.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanelAction.tsx @@ -13,7 +13,6 @@ const InfoPanelAction = ({ label, icon, ...props }: InfoPanelActionProps): React title={typeof label === 'string' ? label : undefined} aria-label={typeof label === 'string' ? label : undefined} {...props} - mi={4} icon={icon} > {label} diff --git a/apps/meteor/client/components/InfoPanel/InfoPanelActionGroup.tsx b/apps/meteor/client/components/InfoPanel/InfoPanelActionGroup.tsx index e8aa992a5dc2..3bf9f7e33c70 100644 --- a/apps/meteor/client/components/InfoPanel/InfoPanelActionGroup.tsx +++ b/apps/meteor/client/components/InfoPanel/InfoPanelActionGroup.tsx @@ -6,7 +6,7 @@ import Section from './InfoPanelSection'; const InfoPanelActionGroup: FC> = (props) => (
- +
); diff --git a/apps/meteor/client/components/Omnichannel/Tags.tsx b/apps/meteor/client/components/Omnichannel/Tags.tsx index 39564ca7f89f..88f5f1a5c6e7 100644 --- a/apps/meteor/client/components/Omnichannel/Tags.tsx +++ b/apps/meteor/client/components/Omnichannel/Tags.tsx @@ -1,4 +1,4 @@ -import { Field, TextInput, Chip, Button } from '@rocket.chat/fuselage'; +import { TextInput, Chip, Button, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import type { ChangeEvent, ReactElement } from 'react'; @@ -71,12 +71,12 @@ const Tags = ({ tags = [], handler, error, tagRequired, department }: TagsProps) return ( <> - + {t('Tags')} - + {EETagsComponent && tagsResult?.tags && tagsResult?.tags.length ? ( - + { @@ -85,10 +85,10 @@ const Tags = ({ tags = [], handler, error, tagRequired, department }: TagsProps) department={department} viewAll={!department} /> - + ) : ( <> - + {t('Add')} - + )} {customTags.length > 0 && ( - + {customTags?.map((tag, i) => ( removeTag(tag)} mie={8}> {tag} ))} - + )} ); diff --git a/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts b/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts index b854866184f7..ef00a6c0b81d 100644 --- a/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts +++ b/apps/meteor/client/components/Omnichannel/hooks/useAgentsList.ts @@ -9,6 +9,9 @@ type AgentsListOptions = { text: string; haveAll: boolean; haveNoAgentsSelectedOption: boolean; + excludeId?: string; + showIdleAgents?: boolean; + onlyAvailable?: boolean; }; type AgentOption = { value: string; label: string; _updatedAt: Date; _id: string }; @@ -26,6 +29,7 @@ export const useAgentsList = ( const reload = useCallback(() => setItemsList(new RecordList()), []); const getAgents = useEndpoint('GET', '/v1/livechat/users/agent'); + const { text, onlyAvailable = false, showIdleAgents = true, excludeId, haveAll, haveNoAgentsSelectedOption } = options; useComponentDidUpdate(() => { options && reload(); @@ -34,7 +38,10 @@ export const useAgentsList = ( const fetchData = useCallback( async (start, end) => { const { users: agents, total } = await getAgents({ - ...(options.text && { text: options.text }), + ...(text && { text }), + ...(excludeId && { excludeId }), + showIdleAgents, + onlyAvailable, offset: start, count: end + start, sort: `{ "name": 1 }`, @@ -43,14 +50,14 @@ export const useAgentsList = ( const items = agents.map((agent) => { const agentOption = { _updatedAt: new Date(agent._updatedAt), - label: agent.username || agent._id, + label: `${agent.name || agent._id} (@${agent.username})`, value: agent._id, _id: agent._id, }; return agentOption; }); - options.haveAll && + haveAll && items.unshift({ label: t('All'), value: 'all', @@ -58,7 +65,7 @@ export const useAgentsList = ( _id: 'all', }); - options.haveNoAgentsSelectedOption && + haveNoAgentsSelectedOption && items.unshift({ label: t('Empty_no_agent_selected'), value: 'no-agent-selected', @@ -71,7 +78,7 @@ export const useAgentsList = ( itemCount: total + 1, }; }, - [getAgents, options.haveAll, options.haveNoAgentsSelectedOption, options.text, t], + [excludeId, getAgents, haveAll, haveNoAgentsSelectedOption, onlyAvailable, showIdleAgents, t, text], ); const { loadMoreItems, initialItemCount } = useScrollableRecordList(itemsList, fetchData, 25); diff --git a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx index 17cbc094160b..67d650186680 100644 --- a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx @@ -1,5 +1,18 @@ import type { ILivechatDepartment } from '@rocket.chat/core-typings'; -import { Field, FieldGroup, Button, TextInput, Modal, Box, CheckBox, Divider, EmailInput } from '@rocket.chat/fuselage'; +import { + Field, + FieldGroup, + Button, + TextInput, + Modal, + Box, + CheckBox, + Divider, + EmailInput, + FieldLabel, + FieldRow, + FieldError, +} from '@rocket.chat/fuselage'; import { usePermission, useSetting, useTranslation, useUserPreference } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useCallback, useState, useEffect, useMemo } from 'react'; @@ -134,8 +147,8 @@ const CloseChatModal = ({ {t('Close_room_description')} - {t('Comment')} - + {t('Comment')} + - - {errors.comment?.message} + + {errors.comment?.message} - {errors.tags?.message} + {errors.tags?.message} {canSendTranscript && ( <> - {t('Chat_transcript')} + {t('Chat_transcript')} {canSendTranscriptPDF && ( - + - + {t('Omnichannel_transcript_pdf')} - - + + )} {canSendTranscriptEmail && ( <> - + - + {t('Omnichannel_transcript_email')} - - + + {transcriptEmail && ( <> - {t('Contact_email')} - + {t('Contact_email')} + - + - {t('Subject')} - + {t('Subject')} + - - {errors.subject?.message} + + {errors.subject?.message} )} )} - + {canSendTranscriptPDF && canSendTranscriptEmail ? t('These_options_affect_this_conversation_only_To_set_default_selections_go_to_My_Account_Omnichannel') : t('This_option_affect_this_conversation_only_To_set_default_selection_go_to_My_Account_Omnichannel')} - + )} diff --git a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx index 82c92d39cc8f..a4d095fdb90d 100644 --- a/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/ForwardChatModal.tsx @@ -1,5 +1,16 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; -import { Field, FieldGroup, Button, TextAreaInput, Modal, Box, PaginatedSelectFiltered, Divider } from '@rocket.chat/fuselage'; +import { + Field, + FieldGroup, + Button, + TextAreaInput, + Modal, + Box, + PaginatedSelectFiltered, + Divider, + FieldLabel, + FieldRow, +} from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useEndpoint, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -8,7 +19,7 @@ import { useForm } from 'react-hook-form'; import { useRecordList } from '../../../hooks/lists/useRecordList'; import { AsyncStatePhase } from '../../../hooks/useAsyncState'; -import UserAutoComplete from '../../UserAutoComplete'; +import AutoCompleteAgent from '../../AutoCompleteAgent'; import { useDepartmentsList } from '../hooks/useDepartmentsList'; const ForwardChatModal = ({ @@ -42,31 +53,9 @@ const ForwardChatModal = ({ ); const { phase: departmentsPhase, items: departments, itemCount: departmentsTotal } = useRecordList(departmentsList); - const _id = { $ne: room.servedBy?._id }; - const conditions = { - _id, - ...(!idleAgentsAllowedForForwarding && { - $or: [ - { - status: { - $exists: true, - $ne: 'offline', - }, - roles: { - $ne: 'bot', - }, - }, - { - roles: 'bot', - }, - ], - }), - statusLivechat: 'available', - }; - const endReached = useCallback( (start) => { - if (departmentsPhase === AsyncStatePhase.LOADING) { + if (departmentsPhase !== AsyncStatePhase.LOADING) { loadMoreDepartments(start, Math.min(50, departmentsTotal)); } }, @@ -102,8 +91,8 @@ const ForwardChatModal = ({ - {t('Forward_to_department')} - + {t('Forward_to_department')} + - + - {t('Forward_to_user')} - - {t('Forward_to_user')} + + { setValue('username', value); }} - value={getValues().username} /> - + - + {t('Leave_a_comment')}{' '} ({t('Optional')}) - - + + - + diff --git a/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx b/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx index e879caf38032..2757b5d9a88b 100644 --- a/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx @@ -1,5 +1,5 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; -import { Field, Button, TextInput, Modal, Box, FieldGroup } from '@rocket.chat/fuselage'; +import { Field, Button, TextInput, Modal, Box, FieldGroup, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { FC } from 'react'; import React, { useCallback, useEffect } from 'react'; @@ -78,28 +78,28 @@ const TranscriptModal: FC = ({ {!!transcriptRequest &&

{t('Livechat_transcript_already_requested_warning')}

} - {t('Email')}* - + {t('Email')}* + - - {errors.email?.message} + + {errors.email?.message} - {t('Subject')}* - + {t('Subject')}* + - - {errors.subject?.message} + + {errors.subject?.message} diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx index 1b7936cafd3e..19074f7f6b34 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx @@ -1,4 +1,4 @@ -import { Box, FieldGroup, TextInput, Field } from '@rocket.chat/fuselage'; +import { Box, FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent, SyntheticEvent } from 'react'; @@ -59,13 +59,13 @@ const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername, invalidAttem > - + {t('Verify_your_email_with_the_code_we_sent')} - - + + - - {invalidAttempt && {t('Invalid_password')}} + + {invalidAttempt && {t('Invalid_password')}} diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx index 3d604fc5004b..4c91e274de68 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx @@ -1,4 +1,4 @@ -import { Box, PasswordInput, FieldGroup, Field } from '@rocket.chat/fuselage'; +import { Box, PasswordInput, FieldGroup, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent, Ref, SyntheticEvent } from 'react'; @@ -43,10 +43,10 @@ const TwoFactorPasswordModal = ({ onConfirm, onClose, invalidAttempt }: TwoFacto > - + {t('For_your_security_you_must_enter_your_current_password_to_continue')} - - + + } @@ -54,8 +54,8 @@ const TwoFactorPasswordModal = ({ onConfirm, onClose, invalidAttempt }: TwoFacto onChange={onChange} placeholder={t('Password')} > - - {invalidAttempt && {t('Invalid_password')}} + + {invalidAttempt && {t('Invalid_password')}} diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx index 266e92586944..04aa7a4f94f0 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx @@ -1,4 +1,4 @@ -import { Box, TextInput, Field, FieldGroup } from '@rocket.chat/fuselage'; +import { Box, TextInput, Field, FieldGroup, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent, SyntheticEvent } from 'react'; @@ -42,13 +42,13 @@ const TwoFactorTotpModal = ({ onConfirm, onClose, invalidAttempt }: TwoFactorTot > - + {t('Open_your_authentication_app_and_enter_the_code')} - - + + - - {invalidAttempt && {t('Invalid_password')}} + + {invalidAttempt && {t('Invalid_password')}} diff --git a/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx b/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx index 96d11cf44cb7..f96c198865cc 100644 --- a/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/UserAndRoomAutoCompleteMultiple/UserAndRoomAutoCompleteMultiple.tsx @@ -18,7 +18,16 @@ const UserAndRoomAutoCompleteMultiple = ({ value, onChange, ...props }: UserAndR const debouncedFilter = useDebouncedValue(filter, 1000); const rooms = useUserSubscriptions( - useMemo(() => ({ open: { $ne: false }, lowerCaseName: new RegExp(escapeRegExp(debouncedFilter), 'i') }), [debouncedFilter]), + useMemo( + () => ({ + open: { $ne: false }, + $or: [ + { lowerCaseFName: new RegExp(escapeRegExp(debouncedFilter), 'i') }, + { lowerCaseName: new RegExp(escapeRegExp(debouncedFilter), 'i') }, + ], + }), + [debouncedFilter], + ), ).filter((room) => { if (!user) { return; diff --git a/apps/meteor/client/components/avatar/RoomAvatarEditor.tsx b/apps/meteor/client/components/avatar/RoomAvatarEditor.tsx index 32948eb42243..04b07e9cd627 100644 --- a/apps/meteor/client/components/avatar/RoomAvatarEditor.tsx +++ b/apps/meteor/client/components/avatar/RoomAvatarEditor.tsx @@ -71,7 +71,7 @@ const RoomAvatarEditor = ({ disabled = false, room, roomAvatar, onChangeAvatar } danger icon='trash' title={t('Accounts_SetDefaultAvatar')} - disabled={roomAvatar === null || isRoomFederated(room) || disabled} + disabled={!roomAvatar || isRoomFederated(room) || disabled} onClick={clickReset} /> diff --git a/apps/meteor/client/components/message/MessageContentBody.tsx b/apps/meteor/client/components/message/MessageContentBody.tsx index 4674528a483f..5552e6da0745 100644 --- a/apps/meteor/client/components/message/MessageContentBody.tsx +++ b/apps/meteor/client/components/message/MessageContentBody.tsx @@ -46,7 +46,7 @@ const MessageContentBody = ({ mentions, channels, md, searchText }: MessageConte text-decoration: underline; } &:focus { - border: 2px solid ${Palette.stroke['stroke-extra-light-highlight']}; + box-shadow: 0 0 0 2px ${Palette.stroke['stroke-extra-light-highlight']}; border-radius: 2px; } } diff --git a/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx b/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx index d59314ae8198..6d86e724b95f 100644 --- a/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx +++ b/apps/meteor/client/components/message/uikit/UiKitMessageBlock.tsx @@ -1,12 +1,12 @@ -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { MessageBlock } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { UiKitComponent, UiKitMessage as UiKitMessageSurfaceRender, UiKitContext } from '@rocket.chat/fuselage-ui-kit'; import type { MessageSurfaceLayout } from '@rocket.chat/ui-kit'; import type { ContextType, ReactElement } from 'react'; -import React from 'react'; +import React, { useMemo } from 'react'; +import { useUiKitActionManager } from '../../../UIKit/hooks/useUiKitActionManager'; import { useVideoConfDispatchOutgoing, useVideoConfIsCalling, @@ -15,27 +15,16 @@ import { useVideoConfManager, useVideoConfSetPreferences, } from '../../../contexts/VideoConfContext'; -import { useUiKitActionManager } from '../../../hooks/useUiKitActionManager'; import { useVideoConfWarning } from '../../../views/room/contextualBar/VideoConference/hooks/useVideoConfWarning'; import GazzodownText from '../../GazzodownText'; -let patched = false; -const patchMessageParser = () => { - if (patched) { - return; - } - - patched = true; -}; - type UiKitMessageBlockProps = { + rid: IRoom['_id']; mid: IMessage['_id']; blocks: MessageSurfaceLayout; - rid: IRoom['_id']; - appId?: string | boolean; // TODO: this is a hack while the context value is not properly typed }; -const UiKitMessageBlock = ({ mid: _mid, blocks, rid, appId }: UiKitMessageBlockProps): ReactElement => { +const UiKitMessageBlock = ({ rid, mid, blocks }: UiKitMessageBlockProps): ReactElement => { const joinCall = useVideoConfJoinCall(); const setPreferences = useVideoConfSetPreferences(); const isCalling = useVideoConfIsCalling(); @@ -61,44 +50,47 @@ const UiKitMessageBlock = ({ mid: _mid, blocks, rid, appId }: UiKitMessageBlockP const actionManager = useUiKitActionManager(); // TODO: this structure is attrociously wrong; we should revisit this - const context: ContextType = { - // @ts-ignore Property 'mid' does not exist on type 'ActionParams'. - action: ({ actionId, value, blockId, mid = _mid, appId }, event) => { - if (appId === 'videoconf-core') { - event.preventDefault(); - setPreferences({ mic: true, cam: false }); - if (actionId === 'join') { - return joinCall(blockId); - } + const contextValue = useMemo( + (): ContextType => ({ + action: ({ appId, actionId, blockId, value }, event) => { + if (appId === 'videoconf-core') { + event.preventDefault(); + setPreferences({ mic: true, cam: false }); + if (actionId === 'join') { + return joinCall(blockId); + } - if (actionId === 'callBack') { - return handleOpenVideoConf(blockId); + if (actionId === 'callBack') { + return handleOpenVideoConf(blockId); + } } - } - actionManager?.triggerBlockAction({ - blockId, - actionId, - value, - mid, - rid, - appId, - container: { - type: UIKitIncomingInteractionContainerType.MESSAGE, - id: mid, - }, - }); - }, - // @ts-ignore Type 'string | boolean | undefined' is not assignable to type 'string'. - appId, - rid, - }; - - patchMessageParser(); // TODO: this is a hack + actionManager.emitInteraction(appId, { + type: 'blockAction', + actionId, + payload: { + blockId, + value, + }, + container: { + type: 'message', + id: mid, + }, + rid, + mid, + }); + }, + appId: '', // TODO: this is a hack + rid, + state: () => undefined, // TODO: this is a hack + values: {}, // TODO: this is a hack + }), + [actionManager, handleOpenVideoConf, joinCall, mid, rid, setPreferences], + ); return ( - + diff --git a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx index 2b54588c6263..b22627bea8d2 100644 --- a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx @@ -61,7 +61,7 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM )} {normalizedMessage.blocks && ( - + )} {!!normalizedMessage?.attachments?.length && } diff --git a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx index 655f96639929..57835ec75e0c 100644 --- a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx @@ -49,7 +49,7 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem )} {normalizedMessage.blocks && ( - + )} {normalizedMessage.attachments && } diff --git a/apps/meteor/client/contexts/OmnichannelContext.ts b/apps/meteor/client/contexts/OmnichannelContext.ts index dc3bf7fdceb4..9a2c0c1ea206 100644 --- a/apps/meteor/client/contexts/OmnichannelContext.ts +++ b/apps/meteor/client/contexts/OmnichannelContext.ts @@ -8,6 +8,7 @@ export type OmnichannelContextValue = { agentAvailable: boolean; routeConfig?: OmichannelRoutingConfig; showOmnichannelQueueLink: boolean; + isOverMacLimit: boolean; livechatPriorities: { data: Serialized[]; isLoading: boolean; @@ -22,6 +23,7 @@ export const OmnichannelContext = createContext({ isEnterprise: false, agentAvailable: false, showOmnichannelQueueLink: false, + isOverMacLimit: false, livechatPriorities: { data: [], isLoading: false, diff --git a/apps/meteor/client/hooks/omnichannel/useIsOverMacLimit.tsx b/apps/meteor/client/hooks/omnichannel/useIsOverMacLimit.tsx new file mode 100644 index 000000000000..e6ced140e1b5 --- /dev/null +++ b/apps/meteor/client/hooks/omnichannel/useIsOverMacLimit.tsx @@ -0,0 +1,6 @@ +import { useOmnichannel } from './useOmnichannel'; + +export const useIsOverMacLimit = (): boolean => { + const { isOverMacLimit } = useOmnichannel(); + return isOverMacLimit; +}; diff --git a/apps/meteor/client/hooks/omnichannel/useIsRoomOverMacLimit.tsx b/apps/meteor/client/hooks/omnichannel/useIsRoomOverMacLimit.tsx new file mode 100644 index 000000000000..4de83506f5a9 --- /dev/null +++ b/apps/meteor/client/hooks/omnichannel/useIsRoomOverMacLimit.tsx @@ -0,0 +1,23 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom, type IOmnichannelGenericRoom, isVoipRoom } from '@rocket.chat/core-typings'; + +import { useIsOverMacLimit } from './useIsOverMacLimit'; + +const getPeriod = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + +export const useIsRoomOverMacLimit = (room: IRoom) => { + const isOverMacLimit = useIsOverMacLimit(); + + if (!isOmnichannelRoom(room) && !isVoipRoom(room)) { + return false; + } + + if (!room.open) { + return false; + } + + const { v: { activity = [] } = {} } = room as IOmnichannelGenericRoom; + + const currentPeriod = getPeriod(new Date()); + return isOverMacLimit && !activity.includes(currentPeriod); +}; diff --git a/apps/meteor/client/hooks/omnichannel/useOmnichannelCloseRoute.ts b/apps/meteor/client/hooks/omnichannel/useOmnichannelCloseRoute.ts new file mode 100644 index 000000000000..746a62bd87e6 --- /dev/null +++ b/apps/meteor/client/hooks/omnichannel/useOmnichannelCloseRoute.ts @@ -0,0 +1,23 @@ +import { useRouter, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useCallback } from 'react'; + +export const useOmnichannelCloseRoute = () => { + const hideConversationAfterClosing = useUserPreference('omnichannelHideConversationAfterClosing') ?? true; + const router = useRouter(); + + const navigateHome = useCallback(() => { + if (!hideConversationAfterClosing) { + return; + } + + const routeName = router.getRouteName(); + + if (routeName === 'omnichannel-current-chats') { + router.navigate({ name: 'omnichannel-current-chats' }); + } else { + router.navigate({ name: 'home' }); + } + }, [hideConversationAfterClosing, router]); + + return { navigateHome }; +}; diff --git a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts index e1c3126985ae..73b0f34836e1 100644 --- a/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts @@ -5,13 +5,15 @@ import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { e2e } from '../../../app/e2e/client/rocketchat.e2e'; -import { useRoom } from '../../views/room/contexts/RoomContext'; +import { dispatchToastMessage } from '../../lib/toast'; +import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; import { useReactiveValue } from '../useReactiveValue'; export const useE2EERoomAction = () => { const enabled = useSetting('E2E_Enable', false); const room = useRoom(); + const subscription = useRoomSubscription(); const readyToEncrypt = useReactiveValue(useCallback(() => e2e.isReady(), [])) || room.encrypted; const permittedToToggleEncryption = usePermission('toggle-room-e2e-encryption', room._id); const permittedToEditRoom = usePermission('edit-room', room._id); @@ -21,8 +23,22 @@ export const useE2EERoomAction = () => { const toggleE2E = useEndpoint('POST', '/v1/rooms.saveRoomSettings'); - const action = useMutableCallback(() => { - void toggleE2E({ rid: room._id, encrypted: !room.encrypted }); + const action = useMutableCallback(async () => { + const { success } = await toggleE2E({ rid: room._id, encrypted: !room.encrypted }); + if (!success) { + return; + } + + dispatchToastMessage({ + type: 'success', + message: room.encrypted + ? t('E2E_Encryption_disabled_for_room', { roomName: room.name }) + : t('E2E_Encryption_enabled_for_room', { roomName: room.name }), + }); + + if (subscription?.autoTranslate) { + dispatchToastMessage({ type: 'success', message: t('AutoTranslate_Disabled_for_room', { roomName: room.name }) }); + } }); const enabledOnRoom = !!room.encrypted; diff --git a/apps/meteor/client/hooks/useAppActionButtons.ts b/apps/meteor/client/hooks/useAppActionButtons.ts index 28d62ef1b75a..d039b2bd7c71 100644 --- a/apps/meteor/client/hooks/useAppActionButtons.ts +++ b/apps/meteor/client/hooks/useAppActionButtons.ts @@ -1,20 +1,22 @@ import type { IUIActionButton, UIActionButtonContext } from '@rocket.chat/apps-engine/definition/ui'; import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint, useSingleStream, useUserId } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSingleStream, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { UiKitTriggerTimeoutError } from '../../app/ui-message/client/UiKitTriggerTimeoutError'; import type { MessageActionConfig, MessageActionContext } from '../../app/ui-utils/client/lib/MessageAction'; import type { MessageBoxAction } from '../../app/ui-utils/client/lib/messageBox'; import { Utilities } from '../../ee/lib/misc/Utilities'; +import { useUiKitActionManager } from '../UIKit/hooks/useUiKitActionManager'; import type { GenericMenuItemProps } from '../components/GenericMenu/GenericMenuItem'; import { useApplyButtonFilters, useApplyButtonAuthFilter } from './useApplyButtonFilters'; -import { useUiKitActionManager } from './useUiKitActionManager'; const getIdForActionButton = ({ appId, actionId }: IUIActionButton): string => `${appId}/${actionId}`; -export const useAppActionButtons = (context?: `${UIActionButtonContext}`) => { +export const useAppActionButtons = (context?: TContext) => { const queryClient = useQueryClient(); const apps = useSingleStream('apps'); @@ -24,7 +26,14 @@ export const useAppActionButtons = (context?: `${UIActionButtonContext}`) => { const result = useQuery(['apps', 'actionButtons'], () => getActionButtons(), { ...(context && { - select: (data) => data.filter((button) => button.context === context), + select: (data) => + data.filter( + ( + button, + ): button is IUIActionButton & { + context: UIActionButtonContext extends infer X ? (X extends TContext ? X : never) : never; + } => button.context === context, + ), }), staleTime: Infinity, }); @@ -55,6 +64,8 @@ export const useAppActionButtons = (context?: `${UIActionButtonContext}`) => { export const useMessageboxAppsActionButtons = () => { const result = useAppActionButtons('messageBoxAction'); const actionManager = useUiKitActionManager(); + const dispatchToastMessage = useToastMessageDispatch(); + const { t } = useTranslation(); const applyButtonFilters = useApplyButtonFilters(); @@ -69,19 +80,31 @@ export const useMessageboxAppsActionButtons = () => { id: getIdForActionButton(action), label: Utilities.getI18nKeyForApp(action.labelI18n, action.appId), action: (params) => { - void actionManager.triggerActionButtonAction({ - rid: params.rid, - tmid: params.tmid, - actionId: action.actionId, - appId: action.appId, - payload: { context: action.context, message: params.chat.composer?.text }, - }); + void actionManager + .emitInteraction(action.appId, { + type: 'actionButton', + rid: params.rid, + tmid: params.tmid, + actionId: action.actionId, + payload: { context: action.context, message: params.chat.composer?.text ?? '' }, + }) + .catch(async (reason) => { + if (reason instanceof UiKitTriggerTimeoutError) { + dispatchToastMessage({ + type: 'error', + message: t('UIKit_Interaction_Timeout'), + }); + return; + } + + return reason; + }); }, }; return item; }), - [actionManager, applyButtonFilters, result.data], + [actionManager, applyButtonFilters, dispatchToastMessage, result.data, t], ); return { ...result, @@ -92,6 +115,8 @@ export const useMessageboxAppsActionButtons = () => { export const useUserDropdownAppsActionButtons = () => { const result = useAppActionButtons('userDropdownAction'); const actionManager = useUiKitActionManager(); + const dispatchToastMessage = useToastMessageDispatch(); + const { t } = useTranslation(); const applyButtonFilters = useApplyButtonAuthFilter(); @@ -107,15 +132,27 @@ export const useUserDropdownAppsActionButtons = () => { // icon: action.icon as GenericMenuItemProps['icon'], content: action.labelI18n, onClick: () => { - actionManager.triggerActionButtonAction({ - actionId: action.actionId, - appId: action.appId, - payload: { context: action.context }, - }); + void actionManager + .emitInteraction(action.appId, { + type: 'actionButton', + actionId: action.actionId, + payload: { context: action.context }, + }) + .catch(async (reason) => { + if (reason instanceof UiKitTriggerTimeoutError) { + dispatchToastMessage({ + type: 'error', + message: t('UIKit_Interaction_Timeout'), + }); + return; + } + + return reason; + }); }, }; }), - [actionManager, applyButtonFilters, result.data], + [actionManager, applyButtonFilters, dispatchToastMessage, result.data, t], ); return { ...result, @@ -127,6 +164,8 @@ export const useMessageActionAppsActionButtons = (context?: MessageActionContext const result = useAppActionButtons('messageAction'); const actionManager = useUiKitActionManager(); const applyButtonFilters = useApplyButtonFilters(); + const dispatchToastMessage = useToastMessageDispatch(); + const { t } = useTranslation(); const data = useMemo( () => result.data @@ -148,20 +187,32 @@ export const useMessageActionAppsActionButtons = (context?: MessageActionContext type: 'apps', variant: action.variant, action: (_, params) => { - void actionManager.triggerActionButtonAction({ - rid: params.message.rid, - tmid: params.message.tmid, - mid: params.message._id, - actionId: action.actionId, - appId: action.appId, - payload: { context: action.context }, - }); + void actionManager + .emitInteraction(action.appId, { + type: 'actionButton', + rid: params.message.rid, + tmid: params.message.tmid, + mid: params.message._id, + actionId: action.actionId, + payload: { context: action.context }, + }) + .catch(async (reason) => { + if (reason instanceof UiKitTriggerTimeoutError) { + dispatchToastMessage({ + type: 'error', + message: t('UIKit_Interaction_Timeout'), + }); + return; + } + + return reason; + }); }, }; return item; }), - [actionManager, applyButtonFilters, context, result.data], + [actionManager, applyButtonFilters, context, dispatchToastMessage, result.data, t], ); return { ...result, diff --git a/apps/meteor/client/hooks/useAppUiKitInteraction.ts b/apps/meteor/client/hooks/useAppUiKitInteraction.ts index 84849f592a48..e620d34a141d 100644 --- a/apps/meteor/client/hooks/useAppUiKitInteraction.ts +++ b/apps/meteor/client/hooks/useAppUiKitInteraction.ts @@ -1,16 +1,8 @@ -import type { UIKitInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; +import type { UiKit } from '@rocket.chat/core-typings'; import { useStream, useUserId } from '@rocket.chat/ui-contexts'; import { useEffect } from 'react'; -export const useAppUiKitInteraction = ( - handlePayloadUserInteraction: ( - type: UIKitInteractionType, - data: { - triggerId: string; - appId: string; - }, - ) => void, -) => { +export const useAppUiKitInteraction = (handleServerInteraction: (interaction: UiKit.ServerInteraction) => void) => { const notifyUser = useStream('notify-user'); const uid = useUserId(); @@ -19,8 +11,9 @@ export const useAppUiKitInteraction = ( return; } - return notifyUser(`${uid}/uiInteraction`, ({ type, ...data }) => { - handlePayloadUserInteraction(type, data); + return notifyUser(`${uid}/uiInteraction`, (interaction) => { + // @ts-ignore + handleServerInteraction(interaction); }); - }, [notifyUser, uid, handlePayloadUserInteraction]); + }, [notifyUser, uid, handleServerInteraction]); }; diff --git a/apps/meteor/client/hooks/useLicense.ts b/apps/meteor/client/hooks/useLicense.ts index 0f568d9bd5cc..ae965d105958 100644 --- a/apps/meteor/client/hooks/useLicense.ts +++ b/apps/meteor/client/hooks/useLicense.ts @@ -1,23 +1,36 @@ +import type { Serialized } from '@rocket.chat/core-typings'; import type { OperationResult } from '@rocket.chat/rest-typings'; -import { useEndpoint, usePermission } from '@rocket.chat/ui-contexts'; -import type { UseQueryResult } from '@tanstack/react-query'; -import { useQuery } from '@tanstack/react-query'; - -export const useLicense = (): UseQueryResult> => { - const getLicenses = useEndpoint('GET', '/v1/licenses.get'); - const canViewLicense = usePermission('view-privileged-setting'); - - return useQuery( - ['licenses', 'getLicenses'], - () => { - if (!canViewLicense) { - throw new Error('unauthorized api call'); - } - return getLicenses(); - }, - { - staleTime: Infinity, - keepPreviousData: true, - }, - ); +import { useEndpoint, useSingleStream } from '@rocket.chat/ui-contexts'; +import type { QueryClient, UseQueryResult } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +type LicenseDataType = Awaited>['license']; + +const invalidateQueryClientLicenses = (() => { + let timeout: ReturnType | undefined; + + return (queryClient: QueryClient) => { + clearTimeout(timeout); + timeout = setTimeout(() => { + timeout = undefined; + queryClient.invalidateQueries(['licenses', 'getLicenses']); + }, 5000); + }; +})(); + +export const useLicense = (): UseQueryResult> => { + const getLicenses = useEndpoint('GET', '/v1/licenses.info'); + + const queryClient = useQueryClient(); + + const notify = useSingleStream('notify-all'); + + useEffect(() => notify('license', () => invalidateQueryClientLicenses(queryClient)), [notify, queryClient]); + + return useQuery(['licenses', 'getLicenses'], () => getLicenses({}), { + staleTime: Infinity, + keepPreviousData: true, + select: (data) => data.license, + }); }; diff --git a/apps/meteor/client/lib/banners.ts b/apps/meteor/client/lib/banners.ts index 89310da2e3c7..91185450a21a 100644 --- a/apps/meteor/client/lib/banners.ts +++ b/apps/meteor/client/lib/banners.ts @@ -1,4 +1,4 @@ -import type { UiKitBannerPayload } from '@rocket.chat/core-typings'; +import type { UiKit } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { Keys as IconName } from '@rocket.chat/icons'; @@ -15,7 +15,7 @@ export type LegacyBannerPayload = { onClose?: () => Promise | void; }; -type BannerPayload = LegacyBannerPayload | UiKitBannerPayload; +type BannerPayload = LegacyBannerPayload | UiKit.BannerView; export const isLegacyPayload = (payload: BannerPayload): payload is LegacyBannerPayload => !('blocks' in payload); @@ -35,7 +35,7 @@ export const open = (payload: BannerPayload): void => { if (isLegacyPayload(_payload)) { return _payload.id === (payload as LegacyBannerPayload).id; } - return (_payload as UiKitBannerPayload).viewId === (payload as UiKitBannerPayload).viewId; + return _payload.viewId === (payload as UiKit.BannerView).viewId; }); if (index === -1) { diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index 1630071be658..8242de07d791 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -144,7 +144,7 @@ export type ChatAPI = { ActionManager: any; readonly flows: { - readonly uploadFiles: (files: readonly File[]) => Promise; + readonly uploadFiles: (files: readonly File[], resetFileInput?: () => void) => Promise; readonly sendMessage: ({ text, tshow }: { text: string; tshow?: boolean; previewUrls?: string[] }) => Promise; readonly processSlashCommand: (message: IMessage, userId: string | null) => Promise; readonly processTooLongMessage: (message: IMessage) => Promise; diff --git a/apps/meteor/client/lib/chats/flows/processSlashCommand.ts b/apps/meteor/client/lib/chats/flows/processSlashCommand.ts index 1551f8eb1f57..c9922162a67c 100644 --- a/apps/meteor/client/lib/chats/flows/processSlashCommand.ts +++ b/apps/meteor/client/lib/chats/flows/processSlashCommand.ts @@ -4,7 +4,7 @@ import { escapeHTML } from '@rocket.chat/string-helpers'; import { hasAtLeastOnePermission } from '../../../../app/authorization/client'; import { settings } from '../../../../app/settings/client'; -import { generateTriggerId } from '../../../../app/ui-message/client/ActionManager'; +import { actionManager } from '../../../../app/ui-message/client/ActionManager'; import { slashCommands } from '../../../../app/utils/client'; import { sdk } from '../../../../app/utils/client/lib/SDKClient'; import { t } from '../../../../app/utils/lib/i18n'; @@ -78,7 +78,7 @@ export const processSlashCommand = async (chat: ChatAPI, message: IMessage): Pro params: [{ eventName: 'slashCommandsStats', timestamp: Date.now(), command: commandName }], }); - const triggerId = generateTriggerId(appId); + const triggerId = actionManager.generateTriggerId(appId); const data = { cmd: commandName, diff --git a/apps/meteor/client/lib/chats/flows/uploadFiles.ts b/apps/meteor/client/lib/chats/flows/uploadFiles.ts index 58eb18400a30..1411ad5a004e 100644 --- a/apps/meteor/client/lib/chats/flows/uploadFiles.ts +++ b/apps/meteor/client/lib/chats/flows/uploadFiles.ts @@ -6,7 +6,7 @@ import { imperativeModal } from '../../imperativeModal'; import { prependReplies } from '../../utils/prependReplies'; import type { ChatAPI } from '../ChatAPI'; -export const uploadFiles = async (chat: ChatAPI, files: readonly File[]): Promise => { +export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFileInput?: () => void): Promise => { const replies = chat.composer?.quotedMessages.get() ?? []; const msg = await prependReplies('', replies); @@ -52,4 +52,5 @@ export const uploadFiles = async (chat: ChatAPI, files: readonly File[]): Promis }; uploadNextFile(); + resetFileInput?.(); }; diff --git a/apps/meteor/client/lib/utils/preventSyntheticEvent.ts b/apps/meteor/client/lib/utils/preventSyntheticEvent.ts new file mode 100644 index 000000000000..773b53a1a88c --- /dev/null +++ b/apps/meteor/client/lib/utils/preventSyntheticEvent.ts @@ -0,0 +1,9 @@ +import type { SyntheticEvent } from 'react'; + +export const preventSyntheticEvent = (e: SyntheticEvent): void => { + if (e) { + (e.nativeEvent || e).stopImmediatePropagation(); + e.stopPropagation(); + e.preventDefault(); + } +}; diff --git a/apps/meteor/client/polyfills/index.ts b/apps/meteor/client/polyfills/index.ts index f07d828a4602..bc91265b04ba 100644 --- a/apps/meteor/client/polyfills/index.ts +++ b/apps/meteor/client/polyfills/index.ts @@ -4,3 +4,4 @@ import './childNodeRemove'; import './cssVars'; import './customEventPolyfill'; import './hoverTouchClick'; +import './promiseFinally'; diff --git a/apps/meteor/client/polyfills/promiseFinally.ts b/apps/meteor/client/polyfills/promiseFinally.ts new file mode 100644 index 000000000000..ab826c2bd0ba --- /dev/null +++ b/apps/meteor/client/polyfills/promiseFinally.ts @@ -0,0 +1,16 @@ +if (!Promise.prototype.finally) { + // eslint-disable-next-line no-extend-native + Promise.prototype.finally = function (callback) { + if (typeof callback !== 'function') { + return this.then(callback, callback); + } + const P = (this.constructor as PromiseConstructor) || Promise; + return this.then( + (value) => P.resolve(callback()).then(() => value), + (err) => + P.resolve(callback()).then(() => { + throw err; + }), + ); + }; +} diff --git a/apps/meteor/client/providers/ActionManagerProvider.tsx b/apps/meteor/client/providers/ActionManagerProvider.tsx index 8faa55260f13..e8961ec357e9 100644 --- a/apps/meteor/client/providers/ActionManagerProvider.tsx +++ b/apps/meteor/client/providers/ActionManagerProvider.tsx @@ -2,7 +2,7 @@ import { ActionManagerContext } from '@rocket.chat/ui-contexts'; import type { ReactNode, ReactElement } from 'react'; import React from 'react'; -import * as ActionManager from '../../app/ui-message/client/ActionManager'; +import { actionManager } from '../../app/ui-message/client/ActionManager'; import { useAppActionButtons } from '../hooks/useAppActionButtons'; import { useAppSlashCommands } from '../hooks/useAppSlashCommands'; import { useAppTranslations } from '../hooks/useAppTranslations'; @@ -16,9 +16,9 @@ const ActionManagerProvider = ({ children }: ActionManagerProviderProps): ReactE useAppTranslations(); useAppActionButtons(); useAppSlashCommands(); - useAppUiKitInteraction(ActionManager.handlePayloadUserInteraction); + useAppUiKitInteraction(actionManager.handleServerInteraction.bind(actionManager)); - return {children}; + return {children}; }; export default ActionManagerProvider; diff --git a/apps/meteor/client/providers/AppsProvider.tsx b/apps/meteor/client/providers/AppsProvider.tsx index 042ce8365474..0103a67113fa 100644 --- a/apps/meteor/client/providers/AppsProvider.tsx +++ b/apps/meteor/client/providers/AppsProvider.tsx @@ -5,7 +5,6 @@ import type { FC } from 'react'; import React, { useEffect } from 'react'; import { AppClientOrchestratorInstance } from '../../ee/client/apps/orchestrator'; -import PageSkeleton from '../components/PageSkeleton'; import { AppsContext } from '../contexts/AppsContext'; import { AsyncStatePhase } from '../lib/asyncState'; import { useInvalidateAppsCountQueryCallback } from '../views/marketplace/hooks/useAppsCountQuery'; @@ -74,16 +73,16 @@ const AppsProvider: FC = ({ children }) => { const store = useQuery( ['marketplace', 'apps-stored', isAdminUser], () => { - if (!marketplace.isSuccess || !instance.isSuccess) { + if (!marketplace.isFetched && !instance.isFetched) { throw new Error('Apps not loaded'); } const marketplaceApps: App[] = []; const installedApps: App[] = []; const privateApps: App[] = []; - const clonedData = [...instance.data]; + const clonedData = [...(instance.data || [])]; - sortByName(marketplace.data).forEach((app) => { + sortByName(marketplace.data || []).forEach((app) => { const appIndex = clonedData.findIndex(({ id }) => id === app.id); const [installedApp] = appIndex > -1 ? clonedData.splice(appIndex, 1) : []; @@ -117,22 +116,18 @@ const AppsProvider: FC = ({ children }) => { return [marketplaceApps, installedApps, privateApps]; }, { - enabled: marketplace.isSuccess && instance.isSuccess && !instance.isRefetching, + enabled: marketplace.isFetched && instance.isFetched, keepPreviousData: true, }, ); - if (!store.isSuccess) { - return ; - } - return ( { await Promise.all([queryClient.invalidateQueries(['marketplace'])]); }, diff --git a/apps/meteor/client/providers/CustomSoundProvider.tsx b/apps/meteor/client/providers/CustomSoundProvider.tsx index b383ad830012..cb2d2933117f 100644 --- a/apps/meteor/client/providers/CustomSoundProvider.tsx +++ b/apps/meteor/client/providers/CustomSoundProvider.tsx @@ -1,10 +1,36 @@ -import { CustomSoundContext } from '@rocket.chat/ui-contexts'; +import { CustomSoundContext, useUserId, useStream } from '@rocket.chat/ui-contexts'; import type { FC } from 'react'; -import React from 'react'; +import React, { useEffect } from 'react'; import { CustomSounds } from '../../app/custom-sounds/client/lib/CustomSounds'; const CustomSoundProvider: FC = ({ children }) => { + const userId = useUserId(); + useEffect(() => { + if (!userId) { + return; + } + void CustomSounds.fetchCustomSoundList(); + }, [userId]); + + const streamAll = useStream('notify-all'); + + useEffect(() => { + if (!userId) { + return; + } + + return streamAll('public-info', ([key, data]) => { + switch (key) { + case 'updateCustomSound': + CustomSounds.update(data[0].soundData); + break; + case 'deleteCustomSound': + CustomSounds.remove(data[0].soundData); + break; + } + }); + }, [userId, streamAll]); return ; }; diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index dadb47caead4..aa12af905521 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -35,8 +35,8 @@ const MeteorProvider: FC = ({ children }) => ( - - + + @@ -56,8 +56,8 @@ const MeteorProvider: FC = ({ children }) => ( - - + + diff --git a/apps/meteor/client/providers/OmnichannelProvider.tsx b/apps/meteor/client/providers/OmnichannelProvider.tsx index d9eee0ac4b00..6e7cf38f8cd5 100644 --- a/apps/meteor/client/providers/OmnichannelProvider.tsx +++ b/apps/meteor/client/providers/OmnichannelProvider.tsx @@ -19,6 +19,7 @@ import { useHasLicenseModule } from '../../ee/client/hooks/useHasLicenseModule'; import { ClientLogger } from '../../lib/ClientLogger'; import type { OmnichannelContextValue } from '../contexts/OmnichannelContext'; import { OmnichannelContext } from '../contexts/OmnichannelContext'; +import { useLicense } from '../hooks/useLicense'; import { useReactiveValue } from '../hooks/useReactiveValue'; const emptyContextValue: OmnichannelContextValue = { @@ -27,6 +28,7 @@ const emptyContextValue: OmnichannelContextValue = { isEnterprise: false, agentAvailable: false, showOmnichannelQueueLink: false, + isOverMacLimit: false, livechatPriorities: { enabled: false, data: [], @@ -63,6 +65,7 @@ const OmnichannelProvider: FC = ({ children }) => { const subscribe = useStream('notify-logged'); const queryClient = useQueryClient(); const isPrioritiesEnabled = isEnterprise && accessible; + const enabled = accessible && !!user && !!routeConfig; const { data: { priorities = [] } = {}, @@ -73,6 +76,10 @@ const OmnichannelProvider: FC = ({ children }) => { enabled: isPrioritiesEnabled, }); + const { data: { preventedActions } = {} } = useLicense(); + + const isOverMacLimit = Boolean(preventedActions?.monthlyActiveContacts); + useEffect(() => { if (!isPrioritiesEnabled) { return; @@ -102,7 +109,6 @@ const OmnichannelProvider: FC = ({ children }) => { } }, [accessible, getRoutingConfig, iceServersSetting, omnichannelRouting, setRouteConfig, voipCallAvailable]); - const enabled = accessible && !!user && !!routeConfig; const manuallySelected = enabled && canViewOmnichannelQueue && !!routeConfig && routeConfig.showQueue && !routeConfig.autoAssignAgent && agentAvailable; @@ -167,6 +173,7 @@ const OmnichannelProvider: FC = ({ children }) => { voipCallAvailable, routeConfig, livechatPriorities, + isOverMacLimit, }; } @@ -185,6 +192,7 @@ const OmnichannelProvider: FC = ({ children }) => { : { enabled: false }, showOmnichannelQueueLink: showOmnichannelQueueLink && !!agentAvailable, livechatPriorities, + isOverMacLimit, }; }, [ enabled, @@ -199,6 +207,7 @@ const OmnichannelProvider: FC = ({ children }) => { routeConfig, queue, showOmnichannelQueueLink, + isOverMacLimit, ]); return ; diff --git a/apps/meteor/client/providers/TranslationProvider.tsx b/apps/meteor/client/providers/TranslationProvider.tsx index fdddb9ec5349..2cf47066c4e4 100644 --- a/apps/meteor/client/providers/TranslationProvider.tsx +++ b/apps/meteor/client/providers/TranslationProvider.tsx @@ -2,7 +2,7 @@ import { useLocalStorage, useMutableCallback } from '@rocket.chat/fuselage-hooks import languages from '@rocket.chat/i18n/dist/languages'; import en from '@rocket.chat/i18n/src/locales/en.i18n.json'; import type { TranslationKey, TranslationContextValue } from '@rocket.chat/ui-contexts'; -import { useMethod, useSetting, TranslationContext, useAbsoluteUrl } from '@rocket.chat/ui-contexts'; +import { useMethod, useSetting, TranslationContext } from '@rocket.chat/ui-contexts'; import type i18next from 'i18next'; import I18NextHttpBackend from 'i18next-http-backend'; import sprintf from 'i18next-sprintf-postprocessor'; @@ -12,6 +12,7 @@ import React, { useEffect, useMemo } from 'react'; import { I18nextProvider, initReactI18next, useTranslation } from 'react-i18next'; import { CachedCollectionManager } from '../../app/ui-cached-collection/client'; +import { getURL } from '../../app/utils/client'; import { i18n, addSprinfToI18n } from '../../app/utils/lib/i18n'; import { AppClientOrchestratorInstance } from '../../ee/client/apps/orchestrator'; import { applyCustomTranslations } from '../lib/utils/applyCustomTranslations'; @@ -39,8 +40,6 @@ const parseToJSON = (customTranslations: string): Record>(); const useI18next = (lng: string): typeof i18next => { - const basePath = useAbsoluteUrl()('/i18n'); - const customTranslations = useSetting('Custom_Translations'); const parsedCustomTranslations = useMemo(() => { @@ -79,6 +78,11 @@ const useI18next = (lng: string): typeof i18next => { if (prefix) { result[key.slice(prefix.length + 1)] = value; + continue; + } + + if (Array.isArray(namespaces) ? namespaces.includes('core') : namespaces === 'core') { + result[key] = value; } } } @@ -100,17 +104,18 @@ const useI18next = (lng: string): typeof i18next => { partialBundledLanguages: true, defaultNS: 'core', backend: { - loadPath: `${basePath}/{{lng}}.json`, + loadPath: 'i18n/{{lng}}.json', parse: (data: string, lngs?: string | string[], namespaces: string | string[] = []) => extractKeys(JSON.parse(data), lngs, namespaces), request: (_options, url, _payload, callback) => { const params = url.split('/'); + const lng = params[params.length - 1]; let promise = localeCache.get(lng); if (!promise) { - promise = fetch(url).then((res) => res.text()); + promise = fetch(getURL(url)).then((res) => res.text()); localeCache.set(lng, promise); } diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index 432a197671f3..09f631ffa6a6 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -10,7 +10,6 @@ import { Subscriptions, ChatRoom } from '../../../app/models/client'; import { getUserPreference } from '../../../app/utils/client'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { afterLogoutCleanUpCallback } from '../../../lib/callbacks/afterLogoutCleanUpCallback'; -import { useIsEnterprise } from '../../hooks/useIsEnterprise'; import { useReactiveValue } from '../../hooks/useReactiveValue'; import { createReactiveSubscriptionFactory } from '../../lib/createReactiveSubscriptionFactory'; import { useCreateFontStyleElement } from '../../views/account/accessibility/hooks/useCreateFontStyleElement'; @@ -180,14 +179,6 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { } }, [preferedLanguage, setPreferedLanguage, setUserLanguage, user?.language, userLanguage, userId, setUserPreferences]); - const { data: license } = useIsEnterprise({ enabled: !!userId }); - - useEffect(() => { - if (!license?.isEnterprise && user?.settings?.preferences?.themeAppearence === 'high-contrast') { - setUserPreferences({ data: { themeAppearence: 'light' } }); - } - }, [license?.isEnterprise, setUserPreferences, user?.settings?.preferences?.themeAppearence]); - return ; }; diff --git a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx index b96c54d4c955..4c51b8a3615b 100644 --- a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx @@ -7,10 +7,10 @@ import type { AllHTMLAttributes, ComponentType, ReactElement, ReactNode } from ' import React, { memo, useMemo } from 'react'; import { useOmnichannelPriorities } from '../../../ee/client/omnichannel/hooks/useOmnichannelPriorities'; -import { PriorityIcon } from '../../../ee/client/omnichannel/priorities/PriorityIcon'; import { RoomIcon } from '../../components/RoomIcon'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import RoomMenu from '../RoomMenu'; +import { OmnichannelBadges } from '../badges/OmnichannelBadges'; import type { useAvatarTemplate } from '../hooks/useAvatarTemplate'; import { normalizeSidebarMessage } from './normalizeSidebarMessage'; @@ -33,6 +33,30 @@ const getMessage = (room: IRoom, lastMessage: IMessage | undefined, t: ReturnTyp return `${lastMessage.u.name || lastMessage.u.username}: ${normalizeSidebarMessage(lastMessage, t)}`; }; +const getBadgeTitle = ( + userMentions: number, + threadUnread: number, + groupMentions: number, + unread: number, + t: ReturnType, +) => { + const title = [] as string[]; + if (userMentions) { + title.push(t('mentions_counter', { count: userMentions })); + } + if (threadUnread) { + title.push(t('threads_counter', { count: threadUnread })); + } + if (groupMentions) { + title.push(t('group_mentions_counter', { count: groupMentions })); + } + const count = unread - userMentions - groupMentions; + if (count > 0) { + title.push(t('unread_messages_counter', { count })); + } + return title.join(', '); +}; + type RoomListRowProps = { extended: boolean; t: ReturnType; @@ -137,14 +161,16 @@ function SideBarItemTemplateWithData({ const isUnread = unread > 0 || threadUnread; const showBadge = !hideUnreadStatus || (!hideMentionStatus && (Boolean(userMentions) || tunreadUser.length > 0)); + const badgeTitle = getBadgeTitle(userMentions, tunread.length, groupMentions, unread, t); + const badges = ( {showBadge && isUnread && ( - + {unread + tunread?.length} )} - {isOmnichannelRoom(room) && isPriorityEnabled && } + {isOmnichannelRoom(room) && } ); diff --git a/apps/meteor/client/sidebar/Sidebar.tsx b/apps/meteor/client/sidebar/Sidebar.tsx index 9c7634872ed4..19606854d0ed 100644 --- a/apps/meteor/client/sidebar/Sidebar.tsx +++ b/apps/meteor/client/sidebar/Sidebar.tsx @@ -1,5 +1,5 @@ import { css } from '@rocket.chat/css-in-js'; -import { Box, Palette } from '@rocket.chat/fuselage'; +import { Box } from '@rocket.chat/fuselage'; import { useSessionStorage } from '@rocket.chat/fuselage-hooks'; import { useLayout, useSetting, useUserPreference } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; @@ -20,8 +20,10 @@ const Sidebar = () => { const [bannerDismissed, setBannerDismissed] = useSessionStorage('presence_cap_notifier', false); const presenceDisabled = useSetting('Presence_broadcast_disabled'); - const sideBarBackground = css` - background-color: ${Palette.surface['surface-tint']}; + const sidebarLink = css` + a { + text-decoration: none; + } `; return ( @@ -35,7 +37,7 @@ const Sidebar = () => { 'rcx-sidebar--main', `rcx-sidebar rcx-sidebar--${sidebarViewMode}`, sidebarHideAvatar && 'rcx-sidebar--hide-avatar', - sideBarBackground, + sidebarLink, ].filter(Boolean)} role='navigation' data-qa-opened={sidebar.isCollapsed ? 'false' : 'true'} diff --git a/apps/meteor/client/sidebar/badges/OmnichannelBadges.tsx b/apps/meteor/client/sidebar/badges/OmnichannelBadges.tsx new file mode 100644 index 000000000000..32fff81d7bb3 --- /dev/null +++ b/apps/meteor/client/sidebar/badges/OmnichannelBadges.tsx @@ -0,0 +1,22 @@ +import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom } from '@rocket.chat/core-typings'; +import React from 'react'; + +import { RoomActivityIcon } from '../../../ee/client/omnichannel/components/RoomActivityIcon'; +import { useOmnichannelPriorities } from '../../../ee/client/omnichannel/hooks/useOmnichannelPriorities'; +import { PriorityIcon } from '../../../ee/client/omnichannel/priorities/PriorityIcon'; + +export const OmnichannelBadges = ({ room }: { room: ISubscription & IRoom }) => { + const { enabled: isPriorityEnabled } = useOmnichannelPriorities(); + + if (!isOmnichannelRoom(room)) { + return null; + } + + return ( + <> + {isPriorityEnabled ? : null} + + + ); +}; diff --git a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx index 57ebffdbcf39..3f001b158d72 100644 --- a/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx +++ b/apps/meteor/client/sidebar/header/CreateChannel/CreateChannelModal.tsx @@ -1,4 +1,18 @@ -import { Box, Modal, Button, TextInput, Icon, Field, ToggleSwitch, FieldGroup } from '@rocket.chat/fuselage'; +import { + Box, + Modal, + Button, + TextInput, + Icon, + Field, + ToggleSwitch, + FieldGroup, + FieldLabel, + FieldRow, + FieldError, + FieldHint, + FieldDescription, +} from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { @@ -194,10 +208,10 @@ const CreateChannelModal = ({ teamId = '', onClose }: CreateChannelModalProps): - + {t('Channel_name')} - - + + - + {errors.name && ( - + {errors.name.message} - + )} - {t('Topic')} - + {t('Topic')} + - - {t('Channel_what_is_this_channel_about')} + + {t('Channel_what_is_this_channel_about')} - {t('Private')} - + {t('Private')} + {isPrivate ? t('Only_invited_users_can_acess_this_channel') : t('Everyone_can_access_this_channel')} - + - {t('Federation_Matrix_Federated')} - {t(getFederationHintKey(federatedModule, federationEnabled))} + {t('Federation_Matrix_Federated')} + {t(getFederationHintKey(federatedModule, federationEnabled))} - {t('Read_only')} - + {t('Read_only')} + {readOnly ? t('Only_authorized_users_can_write_new_messages') : t('All_users_in_the_channel_can_write_new_messages')} - + - {t('Encrypted')} - + {t('Encrypted')} + {isPrivate ? t('Encrypted_channel_Description') : t('Encrypted_not_available')} - + - {t('Broadcast')} - {t('Broadcast_channel_Description')} + {t('Broadcast')} + {t('Broadcast_channel_Description')} - {t('Add_members')} + {t('Add_members')} ; - -type CreateDirectMessageProps = { - onClose: () => void; -}; - -const CreateDirectMessage: FC = ({ onClose }) => { +const CreateDirectMessage = ({ onClose }: { onClose: () => void }) => { const t = useTranslation(); - const [users, setUsers] = useState>([]); + const membersFieldId = useUniqueId(); + const dispatchToastMessage = useToastMessageDispatch(); - const createDirect = useEndpointAction('POST', '/v1/dm.create'); + const createDirectAction = useEndpoint('POST', '/v1/dm.create'); - const onCreate = useMutableCallback(async (e) => { - e.preventDefault(); - if (!users.length) return; - try { - const { - room: { rid }, - } = await createDirect({ usernames: users.join(',') }); + const { + control, + handleSubmit, + formState: { isDirty, isSubmitting, isValidating, errors }, + } = useForm({ mode: 'onBlur', defaultValues: { users: [] } }); + const mutateDirectMessage = useMutation({ + mutationFn: createDirectAction, + onSuccess: ({ room: { rid } }) => { goToRoomById(rid); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: () => { onClose(); - } catch (error) { - console.warn(error); - } + }, }); + const handleCreate = async ({ users }: { users: IUser['username'][] }) => { + return mutateDirectMessage.mutateAsync({ usernames: users.join(',') }); + }; + return ( - ) => } - > + }> - {t('Direct_Messages')} - + {t('Create_direct_message')} + - {t('Direct_message_creation_description')} - - - + {t('Direct_message_creation_description')} + + + + {t('Members')} + + + ( + + )} + /> + + {errors.users && ( + + {errors.users.message} + + )} + + - diff --git a/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx b/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx index 9907aa788894..e14ef6e77b37 100644 --- a/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx +++ b/apps/meteor/client/sidebar/header/CreateTeam/CreateTeamModal.tsx @@ -1,4 +1,17 @@ -import { Box, Button, Field, FieldGroup, Icon, Modal, TextInput, ToggleSwitch } from '@rocket.chat/fuselage'; +import { + Box, + Button, + Field, + Icon, + Modal, + TextInput, + ToggleSwitch, + FieldGroup, + FieldLabel, + FieldRow, + FieldError, + FieldDescription, +} from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useEndpoint, @@ -153,10 +166,10 @@ const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => - + {t('Teams_New_Name_Label')} - - + + void }): ReactElement => aria-describedby={`${nameId}-error`} aria-required='true' /> - + {errors?.name && ( - + {errors.name.message} - + )} - + {t('Teams_New_Description_Label')}{' '} ({t('optional')}) - - + + - + - {t('Teams_New_Private_Label')} - + {t('Teams_New_Private_Label')} + {isPrivate ? t('Teams_New_Private_Description_Enabled') : t('Teams_New_Private_Description_Disabled')} - + void }): ReactElement => - {t('Teams_New_Read_only_Label')} - + {t('Teams_New_Read_only_Label')} + {readOnly ? t('Only_authorized_users_can_write_new_messages') : t('Teams_New_Read_only_Description')} - + void }): ReactElement => - {t('Teams_New_Encrypted_Label')} - + {t('Teams_New_Encrypted_Label')} + {isPrivate ? t('Teams_New_Encrypted_Description_Enabled') : t('Teams_New_Encrypted_Description_Disabled')} - + void }): ReactElement => - {t('Teams_New_Broadcast_Label')} - {t('Teams_New_Broadcast_Description')} + {t('Teams_New_Broadcast_Label')} + {t('Teams_New_Broadcast_Description')} void }): ReactElement => - + {t('Teams_New_Add_members_Label')}{' '} ({t('optional')}) - + - {t('StatusMessage')} - + {t('StatusMessage')} + } /> - - {!allowUserStatusMessageChange && {t('StatusMessage_Change_Disabled')}} - {statusTextError} + + {!allowUserStatusMessageChange && {t('StatusMessage_Change_Disabled')}} + {statusTextError} diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx index a0f90a7d2f2f..6ea69ce6c037 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx @@ -1,4 +1,16 @@ -import { Divider, Modal, ButtonGroup, Button, Field, TextInput, Throbber } from '@rocket.chat/fuselage'; +import { + Divider, + Modal, + ButtonGroup, + Button, + Field, + TextInput, + Throbber, + FieldLabel, + FieldRow, + FieldError, + FieldHint, +} from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useSetModal, useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -63,8 +75,8 @@ const MatrixFederationAddServerModal: VFC =
- {t('Server_name')} - + {t('Server_name')} + = {!isLoading && t('Add')} {isLoading && } - - {isError && errorKey && {t(errorKey)}} - {t('Federation_Example_matrix_server')} + + {isError && errorKey && {t(errorKey)}} + {t('Federation_Example_matrix_server')} {!isLoadingServerList && data?.servers && } diff --git a/apps/meteor/client/sidebar/header/UserMenu.tsx b/apps/meteor/client/sidebar/header/UserMenu.tsx index 9fcc7a0d2274..a53836eda311 100644 --- a/apps/meteor/client/sidebar/header/UserMenu.tsx +++ b/apps/meteor/client/sidebar/header/UserMenu.tsx @@ -24,6 +24,7 @@ const UserMenu = ({ user }: { user: IUser }) => { } + placement='bottom-end' selectionMode='multiple' sections={sections} title={t('User_menu')} @@ -36,6 +37,7 @@ const UserMenu = ({ user }: { user: IUser }) => { } medium + placement='bottom-end' selectionMode='multiple' sections={sections} title={t('User_menu')} diff --git a/apps/meteor/client/sidebar/header/actions/hooks/useAdministrationItems.spec.tsx b/apps/meteor/client/sidebar/header/actions/hooks/useAdministrationItems.spec.tsx index b0b20972d346..cd9e78502e94 100644 --- a/apps/meteor/client/sidebar/header/actions/hooks/useAdministrationItems.spec.tsx +++ b/apps/meteor/client/sidebar/header/actions/hooks/useAdministrationItems.spec.tsx @@ -6,13 +6,12 @@ import { useAdministrationItems } from './useAdministrationItems'; it('should not show upgrade item if has license and not have trial', async () => { const { result, waitFor } = renderHook(() => useAdministrationItems(), { wrapper: mockAppRoot() - .withEndpoint('GET', '/v1/licenses.get', () => ({ - licenses: [ - { - modules: ['testModule'], - meta: { trial: false }, - } as any, - ], + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: { + // @ts-expect-error this is a mock + license: { activeModules: ['testModule'] }, + trial: false, + }, })) .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ registrationStatus: { @@ -25,19 +24,21 @@ it('should not show upgrade item if has license and not have trial', async () => }); await waitFor(() => !!(result.all.length > 1)); - expect(result.current.length).toEqual(1); + + expect(result.current[0]).toEqual( + expect.objectContaining({ + id: 'workspace', + }), + ); }); it('should return an upgrade item if not have license or if have a trial', async () => { const { result, waitFor } = renderHook(() => useAdministrationItems(), { wrapper: mockAppRoot() - .withEndpoint('GET', '/v1/licenses.get', () => ({ - licenses: [ - { - modules: [], - } as any, - ], + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, })) .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ registrationStatus: { @@ -62,12 +63,9 @@ it('should return an upgrade item if not have license or if have a trial', async it('should return omnichannel item if has `view-livechat-manager` permission ', async () => { const { result, waitFor } = renderHook(() => useAdministrationItems(), { wrapper: mockAppRoot() - .withEndpoint('GET', '/v1/licenses.get', () => ({ - licenses: [ - { - modules: [], - } as any, - ], + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, })) .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ registrationStatus: { @@ -90,12 +88,9 @@ it('should return omnichannel item if has `view-livechat-manager` permission ', it('should show administration item if has at least one admin permission', async () => { const { result, waitFor } = renderHook(() => useAdministrationItems(), { wrapper: mockAppRoot() - .withEndpoint('GET', '/v1/licenses.get', () => ({ - licenses: [ - { - modules: [], - } as any, - ], + .withEndpoint('GET', '/v1/licenses.info', () => ({ + // @ts-expect-error this is a mock + license: {}, })) .withEndpoint('GET', '/v1/cloud.registrationStatus', () => ({ registrationStatus: { diff --git a/apps/meteor/client/sidebar/sections/OmnichannelSection.tsx b/apps/meteor/client/sidebar/sections/OmnichannelSection.tsx index e7dec5f3506a..c4538166bba0 100644 --- a/apps/meteor/client/sidebar/sections/OmnichannelSection.tsx +++ b/apps/meteor/client/sidebar/sections/OmnichannelSection.tsx @@ -4,7 +4,9 @@ import { useLayout, useRoute, usePermission, useTranslation } from '@rocket.chat import React, { memo } from 'react'; import { useIsCallEnabled, useIsCallReady } from '../../contexts/CallContext'; +import { useIsOverMacLimit } from '../../hooks/omnichannel/useIsOverMacLimit'; import { useOmnichannelShowQueueLink } from '../../hooks/omnichannel/useOmnichannelShowQueueLink'; +import { OverMacLimitSection } from './OverMacLimitSection'; import { OmniChannelCallDialPad, OmnichannelCallToggle, OmnichannelLivechatToggle } from './actions'; const OmnichannelSection = () => { @@ -16,6 +18,7 @@ const OmnichannelSection = () => { const { sidebar } = useLayout(); const directoryRoute = useRoute('omnichannel-directory'); const queueListRoute = useRoute('livechat-queue'); + const isWorkspaceOverMacLimit = useIsOverMacLimit(); const handleRoute = useMutableCallback((route) => { sidebar.toggle(); @@ -32,25 +35,29 @@ const OmnichannelSection = () => { // The className is a paliative while we make TopBar.ToolBox optional on fuselage return ( - - {t('Omnichannel')} - - {showOmnichannelQueueLink && ( - handleRoute('queue')} /> - )} - {isCallEnabled && } - - {hasPermissionToSeeContactCenter && ( - handleRoute('directory')} - /> - )} - {isCallReady && } - - + <> + {isWorkspaceOverMacLimit && } + + + {t('Omnichannel')} + + {showOmnichannelQueueLink && ( + handleRoute('queue')} /> + )} + {isCallEnabled && } + + {hasPermissionToSeeContactCenter && ( + handleRoute('directory')} + /> + )} + {isCallReady && } + + + ); }; diff --git a/apps/meteor/client/sidebar/sections/OverMacLimitSection.tsx b/apps/meteor/client/sidebar/sections/OverMacLimitSection.tsx new file mode 100644 index 000000000000..53cbd0340339 --- /dev/null +++ b/apps/meteor/client/sidebar/sections/OverMacLimitSection.tsx @@ -0,0 +1,21 @@ +import { Icon, SidebarBanner } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +export const OverMacLimitSection = (): ReactElement => { + const t = useTranslation(); + + const handleClick = () => { + window.open('https://rocket.chat/pricing', '_blank'); + }; + + return ( + } + onClick={handleClick} + /> + ); +}; diff --git a/apps/meteor/client/startup/rootUrlChange.ts b/apps/meteor/client/startup/rootUrlChange.ts index 45f98634a373..4e42874eba4a 100644 --- a/apps/meteor/client/startup/rootUrlChange.ts +++ b/apps/meteor/client/startup/rootUrlChange.ts @@ -6,6 +6,8 @@ import { Roles } from '../../app/models/client'; import { settings } from '../../app/settings/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { t } from '../../app/utils/lib/i18n'; +import FingerprintChangeModal from '../components/FingerprintChangeModal'; +import FingerprintChangeModalConfirmation from '../components/FingerprintChangeModalConfirmation'; import UrlChangeModal from '../components/UrlChangeModal'; import { imperativeModal } from '../lib/imperativeModal'; import { dispatchToastMessage } from '../lib/toast'; @@ -58,3 +60,72 @@ Meteor.startup(() => { return c.stop(); }); }); + +Meteor.startup(() => { + Tracker.autorun((c) => { + const userId = Meteor.userId(); + if (!userId) { + return; + } + + if (!Roles.ready.get() || !isSyncReady.get()) { + return; + } + + if (hasRole(userId, 'admin') === false) { + return c.stop(); + } + + const deploymentFingerPrintVerified = settings.get('Deployment_FingerPrint_Verified'); + if (deploymentFingerPrintVerified == null || deploymentFingerPrintVerified === true) { + return; + } + + const updateWorkspace = (): void => { + imperativeModal.close(); + void sdk.rest.post('/v1/fingerprint', { setDeploymentAs: 'updated-configuration' }).then(() => { + dispatchToastMessage({ type: 'success', message: t('Configuration_update_confirmed') }); + }); + }; + + const setNewWorkspace = (): void => { + imperativeModal.close(); + void sdk.rest.post('/v1/fingerprint', { setDeploymentAs: 'new-workspace' }).then(() => { + dispatchToastMessage({ type: 'success', message: t('New_workspace_confirmed') }); + }); + }; + + const openModal = (): void => { + imperativeModal.open({ + component: FingerprintChangeModal, + props: { + onConfirm: () => { + imperativeModal.open({ + component: FingerprintChangeModalConfirmation, + props: { + onConfirm: setNewWorkspace, + onCancel: openModal, + newWorkspace: true, + }, + }); + }, + onCancel: () => { + imperativeModal.open({ + component: FingerprintChangeModalConfirmation, + props: { + onConfirm: updateWorkspace, + onCancel: openModal, + newWorkspace: false, + }, + }); + }, + onClose: imperativeModal.close, + }, + }); + }; + + openModal(); + + return c.stop(); + }); +}); diff --git a/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx b/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx index ceb904613533..c8179f08bef2 100644 --- a/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx +++ b/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx @@ -1,7 +1,6 @@ import { css } from '@rocket.chat/css-in-js'; import type { SelectOption } from '@rocket.chat/fuselage'; import { - Icon, FieldDescription, Accordion, Box, @@ -14,20 +13,16 @@ import { FieldRow, RadioButton, Select, - Tag, ToggleSwitch, } from '@rocket.chat/fuselage'; -import { useLocalStorage, useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useSetModal, useTranslation, useToastMessageDispatch, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useToastMessageDispatch, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import React, { useMemo } from 'react'; import { Controller, useForm } from 'react-hook-form'; import Page from '../../../components/Page'; -import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; import { getDirtyFields } from '../../../lib/getDirtyFields'; -import HighContrastUpsellModal from './HighContrastUpsellModal'; -import MentionsWithSymbolUpsellModal from './MentionsWithSymbolUpsellModal'; import { fontSizes } from './fontSizes'; import type { AccessibilityPreferencesData } from './hooks/useAcessibilityPreferencesValues'; import { useAccessiblityPreferencesValues } from './hooks/useAcessibilityPreferencesValues'; @@ -36,13 +31,9 @@ import { themeItems as themes } from './themeItems'; const AccessibilityPage = () => { const t = useTranslation(); - const setModal = useSetModal(); const dispatchToastMessage = useToastMessageDispatch(); - const { data: license } = useIsEnterprise(); const preferencesValues = useAccessiblityPreferencesValues(); - const { themeAppearence } = preferencesValues; - const [, setPrevTheme] = useLocalStorage('prevTheme', themeAppearence); const createFontStyleElement = useCreateFontStyleElement(); const displayRolesEnabled = useSetting('UI_DisplayRoles'); @@ -81,7 +72,6 @@ const AccessibilityPage = () => { onError: (error) => dispatchToastMessage({ type: 'error', message: error }), onSettled: (_data, _error, { data: { fontSize } }) => { reset(currentData); - dirtyFields.themeAppearence && setPrevTheme(themeAppearence); dirtyFields.fontSize && fontSize && createFontStyleElement(fontSize); }, }); @@ -101,45 +91,25 @@ const AccessibilityPage = () => {
- {themes.map(({ id, title, description, ...item }, index) => { - const communityDisabled = 'isEEOnly' in item && item.isEEOnly && !license?.isEnterprise; - + {themes.map(({ id, title, description }, index) => { return ( - {t.has(title) ? t(title) : title} - {communityDisabled && ( - - - - {t('Enterprise')} - - - )} + {t(title)} { - if (communityDisabled) { - return ( - setModal( setModal(null)} />)} - checked={false} - /> - ); - } - return onChange(id)} checked={value === id} />; - }} + render={({ field: { onChange, value, ref } }) => ( + onChange(id)} checked={value === id} /> + )} /> - {t.has(description) ? t(description) : description} + {t(description)} ); @@ -164,30 +134,15 @@ const AccessibilityPage = () => { - - {t('Mentions_with_@_symbol')} - - - - {t('Enterprise')} - - - + {t('Mentions_with_@_symbol')} - {license?.isEnterprise ? ( - ( - - )} - /> - ) : ( - setModal( setModal(null)} />)} - checked={false} - /> - )} + ( + + )} + /> void }) => { - const t = useTranslation(); - - const isAdmin = useRole('admin'); - const { handleGoFullyFeatured, handleTalkToSales } = useUpsellActions(); - - if (!isAdmin) { - return ( - - ); - } - return ( - - ); -}; -export default HighContrastUpsellModal; diff --git a/apps/meteor/client/views/account/accessibility/MentionsWithSymbolUpsellModal.tsx b/apps/meteor/client/views/account/accessibility/MentionsWithSymbolUpsellModal.tsx deleted file mode 100644 index 8a998af348c0..000000000000 --- a/apps/meteor/client/views/account/accessibility/MentionsWithSymbolUpsellModal.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useRole, useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; - -import GenericUpsellModal from '../../../components/GenericUpsellModal'; -import { useUpsellActions } from '../../../components/GenericUpsellModal/hooks'; - -const MentionsWithSymbolUpsellModal = ({ onClose }: { onClose: () => void }) => { - const t = useTranslation(); - - const isAdmin = useRole('admin'); - const { handleGoFullyFeatured, handleTalkToSales } = useUpsellActions(); - - if (!isAdmin) { - return ( - - ); - } - return ( - - ); -}; -export default MentionsWithSymbolUpsellModal; diff --git a/apps/meteor/client/views/account/accessibility/themeItems.ts b/apps/meteor/client/views/account/accessibility/themeItems.ts index 62bf3830d952..f16d9128503d 100644 --- a/apps/meteor/client/views/account/accessibility/themeItems.ts +++ b/apps/meteor/client/views/account/accessibility/themeItems.ts @@ -1,4 +1,11 @@ -export const themeItems = [ +import type { TranslationKey } from '@rocket.chat/ui-contexts'; + +type ThemeItem = { + id: string; + title: TranslationKey; + description: TranslationKey; +}; +export const themeItems: ThemeItem[] = [ { id: 'light', title: 'Theme_light', @@ -10,7 +17,6 @@ export const themeItems = [ description: 'Theme_dark_description', }, { - isEEOnly: true, id: 'high-contrast', title: 'Theme_high_contrast', description: 'Theme_high_contrast_description', diff --git a/apps/meteor/client/views/account/featurePreview/AccountFeaturePreviewPage.tsx b/apps/meteor/client/views/account/featurePreview/AccountFeaturePreviewPage.tsx index 715f6fb9b125..29b2a796953e 100644 --- a/apps/meteor/client/views/account/featurePreview/AccountFeaturePreviewPage.tsx +++ b/apps/meteor/client/views/account/featurePreview/AccountFeaturePreviewPage.tsx @@ -3,13 +3,16 @@ import { ButtonGroup, Button, Box, - Field, ToggleSwitch, - FieldGroup, States, StatesIcon, StatesTitle, Accordion, + Field, + FieldGroup, + FieldLabel, + FieldRow, + FieldHint, } from '@rocket.chat/fuselage'; import type { FeaturePreviewProps } from '@rocket.chat/ui-client'; import { useFeaturePreviewList } from '@rocket.chat/ui-client'; @@ -104,12 +107,12 @@ const AccountFeaturePreviewPage = () => { - {t(feature.i18n)} - + {t(feature.i18n)} + - + - {feature.description && {t(feature.description)}} + {feature.description && {t(feature.description)}} {feature.imageUrl && } diff --git a/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx b/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx index 71d025d792cd..54806d879aa1 100644 --- a/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx +++ b/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx @@ -1,6 +1,6 @@ import type { IWebdavAccountIntegration } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; -import { SelectLegacy, Box, Field, Button } from '@rocket.chat/fuselage'; +import { SelectLegacy, Box, Button, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -38,8 +38,8 @@ const AccountIntegrationsPage = (): ReactElement => { - {t('WebDAV_Accounts')} - + {t('WebDAV_Accounts')} + { - + diff --git a/apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx b/apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx index 515446a154f6..d448a180f834 100644 --- a/apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx +++ b/apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx @@ -6,6 +6,7 @@ import { useForm, FormProvider } from 'react-hook-form'; import Page from '../../../components/Page'; import PreferencesConversationTranscript from './PreferencesConversationTranscript'; +import { PreferencesGeneral } from './PreferencesGeneral'; type FormData = { omnichannelTranscriptPDF: boolean; @@ -18,9 +19,10 @@ const OmnichannelPreferencesPage = (): ReactElement => { const omnichannelTranscriptPDF = useUserPreference('omnichannelTranscriptPDF') ?? false; const omnichannelTranscriptEmail = useUserPreference('omnichannelTranscriptEmail') ?? false; + const omnichannelHideConversationAfterClosing = useUserPreference('omnichannelHideConversationAfterClosing') ?? true; const methods = useForm({ - defaultValues: { omnichannelTranscriptPDF, omnichannelTranscriptEmail }, + defaultValues: { omnichannelTranscriptPDF, omnichannelTranscriptEmail, omnichannelHideConversationAfterClosing }, }); const { @@ -48,6 +50,7 @@ const OmnichannelPreferencesPage = (): ReactElement => { + diff --git a/apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx b/apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx index fb1903806fec..9fccaef4593e 100644 --- a/apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx +++ b/apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx @@ -1,4 +1,4 @@ -import { Accordion, Box, Field, FieldGroup, Tag, ToggleSwitch } from '@rocket.chat/fuselage'; +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 React from 'react'; @@ -24,7 +24,7 @@ const PreferencesConversationTranscript = () => { - + {t('Omnichannel_transcript_pdf')} @@ -32,16 +32,16 @@ const PreferencesConversationTranscript = () => { {!canSendTranscriptPDF && hasLicense && {t('No_permission')}} - - + + - + - {t('Accounts_Default_User_Preferences_omnichannelTranscriptPDF_Description')} + {t('Accounts_Default_User_Preferences_omnichannelTranscriptPDF_Description')} - + {t('Omnichannel_transcript_email')} {!canSendTranscriptEmail && ( @@ -50,16 +50,16 @@ const PreferencesConversationTranscript = () => { )} - - + + - + - {t('Accounts_Default_User_Preferences_omnichannelTranscriptEmail_Description')} + {t('Accounts_Default_User_Preferences_omnichannelTranscriptEmail_Description')} diff --git a/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx b/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx new file mode 100644 index 000000000000..67c06bd2c5b2 --- /dev/null +++ b/apps/meteor/client/views/account/omnichannel/PreferencesGeneral.tsx @@ -0,0 +1,26 @@ +import { Box, Field, FieldGroup, FieldHint, FieldLabel, FieldRow, ToggleSwitch } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useFormContext } from 'react-hook-form'; + +export const PreferencesGeneral = (): ReactElement => { + const t = useTranslation(); + const { register } = useFormContext(); + const omnichannelHideAfterClosing = useUniqueId(); + + return ( + + + + {t('Omnichannel_hide_conversation_after_closing')} + + + + + {t('Omnichannel_hide_conversation_after_closing_description')} + + + ); +}; diff --git a/apps/meteor/client/views/account/preferences/PreferencesGlobalSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesGlobalSection.tsx index 4e616072d185..d09492fdc5a6 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesGlobalSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesGlobalSection.tsx @@ -1,5 +1,5 @@ import type { SelectOption } from '@rocket.chat/fuselage'; -import { Accordion, Field, FieldGroup, MultiSelect } from '@rocket.chat/fuselage'; +import { Accordion, Field, FieldGroup, FieldLabel, FieldRow, MultiSelect } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useUserPreference, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; @@ -18,8 +18,8 @@ const PreferencesGlobalSection = () => { - {t('Dont_ask_me_again_list')} - + {t('Dont_ask_me_again_list')} + { )} /> - + diff --git a/apps/meteor/client/views/account/preferences/PreferencesHighlightsSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesHighlightsSection.tsx index 8c05a92bad47..85bfc9072b12 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesHighlightsSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesHighlightsSection.tsx @@ -1,4 +1,4 @@ -import { Accordion, Field, FieldGroup, TextAreaInput } from '@rocket.chat/fuselage'; +import { Accordion, Field, FieldGroup, FieldLabel, FieldRow, FieldHint, TextAreaInput } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; @@ -14,11 +14,11 @@ const PreferencesHighlightsSection = () => { - {t('Highlights_List')} - + {t('Highlights_List')} + - - {t('Highlights_How_To')} + + {t('Highlights_How_To')} diff --git a/apps/meteor/client/views/account/preferences/PreferencesLocalizationSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesLocalizationSection.tsx index 2faaa9da89c2..0dc6e25f1ac3 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesLocalizationSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesLocalizationSection.tsx @@ -1,5 +1,5 @@ import type { SelectOption } from '@rocket.chat/fuselage'; -import { Accordion, Field, Select, FieldGroup } from '@rocket.chat/fuselage'; +import { Accordion, Field, FieldGroup, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useLanguages, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo } from 'react'; @@ -23,8 +23,8 @@ const PreferencesLocalizationSection = () => { - {t('Language')} - + {t('Language')} + { )} /> - - {t('Enter_Behaviour_Description')} + + {t('Enter_Behaviour_Description')} diff --git a/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx index 0775b07cec3e..0b82d7441323 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx @@ -1,4 +1,4 @@ -import { Accordion, Field, FieldGroup, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; +import { Accordion, Field, FieldGroup, FieldRow, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; import { useSetModal, useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback } from 'react'; @@ -77,7 +77,7 @@ const PreferencesMyDataSection = () => { - + - + diff --git a/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx index e0a6017e7efe..2643eab0ebc7 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesNotificationsSection.tsx @@ -1,5 +1,5 @@ import type { SelectOption } from '@rocket.chat/fuselage'; -import { Accordion, Field, Select, FieldGroup, ToggleSwitch, Button, Box } from '@rocket.chat/fuselage'; +import { Accordion, Field, FieldLabel, FieldRow, FieldHint, Select, FieldGroup, ToggleSwitch, Button, Box } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useUserPreference, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; @@ -92,8 +92,8 @@ const PreferencesNotificationsSection = () => { - {t('Desktop_Notifications')} - + {t('Desktop_Notifications')} + {notificationsPermission === 'denied' && t('Desktop_Notifications_Disabled')} {notificationsPermission === 'granted' && ( <> @@ -109,12 +109,12 @@ const PreferencesNotificationsSection = () => { )} - + - {t('Notification_RequireInteraction')} - + {t('Notification_RequireInteraction')} + { /> )} /> - + - {t('Only_works_with_chrome_version_greater_50')} + {t('Only_works_with_chrome_version_greater_50')} - {t('Notification_Desktop_Default_For')} - + {t('Notification_Desktop_Default_For')} + { )} /> - + - {t('Email_Notification_Mode')} - + {t('Email_Notification_Mode')} + { /> )} /> - - + + {canChangeEmailNotification && t('You_need_to_verifiy_your_email_address_to_get_notications')} {!canChangeEmailNotification && t('Email_Notifications_Change_Disabled')} - + {showNewLoginEmailPreference && ( - {t('Receive_Login_Detection_Emails')} - + {t('Receive_Login_Detection_Emails')} + { /> )} /> - + - {t('Receive_Login_Detection_Emails_Description')} + {t('Receive_Login_Detection_Emails_Description')} )} {showCalendarPreference && ( - {t('Notify_Calendar_Events')} - + {t('Notify_Calendar_Events')} + { )} /> - + )} {showMobileRinging && ( - {t('VideoConf_Mobile_Ringing')} - + {t('VideoConf_Mobile_Ringing')} + { )} /> - + )} diff --git a/apps/meteor/client/views/account/preferences/PreferencesSoundSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesSoundSection.tsx index 7b5f051fc017..223d67fd7f95 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesSoundSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesSoundSection.tsx @@ -1,5 +1,5 @@ import type { SelectOption } from '@rocket.chat/fuselage'; -import { Accordion, Field, Select, FieldGroup, ToggleSwitch, Tooltip, Box } from '@rocket.chat/fuselage'; +import { Accordion, Field, FieldLabel, FieldRow, Select, FieldGroup, ToggleSwitch, Tooltip, Box } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useTranslation, useCustomSound } from '@rocket.chat/ui-contexts'; import type { ChangeEvent } from 'react'; @@ -23,8 +23,8 @@ const PreferencesSoundSection = () => { - {t('New_Room_Notification')} - + {t('New_Room_Notification')} + { /> )} /> - + - {t('New_Message_Notification')} - + {t('New_Message_Notification')} + { /> )} /> - + - {t('Mute_Focused_Conversations')} - + {t('Mute_Focused_Conversations')} + { )} /> - + - {t('Notifications_Sound_Volume')} - + {t('Notifications_Sound_Volume')} + { {notificationsSoundVolume} - + diff --git a/apps/meteor/client/views/account/preferences/PreferencesUserPresenceSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesUserPresenceSection.tsx index 3a075c50f7d7..89575e36aa6b 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesUserPresenceSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesUserPresenceSection.tsx @@ -1,4 +1,4 @@ -import { Accordion, Field, NumberInput, FieldGroup, ToggleSwitch, Box } from '@rocket.chat/fuselage'; +import { Accordion, Field, FieldLabel, FieldRow, NumberInput, FieldGroup, ToggleSwitch, Box } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; @@ -16,8 +16,8 @@ const PreferencesUserPresenceSection = () => { - {t('Enable_Auto_Away')} - + {t('Enable_Auto_Away')} + { )} /> - + - {t('Idle_Time_Limit')} - + {t('Idle_Time_Limit')} + - + diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index 1a326db4544d..65b3a0967d49 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -1,5 +1,17 @@ import type { IUser } from '@rocket.chat/core-typings'; -import { Field, FieldGroup, TextInput, TextAreaInput, Box, Icon, Button } from '@rocket.chat/fuselage'; +import { + Field, + FieldGroup, + FieldLabel, + FieldRow, + FieldError, + FieldHint, + TextInput, + TextAreaInput, + Box, + Icon, + Button, +} from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { CustomFieldsForm } from '@rocket.chat/ui-client'; import { @@ -54,6 +66,7 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle const { email, avatar, username } = watch(); const previousEmail = user ? getUserEmailAddress(user) : ''; + const previousUsername = user?.username || ''; const isUserVerified = user?.emails?.[0]?.verified ?? false; const mutateConfirmationEmail = useMutation({ @@ -75,6 +88,10 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle return; } + if (username === previousUsername) { + return; + } + if (!namesRegex.test(username)) { return t('error-invalid-username'); } @@ -139,10 +156,10 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle - + {t('Name')} - - + + ): ReactEle /> )} /> - + {errors.name && ( - + {errors.name.message} - + )} - {!allowRealNameChange && {t('RealName_Change_Disabled')}} + {!allowRealNameChange && {t('RealName_Change_Disabled')}} - + {t('Username')} - - + + ): ReactEle /> )} /> - + {errors?.username && ( - + {errors.username.message} - + )} - {!canChangeUsername && {t('Username_Change_Disabled')}} + {!canChangeUsername && {t('Username_Change_Disabled')}} - {t('StatusMessage')} - + {t('StatusMessage')} + ): ReactEle /> )} /> - + {errors?.statusText && ( - + {errors?.statusText.message} - + )} - {!allowUserStatusMessageChange && {t('StatusMessage_Change_Disabled')}} + {!allowUserStatusMessageChange && {t('StatusMessage_Change_Disabled')}} - {t('Nickname')} - + {t('Nickname')} + ): ReactEle } /> )} /> - + - {t('Bio')} - + {t('Bio')} + ): ReactEle /> )} /> - + {errors?.bio && ( - + {errors.bio.message} - + )} - + {t('Email')} - - + + ): ReactEle {t('Resend_verification_email')} )} - + {errors.email && ( - + {errors?.email?.message} - + )} - {!allowEmailChange && {t('Email_Change_Disabled')}} + {!allowEmailChange && {t('Email_Change_Disabled')}} {customFieldsMetadata && } diff --git a/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx b/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx index 4d73f04c2973..286bd564dd18 100644 --- a/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx +++ b/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx @@ -1,4 +1,4 @@ -import { Box, PasswordInput, TextInput, FieldGroup, Field } from '@rocket.chat/fuselage'; +import { Box, PasswordInput, TextInput, FieldGroup, Field, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { FC } from 'react'; import React, { useState, useCallback } from 'react'; @@ -50,11 +50,11 @@ const ActionConfirmModal: FC = ({ isPassword, onConfirm {isPassword ? t('Enter_your_password_to_delete_your_account') : t('Enter_your_username_to_delete_your_account')} - + {isPassword && } {!isPassword && } - - {inputError} + + {inputError} diff --git a/apps/meteor/client/views/account/security/EndToEnd.tsx b/apps/meteor/client/views/account/security/EndToEnd.tsx index 78410e519e2c..72213f3202ba 100644 --- a/apps/meteor/client/views/account/security/EndToEnd.tsx +++ b/apps/meteor/client/views/account/security/EndToEnd.tsx @@ -1,4 +1,4 @@ -import { Box, Margins, PasswordInput, Field, FieldGroup, Button } from '@rocket.chat/fuselage'; +import { Box, Margins, PasswordInput, Field, FieldGroup, FieldLabel, FieldRow, FieldError, FieldHint, Button } from '@rocket.chat/fuselage'; import { useToastMessageDispatch, useMethod, useTranslation, useLogout } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; import React, { useCallback, useEffect } from 'react'; @@ -71,20 +71,20 @@ const EndToEnd = (props: ComponentProps): ReactElement => { - {t('New_encryption_password')} - + {t('New_encryption_password')} + - - {!keysExist && {t('EncryptionKey_Change_Disabled')}} + + {!keysExist && {t('EncryptionKey_Change_Disabled')}} {hasTypedPassword && ( - {t('Confirm_new_encryption_password')} + {t('Confirm_new_encryption_password')} ): ReactElement => { placeholder={t('Confirm_New_Password_Placeholder')} aria-labelledby='Confirm_new_encryption_password' /> - {errors.passwordConfirm && {errors.passwordConfirm.message}} + {errors.passwordConfirm && {errors.passwordConfirm.message}} )} diff --git a/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx b/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx index 4d38d2e68fc6..97d2a8163cab 100644 --- a/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx +++ b/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx @@ -1,4 +1,4 @@ -import { Box, TextInput, Button, Field, FieldGroup, Margins, CheckBox } from '@rocket.chat/fuselage'; +import { Box, TextInput, Button, Field, FieldGroup, FieldLabel, FieldRow, Margins, CheckBox } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useSetModal, useToastMessageDispatch, useUserId, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -56,18 +56,18 @@ const AddToken = ({ reload, ...props }: { reload: () => void }): ReactElement => return ( - + - - + + - {t('Ignore_Two_Factor_Authentication')} - + {t('Ignore_Two_Factor_Authentication')} + ); diff --git a/apps/meteor/client/views/admin/AdministrationRouter.tsx b/apps/meteor/client/views/admin/AdministrationRouter.tsx index 9a86f6ea61f7..93ea650d0d33 100644 --- a/apps/meteor/client/views/admin/AdministrationRouter.tsx +++ b/apps/meteor/client/views/admin/AdministrationRouter.tsx @@ -49,7 +49,7 @@ const AdministrationRouter = ({ children }: AdministrationRouterProps): ReactEle return; } - const defaultRoutePath = getAdminSidebarItems().find(firstSidebarPage)?.href ?? '/admin/workspace'; + const defaultRoutePath = getAdminSidebarItems().find(firstSidebarPage)?.href ?? '/admin/info'; if (isGoRocketChatLink(defaultRoutePath)) { window.open(defaultRoutePath, '_blank'); diff --git a/apps/meteor/client/views/admin/cloud/hooks/useFeatureBullets.tsx b/apps/meteor/client/views/admin/cloud/hooks/useFeatureBullets.tsx index 926f30b00bb9..ae2d6697ea2c 100644 --- a/apps/meteor/client/views/admin/cloud/hooks/useFeatureBullets.tsx +++ b/apps/meteor/client/views/admin/cloud/hooks/useFeatureBullets.tsx @@ -1,5 +1,5 @@ -import { useTranslation } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; type featureBulletsType = { key: number; @@ -9,7 +9,7 @@ type featureBulletsType = { }; const useFeatureBullets = () => { - const t = useTranslation(); + const { t } = useTranslation(); const featureBullets: featureBulletsType[] = useMemo( () => [ @@ -31,12 +31,6 @@ const useFeatureBullets = () => { description: t('RegisterWorkspace_Features_Omnichannel_Description'), disconnect: t('RegisterWorkspace_Features_Omnichannel_Disconnect'), }, - { - key: 4, - title: t('RegisterWorkspace_Features_ThirdPartyLogin_Title'), - description: t('RegisterWorkspace_Features_ThirdPartyLogin_Description'), - disconnect: t('RegisterWorkspace_Features_ThirdPartyLogin_Disconnect'), - }, ], [t], ); diff --git a/apps/meteor/client/views/admin/cloud/modals/RegisterWorkspaceSetupModal/RegisterWorkspaceSetupStepOneModal.tsx b/apps/meteor/client/views/admin/cloud/modals/RegisterWorkspaceSetupModal/RegisterWorkspaceSetupStepOneModal.tsx index 09548b3349a9..41e8f300d898 100644 --- a/apps/meteor/client/views/admin/cloud/modals/RegisterWorkspaceSetupModal/RegisterWorkspaceSetupStepOneModal.tsx +++ b/apps/meteor/client/views/admin/cloud/modals/RegisterWorkspaceSetupModal/RegisterWorkspaceSetupStepOneModal.tsx @@ -1,4 +1,4 @@ -import { Modal, Box, Field, TextInput, CheckBox, ButtonGroup, Button } from '@rocket.chat/fuselage'; +import { Modal, Box, Field, FieldLabel, FieldRow, TextInput, CheckBox, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { ExternalLink } from '@rocket.chat/ui-client'; import { useEndpoint, useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; @@ -66,14 +66,14 @@ const RegisterWorkspaceSetupStepOneModal = ({ {t('RegisterWorkspace_Setup_Subtitle')} - {t('RegisterWorkspace_Setup_Label')} - + {t('RegisterWorkspace_Setup_Label')} + { setEmail((e.target as HTMLInputElement).value); }} /> - + diff --git a/apps/meteor/client/views/admin/cloud/modals/RegisterWorkspaceSetupModal/RegisterWorkspaceSetupStepTwoModal.tsx b/apps/meteor/client/views/admin/cloud/modals/RegisterWorkspaceSetupModal/RegisterWorkspaceSetupStepTwoModal.tsx index fa1640bc8dfb..734f07442f11 100644 --- a/apps/meteor/client/views/admin/cloud/modals/RegisterWorkspaceSetupModal/RegisterWorkspaceSetupStepTwoModal.tsx +++ b/apps/meteor/client/views/admin/cloud/modals/RegisterWorkspaceSetupModal/RegisterWorkspaceSetupStepTwoModal.tsx @@ -1,4 +1,4 @@ -import { Modal, Box, Field, TextInput } from '@rocket.chat/fuselage'; +import { Modal, Box, Field, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage'; import { useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback, useEffect } from 'react'; import { Trans } from 'react-i18next'; @@ -81,10 +81,10 @@ const RegisterWorkspaceSetupStepTwoModal = ({ email, step, setStep, onClose, int {t('RegisterWorkspace_Setup_Email_Verification')} - {t('Security_code')} - + {t('Security_code')} + - + diff --git a/apps/meteor/client/views/admin/cloud/modals/RegisterWorkspaceTokenModal.tsx b/apps/meteor/client/views/admin/cloud/modals/RegisterWorkspaceTokenModal.tsx index 12ba3798c97e..89728457226b 100644 --- a/apps/meteor/client/views/admin/cloud/modals/RegisterWorkspaceTokenModal.tsx +++ b/apps/meteor/client/views/admin/cloud/modals/RegisterWorkspaceTokenModal.tsx @@ -1,4 +1,4 @@ -import { Box, Button, ButtonGroup, Field, Modal, TextInput } from '@rocket.chat/fuselage'; +import { Box, Button, ButtonGroup, Field, FieldLabel, FieldRow, FieldError, Modal, TextInput } from '@rocket.chat/fuselage'; import { useMethod, useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import type { ChangeEvent } from 'react'; import React, { useState } from 'react'; @@ -79,11 +79,11 @@ const RegisterWorkspaceTokenModal = ({ onClose, onStatusChange, ...props }: Regi
{`2. ${t('RegisterWorkspace_Token_Step_Two')}`} - {t('Registration_Token')} - + {t('Registration_Token')} + - - {error && {t('Token_Not_Recognized')}} + + {error && {t('Token_Not_Recognized')}} diff --git a/apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx b/apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx index eaeaccd3e395..8fae8501327a 100644 --- a/apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx +++ b/apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx @@ -1,4 +1,4 @@ -import { Box, Button, ButtonGroup, Margins, TextInput, Field, Icon } from '@rocket.chat/fuselage'; +import { Box, Button, ButtonGroup, Margins, TextInput, Field, FieldLabel, FieldRow, FieldError, Icon } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent } from 'react'; import React, { useCallback, useState } from 'react'; @@ -78,28 +78,28 @@ const AddCustomEmoji = ({ close, onChange, ...props }: AddCustomEmojiProps): Rea <> - {t('Name')} - + {t('Name')} + - - {errors.name && {t('error-the-field-is-required', { field: t('Name') })}} + + {errors.name && {t('error-the-field-is-required', { field: t('Name') })}} - {t('Aliases')} - + {t('Aliases')} + - - {errors.aliases && {t('Custom_Emoji_Error_Same_Name_And_Alias')}} + + {errors.aliases && {t('Custom_Emoji_Error_Same_Name_And_Alias')}} - + {t('Custom_Emoji')} {/* FIXME: replace to IconButton */} - - {errors.emoji && {t('error-the-field-is-required', { field: t('Custom_Emoji') })}} + + {errors.emoji && {t('error-the-field-is-required', { field: t('Custom_Emoji') })}} {newEmojiPreview && ( diff --git a/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx b/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx index e11b5722fe0a..f561001a20be 100644 --- a/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx +++ b/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx @@ -1,4 +1,16 @@ -import { Box, Button, ButtonGroup, Margins, TextInput, Field, FieldGroup, IconButton } from '@rocket.chat/fuselage'; +import { + Box, + Button, + ButtonGroup, + Margins, + TextInput, + Field, + FieldGroup, + FieldLabel, + FieldRow, + FieldError, + IconButton, +} from '@rocket.chat/fuselage'; import { useSetModal, useToastMessageDispatch, useAbsoluteUrl, useTranslation } from '@rocket.chat/ui-contexts'; import type { FC, ChangeEvent } from 'react'; import React, { useCallback, useState, useMemo, useEffect } from 'react'; @@ -134,24 +146,24 @@ const EditCustomEmoji: FC = ({ close, onChange, data, ...p - {t('Name')} - + {t('Name')} + - - {errors.name && {t('error-the-field-is-required', { field: t('Name') })}} + + {errors.name && {t('error-the-field-is-required', { field: t('Name') })}} - {t('Aliases')} - + {t('Aliases')} + - - {errors.aliases && {t('Custom_Emoji_Error_Same_Name_And_Alias')}} + + {errors.aliases && {t('Custom_Emoji_Error_Same_Name_And_Alias')}} - + {t('Custom_Emoji')} - + {newEmojiPreview && ( diff --git a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx index bf391e8feae1..434cb1769db6 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -1,4 +1,4 @@ -import { Field, TextInput, Box, Icon, Margins, Button, ButtonGroup } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, TextInput, Box, Icon, Margins, Button, ButtonGroup } from '@rocket.chat/fuselage'; import { useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, FormEvent } from 'react'; import React, { useState, useCallback } from 'react'; @@ -86,17 +86,17 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr <> - {t('Name')} - + {t('Name')} + ): void => setName(e.currentTarget.value)} placeholder={t('Name')} /> - + - {t('Sound_File_mp3')} + {t('Sound_File_mp3')} {/* FIXME: replace to IconButton */} diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index d1d214d15e20..44b728821c4c 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -1,4 +1,4 @@ -import { Box, Button, ButtonGroup, Margins, TextInput, Field, IconButton } from '@rocket.chat/fuselage'; +import { Box, Button, ButtonGroup, Margins, TextInput, Field, FieldLabel, FieldRow, IconButton } from '@rocket.chat/fuselage'; import { useSetModal, useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, SyntheticEvent } from 'react'; import React, { useCallback, useState, useMemo, useEffect } from 'react'; @@ -120,17 +120,17 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl <> - {t('Name')} - + {t('Name')} + ): void => setName(e.currentTarget.value)} placeholder={t('Name')} /> - + - {t('Sound_File_mp3')} + {t('Sound_File_mp3')} diff --git a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusForm.tsx b/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusForm.tsx index 2c832e8299ec..9796438fde7e 100644 --- a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusForm.tsx +++ b/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusForm.tsx @@ -1,6 +1,6 @@ import type { IUserStatus } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; -import { FieldGroup, Button, ButtonGroup, TextInput, Field, Select } from '@rocket.chat/fuselage'; +import { FieldGroup, Button, ButtonGroup, TextInput, Field, FieldLabel, FieldRow, FieldError, Select } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useSetModal, useRoute, useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -92,23 +92,23 @@ const CustomUserStatusForm = ({ onClose, onReload, status }: CustomUserStatusFor - {t('Name')} - + {t('Name')} + - - {errors?.name && {t('error-the-field-is-required', { field: t('Name') })}} + + {errors?.name && {t('error-the-field-is-required', { field: t('Name') })}} - {t('Presence')} - + {t('Presence')} + [extension, extension]) || []} @@ -61,7 +61,7 @@ const AssignAgentModal: FC = ({ existingExtension, close placeholder={t('Select_an_option')} onChange={(value) => setExtension(String(value))} /> - + diff --git a/apps/meteor/client/views/admin/settings/inputs/ActionSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/ActionSettingInput.tsx index bd9de953eb30..b674e589d446 100644 --- a/apps/meteor/client/views/admin/settings/inputs/ActionSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/ActionSettingInput.tsx @@ -1,4 +1,4 @@ -import { Button, Field } from '@rocket.chat/fuselage'; +import { Button, FieldRow, FieldHint } from '@rocket.chat/fuselage'; import type { ServerMethods, TranslationKey } from '@rocket.chat/ui-contexts'; import { useMethod, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -29,12 +29,12 @@ function ActionSettingInput({ _id, actionText, value, disabled, sectionChanged } return ( <> - + - - {sectionChanged && {t('Save_to_enable_this_action')}} + + {sectionChanged && {t('Save_to_enable_this_action')}} ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/AssetSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/AssetSettingInput.tsx index 5871296cee6a..f3122a23295b 100644 --- a/apps/meteor/client/views/admin/settings/inputs/AssetSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/AssetSettingInput.tsx @@ -1,4 +1,4 @@ -import { Button, Field, Icon } from '@rocket.chat/fuselage'; +import { Button, FieldLabel, FieldRow, Icon } from '@rocket.chat/fuselage'; import { Random } from '@rocket.chat/random'; import { useToastMessageDispatch, useEndpoint, useTranslation, useUpload } from '@rocket.chat/ui-contexts'; import type { ChangeEventHandler, DragEvent, ReactElement, SyntheticEvent } from 'react'; @@ -58,10 +58,10 @@ function AssetSettingInput({ _id, label, value, asset, fileConstraints }: AssetS return ( <> - + {label} - - + +
{value?.url ? (
- + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/BooleanSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/BooleanSettingInput.tsx index acac16b5b7e8..308e781e8e46 100644 --- a/apps/meteor/client/views/admin/settings/inputs/BooleanSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/BooleanSettingInput.tsx @@ -1,4 +1,4 @@ -import { Field, ToggleSwitch } from '@rocket.chat/fuselage'; +import { FieldLabel, FieldRow, ToggleSwitch } from '@rocket.chat/fuselage'; import type { ReactElement, SyntheticEvent } from 'react'; import React from 'react'; @@ -30,7 +30,7 @@ function BooleanSettingInput({ }; return ( - + - + {label} - + {hasResetButton && } - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/CodeSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/CodeSettingInput.tsx index 2b615a63b8d5..85698b66e2b7 100644 --- a/apps/meteor/client/views/admin/settings/inputs/CodeSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/CodeSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, Field, Flex } from '@rocket.chat/fuselage'; +import { Box, FieldLabel, FieldHint, Flex } from '@rocket.chat/fuselage'; import type { ReactElement } from 'react'; import React from 'react'; @@ -43,12 +43,12 @@ function CodeSettingInput({ <> - + {label} - + {hasResetButton && } - {hint && {hint}} + {hint && {hint}} - + {label} - + {hasResetButton && } - + {editor === 'color' && ( @@ -104,9 +104,9 @@ function ColorSettingInput({ options={allowedTypes.map((type) => [type, t(type)])} /> - + - Variable name: {_id.replace(/theme-color-/, '@')} + Variable name: {_id.replace(/theme-color-/, '@')} ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/FontSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/FontSettingInput.tsx index 2d7a93b9c96f..35b255b4e756 100644 --- a/apps/meteor/client/views/admin/settings/inputs/FontSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/FontSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, Field, Flex, TextInput } from '@rocket.chat/fuselage'; +import { Box, FieldLabel, FieldRow, Flex, TextInput } from '@rocket.chat/fuselage'; import type { FormEventHandler, ReactElement } from 'react'; import React from 'react'; @@ -36,13 +36,13 @@ function FontSettingInput({ <> - + {label} - + {hasResetButton && } - + - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/GenericSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/GenericSettingInput.tsx index 46ffc61a8f3b..32425a57c698 100644 --- a/apps/meteor/client/views/admin/settings/inputs/GenericSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/GenericSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, Field, Flex, TextInput } from '@rocket.chat/fuselage'; +import { Box, FieldLabel, FieldRow, Flex, TextInput } from '@rocket.chat/fuselage'; import type { FormEventHandler, ReactElement } from 'react'; import React from 'react'; @@ -36,13 +36,13 @@ function GenericSettingInput({ <> - + {label} - + {hasResetButton && } - + - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/IntSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/IntSettingInput.tsx index 03f837117223..cd5abd54f481 100644 --- a/apps/meteor/client/views/admin/settings/inputs/IntSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/IntSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, Field, Flex, InputBox } from '@rocket.chat/fuselage'; +import { Box, FieldLabel, FieldRow, Flex, InputBox } from '@rocket.chat/fuselage'; import type { FormEventHandler, ReactElement } from 'react'; import React from 'react'; @@ -37,13 +37,13 @@ function IntSettingInput({ <> - + {label} - + {hasResetButton && } - + - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/LanguageSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/LanguageSettingInput.tsx index 770edd5c12a6..8bfe977aaf39 100644 --- a/apps/meteor/client/views/admin/settings/inputs/LanguageSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/LanguageSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, Field, Flex, Select } from '@rocket.chat/fuselage'; +import { Box, FieldLabel, FieldRow, Flex, Select } from '@rocket.chat/fuselage'; import { useLanguages } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; @@ -40,13 +40,13 @@ function LanguageSettingInput({ <> - + {label} - + {hasResetButton && } - + handleChange(String(value))} options={values.map(({ key, label }) => [key, label])} /> - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/MultiSelectSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/MultiSelectSettingInput.tsx index 114503c959ed..bc8d8062d8a2 100644 --- a/apps/meteor/client/views/admin/settings/inputs/MultiSelectSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/MultiSelectSettingInput.tsx @@ -1,4 +1,4 @@ -import { Field, Flex, Box, MultiSelectFiltered, MultiSelect } from '@rocket.chat/fuselage'; +import { FieldLabel, Flex, Box, MultiSelectFiltered, MultiSelect } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -45,9 +45,9 @@ function MultiSelectSettingInput({ <> - + {label} - + {hasResetButton && } diff --git a/apps/meteor/client/views/admin/settings/inputs/PasswordSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/PasswordSettingInput.tsx index 087d83b98a0d..b7d2c1d48d47 100644 --- a/apps/meteor/client/views/admin/settings/inputs/PasswordSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/PasswordSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, Field, Flex, PasswordInput } from '@rocket.chat/fuselage'; +import { Box, FieldLabel, FieldRow, Flex, PasswordInput } from '@rocket.chat/fuselage'; import type { EventHandler, ReactElement, SyntheticEvent } from 'react'; import React from 'react'; @@ -37,13 +37,13 @@ function PasswordSettingInput({ <> - + {label} - + {hasResetButton && } - + - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/RelativeUrlSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/RelativeUrlSettingInput.tsx index 0541ea81eb36..b94581706757 100644 --- a/apps/meteor/client/views/admin/settings/inputs/RelativeUrlSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/RelativeUrlSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, Field, Flex, UrlInput } from '@rocket.chat/fuselage'; +import { Box, FieldLabel, Flex, UrlInput } from '@rocket.chat/fuselage'; import { useAbsoluteUrl } from '@rocket.chat/ui-contexts'; import type { EventHandler, ReactElement, SyntheticEvent } from 'react'; import React from 'react'; @@ -40,9 +40,9 @@ function RelativeUrlSettingInput({ <> - + {label} - + {hasResetButton && } diff --git a/apps/meteor/client/views/admin/settings/inputs/RoomPickSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/RoomPickSettingInput.tsx index d44235afbde9..15423742ff91 100644 --- a/apps/meteor/client/views/admin/settings/inputs/RoomPickSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/RoomPickSettingInput.tsx @@ -1,5 +1,5 @@ import type { SettingValueRoomPick } from '@rocket.chat/core-typings'; -import { Box, Field, Flex } from '@rocket.chat/fuselage'; +import { Box, FieldLabel, FieldRow, Flex } from '@rocket.chat/fuselage'; import type { ReactElement } from 'react'; import React from 'react'; @@ -42,13 +42,13 @@ function RoomPickSettingInput({ <> - + {label} - + {hasResetButton && } - + - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/SelectSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/SelectSettingInput.tsx index 28014d65d375..a6fa88f7ffe7 100644 --- a/apps/meteor/client/views/admin/settings/inputs/SelectSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/SelectSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, Field, Flex, Select } from '@rocket.chat/fuselage'; +import { Box, FieldLabel, FieldRow, Flex, Select } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -43,13 +43,13 @@ function SelectSettingInput({ <> - + {label} - + {hasResetButton && } - + handleChange(String(value))} options={moment.tz.names().map((key) => [key, key])} /> - + ); } diff --git a/apps/meteor/client/views/admin/settings/inputs/StringSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/StringSettingInput.tsx index 30b79e01683f..3d0ba78a127a 100644 --- a/apps/meteor/client/views/admin/settings/inputs/StringSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/inputs/StringSettingInput.tsx @@ -1,4 +1,4 @@ -import { Box, Field, Flex, TextAreaInput, TextInput } from '@rocket.chat/fuselage'; +import { Box, FieldLabel, FieldRow, Flex, TextAreaInput, TextInput } from '@rocket.chat/fuselage'; import type { EventHandler, ReactElement, SyntheticEvent } from 'react'; import React from 'react'; @@ -43,13 +43,13 @@ function StringSettingInput({ <> - + {label} - + {hasResetButton && } - + {multiline ? ( )} - + ); } diff --git a/apps/meteor/client/views/admin/sidebarItems.ts b/apps/meteor/client/views/admin/sidebarItems.ts index 50a3284b5ed1..b9650e3c93d0 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -8,7 +8,7 @@ export const { subscribeToSidebarItems: subscribeToAdminSidebarItems, } = createSidebarItems([ { - href: '/admin/workspace', + href: '/admin/info', i18nLabel: 'Workspace', icon: 'info-circled', permissionGranted: (): boolean => hasPermission('view-statistics'), @@ -112,8 +112,8 @@ export const { permissionGranted: (): boolean => hasPermission('run-import'), }, { - href: '/admin/records', - i18nLabel: 'Records', + href: '/admin/reports', + i18nLabel: 'Reports', icon: 'post', permissionGranted: (): boolean => hasPermission('view-logs'), }, diff --git a/apps/meteor/client/views/admin/users/AdminUserForm.tsx b/apps/meteor/client/views/admin/users/AdminUserForm.tsx index 334aca68b8f8..1912150b4a48 100644 --- a/apps/meteor/client/views/admin/users/AdminUserForm.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserForm.tsx @@ -1,6 +1,10 @@ import type { AvatarObject, IUser, Serialized } from '@rocket.chat/core-typings'; import { Field, + FieldLabel, + FieldRow, + FieldError, + FieldHint, TextInput, TextAreaInput, PasswordInput, @@ -179,8 +183,8 @@ const UserForm = ({ userData, onReload, ...props }: AdminUserFormProps) => { )} - {t('Name')} - + {t('Name')} + { /> )} /> - + {errors?.name && ( - + {errors.name.message} - + )} - {t('Username')} - + {t('Username')} + { /> )} /> - + {errors?.username && ( - + {errors.username.message} - + )} - {t('Email')} - + {t('Email')} + { /> )} /> - + {errors?.email && ( - + {errors.email.message} - + )} - {t('Verified')} - + {t('Verified')} + } /> - + - {t('StatusMessage')} - + {t('StatusMessage')} + { /> )} /> - + {errors?.statusText && ( - + {errors.statusText.message} - + )} - {t('Bio')} - + {t('Bio')} + { /> )} /> - + {errors?.bio && ( - + {errors.bio.message} - + )} - {t('Nickname')} - + {t('Nickname')} + { } /> )} /> - + {!setRandomPassword && ( - {t('Password')} - + {t('Password')} + { /> )} /> - + {errors?.password && ( - + {errors.password.message} - + )} )} - {t('Require_password_change')} - + {t('Require_password_change')} + { /> )} /> - + - {t('Set_random_password_and_send_by_email')} - + {t('Set_random_password_and_send_by_email')} + { /> )} /> - + {!isSmtpEnabled && ( - )} - {t('Roles')} - + {t('Roles')} + {roleError && {roleError}} {!roleError && ( { )} /> )} - - {errors?.roles && {errors.roles.message}} + + {errors?.roles && {errors.roles.message}} - {t('Join_default_channels')} - + {t('Join_default_channels')} + { )} /> - + - {t('Send_welcome_email')} - + {t('Send_welcome_email')} + { /> )} /> - + {!isSmtpEnabled && ( - diff --git a/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx b/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx index 7771298ceb73..9307a2596a71 100644 --- a/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx +++ b/apps/meteor/client/views/admin/viewLogs/AnalyticsReports.tsx @@ -1,4 +1,4 @@ -import { Box, Icon, Skeleton } from '@rocket.chat/fuselage'; +import { Box, Icon, Skeleton, Scrollable } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; @@ -10,28 +10,28 @@ const AnalyticsReports = () => { const { data, isLoading, isSuccess, isError } = useAnalyticsObject(); return ( - <> - + + {t('How_and_why_we_collect_usage_data')} - {t('Analytics_page_briefing')} - - - {isSuccess &&
{JSON.stringify(data, null, '\t')}
} - {isError && t('Something_went_wrong_try_again_later')} - {isLoading && ( - <> - - - - - )} + + {t('Analytics_page_briefing_first_paragraph')} + + {t('Analytics_page_briefing_second_paragraph')}
- + + + {isSuccess &&
{JSON.stringify(data, null, '\t')}
} + {isError && t('Something_went_wrong_try_again_later')} + {isLoading && Array.from({ length: 10 }).map((_, index) => )} + <> +
+
+
); }; diff --git a/apps/meteor/client/views/admin/viewLogs/ServerLogs.tsx b/apps/meteor/client/views/admin/viewLogs/ServerLogs.tsx index 4787dfb12a6d..aa496de024fa 100644 --- a/apps/meteor/client/views/admin/viewLogs/ServerLogs.tsx +++ b/apps/meteor/client/views/admin/viewLogs/ServerLogs.tsx @@ -166,7 +166,7 @@ const ServerLogs = (): ReactElement => { }, [sendToBottomIfNecessary]); return ( - + { const t = useTranslation(); - const [tab, setTab] = useState('Logs'); return ( - - - - + + + + setTab('Logs')} selected={tab === 'Logs'}> {t('Logs')} @@ -24,8 +23,8 @@ const ViewLogsPage = (): ReactElement => { {t('Analytic_reports')} - {tab === 'Logs' ? : } - + + {tab === 'Logs' ? : } ); }; diff --git a/apps/meteor/client/views/banners/BannerRegion.tsx b/apps/meteor/client/views/banners/BannerRegion.tsx index c5394f787229..b79c156db842 100644 --- a/apps/meteor/client/views/banners/BannerRegion.tsx +++ b/apps/meteor/client/views/banners/BannerRegion.tsx @@ -22,7 +22,7 @@ const BannerRegion = (): ReactElement | null => { return ; } - return ; + return ; }; export default BannerRegion; diff --git a/apps/meteor/client/views/banners/UiKitBanner.tsx b/apps/meteor/client/views/banners/UiKitBanner.tsx index 7cb52dd8d3c9..64a602d548dc 100644 --- a/apps/meteor/client/views/banners/UiKitBanner.tsx +++ b/apps/meteor/client/views/banners/UiKitBanner.tsx @@ -1,55 +1,93 @@ -import type { UIKitActionEvent, UiKitBannerProps } from '@rocket.chat/core-typings'; +import type { UiKit } from '@rocket.chat/core-typings'; import { Banner, Icon } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { UiKitContext, bannerParser, UiKitBanner as UiKitBannerSurfaceRender, UiKitComponent } from '@rocket.chat/fuselage-ui-kit'; -import type { Keys as IconName } from '@rocket.chat/icons'; -import type { LayoutBlock } from '@rocket.chat/ui-kit'; -import type { FC, ReactElement, ContextType } from 'react'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import type { ReactElement, ContextType } from 'react'; import React, { useMemo } from 'react'; -import { useUIKitHandleAction } from '../../UIKit/hooks/useUIKitHandleAction'; -import { useUIKitHandleClose } from '../../UIKit/hooks/useUIKitHandleClose'; -import { useUIKitStateManager } from '../../UIKit/hooks/useUIKitStateManager'; +import { useUiKitActionManager } from '../../UIKit/hooks/useUiKitActionManager'; +import { useUiKitView } from '../../UIKit/hooks/useUiKitView'; import MarkdownText from '../../components/MarkdownText'; -import * as banners from '../../lib/banners'; // TODO: move this to fuselage-ui-kit itself bannerParser.mrkdwn = ({ text }): ReactElement => ; -const UiKitBanner: FC = ({ payload }) => { - const state = useUIKitStateManager(payload); +type UiKitBannerProps = { + key: UiKit.BannerView['viewId']; // force re-mount when viewId changes + initialView: UiKit.BannerView; +}; + +const UiKitBanner = ({ initialView }: UiKitBannerProps) => { + const { view, values, state } = useUiKitView(initialView); const icon = useMemo(() => { - if (state.icon) { - return ; + if (view.icon) { + return ; } return null; - }, [state.icon]); + }, [view.icon]); - const handleClose = useUIKitHandleClose(state, () => banners.close()); + const dispatchToastMessage = useToastMessageDispatch(); + const handleClose = useMutableCallback(() => { + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.viewId, + view: { + ...view, + id: view.viewId, + state, + }, + isCleared: true, + }, + }) + .catch((error) => { + dispatchToastMessage({ type: 'error', message: error }); + return Promise.reject(error); + }) + .finally(() => { + actionManager.disposeView(view.viewId); + }); + }); - const action = useUIKitHandleAction(state); + const actionManager = useUiKitActionManager(); - const contextValue = useMemo>( - () => ({ - action: async (event): Promise => { - if (!event.viewId) { + const contextValue = useMemo( + (): ContextType => ({ + action: async ({ appId, viewId, actionId, blockId, value }) => { + if (!appId || !viewId) { return; } - await action(event as UIKitActionEvent); - banners.closeById(state.viewId); + + await actionManager.emitInteraction(appId, { + type: 'blockAction', + actionId, + container: { + type: 'view', + id: viewId, + }, + payload: { + blockId, + value, + }, + }); + + actionManager.disposeView(view.viewId); }, state: (): void => undefined, - appId: state.appId, - values: {}, + appId: view.appId, + values: values as any, }), - [action, state.appId, state.viewId], + [view, values, actionManager], ); return ( - + - + ); diff --git a/apps/meteor/client/views/banners/hooks/useRemoteBanners.ts b/apps/meteor/client/views/banners/hooks/useRemoteBanners.ts index ff42d4ae9ace..ebed89e06037 100644 --- a/apps/meteor/client/views/banners/hooks/useRemoteBanners.ts +++ b/apps/meteor/client/views/banners/hooks/useRemoteBanners.ts @@ -1,5 +1,5 @@ import { BannerPlatform } from '@rocket.chat/core-typings'; -import type { IBanner, Serialized, UiKitBannerPayload } from '@rocket.chat/core-typings'; +import type { IBanner, Serialized, UiKit } from '@rocket.chat/core-typings'; import { useEndpoint, useStream, useUserId, ServerContext } from '@rocket.chat/ui-contexts'; import { useContext, useEffect } from 'react'; @@ -22,7 +22,7 @@ export const useRemoteBanners = () => { const { signal } = controller; - const mapBanner = (banner: Serialized): UiKitBannerPayload => ({ + const mapBanner = (banner: Serialized): UiKit.BannerView => ({ ...banner.view, viewId: banner.view.viewId || banner._id, }); diff --git a/apps/meteor/client/views/e2e/EnterE2EPasswordModal.tsx b/apps/meteor/client/views/e2e/EnterE2EPasswordModal.tsx index 76d5f3b61e28..249ae6df5efa 100644 --- a/apps/meteor/client/views/e2e/EnterE2EPasswordModal.tsx +++ b/apps/meteor/client/views/e2e/EnterE2EPasswordModal.tsx @@ -1,4 +1,4 @@ -import { Box, PasswordInput, Field, FieldGroup } from '@rocket.chat/fuselage'; +import { Box, PasswordInput, Field, FieldGroup, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -50,7 +50,7 @@ const EnterE2EPasswordModal = ({ - + - - {passwordError} + + {passwordError} diff --git a/apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx b/apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx new file mode 100644 index 000000000000..be4728732284 --- /dev/null +++ b/apps/meteor/client/views/hooks/roomActions/useDeleteRoom.tsx @@ -0,0 +1,89 @@ +import type { IRoom, RoomAdminFieldsType } from '@rocket.chat/core-typings'; +import { isRoomFederated } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useSetModal, useToastMessageDispatch, useRouter, usePermission, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; +import React from 'react'; + +import GenericModal from '../../../components/GenericModal'; +import DeleteTeamModal from '../../teams/contextualBar/info/DeleteTeam'; + +export const useDeleteRoom = (room: IRoom | Pick, { reload }: { reload?: () => void } = {}) => { + const t = useTranslation(); + const router = useRouter(); + const setModal = useSetModal(); + const dispatchToastMessage = useToastMessageDispatch(); + const hasPermissionToDelete = usePermission(`delete-${room.t}`, room._id); + const canDeleteRoom = isRoomFederated(room) ? false : hasPermissionToDelete; + + const isAdminRoute = router.getRouteName() === 'admin-rooms'; + + const deleteRoomEndpoint = useEndpoint('POST', '/v1/rooms.delete'); + const deleteTeamEndpoint = useEndpoint('POST', '/v1/teams.delete'); + + const deleteRoomMutation = useMutation({ + mutationFn: deleteRoomEndpoint, + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Room_has_been_deleted') }); + if (isAdminRoute) { + return router.navigate('/admin/rooms'); + } + + return router.navigate('/home'); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: () => { + setModal(null); + reload?.(); + }, + }); + + const deleteTeamMutation = useMutation({ + mutationFn: deleteTeamEndpoint, + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Team_has_been_deleted') }); + if (isAdminRoute) { + return router.navigate('/admin/rooms'); + } + + return router.navigate('/home'); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: () => { + setModal(null); + reload?.(); + }, + }); + + const isDeleting = deleteTeamMutation.isLoading || deleteRoomMutation.isLoading; + + const handleDelete = useMutableCallback(() => { + const handleDeleteTeam = async (roomsToRemove: IRoom['_id'][]) => { + if (!room.teamId) { + return; + } + + deleteTeamMutation.mutateAsync({ teamId: room.teamId, ...(roomsToRemove.length && { roomsToRemove }) }); + }; + + if (room.teamMain && room.teamId) { + return setModal( setModal(null)} teamId={room.teamId} />); + } + + const handleDeleteRoom = async () => { + deleteRoomMutation.mutateAsync({ roomId: room._id }); + }; + + setModal( + setModal(null)} confirmText={t('Yes_delete_it')}> + {t('Delete_Room_Warning')} + , + ); + }); + + return { handleDelete, canDeleteRoom, isDeleting }; +}; diff --git a/apps/meteor/client/views/hooks/useDepartmentsByUnitsList.ts b/apps/meteor/client/views/hooks/useDepartmentsByUnitsList.ts index 6c402a199ded..e6cffadaaa0b 100644 --- a/apps/meteor/client/views/hooks/useDepartmentsByUnitsList.ts +++ b/apps/meteor/client/views/hooks/useDepartmentsByUnitsList.ts @@ -1,4 +1,4 @@ -import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, IOmnichannelBusinessUnit } from '@rocket.chat/core-typings'; import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import { useCallback, useState } from 'react'; @@ -7,7 +7,7 @@ import { useComponentDidUpdate } from '../../hooks/useComponentDidUpdate'; import { RecordList } from '../../lib/lists/RecordList'; type DepartmentsListOptions = { - unitId: string; + unitId?: IOmnichannelBusinessUnit['_id']; filter: string; }; diff --git a/apps/meteor/client/views/hooks/useUpgradeTabParams.ts b/apps/meteor/client/views/hooks/useUpgradeTabParams.ts index 65dd4cb1e396..abfa9da251dc 100644 --- a/apps/meteor/client/views/hooks/useUpgradeTabParams.ts +++ b/apps/meteor/client/views/hooks/useUpgradeTabParams.ts @@ -1,4 +1,3 @@ -import type { ILicenseV2, ILicenseV3 } from '@rocket.chat/license'; import { useSetting } from '@rocket.chat/ui-contexts'; import { format } from 'date-fns'; @@ -14,14 +13,11 @@ export const useUpgradeTabParams = (): { tabType: UpgradeTabVariant | false; tri const { data: registrationStatusData, isSuccess: isSuccessRegistrationStatus } = useRegistrationStatus(); const registered = registrationStatusData?.registrationStatus?.workspaceRegistered ?? false; - const hasValidLicense = licensesData?.licenses.some((license) => license.modules.length > 0) ?? false; + const hasValidLicense = Boolean(licensesData?.license ?? false); const hadExpiredTrials = cloudWorkspaceHadTrial ?? false; - const licenses = (licensesData?.licenses || []) as (Partial & { modules: string[] })[]; - - const trialLicense = licenses.find(({ meta, information }) => information?.trial ?? meta?.trial); - const isTrial = Boolean(trialLicense); - const trialEndDateStr = trialLicense?.information?.visualExpiration || trialLicense?.meta?.trialEnd || trialLicense?.cloudMeta?.trialEnd; + const isTrial = Boolean(licensesData?.trial); + const trialEndDateStr = licensesData?.license?.information?.visualExpiration; const trialEndDate = trialEndDateStr ? format(new Date(trialEndDateStr), 'yyyy-MM-dd') : undefined; const upgradeTabType = getUpgradeTabType({ diff --git a/apps/meteor/client/views/marketplace/AppInstallPage.js b/apps/meteor/client/views/marketplace/AppInstallPage.js index 77afd5361c88..f3b4f02b0c16 100644 --- a/apps/meteor/client/views/marketplace/AppInstallPage.js +++ b/apps/meteor/client/views/marketplace/AppInstallPage.js @@ -1,4 +1,5 @@ -import { Button, ButtonGroup, Icon, Field, FieldGroup, TextInput, Throbber } from '@rocket.chat/fuselage'; +import { Button, ButtonGroup, Icon, Field, FieldGroup, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useSetModal, useEndpoint, @@ -8,13 +9,13 @@ import { useRouter, useSearchParameter, } from '@rocket.chat/ui-contexts'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; +import { useForm, Controller } from 'react-hook-form'; import { AppClientOrchestratorInstance } from '../../../ee/client/apps/orchestrator'; import Page from '../../components/Page'; import { useAppsReload } from '../../contexts/hooks/useAppsReload'; import { useFileInput } from '../../hooks/useFileInput'; -import { useForm } from '../../hooks/useForm'; import AppPermissionsReviewModal from './AppPermissionsReviewModal'; import AppUpdateModal from './AppUpdateModal'; import AppInstallModal from './components/AppInstallModal/AppInstallModal'; @@ -48,22 +49,12 @@ function AppInstallPage() { const appCountQuery = useAppsCountQuery('private'); - const { values, handlers } = useForm({ - file: {}, - url: queryUrl, - }); + const { control, setValue, watch } = useForm({ defaultValues: { url: queryUrl || '' } }); + const { file, url } = watch(); - const { file, url } = values; + const canSave = !!url || !!file?.name; - const canSave = !!url || !!file.name; - - const { handleFile, handleUrl } = handlers; - - useEffect(() => { - queryUrl && handleUrl(queryUrl); - }, [queryUrl, handleUrl]); - - const [handleUploadButtonClick] = useFileInput(handleFile, 'app'); + const [handleUploadButtonClick] = useFileInput((value) => setValue('file', value), 'app'); const sendFile = async (permissionsGranted, appFile, appId) => { let app; @@ -200,35 +191,52 @@ function AppInstallPage() { }); }; + const urlField = useUniqueId(); + const fileField = useUniqueId(); + return ( - {t('App_Url_to_Install_From')} - - } /> - + {t('App_Url_to_Install_From')} + + ( + } {...field} /> + )} + /> + - {t('App_Url_to_Install_From_File')} - - - {t('Browse_Files')} - - } + {t('App_Url_to_Install_From_File')} + + ( + + {t('Browse_Files')} + + } + /> + )} /> - + diff --git a/apps/meteor/client/views/marketplace/AppsPage/AppsFilters.tsx b/apps/meteor/client/views/marketplace/AppsPage/AppsFilters.tsx index 2fdbffda7d4d..f1332284c97f 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/AppsFilters.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/AppsFilters.tsx @@ -50,7 +50,7 @@ const AppsFilters = ({ const appsSearchPlaceholders: { [key: string]: string } = { explore: t('Search_Apps'), - enterprise: t('Search_Enterprise_Apps'), + enterprise: t('Search_Premium_Apps'), installed: t('Search_Installed_Apps'), requested: t('Search_Requested_Apps'), private: t('Search_Private_apps'), diff --git a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx index 5b47634bff29..43feaa8b2999 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx @@ -32,6 +32,7 @@ const AppsPageContent = (): ReactElement => { const context = useRouteParameter('context'); const isMarketplace = context === 'explore'; + const isPremium = context === 'premium'; const isRequested = context === 'requested'; const [freePaidFilterStructure, setFreePaidFilterStructure] = useState({ @@ -40,7 +41,7 @@ const AppsPageContent = (): ReactElement => { { id: 'all', label: t('All_Prices'), checked: true }, { id: 'free', label: t('Free_Apps'), checked: false }, { id: 'paid', label: t('Paid_Apps'), checked: false }, - { id: 'enterprise', label: t('Enterprise'), checked: false }, + { id: 'premium', label: t('Premium'), checked: false }, ], }); const freePaidFilterOnSelected = useRadioToggle(setFreePaidFilterStructure); @@ -89,7 +90,7 @@ const AppsPageContent = (): ReactElement => { const getAppsData = useCallback((): appsDataType => { switch (context) { - case 'enterprise': + case 'premium': case 'explore': case 'requested': return marketplaceApps; @@ -133,7 +134,8 @@ const AppsPageContent = (): ReactElement => { const noInstalledApps = appsResult.phase === AsyncStatePhase.RESOLVED && !isMarketplace && appsResult.value.totalAppsLength === 0; - const noMarketplaceOrInstalledAppMatches = appsResult.phase === AsyncStatePhase.RESOLVED && isMarketplace && appsResult.value.count === 0; + const noMarketplaceOrInstalledAppMatches = + appsResult.phase === AsyncStatePhase.RESOLVED && (isMarketplace || isPremium) && appsResult.value.count === 0; const noInstalledAppMatches = appsResult.phase === AsyncStatePhase.RESOLVED && @@ -141,7 +143,7 @@ const AppsPageContent = (): ReactElement => { appsResult.value.totalAppsLength !== 0 && appsResult.value.count === 0; - const noAppRequests = context === 'requested' && appsResult?.value?.totalAppsLength !== 0 && appsResult?.value?.count === 0; + const noAppRequests = context === 'requested' && appsResult?.value?.count === 0; const noErrorsOcurred = !noMarketplaceOrInstalledAppMatches && !noInstalledAppMatches && !noInstalledApps && !noAppRequests; @@ -186,6 +188,30 @@ const AppsPageContent = (): ReactElement => { toggleInitialSortOption(isRequested); }, [isMarketplace, isRequested, sortFilterOnSelected, t, toggleInitialSortOption]); + const getEmptyState = () => { + if (noAppRequests) { + return ; + } + + if (noMarketplaceOrInstalledAppMatches) { + return ; + } + + if (noInstalledAppMatches) { + return ( + + ); + } + + if (noInstalledApps) { + return context === 'private' ? : ; + } + }; + return ( <> { noErrorsOcurred={noErrorsOcurred} /> )} - {noAppRequests && } - {noMarketplaceOrInstalledAppMatches && ( - - )} - {noInstalledAppMatches && ( - - )} - {noInstalledApps && <>{context === 'private' ? : }} + {getEmptyState()} {appsResult.phase === AsyncStatePhase.REJECTED && } ); diff --git a/apps/meteor/client/views/marketplace/AppsPage/NoAppRequestsEmptyState.tsx b/apps/meteor/client/views/marketplace/AppsPage/NoAppRequestsEmptyState.tsx index 230c52a7a9a5..dc7d77e64a73 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/NoAppRequestsEmptyState.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/NoAppRequestsEmptyState.tsx @@ -1,4 +1,4 @@ -import { States, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage'; +import { Box, States, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; @@ -6,10 +6,13 @@ const NoAppRequestsEmptyState = () => { const t = useTranslation(); return ( - - {t('No_requested_apps')} - {t('Requested_apps_will_appear_here')} - + + + + {t('No_requested_apps')} + {t('Requested_apps_will_appear_here')} + + ); }; diff --git a/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyState.tsx b/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyState.tsx index d52d7b92a773..b7fec778401a 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyState.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/PrivateEmptyState.tsx @@ -8,7 +8,7 @@ const PrivateEmptyState = () => { return ( - + {t('No_private_apps_installed')} {t('Private_apps_are_side-loaded')} diff --git a/apps/meteor/client/views/marketplace/BundleChips.tsx b/apps/meteor/client/views/marketplace/BundleChips.tsx index 9f988534fe14..4e2953bd8519 100644 --- a/apps/meteor/client/views/marketplace/BundleChips.tsx +++ b/apps/meteor/client/views/marketplace/BundleChips.tsx @@ -18,15 +18,15 @@ const BundleChips = ({ bundledIn }: BundleChipsProps): ReactElement => { return ( <> - {bundledIn.map((bundle) => ( + {bundledIn.map(({ bundleId, bundleName }) => ( - {bundle.bundleName} + {bundleName} ))} diff --git a/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownAnchor.tsx b/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownAnchor.tsx index 91e66683e66f..b3e43fda942f 100644 --- a/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownAnchor.tsx +++ b/apps/meteor/client/views/marketplace/components/CategoryFilter/CategoryDropDownAnchor.tsx @@ -1,4 +1,6 @@ -import { Box, Button, Icon } from '@rocket.chat/fuselage'; +import type { Button } from '@rocket.chat/fuselage'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import colorTokens from '@rocket.chat/fuselage-tokens/colors.json'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, MouseEventHandler } from 'react'; import React, { forwardRef } from 'react'; @@ -15,33 +17,42 @@ const CategoryDropDownAnchor = forwardRef {selectedCategoriesCount > 0 && ( {selectedCategoriesCount} diff --git a/apps/meteor/client/views/marketplace/components/EnabledAppsCount.tsx b/apps/meteor/client/views/marketplace/components/EnabledAppsCount.tsx index 72cfa5474346..da135cbdedfc 100644 --- a/apps/meteor/client/views/marketplace/components/EnabledAppsCount.tsx +++ b/apps/meteor/client/views/marketplace/components/EnabledAppsCount.tsx @@ -15,7 +15,7 @@ const EnabledAppsCount = ({ percentage: number; limit: number; enabled: number; - context: 'private' | 'explore' | 'installed' | 'enterprise' | 'requested'; + context: 'private' | 'explore' | 'installed' | 'premium' | 'requested'; }): ReactElement | null => { const t = useTranslation(); diff --git a/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx b/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx index f93cb1fcd339..7696801c3124 100644 --- a/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx +++ b/apps/meteor/client/views/marketplace/components/MarketplaceHeader.tsx @@ -12,7 +12,7 @@ import EnabledAppsCount from './EnabledAppsCount'; const MarketplaceHeader = ({ title }: { title: string }): ReactElement | null => { const t = useTranslation(); const isAdmin = usePermission('manage-apps'); - const context = (useRouteParameter('context') || 'explore') as 'private' | 'explore' | 'installed' | 'enterprise' | 'requested'; + const context = (useRouteParameter('context') || 'explore') as 'private' | 'explore' | 'installed' | 'premium' | 'requested'; const route = useRoute('marketplace'); const setModal = useSetModal(); const result = useAppsCountQuery(context); diff --git a/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx b/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx index 36e4ff55657f..f480b2a60280 100644 --- a/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx +++ b/apps/meteor/client/views/marketplace/components/RadioDropDown/RadioDownAnchor.tsx @@ -1,4 +1,5 @@ -import { Box, Button, Icon } from '@rocket.chat/fuselage'; +import type { Button } from '@rocket.chat/fuselage'; +import { Box, Icon } from '@rocket.chat/fuselage'; import type { ComponentProps } from 'react'; import React, { forwardRef } from 'react'; @@ -14,22 +15,25 @@ const RadioDownAnchor = forwardRef(functi return ( {selected} diff --git a/apps/meteor/client/views/marketplace/helpers/handleAPIError.ts b/apps/meteor/client/views/marketplace/helpers/handleAPIError.ts index 61ec512bd6ee..108e695b6bd8 100644 --- a/apps/meteor/client/views/marketplace/helpers/handleAPIError.ts +++ b/apps/meteor/client/views/marketplace/helpers/handleAPIError.ts @@ -8,10 +8,10 @@ const shouldHandleErrorAsWarning = (message: string): boolean => { }; export const handleAPIError = (errorObject: unknown): void => { - const { message = '', error = '' } = errorObject as { message?: string; error?: string }; + const { error = '', message = error } = errorObject as { message?: string; error?: string }; if (shouldHandleErrorAsWarning(message)) { - return dispatchToastMessage({ type: 'warning', message: t(message) }); + return dispatchToastMessage({ type: 'error', message: t(message) }); } dispatchToastMessage({ type: 'error', message: t(`Apps_Error_${error}`) }); diff --git a/apps/meteor/client/views/marketplace/hooks/useAppInfo.ts b/apps/meteor/client/views/marketplace/hooks/useAppInfo.ts index 7e69cdeef97c..44ab240ce7b3 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppInfo.ts +++ b/apps/meteor/client/views/marketplace/hooks/useAppInfo.ts @@ -36,7 +36,7 @@ export const useAppInfo = (appId: string, context: string): AppInfo | undefined } let appResult: App | undefined; - const marketplaceAppsContexts = ['explore', 'enterprise', 'requested']; + const marketplaceAppsContexts = ['explore', 'premium', 'requested']; if (marketplaceAppsContexts.includes(context)) appResult = marketplaceApps.value?.apps.find((app) => app.id === appId); diff --git a/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts b/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts index 10689c773479..a571eac3b71f 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts +++ b/apps/meteor/client/views/marketplace/hooks/useAppsCountQuery.ts @@ -11,10 +11,10 @@ const getProgressBarValues = (numberOfEnabledApps: number, enabledAppsLimit: num percentage: Math.round((numberOfEnabledApps / enabledAppsLimit) * 100), }); -export type MarketplaceRouteContext = 'private' | 'explore' | 'installed' | 'enterprise' | 'requested'; +export type MarketplaceRouteContext = 'private' | 'explore' | 'installed' | 'premium' | 'requested'; export function isMarketplaceRouteContext(context: string): context is MarketplaceRouteContext { - return ['private', 'explore', 'installed', 'enterprise', 'requested'].includes(context); + return ['private', 'explore', 'installed', 'premium', 'requested'].includes(context); } export const useAppsCountQuery = (context: MarketplaceRouteContext) => { diff --git a/apps/meteor/client/views/marketplace/hooks/useCategories.ts b/apps/meteor/client/views/marketplace/hooks/useCategories.ts index 4a457a146046..bf7a022f2ae3 100644 --- a/apps/meteor/client/views/marketplace/hooks/useCategories.ts +++ b/apps/meteor/client/views/marketplace/hooks/useCategories.ts @@ -21,11 +21,13 @@ export const useCategories = (): [CategoryDropDownGroups, selectedCategoriesList try { const fetchedCategories = await AppClientOrchestratorInstance.getCategories(); - const mappedCategories = fetchedCategories.map((currentCategory) => ({ - id: currentCategory.id, - label: currentCategory.title, - checked: false, - })); + const mappedCategories = fetchedCategories + .filter((currentCategory) => !currentCategory.hidden) + .map((currentCategory) => ({ + id: currentCategory.id, + label: currentCategory.title, + checked: false, + })); setCategories([ { diff --git a/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts b/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts index 1027aae75a8a..437c8d35207d 100644 --- a/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts +++ b/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts @@ -66,7 +66,7 @@ export const useFilteredApps = ({ const filterByPurchaseType: Record App[]> = { all: fallback, paid: (apps: App[]) => apps.filter(filterAppsByPaid), - enterprise: (apps: App[]) => apps.filter(filterAppsByEnterprise), + premium: (apps: App[]) => apps.filter(filterAppsByEnterprise), free: (apps: App[]) => apps.filter(filterAppsByFree), }; @@ -80,7 +80,7 @@ export const useFilteredApps = ({ explore: fallback, installed: fallback, private: fallback, - enterprise: (apps: App[]) => apps.filter(({ categories }) => categories.includes('Enterprise')), + premium: (apps: App[]) => apps.filter(({ categories }) => categories.includes('Premium')), requested: (apps: App[]) => apps.filter(({ appRequestStats, installed }) => Boolean(appRequestStats) && !installed), }; diff --git a/apps/meteor/client/views/marketplace/sidebarItems.tsx b/apps/meteor/client/views/marketplace/sidebarItems.tsx index bafcc4e62c58..f829cccf3238 100644 --- a/apps/meteor/client/views/marketplace/sidebarItems.tsx +++ b/apps/meteor/client/views/marketplace/sidebarItems.tsx @@ -17,9 +17,9 @@ export const { permissionGranted: (): boolean => hasAtLeastOnePermission(['access-marketplace', 'manage-apps']), }, { - href: '/marketplace/enterprise', + href: '/marketplace/premium', icon: 'lightning', - i18nLabel: 'Enterprise', + i18nLabel: 'Premium', permissionGranted: (): boolean => hasAtLeastOnePermission(['access-marketplace', 'manage-apps']), }, { diff --git a/apps/meteor/client/views/modal/uikit/ModalBlock.tsx b/apps/meteor/client/views/modal/uikit/ModalBlock.tsx index 7993355206e7..bd0876fc49ad 100644 --- a/apps/meteor/client/views/modal/uikit/ModalBlock.tsx +++ b/apps/meteor/client/views/modal/uikit/ModalBlock.tsx @@ -1,4 +1,4 @@ -import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; +import type { UiKit } from '@rocket.chat/core-typings'; import { Modal, AnimatedVisibility, Button, Box } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { UiKitComponent, UiKitModal, modalParser } from '@rocket.chat/fuselage-ui-kit'; @@ -38,7 +38,7 @@ const focusableElementsStringInvalid = ` [contenteditable]:invalid`; type ModalBlockParams = { - view: IUIKitSurface & { showIcon?: boolean }; + view: UiKit.ModalView; errors: any; appId: string; onSubmit: FormEventHandler; @@ -55,7 +55,7 @@ const KeyboardCode = new Map([ ['TAB', 9], ]); -const ModalBlock = ({ view, errors, appId, onSubmit, onClose, onCancel }: ModalBlockParams): ReactElement => { +const ModalBlock = ({ view, errors, onSubmit, onClose, onCancel }: ModalBlockParams): ReactElement => { const id = `modal_id_${useUniqueId()}`; const ref = useRef(null); @@ -165,7 +165,7 @@ const ModalBlock = ({ view, errors, appId, onSubmit, onClose, onCancel }: ModalB - {view.showIcon ? : null} + {view.showIcon ? : null} {modalParser.text(view.title, BlockContext.NONE, 0)} @@ -182,7 +182,7 @@ const ModalBlock = ({ view, errors, appId, onSubmit, onClose, onCancel }: ModalB )} {view.submit && ( - )} diff --git a/apps/meteor/client/views/modal/uikit/UiKitModal.tsx b/apps/meteor/client/views/modal/uikit/UiKitModal.tsx index b985f94b09b9..52aaa49ed009 100644 --- a/apps/meteor/client/views/modal/uikit/UiKitModal.tsx +++ b/apps/meteor/client/views/modal/uikit/UiKitModal.tsx @@ -1,139 +1,130 @@ -import { UIKitIncomingInteractionContainerType } from '@rocket.chat/apps-engine/definition/uikit/UIKitIncomingInteractionContainer'; +import type { UiKit } from '@rocket.chat/core-typings'; import { useDebouncedCallback, useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { UiKitContext } from '@rocket.chat/fuselage-ui-kit'; import { MarkupInteractionContext } from '@rocket.chat/gazzodown'; -import type { LayoutBlock } from '@rocket.chat/ui-kit'; -import type { ContextType, ReactElement, ReactEventHandler } from 'react'; -import React from 'react'; +import type { ContextType, FormEvent } from 'react'; +import React, { useMemo } from 'react'; -import { useUiKitActionManager } from '../../../hooks/useUiKitActionManager'; +import { useUiKitActionManager } from '../../../UIKit/hooks/useUiKitActionManager'; +import { useUiKitView } from '../../../UIKit/hooks/useUiKitView'; import { detectEmoji } from '../../../lib/utils/detectEmoji'; +import { preventSyntheticEvent } from '../../../lib/utils/preventSyntheticEvent'; import ModalBlock from './ModalBlock'; -import type { ActionManagerState } from './hooks/useActionManagerState'; -import { useActionManagerState } from './hooks/useActionManagerState'; -import { useValues } from './hooks/useValues'; -const UiKitModal = (props: ActionManagerState): ReactElement => { - const actionManager = useUiKitActionManager(); - const state = useActionManagerState(props); - - const { appId, viewId, mid: _mid, errors, view } = state; - - const [values, updateValues] = useValues(view.blocks as LayoutBlock[]); +type UiKitModalProps = { + key: UiKit.ModalView['id']; // force re-mount when viewId changes + initialView: UiKit.ModalView; +}; - const groupStateByBlockId = (values: { value: unknown; blockId: string }[]) => - Object.entries(values).reduce((obj, [key, { blockId, value }]) => { - obj[blockId] = obj[blockId] || {}; - obj[blockId][key] = value; +const UiKitModal = ({ initialView }: UiKitModalProps) => { + const actionManager = useUiKitActionManager(); + const { view, errors, values, updateValues, state } = useUiKitView(initialView); - return obj; - }, {}); + const emitInteraction = useMemo(() => actionManager.emitInteraction.bind(actionManager), [actionManager]); + const debouncedEmitInteraction = useDebouncedCallback(emitInteraction, 700); - const prevent: ReactEventHandler = (e) => { - if (e) { - (e.nativeEvent || e).stopImmediatePropagation(); - e.stopPropagation(); - e.preventDefault(); - } - }; + // TODO: this structure is atrociously wrong; we should revisit this + const contextValue = useMemo( + (): ContextType => ({ + action: async ({ actionId, viewId, appId, dispatchActionConfig, blockId, value }) => { + if (!appId || !viewId) { + return; + } - const debouncedBlockAction = useDebouncedCallback((actionId, appId, value, blockId, mid) => { - actionManager.triggerBlockAction({ - container: { - type: UIKitIncomingInteractionContainerType.VIEW, - id: viewId, - }, - actionId, - appId, - value, - blockId, - mid, - }); - }, 700); + const emit = dispatchActionConfig?.includes('on_character_entered') ? debouncedEmitInteraction : emitInteraction; - // TODO: this structure is atrociously wrong; we should revisit this - const context: ContextType = { - // @ts-expect-error Property 'mid' does not exist on type 'ActionParams'. - action: ({ actionId, appId, value, blockId, mid = _mid, dispatchActionConfig }) => { - if (Array.isArray(dispatchActionConfig) && dispatchActionConfig.includes('on_character_entered')) { - debouncedBlockAction(actionId, appId, value, blockId, mid); - } else { - actionManager.triggerBlockAction({ + await emit(appId, { + type: 'blockAction', + actionId, container: { - type: UIKitIncomingInteractionContainerType.VIEW, + type: 'view', id: viewId, }, + payload: { + blockId, + value, + }, + }); + }, + state: ({ actionId, value, /* ,appId, */ blockId = 'default' }) => { + updateValues({ actionId, - appId, - value, - blockId, - mid, + payload: { + blockId, + value, + }, }); - } - }, + }, + ...view, + values, + viewId: view.id, + }), + [debouncedEmitInteraction, emitInteraction, updateValues, values, view], + ); - state: ({ actionId, value, /* ,appId, */ blockId = 'default' }) => { - updateValues({ - actionId, + const handleSubmit = useMutableCallback((e: FormEvent) => { + preventSyntheticEvent(e); + void actionManager + .emitInteraction(view.appId, { + type: 'viewSubmit', payload: { - blockId, - value, + view: { + ...view, + state, + }, }, + viewId: view.id, + }) + .finally(() => { + actionManager.disposeView(view.id); }); - }, - ...state, - values, - }; - - const handleSubmit = useMutableCallback((e) => { - prevent(e); - actionManager.triggerSubmitView({ - viewId, - appId, - payload: { - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - }, - }); }); - const handleCancel = useMutableCallback((e) => { - prevent(e); - actionManager.triggerCancel({ - viewId, - appId, - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - }); + const handleCancel = useMutableCallback((e: FormEvent) => { + preventSyntheticEvent(e); + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.id, + view: { + ...view, + state, + }, + isCleared: false, + }, + }) + .finally(() => { + actionManager.disposeView(view.id); + }); }); const handleClose = useMutableCallback(() => { - actionManager.triggerCancel({ - viewId, - appId, - view: { - ...view, - id: viewId, - state: groupStateByBlockId(values), - }, - isCleared: true, - }); + void actionManager + .emitInteraction(view.appId, { + type: 'viewClosed', + payload: { + viewId: view.id, + view: { + ...view, + state, + }, + isCleared: true, + }, + }) + .finally(() => { + actionManager.disposeView(view.id); + }); }); return ( - + - + ); diff --git a/apps/meteor/client/views/modal/uikit/getButtonStyle.ts b/apps/meteor/client/views/modal/uikit/getButtonStyle.ts index 4a78cb5e250a..89b489fd66fc 100644 --- a/apps/meteor/client/views/modal/uikit/getButtonStyle.ts +++ b/apps/meteor/client/views/modal/uikit/getButtonStyle.ts @@ -1,6 +1,6 @@ -import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; +import type { ButtonElement } from '@rocket.chat/ui-kit'; // TODO: Move to fuselage-ui-kit -export const getButtonStyle = (view: IUIKitSurface): { danger: boolean } | { primary: boolean } => { - return view.submit?.style === 'danger' ? { danger: true } : { primary: true }; +export const getButtonStyle = (buttonElement: ButtonElement): { danger: boolean } | { primary: boolean } => { + return buttonElement?.style === 'danger' ? { danger: true } : { primary: true }; }; diff --git a/apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts b/apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts deleted file mode 100644 index fb1da19010e3..000000000000 --- a/apps/meteor/client/views/modal/uikit/hooks/useActionManagerState.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { IUIKitSurface } from '@rocket.chat/apps-engine/definition/uikit'; -import { useEffect, useState } from 'react'; - -import { useUiKitActionManager } from '../../../../hooks/useUiKitActionManager'; - -export type ActionManagerState = { - viewId: string; - type: 'errors' | string; - appId: string; - mid: string; - errors: Record; - view: IUIKitSurface; -}; - -export const useActionManagerState = (initialState: ActionManagerState) => { - const actionManager = useUiKitActionManager(); - const [state, setState] = useState(initialState); - - const { viewId } = state; - - useEffect(() => { - const handleUpdate = ({ type, errors, ...data }: ActionManagerState) => { - if (type === 'errors') { - setState((state) => ({ ...state, errors, type })); - return; - } - - setState({ ...data, type, errors }); - }; - - actionManager.on(viewId, handleUpdate); - - return () => { - actionManager.off(viewId, handleUpdate); - }; - }, [actionManager, viewId]); - - return state; -}; diff --git a/apps/meteor/client/views/modal/uikit/hooks/useValues.ts b/apps/meteor/client/views/modal/uikit/hooks/useValues.ts deleted file mode 100644 index 34a8eb0c5ae2..000000000000 --- a/apps/meteor/client/views/modal/uikit/hooks/useValues.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import type { LayoutBlock } from '@rocket.chat/ui-kit'; -import { useReducer } from 'react'; - -type LayoutBlockWithElement = Extract; -type LayoutBlockWithElements = Extract; -type ElementFromLayoutBlock = LayoutBlockWithElement['element'] | LayoutBlockWithElements['elements'][number]; - -const hasElementInBlock = (block: LayoutBlock): block is LayoutBlockWithElement => 'element' in block; -const hasElementsInBlock = (block: LayoutBlock): block is LayoutBlockWithElements => 'elements' in block; -const hasInitialValueAndActionId = ( - element: ElementFromLayoutBlock, -): element is Extract & { initialValue: unknown } => - 'initialValue' in element && 'actionId' in element && typeof element.actionId === 'string' && !!element?.initialValue; - -const extractValue = (element: ElementFromLayoutBlock, obj: Record, blockId?: string) => { - if (hasInitialValueAndActionId(element)) { - obj[element.actionId] = { value: element.initialValue, blockId }; - } -}; - -const reduceBlocks = (obj: Record, block: LayoutBlock) => { - if (hasElementInBlock(block)) { - extractValue(block.element, obj, block.blockId); - } - if (hasElementsInBlock(block)) { - for (const element of block.elements) { - extractValue(element, obj, block.blockId); - } - } - - return obj; -}; - -export const useValues = (blocks: LayoutBlock[]) => { - const reducer = useMutableCallback((values, { actionId, payload }) => ({ - ...values, - [actionId]: payload, - })); - - const initializer = useMutableCallback((blocks: LayoutBlock[]) => { - const obj: Record = {}; - - return blocks.reduce(reduceBlocks, obj); - }); - - return useReducer(reducer, blocks, initializer); -}; diff --git a/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx b/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx index b6b536925ba1..aa5a917405b4 100644 --- a/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx +++ b/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx @@ -1,5 +1,17 @@ import type { ILivechatAgent, ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; -import { Field, TextInput, Button, Box, MultiSelect, Icon, Select, ContextualbarFooter, ButtonGroup } from '@rocket.chat/fuselage'; +import { + Field, + FieldLabel, + FieldRow, + TextInput, + Button, + Box, + MultiSelect, + Icon, + Select, + ContextualbarFooter, + ButtonGroup, +} from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useSetting, useMethod, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import type { FC, ReactElement } from 'react'; @@ -121,26 +133,26 @@ const AgentEdit: FC = ({ data, userDepartments, availableDepartm )} - {t('Name')} - + {t('Name')} + - + - {t('Username')} - + {t('Username')} + } /> - + - {t('Email')} - + {t('Email')} + } /> - + - {t('Departments')} - + {t('Departments')} + = ({ data, userDepartments, availableDepartm placeholder={t('Select_an_option')} onChange={handleDepartments} /> - + - {t('Status')} - + {t('Status')} + setChartName(String(value))} /> - + diff --git a/apps/meteor/client/views/omnichannel/analytics/DateRangePicker.tsx b/apps/meteor/client/views/omnichannel/analytics/DateRangePicker.tsx index b84996f168d4..40c32f4d6980 100644 --- a/apps/meteor/client/views/omnichannel/analytics/DateRangePicker.tsx +++ b/apps/meteor/client/views/omnichannel/analytics/DateRangePicker.tsx @@ -1,4 +1,4 @@ -import { Box, InputBox, Menu, Field } from '@rocket.chat/fuselage'; +import { Box, InputBox, Menu, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { Moment } from 'moment'; @@ -6,6 +6,8 @@ import moment from 'moment'; import type { ComponentProps } from 'react'; import React, { useState, useMemo, useEffect } from 'react'; +moment.locale('en'); + type DateRangePickerProps = Omit, 'onChange'> & { onChange(range: { start: string; end: string }): void; }; @@ -112,21 +114,21 @@ const DateRangePicker = ({ onChange = () => undefined, ...props }: DateRangePick - {t('Start')} - + {t('Start')} + - + - {t('End')} - + {t('End')} + - + diff --git a/apps/meteor/client/views/omnichannel/appearance/AppearanceForm.tsx b/apps/meteor/client/views/omnichannel/appearance/AppearanceForm.tsx index 9d5e176301a8..4983c5ca8837 100644 --- a/apps/meteor/client/views/omnichannel/appearance/AppearanceForm.tsx +++ b/apps/meteor/client/views/omnichannel/appearance/AppearanceForm.tsx @@ -1,4 +1,16 @@ -import { Box, Field, TextInput, ToggleSwitch, Accordion, FieldGroup, InputBox, TextAreaInput, NumberInput } from '@rocket.chat/fuselage'; +import { + Box, + Field, + FieldLabel, + FieldRow, + TextInput, + ToggleSwitch, + Accordion, + FieldGroup, + InputBox, + TextAreaInput, + NumberInput, +} from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { FC, FormEvent } from 'react'; @@ -105,46 +117,46 @@ const AppearanceForm: FC = ({ values = {}, handlers = {} }) - {t('Title')} - + {t('Title')} + - + - {t('Title_bar_color')} - + {t('Title_bar_color')} + - + - {t('Message_Characther_Limit')} - + {t('Message_Characther_Limit')} + - + - + - + - {t('Show_agent_info')} - + {t('Show_agent_info')} + - + - {t('Show_agent_email')} - + {t('Show_agent_email')} + - + @@ -154,66 +166,66 @@ const AppearanceForm: FC = ({ values = {}, handlers = {} }) - {t('Display_offline_form')} - + {t('Display_offline_form')} + - + - {t('Offline_form_unavailable_message')} - + {t('Offline_form_unavailable_message')} + - + - {t('Offline_message')} - + {t('Offline_message')} + - + - {t('Title_offline')} - + {t('Title_offline')} + - + - {t('Title_bar_color_offline')} - + {t('Title_bar_color_offline')} + - + - {t('Email_address_to_send_offline_messages')} - + {t('Email_address_to_send_offline_messages')} + - + - {t('Offline_success_message')} - + {t('Offline_success_message')} + - + @@ -222,64 +234,64 @@ const AppearanceForm: FC = ({ values = {}, handlers = {} }) - {t('Enabled')} - + {t('Enabled')} + - + - {t('Show_name_field')} - + {t('Show_name_field')} + - + - {t('Show_email_field')} - + {t('Show_email_field')} + - + - {t('Livechat_registration_form_message')} - + {t('Livechat_registration_form_message')} + - + - {t('Conversation_finished_message')} - + {t('Conversation_finished_message')} + - + - {t('Conversation_finished_text')} - + {t('Conversation_finished_text')} + - + diff --git a/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js index c274ebee272d..9acf9d2fd167 100644 --- a/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js +++ b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js @@ -7,6 +7,7 @@ import { useForm } from '../../../hooks/useForm'; import { useReactiveValue } from '../../../hooks/useReactiveValue'; import { useFormsSubscription } from '../additionalForms'; import BusinessHourForm from './BusinessHoursForm'; +import BusinessHoursTimeZone from './BusinessHoursTimeZone'; const useChangeHandler = (name, ref) => useMutableCallback((val) => { @@ -29,12 +30,10 @@ const BusinessHoursFormContainer = ({ data, saveRef, onChange = () => {} }) => { const [hasChangesMultiple, setHasChangesMultiple] = useState(false); const [hasChangesTimeZone, setHasChangesTimeZone] = useState(false); - const { useBusinessHoursTimeZone = cleanFunc, useBusinessHoursMultiple = cleanFunc } = forms; + const { useBusinessHoursMultiple = cleanFunc } = forms; - const TimezoneForm = useBusinessHoursTimeZone(); const MultipleBHForm = useBusinessHoursMultiple(); - const showTimezone = useReactiveValue(useMutableCallback(() => businessHourManager.showTimezoneTemplate())); const showMultipleBHForm = useReactiveValue(useMutableCallback(() => businessHourManager.showCustomTemplate(data))); const onChangeTimezone = useChangeHandler('timezone', saveRef); @@ -45,7 +44,7 @@ const BusinessHoursFormContainer = ({ data, saveRef, onChange = () => {} }) => { saveRef.current.form = values; useEffect(() => { - onChange(hasUnsavedChanges || (showMultipleBHForm && hasChangesMultiple) || (showTimezone && hasChangesTimeZone)); + onChange(hasUnsavedChanges || (showMultipleBHForm && hasChangesMultiple) || hasChangesTimeZone); }); return ( @@ -54,9 +53,11 @@ const BusinessHoursFormContainer = ({ data, saveRef, onChange = () => {} }) => { {showMultipleBHForm && MultipleBHForm && ( )} - {showTimezone && TimezoneForm && ( - - )} + diff --git a/apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursTimeZone.js b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursTimeZone.js similarity index 86% rename from apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursTimeZone.js rename to apps/meteor/client/views/omnichannel/businessHours/BusinessHoursTimeZone.js index c305dc084e68..8826fc621455 100644 --- a/apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursTimeZone.js +++ b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursTimeZone.js @@ -2,8 +2,8 @@ import { SelectFiltered, Field } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo } from 'react'; -import { useForm } from '../../../../client/hooks/useForm'; -import { useTimezoneNameList } from '../../../../client/hooks/useTimezoneNameList'; +import { useForm } from '../../../hooks/useForm'; +import { useTimezoneNameList } from '../../../hooks/useTimezoneNameList'; const getInitialData = (data = {}) => ({ name: data ?? '', diff --git a/apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursTimeZone.stories.tsx b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursTimeZone.stories.tsx similarity index 91% rename from apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursTimeZone.stories.tsx rename to apps/meteor/client/views/omnichannel/businessHours/BusinessHoursTimeZone.stories.tsx index 35c133caf72a..af00db33322e 100644 --- a/apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursTimeZone.stories.tsx +++ b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursTimeZone.stories.tsx @@ -5,7 +5,7 @@ import React from 'react'; import BusinessHoursTimeZone from './BusinessHoursTimeZone'; export default { - title: 'Enterprise/Omnichannel/BusinessHoursTimeZone', + title: 'Omnichannel/BusinessHoursTimeZone', component: BusinessHoursTimeZone, decorators: [ (fn) => ( diff --git a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx index 12342a6258a3..dab42e58e300 100644 --- a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx +++ b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx @@ -105,7 +105,7 @@ const ContactHistoryMessage: FC<{ )} - {message.blocks && } + {message.blocks && } {message.attachments && } diff --git a/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx b/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx index d82498ff1e50..0194290432cd 100644 --- a/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx +++ b/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx @@ -1,4 +1,4 @@ -import { Pagination } from '@rocket.chat/fuselage'; +import { Banner, Icon, Pagination } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import type { GETLivechatRoomsParams } from '@rocket.chat/rest-typings'; import { usePermission, useTranslation } from '@rocket.chat/ui-contexts'; @@ -7,6 +7,7 @@ import moment from 'moment'; import type { ComponentProps, ReactElement } from 'react'; import React, { memo, useCallback, useMemo, useState } from 'react'; +import { RoomActivityIcon } from '../../../../ee/client/omnichannel/components/RoomActivityIcon'; import { useOmnichannelPriorities } from '../../../../ee/client/omnichannel/hooks/useOmnichannelPriorities'; import { PriorityIcon } from '../../../../ee/client/omnichannel/priorities/PriorityIcon'; import GenericNoResults from '../../../components/GenericNoResults'; @@ -22,6 +23,7 @@ import { import { usePagination } from '../../../components/GenericTable/hooks/usePagination'; import { useSort } from '../../../components/GenericTable/hooks/useSort'; import Page from '../../../components/Page'; +import { useIsOverMacLimit } from '../../../hooks/omnichannel/useIsOverMacLimit'; import CustomFieldsList from './CustomFieldsList'; import FilterByText from './FilterByText'; import RemoveChatButton from './RemoveChatButton'; @@ -118,6 +120,7 @@ const currentChatQuery: useQueryType = ( }; const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: string) => void }): ReactElement => { + const isWorkspaceOverMacLimit = useIsOverMacLimit(); const { sortBy, sortDirection, setSort } = useSort<'fname' | 'departmentId' | 'servedBy' | 'priorityWeight' | 'ts' | 'lm' | 'open'>( 'ts', 'desc', @@ -165,7 +168,8 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s }); const renderRow = useCallback( - ({ _id, fname, servedBy, ts, lm, department, open, onHold, priorityWeight }) => { + (room) => { + const { _id, fname, servedBy, ts, lm, department, open, onHold, priorityWeight } = room; const getStatusText = (open: boolean, onHold: boolean): string => { if (!open) return t('Closed'); return onHold ? t('On_Hold_Chats') : t('Open'); @@ -194,7 +198,7 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s {moment(lm).format('L LTS')} - {getStatusText(open, onHold)} + {getStatusText(open, onHold)} {canRemoveClosedChats && !open && } @@ -301,6 +305,17 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s hasCustomFields={hasCustomFields} /> )} + {isWorkspaceOverMacLimit && ( + } + title={t('The_workspace_has_exceeded_the_monthly_limit_of_active_contacts')} + style={{ marginBlock: '2rem' }} + > + {t('Talk_to_your_workspace_admin_to_address_this_issue')} + + )} {isSuccess && data?.rooms.length === 0 && queryHasChanged && } {isSuccess && data?.rooms.length === 0 && !queryHasChanged && ( - {customField.label} - + {customField.label} + [item, item])} /> )} /> - + ); } return ( - {customField.label} - + {customField.label} + - + ); })} diff --git a/apps/meteor/client/views/omnichannel/customFields/CustomFieldsTable.tsx b/apps/meteor/client/views/omnichannel/customFields/CustomFieldsTable.tsx index ce7780181ddc..f9872c710a5e 100644 --- a/apps/meteor/client/views/omnichannel/customFields/CustomFieldsTable.tsx +++ b/apps/meteor/client/views/omnichannel/customFields/CustomFieldsTable.tsx @@ -43,7 +43,9 @@ const CustomFieldsTable = ({ reload }: { reload: MutableRefObject<() => void> }) ); const getCustomFields = useEndpoint('GET', '/v1/livechat/custom-fields'); - const { data, isSuccess, isLoading, refetch } = useQuery(['livechat-customFields', query], async () => getCustomFields(query)); + const { data, isSuccess, isLoading, refetch } = useQuery(['livechat-customFields', query, debouncedFilter], async () => + getCustomFields(query), + ); const [defaultQuery] = useState(hashQueryKey([query])); const queryHasChanged = defaultQuery !== hashQueryKey([query]); @@ -105,7 +107,7 @@ const CustomFieldsTable = ({ reload }: { reload: MutableRefObject<() => void> }) {isSuccess && data.customFields.length > 0 && ( <> - + {headers} {data.customFields.map(({ label, _id, scope, visibility }) => ( diff --git a/apps/meteor/client/views/omnichannel/departments/DepartmentTags/index.tsx b/apps/meteor/client/views/omnichannel/departments/DepartmentTags/index.tsx index b1542f857316..cead6a0fb15f 100644 --- a/apps/meteor/client/views/omnichannel/departments/DepartmentTags/index.tsx +++ b/apps/meteor/client/views/omnichannel/departments/DepartmentTags/index.tsx @@ -1,4 +1,4 @@ -import { Button, Chip, Field, TextInput } from '@rocket.chat/fuselage'; +import { Button, Chip, FieldRow, FieldHint, TextInput } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { FormEvent } from 'react'; import React, { useCallback, useState } from 'react'; @@ -28,7 +28,7 @@ export const DepartmentTags = ({ error, value: tags, onChange }: DepartmentTagsP return ( <> - + {t('Add')} - + - {t('Conversation_closing_tags_description')} + {t('Conversation_closing_tags_description')} {tags?.length > 0 && ( - + {tags.map((tag, i) => ( {tag} ))} - + )} ); diff --git a/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx b/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx index 3a408bfa7507..bb1d5b057437 100644 --- a/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx +++ b/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx @@ -2,6 +2,9 @@ import type { ILivechatDepartment, ILivechatDepartmentAgents, Serialized } from import { FieldGroup, Field, + FieldLabel, + FieldRow, + FieldError, TextInput, Box, Icon, @@ -12,10 +15,10 @@ import { Button, PaginatedSelectFiltered, } from '@rocket.chat/fuselage'; -import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useDebouncedValue, useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useMethod, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { validateEmail } from '../../../../lib/emailValidator'; @@ -127,10 +130,13 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen } = useForm({ mode: 'onChange', defaultValues: initialValues }); const requestTagBeforeClosingChat = watch('requestTagBeforeClosingChat'); - const offlineMessageChannelName = watch('offlineMessageChannelName'); + + const [fallbackFilter, setFallbackFilter] = useState(''); + + const debouncedFallbackFilter = useDebouncedValue(fallbackFilter, 500); const { itemsList: RoomsList, loadMoreItems: loadMoreRooms } = useRoomsList( - useMemo(() => ({ text: offlineMessageChannelName }), [offlineMessageChannelName]), + useMemo(() => ({ text: debouncedFallbackFilter }), [debouncedFallbackFilter]), ); const { phase: roomsPhase, items: roomsItems, itemCount: roomsTotal } = useRecordList(RoomsList); @@ -240,16 +246,16 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen > - {t('Enabled')} - + {t('Enabled')} + - + - {t('Name')}* - + {t('Name')}* + - - {errors.name && {errors.name?.message}} + + {errors.name && {errors.name?.message}} - {t('Description')} - + {t('Description')} + - + - {t('Show_on_registration_page')} - + {t('Show_on_registration_page')} + - + - {t('Email')}* - + {t('Email')}* + validateEmail(email) || t('error-invalid-email-address'), })} /> - - {errors.email && {errors.email?.message}} + + {errors.email && {errors.email?.message}} - {t('Show_on_offline_page')} - + {t('Show_on_offline_page')} + - + - {t('Livechat_DepartmentOfflineMessageToChannel')} - + {t('Livechat_DepartmentOfflineMessageToChannel')} + void} options={roomsItems} placeholder={t('Channel_name')} endReached={ roomsPhase === AsyncStatePhase.LOADING ? () => undefined : (start) => loadMoreRooms(start, Math.min(50, roomsTotal)) } + aria-busy={fallbackFilter !== debouncedFallbackFilter} /> )} /> - + {MaxChats && ( @@ -421,7 +428,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen {AutoCompleteDepartment && ( - {t('Fallback_forward_department')} + {t('Fallback_forward_department')} - {t('Request_tag_before_closing_chat')} - + {t('Request_tag_before_closing_chat')} + - + {requestTagBeforeClosingChat && ( - {t('Conversation_closing_tags')}* + {t('Conversation_closing_tags')}* )} /> - {errors.chatClosingTags && {errors.chatClosingTags?.message}} + {errors.chatClosingTags && {errors.chatClosingTags?.message}} )} @@ -475,7 +482,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen - {t('Agents')}: + {t('Agents')}: diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEdit.tsx b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEdit.tsx index 433af2135068..89b7b4582fa5 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEdit.tsx +++ b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit/RoomEdit.tsx @@ -1,5 +1,5 @@ import type { ILivechatVisitor, IOmnichannelRoom, Serialized } from '@rocket.chat/core-typings'; -import { Field, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { CustomFieldsForm } from '@rocket.chat/ui-client'; import { useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; @@ -127,10 +127,10 @@ function RoomEdit({ room, visitor, reload, reloadInfo, onClose }: RoomEditProps) )} - {t('Topic')} - + {t('Topic')} + - + diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx index afb609e5f801..a41c155c4f9d 100644 --- a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx +++ b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.tsx @@ -1,5 +1,5 @@ import type { ILivechatVisitor, Serialized } from '@rocket.chat/core-typings'; -import { Field, TextInput, ButtonGroup, Button, ContextualbarContent } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, FieldError, TextInput, ButtonGroup, Button, ContextualbarContent } from '@rocket.chat/fuselage'; import { CustomFieldsForm } from '@rocket.chat/ui-client'; import { useToastMessageDispatch, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; @@ -189,25 +189,25 @@ const ContactNewEdit = ({ id, data, close }: ContactNewEditProps): ReactElement <> - {t('Name')}* - + {t('Name')}* + - - {errors.name?.message} + + {errors.name?.message} - {t('Email')} - + {t('Email')} + - - {errors.email?.message} + + {errors.email?.message} - {t('Phone')} - + {t('Phone')} + - - {errors.phone?.message} + + {errors.phone?.message} {canViewCustomFields() && } {ContactManager && } diff --git a/apps/meteor/client/views/omnichannel/managers/AddManager.tsx b/apps/meteor/client/views/omnichannel/managers/AddManager.tsx index d6668d7f35d7..b4f56f78b62b 100644 --- a/apps/meteor/client/views/omnichannel/managers/AddManager.tsx +++ b/apps/meteor/client/views/omnichannel/managers/AddManager.tsx @@ -1,4 +1,4 @@ -import { Button, Box, Field } from '@rocket.chat/fuselage'; +import { Button, Box, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -34,13 +34,13 @@ const AddManager = ({ reload }: { reload: () => void }): ReactElement => { return ( - {t('Username')} - + {t('Username')} + - + ); diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.js deleted file mode 100644 index 95d455fb7eb1..000000000000 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.js +++ /dev/null @@ -1,14 +0,0 @@ -import moment from 'moment'; - -export const getMomentChartLabelsAndData = () => { - const timingLabels = []; - const initData = []; - const today = moment().startOf('day'); - for (let m = today; m.diff(moment(), 'hours') < 0; m.add(1, 'hours')) { - const hour = m.format('H'); - timingLabels.push(`${moment(hour, ['H']).format('hA')}-${moment((parseInt(hour) + 1) % 24, ['H']).format('hA')}`); - initData.push(0); - } - - return [timingLabels, initData]; -}; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.spec.ts b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.spec.ts new file mode 100644 index 000000000000..ec5eb1268233 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.spec.ts @@ -0,0 +1,52 @@ +import moment from 'moment-timezone'; + +import { getMomentChartLabelsAndData } from './getMomentChartLabelsAndData'; + +moment.tz.setDefault('UTC'); + +describe.each([ + [ + 'en', + [ + '12AM-1AM', + '1AM-2AM', + '2AM-3AM', + '3AM-4AM', + '4AM-5AM', + '5AM-6AM', + '6AM-7AM', + '7AM-8AM', + '8AM-9AM', + '9AM-10AM', + '10AM-11AM', + '11AM-12PM', + ], + ], + /** @see: https://github.com/RocketChat/Rocket.Chat/issues/30191 */ + [ + 'fa', + [ + '۱۲قبل از ظهر-۱قبل از ظهر', + '۱قبل از ظهر-۲قبل از ظهر', + '۲قبل از ظهر-۳قبل از ظهر', + '۳قبل از ظهر-۴قبل از ظهر', + '۴قبل از ظهر-۵قبل از ظهر', + '۵قبل از ظهر-۶قبل از ظهر', + '۶قبل از ظهر-۷قبل از ظهر', + '۷قبل از ظهر-۸قبل از ظهر', + '۸قبل از ظهر-۹قبل از ظهر', + '۹قبل از ظهر-۱۰قبل از ظهر', + '۱۰قبل از ظهر-۱۱قبل از ظهر', + '۱۱قبل از ظهر-۱۲بعد از ظهر', + ], + ], +])(`%p language`, (language, expectedTimingLabels) => { + beforeEach(() => { + moment.locale(language); + }); + + it('should create timing labels from midnight to noon', () => { + const [timingLabels] = getMomentChartLabelsAndData(12 * 60 * 60 * 1000); + expect(timingLabels).toStrictEqual(expectedTimingLabels); + }); +}); diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.ts b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.ts new file mode 100644 index 000000000000..7bf2f7a6553b --- /dev/null +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentChartLabelsAndData.ts @@ -0,0 +1,14 @@ +import moment from 'moment'; + +export const getMomentChartLabelsAndData = (timestamp = Date.now()) => { + const timingLabels = []; + const initData = []; + const today = moment(timestamp).startOf('day'); + for (let m = today; m.diff(moment(timestamp), 'hours') < 0; m.add(1, 'hours')) { + const n = moment(m).add(1, 'hours'); + timingLabels.push(`${m.format('hA')}-${n.format('hA')}`); + initData.push(0); + } + + return [timingLabels, initData]; +}; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.js deleted file mode 100644 index 38b942f7b5b0..000000000000 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.js +++ /dev/null @@ -1,7 +0,0 @@ -import moment from 'moment'; - -export const getMomentCurrentLabel = () => { - const hour = moment(new Date()).format('H'); - - return `${moment(hour, ['H']).format('hA')}-${moment((parseInt(hour) + 1) % 24, ['H']).format('hA')}`; -}; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.spec.ts b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.spec.ts new file mode 100644 index 000000000000..d0d98c1c39d9 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.spec.ts @@ -0,0 +1,20 @@ +import moment from 'moment-timezone'; + +import { getMomentCurrentLabel } from './getMomentCurrentLabel'; + +moment.tz.setDefault('UTC'); + +describe.each([ + ['en', '12PM-1PM'], + /** @see: https://github.com/RocketChat/Rocket.Chat/issues/30191 */ + ['fa', '۱۲بعد از ظهر-۱بعد از ظهر'], +])(`%p language`, (language, expectedLabel) => { + beforeEach(() => { + moment.locale(language); + }); + + it('should create timing labels from midnight to noon', () => { + const label = getMomentCurrentLabel(12 * 60 * 60 * 1000); + expect(label).toStrictEqual(expectedLabel); + }); +}); diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.ts b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.ts new file mode 100644 index 000000000000..17085a91b0d7 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/getMomentCurrentLabel.ts @@ -0,0 +1,8 @@ +import moment from 'moment'; + +export const getMomentCurrentLabel = (timestamp = Date.now()) => { + const m = moment(timestamp); + const n = moment(m).add(1, 'hours'); + + return `${m.format('hA')}-${n.format('hA')}`; +}; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.js deleted file mode 100644 index f3af5a42624a..000000000000 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.js +++ /dev/null @@ -1,11 +0,0 @@ -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; - -import { updateChart } from '../../../../../app/livechat/client/lib/chartHandler'; - -export const useUpdateChartData = ({ context, canvas, init, t }) => - useMutableCallback(async (label, data) => { - if (!context.current) { - context.current = await init(canvas.current, context.current, t); - } - await updateChart(context.current, label, data); - }); diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.ts b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.ts new file mode 100644 index 000000000000..805d828a9893 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.ts @@ -0,0 +1,26 @@ +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { type Chart } from 'chart.js'; +import { type TFunction } from 'i18next'; +import { type RefObject } from 'react'; + +import { updateChart } from '../../../../../app/livechat/client/lib/chartHandler'; + +type UseUpdateChartDataOptions = { + context: RefObject; + canvas: RefObject; + init: (canvas: HTMLCanvasElement, context: undefined, t: TFunction) => Promise; + t: TFunction; +}; + +export const useUpdateChartData = ({ context: contextRef, canvas: canvasRef, init, t }: UseUpdateChartDataOptions) => + useMutableCallback(async (label: string, data: { [x: string]: number }) => { + const canvas = canvasRef.current; + + if (!canvas) { + return; + } + + const context = contextRef.current ?? (await init(canvas, undefined, t)); + + await updateChart(context, label, data); + }); diff --git a/apps/meteor/client/views/omnichannel/sidebarItems.ts b/apps/meteor/client/views/omnichannel/sidebarItems.tsx similarity index 93% rename from apps/meteor/client/views/omnichannel/sidebarItems.ts rename to apps/meteor/client/views/omnichannel/sidebarItems.tsx index 048bcb4ef88e..7942764a8b89 100644 --- a/apps/meteor/client/views/omnichannel/sidebarItems.ts +++ b/apps/meteor/client/views/omnichannel/sidebarItems.tsx @@ -13,12 +13,6 @@ export const { i18nLabel: 'Current_Chats', permissionGranted: (): boolean => hasPermission('view-livechat-current-chats'), }, - { - href: '/omnichannel/reports', - icon: 'file', - i18nLabel: 'Reports', - permissionGranted: (): boolean => hasPermission('view-livechat-reports'), - }, { href: '/omnichannel/analytics', icon: 'dashboard', diff --git a/apps/meteor/client/views/omnichannel/triggers/TriggersForm.tsx b/apps/meteor/client/views/omnichannel/triggers/TriggersForm.tsx index 72c8d30d973e..7c9476059411 100644 --- a/apps/meteor/client/views/omnichannel/triggers/TriggersForm.tsx +++ b/apps/meteor/client/views/omnichannel/triggers/TriggersForm.tsx @@ -1,5 +1,5 @@ import type { SelectOption } from '@rocket.chat/fuselage'; -import { Box, Field, TextInput, ToggleSwitch, Select, TextAreaInput } from '@rocket.chat/fuselage'; +import { Box, Field, FieldLabel, FieldRow, FieldError, TextInput, ToggleSwitch, Select, TextAreaInput } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, FC, FormEvent } from 'react'; @@ -138,55 +138,55 @@ const TriggersForm: FC = ({ values, handlers, className }) => <> - {t('Enabled')} - + {t('Enabled')} + - + - {t('Run_only_once_for_each_visitor')} - + {t('Run_only_once_for_each_visitor')} + - + - {t('Name')}* - + {t('Name')}* + - - {nameError} + + {nameError} - {t('Description')} - + {t('Description')} + - + - {t('Condition')} - + {t('Condition')} + = ({ values, handlers, className }) => onChange={handleActionSender} placeholder={t('Select_an_option')} /> - + {actionSender === 'custom' && ( - + - + )} - + - - {msgError} + + {msgError} ); diff --git a/apps/meteor/client/views/omnichannel/triggers/TriggersRow.tsx b/apps/meteor/client/views/omnichannel/triggers/TriggersRow.tsx index 8a1a81d9f64a..5ade384582c2 100644 --- a/apps/meteor/client/views/omnichannel/triggers/TriggersRow.tsx +++ b/apps/meteor/client/views/omnichannel/triggers/TriggersRow.tsx @@ -1,7 +1,7 @@ import type { ILivechatTrigger } from '@rocket.chat/core-typings'; import { IconButton } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useSetModal, useToastMessageDispatch, useRoute, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; +import { useSetModal, useToastMessageDispatch, useRoute, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; import GenericModal from '../../../components/GenericModal'; @@ -13,7 +13,7 @@ const TriggersRow = ({ _id, name, description, enabled, reload }: TriggersRowPro const t = useTranslation(); const setModal = useSetModal(); const triggersRoute = useRoute('omnichannel-triggers'); - const deleteTrigger = useMethod('livechat:removeTrigger'); + const deleteTrigger = useEndpoint('DELETE', '/v1/livechat/triggers/:_id', { _id }); const dispatchToastMessage = useToastMessageDispatch(); const handleClick = useMutableCallback(() => { @@ -35,7 +35,7 @@ const TriggersRow = ({ _id, name, description, enabled, reload }: TriggersRowPro e.stopPropagation(); const onDeleteTrigger = async () => { try { - await deleteTrigger(_id); + await deleteTrigger(); dispatchToastMessage({ type: 'success', message: t('Trigger_removed') }); reload(); } catch (error) { 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 b42b559e4dfa..0b6453f92ab7 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 @@ -315,7 +315,6 @@ export const useQuickActions = (): { const canRoomBePlacedOnHold = !room.onHold && room.u; const canAgentPlaceOnHold = !room.lastMessage?.token; const canPlaceChatOnHold = Boolean(manualOnHoldAllowed && canRoomBePlacedOnHold && (!restrictedOnHold || canAgentPlaceOnHold)); - const isRoomOverMacLimit = useIsRoomOverMacLimit(room); const hasPermissionButtons = (id: string): boolean => { diff --git a/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx.orig b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx.orig new file mode 100644 index 000000000000..808ee17e988c --- /dev/null +++ b/apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx.orig @@ -0,0 +1,365 @@ +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { + useSetModal, + useToastMessageDispatch, + useUserId, + useSetting, + usePermission, + useRole, + useEndpoint, + useMethod, + useTranslation, + useRouter, +} from '@rocket.chat/ui-contexts'; +import React, { useCallback, useState, useEffect } from 'react'; + +import { LivechatInquiry } from '../../../../../../../app/livechat/client/collections/LivechatInquiry'; +import { LegacyRoomManager } from '../../../../../../../app/ui-utils/client'; +import PlaceChatOnHoldModal from '../../../../../../../ee/app/livechat-enterprise/client/components/modals/PlaceChatOnHoldModal'; +import { useHasLicenseModule } from '../../../../../../../ee/client/hooks/useHasLicenseModule'; +import CloseChatModal from '../../../../../../components/Omnichannel/modals/CloseChatModal'; +import CloseChatModalData from '../../../../../../components/Omnichannel/modals/CloseChatModalData'; +import ForwardChatModal from '../../../../../../components/Omnichannel/modals/ForwardChatModal'; +import ReturnChatQueueModal from '../../../../../../components/Omnichannel/modals/ReturnChatQueueModal'; +import TranscriptModal from '../../../../../../components/Omnichannel/modals/TranscriptModal'; +import { useIsRoomOverMacLimit } from '../../../../../../hooks/omnichannel/useIsRoomOverMacLimit'; +import { useOmnichannelRouteConfig } from '../../../../../../hooks/omnichannel/useOmnichannelRouteConfig'; +import { quickActionHooks } from '../../../../../../ui'; +import { useOmnichannelRoom } from '../../../../contexts/RoomContext'; +import type { QuickActionsActionConfig } from '../../../../lib/quickActions'; +import { QuickActionsEnum } from '../../../../lib/quickActions'; +import { usePutChatOnHoldMutation } from './usePutChatOnHoldMutation'; +import { useReturnChatToQueueMutation } from './useReturnChatToQueueMutation'; + +export const useQuickActions = (): { + quickActions: QuickActionsActionConfig[]; + actionDefault: (actionId: string) => void; +} => { + const room = useOmnichannelRoom(); + const setModal = useSetModal(); + const router = useRouter(); + + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const [onHoldModalActive, setOnHoldModalActive] = useState(false); + + const visitorRoomId = room.v._id; + const rid = room._id; + const uid = useUserId(); + const roomLastMessage = room.lastMessage; + + const getVisitorInfo = useEndpoint('GET', '/v1/livechat/visitors.info'); + + const getVisitorEmail = useMutableCallback(async () => { + if (!visitorRoomId) { + return; + } + + const { + visitor: { visitorEmails }, + } = await getVisitorInfo({ visitorId: visitorRoomId }); + + if (visitorEmails?.length && visitorEmails[0].address) { + return visitorEmails[0].address; + } + }); + + useEffect(() => { + if (onHoldModalActive && roomLastMessage?.token) { + setModal(null); + } + }, [roomLastMessage, onHoldModalActive, setModal]); + + const closeModal = useCallback(() => setModal(null), [setModal]); + + const requestTranscript = useEndpoint('POST', '/v1/livechat/transcript/:rid', { rid }); + + const handleRequestTranscript = useCallback( + async (email: string, subject: string) => { + try { + await requestTranscript({ email, subject }); + closeModal(); + dispatchToastMessage({ + type: 'success', + message: t('Livechat_email_transcript_has_been_requested'), + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, + [closeModal, dispatchToastMessage, requestTranscript, t], + ); + + const sendTranscriptPDF = useEndpoint('POST', '/v1/omnichannel/:rid/request-transcript', { rid }); + + const handleSendTranscriptPDF = useCallback(async () => { + try { + await sendTranscriptPDF(); + dispatchToastMessage({ + type: 'success', + message: t('Livechat_transcript_has_been_requested'), + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, [dispatchToastMessage, sendTranscriptPDF, t]); + + const sendTranscript = useMethod('livechat:sendTranscript'); + + const handleSendTranscript = useCallback( + async (email: string, subject: string, token: string) => { + try { + await sendTranscript(token, rid, email, subject); + closeModal(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, + [closeModal, dispatchToastMessage, rid, sendTranscript], + ); + + const discardTranscript = useEndpoint('DELETE', '/v1/livechat/transcript/:rid', { rid }); + + const handleDiscardTranscript = useCallback(async () => { + try { + await discardTranscript(); + dispatchToastMessage({ + type: 'success', + message: t('Livechat_transcript_request_has_been_canceled'), + }); + closeModal(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, [closeModal, discardTranscript, dispatchToastMessage, t]); + + const forwardChat = useEndpoint('POST', '/v1/livechat/room.forward'); + + const handleForwardChat = useCallback( + async (departmentId?: string, userId?: string, comment?: string) => { + if (departmentId && userId) { + return; + } + const transferData: { + roomId: string; + clientAction: boolean; + comment?: string; + departmentId?: string; + userId?: string; + } = { + roomId: rid, + comment, + clientAction: true, + }; + + if (departmentId) { + transferData.departmentId = departmentId; + } + if (userId) { + transferData.userId = userId; + } + + try { + await forwardChat(transferData); + dispatchToastMessage({ type: 'success', message: t('Transferred') }); + router.navigate('/home'); + LegacyRoomManager.close(room.t + rid); + closeModal(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, + [closeModal, dispatchToastMessage, forwardChat, room.t, rid, router, t], + ); + + const closeChat = useEndpoint('POST', '/v1/livechat/room.closeByUser'); + + const handleClose = useCallback( + async ( + comment?: string, + tags?: string[], + preferences?: { omnichannelTranscriptPDF: boolean; omnichannelTranscriptEmail: boolean }, + requestData?: { email: string; subject: string }, + ) => { + try { + await closeChat({ + rid, + ...(comment && { comment }), + ...(tags && { tags }), + ...(preferences?.omnichannelTranscriptPDF && { generateTranscriptPdf: true }), + ...(preferences?.omnichannelTranscriptEmail && requestData + ? { + transcriptEmail: { + sendToVisitor: preferences?.omnichannelTranscriptEmail, + requestData, + }, + } + : { transcriptEmail: { sendToVisitor: false } }), + }); + LivechatInquiry.remove({ rid }); + closeModal(); + dispatchToastMessage({ type: 'success', message: t('Chat_closed_successfully') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, + [closeChat, closeModal, dispatchToastMessage, rid, t], + ); + + const returnChatToQueueMutation = useReturnChatToQueueMutation({ + onSuccess: () => { + LegacyRoomManager.close(room.t + rid); + router.navigate('/home'); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: () => { + closeModal(); + }, + }); + + const putChatOnHoldMutation = usePutChatOnHoldMutation({ + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('Chat_On_Hold_Successfully') }); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: () => { + closeModal(); + }, + }); + + const handleAction = useMutableCallback(async (id: string) => { + switch (id) { + case QuickActionsEnum.MoveQueue: + setModal( + returnChatToQueueMutation.mutate(rid)} + onCancel={(): void => { + closeModal(); + }} + />, + ); + break; + case QuickActionsEnum.TranscriptPDF: + handleSendTranscriptPDF(); + break; + case QuickActionsEnum.TranscriptEmail: + const visitorEmail = await getVisitorEmail(); + + if (!visitorEmail) { + dispatchToastMessage({ type: 'error', message: t('Customer_without_registered_email') }); + break; + } + + setModal( + , + ); + break; + case QuickActionsEnum.ChatForward: + setModal(); + break; + case QuickActionsEnum.CloseChat: + const email = await getVisitorEmail(); + setModal( + room.departmentId ? ( + + ) : ( + + ), + ); + break; + case QuickActionsEnum.OnHoldChat: + setModal( + putChatOnHoldMutation.mutate(rid)} + onCancel={(): void => { + closeModal(); + setOnHoldModalActive(false); + }} + />, + ); + setOnHoldModalActive(true); + break; + default: + break; + } + }); + + const omnichannelRouteConfig = useOmnichannelRouteConfig(); + + const manualOnHoldAllowed = useSetting('Livechat_allow_manual_on_hold'); + + const hasManagerRole = useRole('livechat-manager'); + const hasMonitorRole = useRole('livechat-monitor'); + + const roomOpen = room?.open && (room.u?._id === uid || hasManagerRole || hasMonitorRole) && room?.lastMessage?.t !== 'livechat-close'; + const canMoveQueue = !!omnichannelRouteConfig?.returnQueue && room?.u !== undefined; + const canForwardGuest = usePermission('transfer-livechat-guest'); + const canSendTranscriptEmail = usePermission('send-omnichannel-chat-transcript'); + const hasLicense = useHasLicenseModule('livechat-enterprise'); + const canSendTranscriptPDF = usePermission('request-pdf-transcript'); + const canCloseRoom = usePermission('close-livechat-room'); + const canCloseOthersRoom = usePermission('close-others-livechat-room'); +<<<<<<< HEAD + const restrictedOnHold = useSetting('Livechat_allow_manual_on_hold_upon_agent_engagement_only'); + const canRoomBePlacedOnHold = !room.onHold && room.u; + const canAgentPlaceOnHold = !room.lastMessage?.token; + const canPlaceChatOnHold = Boolean(manualOnHoldAllowed && canRoomBePlacedOnHold && (!restrictedOnHold || canAgentPlaceOnHold)); + +======= + const canPlaceChatOnHold = Boolean(!room.onHold && room.u && !(room as any).lastMessage?.token && manualOnHoldAllowed); +>>>>>>> develop + const isRoomOverMacLimit = useIsRoomOverMacLimit(room); + + const hasPermissionButtons = (id: string): boolean => { + switch (id) { + case QuickActionsEnum.MoveQueue: + return !isRoomOverMacLimit && !!roomOpen && canMoveQueue; + case QuickActionsEnum.ChatForward: + return !isRoomOverMacLimit && !!roomOpen && canForwardGuest; + case QuickActionsEnum.Transcript: + return !isRoomOverMacLimit && (canSendTranscriptEmail || (hasLicense && canSendTranscriptPDF)); + case QuickActionsEnum.TranscriptEmail: + return !isRoomOverMacLimit && canSendTranscriptEmail; + case QuickActionsEnum.TranscriptPDF: + return hasLicense && !isRoomOverMacLimit && canSendTranscriptPDF; + case QuickActionsEnum.CloseChat: + return !!roomOpen && (canCloseRoom || canCloseOthersRoom); + case QuickActionsEnum.OnHoldChat: + return !!roomOpen && canPlaceChatOnHold; + default: + break; + } + return false; + }; + + const quickActions = quickActionHooks + .map((quickActionHook) => quickActionHook()) + .filter((quickAction): quickAction is QuickActionsActionConfig => !!quickAction) + .filter((action) => { + const { options, id } = action; + if (options) { + action.options = options.filter(({ id }) => hasPermissionButtons(id)); + } + + return hasPermissionButtons(id); + }) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + const actionDefault = useMutableCallback((actionId: string) => { + handleAction(actionId); + }); + + return { quickActions, actionDefault }; +}; diff --git a/apps/meteor/client/views/room/Header/icons/Encrypted.tsx b/apps/meteor/client/views/room/Header/icons/Encrypted.tsx index 35aecc1e2dfb..bd380c5d8af2 100644 --- a/apps/meteor/client/views/room/Header/icons/Encrypted.tsx +++ b/apps/meteor/client/views/room/Header/icons/Encrypted.tsx @@ -2,23 +2,34 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import colors from '@rocket.chat/fuselage-tokens/colors'; import { HeaderState } from '@rocket.chat/ui-client'; -import { useSetting, usePermission, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; +import { useSetting, usePermission, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import React, { memo } from 'react'; +import { dispatchToastMessage } from '../../../../lib/toast'; + const Encrypted = ({ room }: { room: IRoom }) => { const t = useTranslation(); const e2eEnabled = useSetting('E2E_Enable'); - const toggleE2E = useMethod('saveRoomSettings'); + const toggleE2E = useEndpoint('POST', '/v1/rooms.saveRoomSettings'); const canToggleE2E = usePermission('toggle-room-e2e-encryption'); const encryptedLabel = canToggleE2E ? t('Encrypted_key_title') : t('Encrypted'); - const handleE2EClick = useMutableCallback(() => { + const handleE2EClick = useMutableCallback(async () => { if (!canToggleE2E) { return; } - toggleE2E(room._id, 'encrypted', !room?.encrypted); + + const { success } = await toggleE2E({ rid: room._id, encrypted: !room.encrypted }); + if (!success) { + return; + } + + dispatchToastMessage({ + type: 'success', + message: t('E2E_Encryption_disabled_for_room', { roomName: room.name }), + }); }); return e2eEnabled && room?.encrypted ? ( - + ) : null; }; diff --git a/apps/meteor/client/views/room/Room.tsx b/apps/meteor/client/views/room/Room.tsx index d53254647483..d8cf86dbbb48 100644 --- a/apps/meteor/client/views/room/Room.tsx +++ b/apps/meteor/client/views/room/Room.tsx @@ -23,7 +23,7 @@ const Room = (): ReactElement => { const toolbox = useRoomToolbox(); - const appsContextualBarContext = useAppsContextualBar(); + const contextualBarView = useAppsContextualBar(); return ( @@ -41,16 +41,11 @@ const Room = (): ReactElement => { )) || - (appsContextualBarContext && ( + (contextualBarView && ( }> - + diff --git a/apps/meteor/client/views/room/body/RoomForeword/RoomForewordUsernameList.tsx b/apps/meteor/client/views/room/body/RoomForeword/RoomForewordUsernameList.tsx index b84a867d11d7..4677bb5e1ad3 100644 --- a/apps/meteor/client/views/room/body/RoomForeword/RoomForewordUsernameList.tsx +++ b/apps/meteor/client/views/room/body/RoomForeword/RoomForewordUsernameList.tsx @@ -1,4 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; +import { Margins } from '@rocket.chat/fuselage'; import { useSetting } from '@rocket.chat/ui-contexts'; import type { VFC } from 'react'; import React from 'react'; @@ -11,7 +12,7 @@ type RoomForewordUsernameListProps = { usernames: Array = ({ usernames }) => { const useRealName = Boolean(useSetting('UI_Use_Real_Name')); return ( - <> + {usernames.map((username) => ( = ({ username useRealName={useRealName} /> ))} - + ); }; diff --git a/apps/meteor/client/views/room/body/RoomForeword/RoomForewordUsernameListItem.tsx b/apps/meteor/client/views/room/body/RoomForeword/RoomForewordUsernameListItem.tsx index 5ac168b91846..a0732b35d29d 100644 --- a/apps/meteor/client/views/room/body/RoomForeword/RoomForewordUsernameListItem.tsx +++ b/apps/meteor/client/views/room/body/RoomForeword/RoomForewordUsernameListItem.tsx @@ -1,5 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; -import { Box, Icon, Tag, Skeleton } from '@rocket.chat/fuselage'; +import { Icon, Tag, Skeleton } from '@rocket.chat/fuselage'; import type { VFC } from 'react'; import React from 'react'; @@ -16,13 +16,11 @@ const RoomForewordUsernameListItem: VFC = ({ const { data, isLoading, isError } = useUserInfoQuery({ username }); return ( - - } className='mention-link' data-username={username} large> - {isLoading && } - {!isLoading && isError && username} - {!isLoading && !isError && getUserDisplayName(data?.user?.name, username, useRealName)} - - + } data-username={username} large href={href}> + {isLoading && } + {!isLoading && isError && username} + {!isLoading && !isError && getUserDisplayName(data?.user?.name, username, useRealName)} + ); }; diff --git a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts index bd89a3d72a60..2df567e77fb0 100644 --- a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts +++ b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts @@ -4,6 +4,7 @@ import type { ReactNode } from 'react'; import type React from 'react'; import { useCallback, useMemo } from 'react'; +import { useIsRoomOverMacLimit } from '../../../../hooks/omnichannel/useIsRoomOverMacLimit'; import { useReactiveValue } from '../../../../hooks/useReactiveValue'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; import { useChat } from '../../contexts/ChatContext'; @@ -24,6 +25,8 @@ export const useFileUploadDropTarget = (): readonly [ const room = useRoom(); const { triggerProps, overlayProps } = useDropTarget(); + const isRoomOverMacLimit = useIsRoomOverMacLimit(room); + const t = useTranslation(); const fileUploadEnabled = useSetting('FileUpload_Enabled') as boolean; @@ -46,7 +49,7 @@ export const useFileUploadDropTarget = (): readonly [ }); const allOverlayProps = useMemo(() => { - if (!fileUploadEnabled) { + if (!fileUploadEnabled || isRoomOverMacLimit) { return { enabled: false, reason: t('FileUpload_Disabled'), @@ -67,7 +70,7 @@ export const useFileUploadDropTarget = (): readonly [ onFileDrop, ...overlayProps, } as const; - }, [fileUploadAllowedForUser, fileUploadEnabled, onFileDrop, overlayProps, t]); + }, [fileUploadAllowedForUser, fileUploadEnabled, isRoomOverMacLimit, onFileDrop, overlayProps, t]); return [triggerProps, allOverlayProps] as const; }; diff --git a/apps/meteor/client/views/room/body/hooks/useGoToHomeOnRemoved.ts b/apps/meteor/client/views/room/body/hooks/useGoToHomeOnRemoved.ts index a087d288d0d7..068c97a2de4b 100644 --- a/apps/meteor/client/views/room/body/hooks/useGoToHomeOnRemoved.ts +++ b/apps/meteor/client/views/room/body/hooks/useGoToHomeOnRemoved.ts @@ -1,9 +1,9 @@ -import type { IRoom } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom, type IRoom } from '@rocket.chat/core-typings'; import { useRoute, useStream, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; -const IGNORED_ROOMS = ['l', 'v']; +import { useOmnichannelCloseRoute } from '../../../../hooks/omnichannel/useOmnichannelCloseRoute'; export function useGoToHomeOnRemoved(room: IRoom, userId: string | undefined): void { const homeRouter = useRoute('home'); @@ -11,6 +11,7 @@ export function useGoToHomeOnRemoved(room: IRoom, userId: string | undefined): v const dispatchToastMessage = useToastMessageDispatch(); const subscribeToNotifyUser = useStream('notify-user'); const t = useTranslation(); + const { navigateHome } = useOmnichannelCloseRoute(); useEffect(() => { if (!userId) { @@ -21,19 +22,35 @@ export function useGoToHomeOnRemoved(room: IRoom, userId: string | undefined): v if (event === 'removed' && subscription.rid === room._id) { queryClient.invalidateQueries(['rooms', room._id]); - if (!IGNORED_ROOMS.includes(room.t)) { - dispatchToastMessage({ - type: 'info', - message: t('You_have_been_removed_from__roomName_', { - roomName: room?.fname || room?.name || '', - }), - }); + if (isOmnichannelRoom(room)) { + navigateHome(); + return; } + dispatchToastMessage({ + type: 'info', + message: t('You_have_been_removed_from__roomName_', { + roomName: room?.fname || room?.name || '', + }), + }); + homeRouter.push({}); } }); return unSubscribeFromNotifyUser; - }, [userId, homeRouter, subscribeToNotifyUser, room._id, room?.fname, room?.name, t, dispatchToastMessage, queryClient, room.t]); + }, [ + userId, + homeRouter, + subscribeToNotifyUser, + room._id, + room?.fname, + room?.name, + t, + dispatchToastMessage, + queryClient, + room.t, + room, + navigateHome, + ]); } diff --git a/apps/meteor/client/views/room/composer/ComposerOmnichannel/ComposerOmnichannel.tsx b/apps/meteor/client/views/room/composer/ComposerOmnichannel/ComposerOmnichannel.tsx index 649f9a9a4264..5ba9b580e109 100644 --- a/apps/meteor/client/views/room/composer/ComposerOmnichannel/ComposerOmnichannel.tsx +++ b/apps/meteor/client/views/room/composer/ComposerOmnichannel/ComposerOmnichannel.tsx @@ -3,6 +3,7 @@ import { useTranslation, useUserId } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; +import { useIsRoomOverMacLimit } from '../../../../hooks/omnichannel/useIsRoomOverMacLimit'; import { useOmnichannelRoom, useUserIsSubscribed } from '../../contexts/RoomContext'; import type { ComposerMessageProps } from '../ComposerMessage'; import ComposerMessage from '../ComposerMessage'; @@ -11,7 +12,8 @@ import { ComposerOmnichannelJoin } from './ComposerOmnichannelJoin'; import { ComposerOmnichannelOnHold } from './ComposerOmnichannelOnHold'; const ComposerOmnichannel = (props: ComposerMessageProps): ReactElement => { - const { servedBy, queuedAt, open, onHold } = useOmnichannelRoom(); + const room = useOmnichannelRoom(); + const { servedBy, queuedAt, open, onHold } = room; const userId = useUserId(); const isSubscribed = useUserIsSubscribed(); @@ -22,8 +24,14 @@ const ComposerOmnichannel = (props: ComposerMessageProps): ReactElement => { const isSameAgent = servedBy?._id === userId; + const isRoomOverMacLimit = useIsRoomOverMacLimit(room); + if (!open) { - return {t('This_conversation_is_already_closed')}; + return {t('This_conversation_is_already_closed')}; + } + + if (isRoomOverMacLimit) { + return {t('Workspace_exceeded_MAC_limit_disclaimer')}; } if (onHold) { diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index de9a96dc43b4..da598c00be11 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -141,7 +141,7 @@ const MessageBox = ({ [chat, storageID], ); - const autofocusRef = useMessageBoxAutoFocus(); + const autofocusRef = useMessageBoxAutoFocus(!isMobile); const useEmojis = useUserPreference('useEmojis'); diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/CreateDiscussionAction.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/CreateDiscussionAction.tsx index 3a1f1eef6dcc..419c6c2cfdda 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/CreateDiscussionAction.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/CreateDiscussionAction.tsx @@ -14,8 +14,8 @@ const CreateDiscussionAction = ({ room }: { room: IRoom }) => { setModal( setModal(null)} defaultParentRoom={room?.prid || room?._id} />); const discussionEnabled = useSetting('Discussion_enabled') as boolean; - const canStartDiscussion = usePermission('start-discussion'); - const canSstartDiscussionOtherUser = usePermission('start-discussion-other-user'); + const canStartDiscussion = usePermission('start-discussion', room._id); + const canSstartDiscussionOtherUser = usePermission('start-discussion-other-user', room._id); const allowDiscussion = room && discussionEnabled && !isRoomFederated(room) && (canStartDiscussion || canSstartDiscussionOtherUser); diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/FileUploadAction.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/FileUploadAction.tsx index 73b293a60047..f9c826fceb4b 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/FileUploadAction.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/FileUploadAction.tsx @@ -18,6 +18,14 @@ const FileUploadAction = ({ collapsed, chatContext, disabled, ...props }: FileUp const fileInputRef = useRef(null); const chat = useChat() ?? chatContext; + const resetFileInput = () => { + if (!fileInputRef.current) { + return; + } + + fileInputRef.current.value = ''; + }; + const handleUploadChange = async (e: ChangeEvent) => { const { mime } = await import('../../../../../../../app/utils/lib/mimeTypes'); const filesToUpload = Array.from(e.target.files ?? []).map((file) => { @@ -26,8 +34,7 @@ const FileUploadAction = ({ collapsed, chatContext, disabled, ...props }: FileUp }); return file; }); - - chat?.flows.uploadFiles(filesToUpload); + chat?.flows.uploadFiles(filesToUpload, resetFileInput); }; const handleUpload = () => { diff --git a/apps/meteor/client/views/room/composer/messageBox/hooks/useMessageBoxAutoFocus.ts b/apps/meteor/client/views/room/composer/messageBox/hooks/useMessageBoxAutoFocus.ts index 5ea6db79a869..b8efd9391f87 100644 --- a/apps/meteor/client/views/room/composer/messageBox/hooks/useMessageBoxAutoFocus.ts +++ b/apps/meteor/client/views/room/composer/messageBox/hooks/useMessageBoxAutoFocus.ts @@ -1,13 +1,13 @@ import type { Ref } from 'react'; -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; /** * if the user is types outside the message box and its not actually typing in any input field * then the message box should be focused * @returns callbackRef to bind the logic to the message box */ -export const useMessageBoxAutoFocus = (): Ref => { - const ref = useRef(null); +export const useMessageBoxAutoFocus = (enabled: boolean): Ref => { + const ref = useRef(); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -43,5 +43,22 @@ export const useMessageBoxAutoFocus = (): Ref => { }; }, []); - return ref; + return useCallback( + (node: HTMLElement | null) => { + if (!node) { + return; + } + + ref.current = node; + + if (!enabled) { + return; + } + + if (ref.current) { + ref.current.focus(); + } + }, + [enabled, ref], + ); }; diff --git a/apps/meteor/client/views/room/contextualBar/AutoTranslate/AutoTranslate.tsx b/apps/meteor/client/views/room/contextualBar/AutoTranslate/AutoTranslate.tsx index a15edebf8c16..ad1560d3078d 100644 --- a/apps/meteor/client/views/room/contextualBar/AutoTranslate/AutoTranslate.tsx +++ b/apps/meteor/client/views/room/contextualBar/AutoTranslate/AutoTranslate.tsx @@ -1,4 +1,4 @@ -import { FieldGroup, Field, ToggleSwitch, Select } from '@rocket.chat/fuselage'; +import { Callout, FieldGroup, Field, FieldLabel, FieldRow, ToggleSwitch, Select } from '@rocket.chat/fuselage'; import type { SelectOption } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ChangeEvent } from 'react'; @@ -11,6 +11,7 @@ import { ContextualbarIcon, ContextualbarContent, } from '../../../../components/Contextualbar'; +import { useRoom } from '../../contexts/RoomContext'; type AutoTranslateProps = { language: string; @@ -30,6 +31,7 @@ const AutoTranslate = ({ handleClose, }: AutoTranslateProps): ReactElement => { const t = useTranslation(); + const room = useRoom(); return ( <> @@ -40,15 +42,25 @@ const AutoTranslate = ({ + {room.encrypted && ( + + {t('Automatic_translation_not_available_info')} + + )} - - - {t('Automatic_Translation')} - + + + {t('Automatic_Translation')} + - {t('Language')} - + {t('Translate_to')} + setType(String(value))} placeholder={t('Type')} options={exportOptions} /> - - - - {type && type === 'file' && } - {type && type === 'email' && } - + + {methods.watch('type') === 'email' && ( + + )} + {methods.watch('type') === 'file' && ( + + )} + ); }; diff --git a/apps/meteor/client/views/room/contextualBar/ExportMessages/FileExport.tsx b/apps/meteor/client/views/room/contextualBar/ExportMessages/FileExport.tsx index efbf60b69606..3d5bc5966cab 100644 --- a/apps/meteor/client/views/room/contextualBar/ExportMessages/FileExport.tsx +++ b/apps/meteor/client/views/room/contextualBar/ExportMessages/FileExport.tsx @@ -1,32 +1,27 @@ import type { IRoom } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; -import { Field, Select, ButtonGroup, Button, FieldGroup, InputBox } from '@rocket.chat/fuselage'; -import { useToastMessageDispatch, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC, MouseEventHandler } from 'react'; +import { Field, FieldLabel, FieldRow, Select, ButtonGroup, Button, FieldGroup, InputBox } from '@rocket.chat/fuselage'; +import { useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; -import { useForm } from '../../../../hooks/useForm'; +import { ContextualbarScrollableContent, ContextualbarFooter } from '../../../../components/Contextualbar'; +import type { MailExportFormValues } from './ExportMessages'; +import { useRoomExportMutation } from './useRoomExportMutation'; -type MailExportFormValues = { - dateFrom: string; - dateTo: string; - format: 'html' | 'json'; +type FileExportProps = { + formId: string; + rid: IRoom['_id']; + onCancel: () => void; + exportOptions: SelectOption[]; }; -type FileExportProps = { onCancel: MouseEventHandler; rid: IRoom['_id'] }; - -const FileExport: FC = ({ onCancel, rid }) => { +const FileExport = ({ formId, rid, exportOptions, onCancel }: FileExportProps) => { const t = useTranslation(); - - const { values, handlers } = useForm({ - dateFrom: '', - dateTo: '', - format: 'html', - }); - - const { dateFrom, dateTo, format } = values as MailExportFormValues; - - const { handleDateFrom, handleDateTo, handleFormat } = handlers; + const { control, handleSubmit } = useFormContext(); + const roomExportMutation = useRoomExportMutation(); + const formFocus = useAutoFocus(); const outputOptions = useMemo( () => [ @@ -36,59 +31,74 @@ const FileExport: FC = ({ onCancel, rid }) => { [t], ); - const roomsExport = useEndpoint('POST', '/v1/rooms.export'); - - const dispatchToastMessage = useToastMessageDispatch(); - - const handleSubmit = async (): Promise => { - try { - await roomsExport({ - rid, - type: 'file', - dateFrom, - dateTo, - format, - }); - - dispatchToastMessage({ - type: 'success', - message: t('Your_email_has_been_queued_for_sending'), - }); - } catch (error) { - dispatchToastMessage({ - type: 'error', - message: error, - }); - } + const handleExport = ({ type, dateFrom, dateTo, format }: MailExportFormValues) => { + roomExportMutation.mutateAsync({ + rid, + type, + dateFrom, + dateTo, + format, + }); }; + const typeField = useUniqueId(); + const dateFromField = useUniqueId(); + const dateToField = useUniqueId(); + const formatField = useUniqueId(); + return ( - - - {t('Date_From')} - - - - - - {t('Date_to')} - - - - - - {t('Output_format')} - - } + /> + + + + {t('Date_From')} + + } + /> + + + + {t('Date_to')} + + } /> + + + + {t('Output_format')} + + } + /> + + + + 0 ? 'success' : 'info'}> +

{`${count} Messages selected`}

+ {count > 0 && ( + + {t('Click_here_to_clear_the_selection')} + + )} + {count === 0 && {t('Click_the_messages_you_would_like_to_send_by_email')}} +
+ (messagesCount > 0 ? undefined : t('Mail_Message_No_messages_selected_select_all')), + })} + /> + {errors.messagesCount && {errors.messagesCount.message}} +
+ + {t('To_users')} + + ( + { + onChange(value); + clearErrors('additionalEmails'); + }} + onBlur={onBlur} + name={name} + /> + )} + /> + + + + {t('To_additional_emails')} + + { + if (additionalEmails === '') { + return undefined; + } + + if (additionalEmails !== '' && validateEmail(additionalEmails)) { + return undefined; + } + + return t('Mail_Message_Invalid_emails', additionalEmails); + }, + validateToUsers: (additionalEmails) => { + if (additionalEmails !== '' || toUsers?.length > 0) { + return undefined; + } + + return t('Mail_Message_Missing_to'); + }, + }, + }} + render={({ field }) => ( + } + aria-describedby={`${additionalEmailsField}-error`} + aria-invalid={Boolean(errors?.additionalEmails?.message)} + error={errors?.additionalEmails?.message} + /> + )} + /> + + {errors?.additionalEmails && ( + + {errors.additionalEmails.message} + + )} + + + {t('Subject')} + + } />} + /> + + +
+ + + + + + + + + ); }; diff --git a/apps/meteor/client/views/room/contextualBar/ExportMessages/useRoomExportMutation.ts b/apps/meteor/client/views/room/contextualBar/ExportMessages/useRoomExportMutation.ts new file mode 100644 index 000000000000..ef8d67e3af3d --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/ExportMessages/useRoomExportMutation.ts @@ -0,0 +1,24 @@ +import { useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; + +export const useRoomExportMutation = () => { + const t = useTranslation(); + const roomsExport = useEndpoint('POST', '/v1/rooms.export'); + const dispatchToastMessage = useToastMessageDispatch(); + + return useMutation({ + mutationFn: roomsExport, + onSuccess: () => { + dispatchToastMessage({ + type: 'success', + message: t('Your_email_has_been_queued_for_sending'), + }); + }, + onError: (error) => { + dispatchToastMessage({ + type: 'error', + message: error, + }); + }, + }); +}; diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditChannel.js b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditChannel.js deleted file mode 100644 index be7a8b1e4238..000000000000 --- a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditChannel.js +++ /dev/null @@ -1,511 +0,0 @@ -import { isRoomFederated } from '@rocket.chat/core-typings'; -import { - Field, - TextInput, - PasswordInput, - ToggleSwitch, - MultiSelect, - Accordion, - Callout, - NumberInput, - FieldGroup, - Button, - ButtonGroup, - Box, - TextAreaInput, -} from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { - useSetModal, - useSetting, - usePermission, - useAtLeastOnePermission, - useRole, - useMethod, - useTranslation, - useRouter, -} from '@rocket.chat/ui-contexts'; -import React, { useCallback, useMemo, useRef } from 'react'; - -import { e2e } from '../../../../../../app/e2e/client/rocketchat.e2e'; -import { MessageTypesValues } from '../../../../../../app/lib/lib/MessageTypes'; -import { RoomSettingsEnum } from '../../../../../../definition/IRoomTypeConfig'; -import { - ContextualbarHeader, - ContextualbarBack, - ContextualbarTitle, - ContextualbarClose, - ContextualbarScrollableContent, - ContextualbarFooter, -} from '../../../../../components/Contextualbar'; -import GenericModal from '../../../../../components/GenericModal'; -import RawText from '../../../../../components/RawText'; -import RoomAvatarEditor from '../../../../../components/avatar/RoomAvatarEditor'; -import { useEndpointAction } from '../../../../../hooks/useEndpointAction'; -import { useForm } from '../../../../../hooks/useForm'; -import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; - -const typeMap = { - c: 'Channels', - p: 'Groups', - d: 'DMs', -}; - -const useInitialValues = (room, settings) => { - const { - t, - ro, - archived, - topic, - description, - announcement, - joinCodeRequired, - sysMes, - encrypted, - retention = {}, - reactWhenReadOnly, - } = room; - - const { retentionPolicyEnabled, maxAgeDefault } = settings; - - const retentionEnabledDefault = useSetting(`RetentionPolicy_AppliesTo${typeMap[room.t]}`); - const excludePinnedDefault = useSetting('RetentionPolicy_DoNotPrunePinned'); - const filesOnlyDefault = useSetting('RetentionPolicy_FilesOnly'); - - return useMemo( - () => ({ - roomName: t === 'd' ? room.usernames.join(' x ') : roomCoordinator.getRoomName(t, { type: t, ...room }), - roomType: t, - readOnly: !!ro, - reactWhenReadOnly, - archived: !!archived, - roomTopic: topic ?? '', - roomDescription: description ?? '', - roomAnnouncement: announcement ?? '', - roomAvatar: undefined, - joinCode: '', - joinCodeRequired: !!joinCodeRequired, - systemMessages: Array.isArray(sysMes) ? sysMes : [], - hideSysMes: !!sysMes?.length, - encrypted, - ...(retentionPolicyEnabled && { - retentionEnabled: retention.enabled ?? retentionEnabledDefault, - retentionOverrideGlobal: !!retention.overrideGlobal, - retentionMaxAge: Math.min(retention.maxAge, maxAgeDefault) || maxAgeDefault, - retentionExcludePinned: retention.excludePinned ?? excludePinnedDefault, - retentionFilesOnly: retention.filesOnly ?? filesOnlyDefault, - }), - }), - [ - announcement, - archived, - description, - excludePinnedDefault, - filesOnlyDefault, - joinCodeRequired, - maxAgeDefault, - retention.enabled, - retention.excludePinned, - retention.filesOnly, - retention.maxAge, - retention.overrideGlobal, - retentionEnabledDefault, - retentionPolicyEnabled, - ro, - room, - sysMes, - t, - topic, - encrypted, - reactWhenReadOnly, - ], - ); -}; - -const getCanChangeType = (room, canCreateChannel, canCreateGroup, isAdmin) => - (!room.default || isAdmin) && ((room.t === 'p' && canCreateChannel) || (room.t === 'c' && canCreateGroup)); - -function EditChannel({ room, onClickClose, onClickBack }) { - const t = useTranslation(); - - const setModal = useSetModal(); - - const retentionPolicyEnabled = useSetting('RetentionPolicy_Enabled'); - const maxAgeDefault = useSetting(`RetentionPolicy_MaxAge_${typeMap[room.t]}`) || 30; - - const saveData = useRef({}); - const router = useRouter(); - - const onChange = useCallback(({ initialValue, value, key }) => { - const { current } = saveData; - if (JSON.stringify(initialValue) !== JSON.stringify(value)) { - if (key === 'systemMessages' && value?.length > 0) { - current.hideSysMes = true; - } - current[key] = value; - } else { - delete current[key]; - } - }, []); - - const { values, handlers, hasUnsavedChanges, reset, commit } = useForm( - useInitialValues(room, { retentionPolicyEnabled, maxAgeDefault }), - onChange, - ); - - const sysMesOptions = useMemo(() => MessageTypesValues.map(({ key, i18nLabel }) => [key, t(i18nLabel)]), [t]); - - const { - roomName, - roomType, - readOnly, - encrypted, - roomAvatar, - archived, - roomTopic, - roomDescription, - roomAnnouncement, - reactWhenReadOnly, - joinCode, - joinCodeRequired, - systemMessages, - hideSysMes, - retentionEnabled, - retentionOverrideGlobal, - retentionMaxAge, - retentionExcludePinned, - retentionFilesOnly, - } = values; - - const { - handleJoinCode, - handleJoinCodeRequired, - handleSystemMessages, - handleEncrypted, - handleHideSysMes, - handleRoomName, - handleReadOnly, - handleArchived, - handleRoomAvatar, - handleReactWhenReadOnly, - handleRoomType, - handleRoomTopic, - handleRoomDescription, - handleRoomAnnouncement, - handleRetentionEnabled, - handleRetentionOverrideGlobal, - handleRetentionMaxAge, - handleRetentionExcludePinned, - handleRetentionFilesOnly, - } = handlers; - - const [ - canViewName, - canViewTopic, - canViewAnnouncement, - canViewArchived, - canViewDescription, - canViewType, - canViewReadOnly, - canViewHideSysMes, - canViewJoinCode, - canViewEncrypted, - ] = useMemo(() => { - const isAllowed = roomCoordinator.getRoomDirectives(room.t).allowRoomSettingChange || (() => {}); - return [ - isAllowed(room, RoomSettingsEnum.NAME), - isAllowed(room, RoomSettingsEnum.TOPIC), - isAllowed(room, RoomSettingsEnum.ANNOUNCEMENT), - isAllowed(room, RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE), - isAllowed(room, RoomSettingsEnum.DESCRIPTION), - isAllowed(room, RoomSettingsEnum.TYPE), - isAllowed(room, RoomSettingsEnum.READ_ONLY), - isAllowed(room, RoomSettingsEnum.SYSTEM_MESSAGES), - isAllowed(room, RoomSettingsEnum.JOIN_CODE), - isAllowed(room, RoomSettingsEnum.REACT_WHEN_READ_ONLY), - isAllowed(room, RoomSettingsEnum.E2E), - ]; - }, [room]); - - const isAdmin = useRole('admin'); - - const canCreateChannel = usePermission('create-c'); - const canCreateGroup = usePermission('create-p'); - const canChangeType = getCanChangeType(room, canCreateChannel, canCreateGroup, isAdmin); - const canSetRo = usePermission('set-readonly', room._id); - const canSetReactWhenRo = usePermission('set-react-when-readonly', room._id); - const canEditRoomRetentionPolicy = usePermission('edit-room-retention-policy', room._id); - const canArchiveOrUnarchive = useAtLeastOnePermission( - useMemo(() => ['archive-room', 'unarchive-room'], []), - room._id, - ); - const canDelete = usePermission(`delete-${room.t}`); - const canToggleEncryption = usePermission('toggle-room-e2e-encryption', room._id) && (room.encrypted || e2e.isReady()); - - const changeArchivation = archived !== !!room.archived; - const archiveSelector = room.archived ? 'unarchive' : 'archive'; - const archiveMessage = room.archived ? 'Room_has_been_unarchived' : 'Room_has_been_archived'; - const saveAction = useEndpointAction('POST', '/v1/rooms.saveRoomSettings', { - successMessage: t('Room_updated_successfully'), - }); - const archiveAction = useEndpointAction('POST', '/v1/rooms.changeArchivationState', { successMessage: t(archiveMessage) }); - - const handleSave = useMutableCallback(async () => { - const { joinCodeRequired, hideSysMes, ...data } = saveData.current; - delete data.archived; - const save = () => - saveAction({ - rid: room._id, - ...data, - ...(joinCode && { joinCode: joinCodeRequired ? joinCode : '' }), - ...((data.systemMessages || !hideSysMes) && { - systemMessages: hideSysMes && systemMessages, - }), - }); - - const archive = () => archiveAction({ rid: room._id, action: archiveSelector }); - - await Promise.all([hasUnsavedChanges && save(), changeArchivation && archive()].filter(Boolean)); - saveData.current = {}; - commit(); - }); - - const deleteRoom = useMethod('eraseRoom'); - - const handleDelete = useMutableCallback(() => { - const onCancel = () => setModal(undefined); - const onConfirm = async () => { - await deleteRoom(room._id); - onCancel(); - router.navigate('/home'); - }; - - setModal( - - {t('Delete_Room_Warning')} - , - ); - }); - - const changeRoomType = useMutableCallback(() => { - handleRoomType(roomType === 'p' ? 'c' : 'p'); - }); - - const onChangeMaxAge = useMutableCallback((e) => { - handleRetentionMaxAge(Math.max(1, Number(e.currentTarget.value))); - }); - - const isFederated = useMemo(() => isRoomFederated(room), [room]); - - return ( - <> - - {onClickBack && } - {room.teamId ? t('edit-team') : t('edit-room')} - {onClickClose && } - - e.preventDefault())}> - - - - - {t('Name')} - - - - - {canViewDescription && ( - - {t('Description')} - - - - - )} - {canViewAnnouncement && ( - - {t('Announcement')} - - - - - )} - {canViewTopic && ( - - {t('Topic')} - - - - - )} - {canViewType && ( - - - {t('Private')} - - - - - {t('Teams_New_Private_Description_Enabled')} - - )} - {canViewReadOnly && ( - - - {t('Read_only')} - - - - - {t('Only_authorized_users_can_write_new_messages')} - - )} - {readOnly && ( - - - {t('React_when_read_only')} - - - - - {t('Only_authorized_users_can_react_to_messages')} - - )} - {canViewArchived && ( - - - {t('Room_archivation_state_true')} - - - - - - )} - {canViewJoinCode && ( - - - {t('Password_to_access')} - - - - - - - - - )} - {canViewHideSysMes && ( - - - {t('Hide_System_Messages')} - - - - - - - - - )} - {canViewEncrypted && ( - - - {t('Encrypted')} - - - - - - )} - {retentionPolicyEnabled && ( - - - - - - {t('RetentionPolicyRoom_Enabled')} - - - - - - - - {t('RetentionPolicyRoom_OverrideGlobal')} - - - - - - {retentionOverrideGlobal && ( - <> - - {t('RetentionPolicyRoom_ReadTheDocs')} - - - {t('RetentionPolicyRoom_MaxAge', { max: maxAgeDefault })} - - - - - - - {t('RetentionPolicyRoom_ExcludePinned')} - - - - - - - - {t('RetentionPolicyRoom_FilesOnly')} - - - - - - - )} - - - - )} - - - - - - - - - - - - ); -} - -export default EditChannel; diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditChannelWithData.js b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditChannelWithData.js deleted file mode 100644 index e64dcc17562a..000000000000 --- a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditChannelWithData.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -import { useRoom } from '../../../contexts/RoomContext'; -import { useRoomToolbox } from '../../../contexts/RoomToolboxContext'; -import EditChannel from './EditChannel'; - -function EditChannelWithData({ onClickBack }) { - const room = useRoom(); - const { closeTab } = useRoomToolbox(); - - return ; -} - -export default EditChannelWithData; diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx new file mode 100644 index 000000000000..b2c552fd3d87 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx @@ -0,0 +1,487 @@ +import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings'; +import { isRoomFederated } from '@rocket.chat/core-typings'; +import type { SelectOption } from '@rocket.chat/fuselage'; +import { + Field, + FieldRow, + FieldLabel, + FieldHint, + TextInput, + PasswordInput, + ToggleSwitch, + MultiSelect, + Accordion, + Callout, + NumberInput, + FieldGroup, + Button, + ButtonGroup, + Box, + TextAreaInput, +} from '@rocket.chat/fuselage'; +import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { useSetting, useTranslation, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; +import React, { useMemo } from 'react'; +import { useForm, Controller } from 'react-hook-form'; + +import { MessageTypesValues } from '../../../../../../app/lib/lib/MessageTypes'; +import { + ContextualbarHeader, + ContextualbarBack, + ContextualbarTitle, + ContextualbarClose, + ContextualbarScrollableContent, + ContextualbarFooter, +} from '../../../../../components/Contextualbar'; +import RawText from '../../../../../components/RawText'; +import RoomAvatarEditor from '../../../../../components/avatar/RoomAvatarEditor'; +import { getDirtyFields } from '../../../../../lib/getDirtyFields'; +import { useDeleteRoom } from '../../../../hooks/roomActions/useDeleteRoom'; +import { useEditRoomInitialValues } from './useEditRoomInitialValues'; +import { useEditRoomPermissions } from './useEditRoomPermissions'; + +type EditRoomInfoProps = { + room: IRoomWithRetentionPolicy; + onClickClose: () => void; + onClickBack: () => void; +}; + +const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const isFederated = useMemo(() => isRoomFederated(room), [room]); + + const retentionPolicy = useSetting('RetentionPolicy_Enabled'); + const { handleDelete, canDeleteRoom } = useDeleteRoom(room); + const defaultValues = useEditRoomInitialValues(room); + + const { + watch, + reset, + control, + handleSubmit, + formState: { isDirty, dirtyFields, errors }, + } = useForm({ mode: 'onBlur', defaultValues }); + + const sysMesOptions: SelectOption[] = useMemo( + () => MessageTypesValues.map(({ key, i18nLabel }) => [key, t(i18nLabel as TranslationKey)]), + [t], + ); + + const { readOnly, archived, joinCodeRequired, hideSysMes, retentionEnabled, retentionMaxAge, retentionOverrideGlobal } = watch(); + + const { + canChangeType, + canSetReadOnly, + canSetReactWhenReadOnly, + canEditRoomRetentionPolicy, + canArchiveOrUnarchive, + canToggleEncryption, + canViewName, + canViewTopic, + canViewAnnouncement, + canViewArchived, + canViewDescription, + canViewType, + canViewReadOnly, + canViewHideSysMes, + canViewJoinCode, + canViewEncrypted, + } = useEditRoomPermissions(room); + + const changeArchiving = archived !== !!room.archived; + + const saveAction = useEndpoint('POST', '/v1/rooms.saveRoomSettings'); + const archiveAction = useEndpoint('POST', '/v1/rooms.changeArchivationState'); + + const handleUpdateRoomData = useMutableCallback(async ({ hideSysMes, ...formData }) => { + const data = getDirtyFields(formData, dirtyFields); + + try { + await saveAction({ + rid: room._id, + ...data, + ...(data.joinCode && { joinCode: joinCodeRequired ? data.joinCode : '' }), + ...((data.systemMessages || !hideSysMes) && { + systemMessages: hideSysMes && data.systemMessages, + }), + }); + + dispatchToastMessage({ type: 'success', message: t('Room_updated_successfully') }); + onClickClose(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const handleArchive = useMutableCallback(async () => { + try { + await archiveAction({ rid: room._id, action: room.archived ? 'unarchive' : 'archive' }); + dispatchToastMessage({ type: 'success', message: room.archived ? t('Room_has_been_unarchived') : t('Room_has_been_archived') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const handleSave = useMutableCallback(async (data) => { + await Promise.all([isDirty && handleUpdateRoomData(data), changeArchiving && handleArchive()].filter(Boolean)); + }); + + const formId = useUniqueId(); + const roomNameField = useUniqueId(); + const roomDescriptionField = useUniqueId(); + const roomAnnouncementField = useUniqueId(); + const roomTopicField = useUniqueId(); + const roomTypeField = useUniqueId(); + const readOnlyField = useUniqueId(); + const reactWhenReadOnlyField = useUniqueId(); + const archivedField = useUniqueId(); + const joinCodeRequiredField = useUniqueId(); + const hideSysMesField = useUniqueId(); + const encryptedField = useUniqueId(); + const retentionEnabledField = useUniqueId(); + const retentionOverrideGlobalField = useUniqueId(); + const retentionMaxAgeField = useUniqueId(); + const retentionExcludePinnedField = useUniqueId(); + const retentionFilesOnlyField = useUniqueId(); + + return ( + <> + + {onClickBack && } + {room.teamId ? t('edit-team') : t('edit-room')} + {onClickClose && } + + +
+ + } + /> + + + + + {t('Name')} + + + } + /> + + {errors.roomName && {errors.roomName.message}} + + {canViewDescription && ( + + {t('Description')} + + } + /> + + + )} + {canViewAnnouncement && ( + + {t('Announcement')} + + } + /> + + + )} + {canViewTopic && ( + + {t('Topic')} + + } + /> + + + )} + {canViewType && ( + + + {t('Private')} + + ( + onChange(value === 'p' ? 'c' : 'p')} + aria-describedby={`${roomTypeField}-hint`} + /> + )} + /> + + + {t('Teams_New_Private_Description_Enabled')} + + )} + {canViewReadOnly && ( + + + {t('Read_only')} + + ( + + )} + /> + + + {t('Only_authorized_users_can_write_new_messages')} + + )} + {readOnly && ( + + + {t('React_when_read_only')} + + ( + + )} + /> + + + {t('Only_authorized_users_can_react_to_messages')} + + )} + {canViewArchived && ( + + + {t('Room_archivation_state_true')} + + ( + + )} + /> + + + + )} + {canViewJoinCode && ( + + + {t('Password_to_access')} + + ( + + )} + /> + + + + } + /> + + + )} + {canViewHideSysMes && ( + + + {t('Hide_System_Messages')} + + ( + + )} + /> + + + + ( + + )} + /> + + + )} + {canViewEncrypted && ( + + + {t('Encrypted')} + + ( + + )} + /> + + + + )} + + {retentionPolicy && ( + + + + + + {t('RetentionPolicyRoom_Enabled')} + + ( + + )} + /> + + + + + + {t('RetentionPolicyRoom_OverrideGlobal')} + + ( + + )} + /> + + + + {retentionOverrideGlobal && ( + <> + + {t('RetentionPolicyRoom_ReadTheDocs')} + + + {t('RetentionPolicyRoom_MaxAge', { max: retentionMaxAge })} + + ( + onChange(Math.max(1, Number(currentValue)))} + /> + )} + /> + + + + + {t('RetentionPolicyRoom_ExcludePinned')} + + ( + + )} + /> + + + + + + {t('RetentionPolicyRoom_FilesOnly')} + + ( + + )} + /> + + + + + )} + + + + )} +
+
+ + + + + + + + + + + ); +}; + +export default EditRoomInfo; diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfoWithData.tsx b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfoWithData.tsx new file mode 100644 index 000000000000..ad758c1bc8a6 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfoWithData.tsx @@ -0,0 +1,15 @@ +import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings'; +import React from 'react'; + +import { useRoom } from '../../../contexts/RoomContext'; +import { useRoomToolbox } from '../../../contexts/RoomToolboxContext'; +import EditRoomInfo from './EditRoomInfo'; + +const EditRoomInfoWithData = ({ onClickBack }: { onClickBack: () => void }) => { + const room = useRoom() as IRoomWithRetentionPolicy; + const { closeTab } = useRoomToolbox(); + + return ; +}; + +export default EditRoomInfoWithData; diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/index.ts b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/index.ts index 4083ad9a958f..d8b31e17800c 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/index.ts +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/index.ts @@ -1 +1 @@ -export { default } from './EditChannelWithData'; +export { default } from './EditRoomInfoWithData'; diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomInitialValues.ts b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomInitialValues.ts new file mode 100644 index 000000000000..f36802bb9f56 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomInitialValues.ts @@ -0,0 +1,71 @@ +import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings'; +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; + +const getPolicyRoomType = (roomType: IRoomWithRetentionPolicy['t']) => { + switch (roomType) { + case 'c': + return 'Channels'; + case 'p': + return 'Groups'; + case 'd': + return 'DMs'; + } +}; + +export const useEditRoomInitialValues = (room: IRoomWithRetentionPolicy) => { + const { t, ro, archived, topic, description, announcement, joinCodeRequired, sysMes, encrypted, retention, reactWhenReadOnly } = room; + + const retentionPolicyEnabled = useSetting('RetentionPolicy_Enabled'); + const maxAgeDefault = useSetting(`RetentionPolicy_MaxAge_${getPolicyRoomType(room.t)}`) || 30; + const retentionEnabledDefault = useSetting(`RetentionPolicy_AppliesTo${getPolicyRoomType(room.t)}`); + const excludePinnedDefault = useSetting('RetentionPolicy_DoNotPrunePinned'); + const filesOnlyDefault = useSetting('RetentionPolicy_FilesOnly'); + + return useMemo( + () => ({ + roomName: t === 'd' && room.usernames ? room.usernames.join(' x ') : roomCoordinator.getRoomName(t, room), + roomType: t, + readOnly: !!ro, + reactWhenReadOnly, + archived: !!archived, + roomTopic: topic ?? '', + roomDescription: description ?? '', + roomAnnouncement: announcement ?? '', + roomAvatar: undefined, + joinCode: '', + joinCodeRequired: !!joinCodeRequired, + systemMessages: Array.isArray(sysMes) ? sysMes : [], + hideSysMes: Array.isArray(sysMes) ? !!sysMes?.length : !!sysMes, + encrypted, + ...(retentionPolicyEnabled && { + retentionEnabled: retention?.enabled ?? retentionEnabledDefault, + retentionOverrideGlobal: !!retention?.overrideGlobal, + retentionMaxAge: Math.min(retention?.maxAge, maxAgeDefault) || maxAgeDefault, + retentionExcludePinned: retention?.excludePinned ?? excludePinnedDefault, + retentionFilesOnly: retention?.filesOnly ?? filesOnlyDefault, + }), + }), + [ + announcement, + archived, + description, + excludePinnedDefault, + filesOnlyDefault, + joinCodeRequired, + maxAgeDefault, + retention, + retentionEnabledDefault, + retentionPolicyEnabled, + ro, + room, + sysMes, + t, + topic, + encrypted, + reactWhenReadOnly, + ], + ); +}; diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomPermissions.ts b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomPermissions.ts new file mode 100644 index 000000000000..7b9e8c353941 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomPermissions.ts @@ -0,0 +1,77 @@ +import type { IRoom, IRoomWithRetentionPolicy } from '@rocket.chat/core-typings'; +import { usePermission, useAtLeastOnePermission, useRole } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { e2e } from '../../../../../../app/e2e/client/rocketchat.e2e'; +import { RoomSettingsEnum } from '../../../../../../definition/IRoomTypeConfig'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; + +const getCanChangeType = (room: IRoom | IRoomWithRetentionPolicy, canCreateChannel: boolean, canCreateGroup: boolean, isAdmin: boolean) => + (!room.default || isAdmin) && ((room.t === 'p' && canCreateChannel) || (room.t === 'c' && canCreateGroup)); + +export const useEditRoomPermissions = (room: IRoom | IRoomWithRetentionPolicy) => { + const isAdmin = useRole('admin'); + const canCreateChannel = usePermission('create-c'); + const canCreateGroup = usePermission('create-p'); + + const canChangeType = getCanChangeType(room, canCreateChannel, canCreateGroup, isAdmin); + const canSetReadOnly = usePermission('set-readonly', room._id); + const canSetReactWhenReadOnly = usePermission('set-react-when-readonly', room._id); + const canEditRoomRetentionPolicy = usePermission('edit-room-retention-policy', room._id); + const canArchiveOrUnarchive = useAtLeastOnePermission( + useMemo(() => ['archive-room', 'unarchive-room'], []), + room._id, + ); + const canToggleEncryption = usePermission('toggle-room-e2e-encryption', room._id) && (room.encrypted || e2e.isReady()); + + const [ + canViewName, + canViewTopic, + canViewAnnouncement, + canViewArchived, + canViewDescription, + canViewType, + canViewReadOnly, + canViewHideSysMes, + canViewJoinCode, + canViewEncrypted, + ] = useMemo(() => { + const isAllowed = + roomCoordinator.getRoomDirectives(room.t)?.allowRoomSettingChange || + (() => { + undefined; + }); + return [ + isAllowed(room, RoomSettingsEnum.NAME), + isAllowed(room, RoomSettingsEnum.TOPIC), + isAllowed(room, RoomSettingsEnum.ANNOUNCEMENT), + isAllowed(room, RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE), + isAllowed(room, RoomSettingsEnum.DESCRIPTION), + isAllowed(room, RoomSettingsEnum.TYPE), + isAllowed(room, RoomSettingsEnum.READ_ONLY), + isAllowed(room, RoomSettingsEnum.SYSTEM_MESSAGES), + isAllowed(room, RoomSettingsEnum.JOIN_CODE), + isAllowed(room, RoomSettingsEnum.REACT_WHEN_READ_ONLY), + isAllowed(room, RoomSettingsEnum.E2E), + ]; + }, [room]); + + return { + canChangeType, + canSetReadOnly, + canSetReactWhenReadOnly, + canEditRoomRetentionPolicy, + canArchiveOrUnarchive, + canToggleEncryption, + canViewName, + canViewTopic, + canViewAnnouncement, + canViewArchived, + canViewDescription, + canViewType, + canViewReadOnly, + canViewHideSysMes, + canViewJoinCode, + canViewEncrypted, + }; +}; 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 147c02979026..7828363227ac 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.tsx +++ b/apps/meteor/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.tsx @@ -48,7 +48,7 @@ const RoomInfo = ({ room, icon, onClickBack, onClickClose, onClickEnterRoom, onC void) => { - const t = useTranslation(); - const setModal = useSetModal(); - const dispatchToastMessage = useToastMessageDispatch(); - const router = useRouter(); - - const hasPermissionToDelete = usePermission(room.t === 'c' ? 'delete-c' : 'delete-p', room._id); - const canDelete = isRoomFederated(room) ? false : hasPermissionToDelete; - - const deleteRoom = useEndpoint('POST', room.t === 'c' ? '/v1/channels.delete' : '/v1/groups.delete'); - - const handleDelete = useMutableCallback(() => { - const onConfirm = async () => { - try { - await deleteRoom({ roomId: room._id }); - dispatchToastMessage({ type: 'success', message: t('Room_has_been_deleted') }); - if (resetState) { - return resetState(); - } - - router.navigate('/home'); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - setModal(null); - }; - - setModal( - setModal(null)} confirmText={t('Yes_delete_it')}> - {t('Delete_Room_Warning')} - , - ); - }); - - return canDelete ? handleDelete : null; -}; diff --git a/apps/meteor/client/views/room/contextualBar/Info/hooks/useRoomActions.ts b/apps/meteor/client/views/room/contextualBar/Info/hooks/useRoomActions.ts index d0ab03bd9ee7..638cd23b66ed 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/hooks/useRoomActions.ts +++ b/apps/meteor/client/views/room/contextualBar/Info/hooks/useRoomActions.ts @@ -1,10 +1,9 @@ -import { isRoomFederated } from '@rocket.chat/core-typings'; import type { IRoom } from '@rocket.chat/core-typings'; import { useTranslation } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; +import { useDeleteRoom } from '../../../../hooks/roomActions/useDeleteRoom'; import { useRoomConvertToTeam } from './actions/useRoomConvertToTeam'; -import { useRoomDelete } from './actions/useRoomDelete'; import { useRoomHide } from './actions/useRoomHide'; import { useRoomLeave } from './actions/useRoomLeave'; import { useRoomMoveToTeam } from './actions/useRoomMoveToTeam'; @@ -16,11 +15,10 @@ type RoomActions = { export const useRoomActions = (room: IRoom, { onClickEnterRoom, onClickEdit }: RoomActions, resetState?: () => void) => { const t = useTranslation(); - const isFederated = isRoomFederated(room); const handleHide = useRoomHide(room); const handleLeave = useRoomLeave(room); - const handleDelete = useRoomDelete(room, resetState); + const { handleDelete, canDeleteRoom } = useDeleteRoom(room, { reload: resetState }); const handleMoveToTeam = useRoomMoveToTeam(room); const handleConvertToTeam = useRoomConvertToTeam(room); @@ -40,7 +38,7 @@ export const useRoomActions = (room: IRoom, { onClickEnterRoom, onClickEdit }: R action: onClickEdit, }, }), - ...(!isFederated && + ...(canDeleteRoom && handleDelete && { delete: { label: t('Delete'), @@ -77,7 +75,7 @@ export const useRoomActions = (room: IRoom, { onClickEnterRoom, onClickEdit }: R }, }), }), - [onClickEdit, t, handleDelete, handleMoveToTeam, handleConvertToTeam, handleHide, handleLeave, onClickEnterRoom, isFederated], + [onClickEdit, t, handleDelete, handleMoveToTeam, handleConvertToTeam, handleHide, handleLeave, onClickEnterRoom, canDeleteRoom], ); return memoizedActions; diff --git a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearchForm.tsx b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearchForm.tsx index bc25f538643d..f1f69f9299f3 100644 --- a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearchForm.tsx +++ b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearchForm.tsx @@ -1,5 +1,5 @@ import type { IMessageSearchProvider } from '@rocket.chat/core-typings'; -import { Box, Field, Icon, TextInput, ToggleSwitch } from '@rocket.chat/fuselage'; +import { Box, Field, FieldLabel, FieldRow, FieldHint, Icon, TextInput, ToggleSwitch } from '@rocket.chat/fuselage'; import { useDebouncedCallback, useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; @@ -55,7 +55,7 @@ const MessageSearchForm = ({ provider, onSearch }: MessageSearchFormProps) => { > - + } placeholder={t('Search_Messages')} @@ -63,15 +63,15 @@ const MessageSearchForm = ({ provider, onSearch }: MessageSearchFormProps) => { autoComplete='off' {...register('searchText')} /> - - {provider.description && } + + {provider.description && } {globalSearchEnabled && ( - + - {t('Global_Search')} - + {t('Global_Search')} + )} diff --git a/apps/meteor/client/views/room/contextualBar/NotificationPreferences/components/NotificationPreference.tsx b/apps/meteor/client/views/room/contextualBar/NotificationPreferences/components/NotificationPreference.tsx index f187312e12ea..c89584bd1eef 100644 --- a/apps/meteor/client/views/room/contextualBar/NotificationPreferences/components/NotificationPreference.tsx +++ b/apps/meteor/client/views/room/contextualBar/NotificationPreferences/components/NotificationPreference.tsx @@ -1,5 +1,5 @@ import type { SelectOption } from '@rocket.chat/fuselage'; -import { Field, Select } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage'; import type { ReactElement } from 'react'; import React from 'react'; @@ -21,11 +21,11 @@ const NotificationPreference = ({ ...props }: NotificationPreferenceProps): ReactElement => ( - {name} - + {name} + onChange(String(value))} /> - + ); }; diff --git a/apps/meteor/ee/client/omnichannel/additionalForms/register.ts b/apps/meteor/ee/client/omnichannel/additionalForms/register.ts index df11a435ab5c..d52a20e40734 100644 --- a/apps/meteor/ee/client/omnichannel/additionalForms/register.ts +++ b/apps/meteor/ee/client/omnichannel/additionalForms/register.ts @@ -6,7 +6,6 @@ import { registerForm } from '../../../../client/views/omnichannel/additionalFor import { hasLicense } from '../../../app/license/client'; import type CurrentChatTags from '../tags/CurrentChatTags'; import type BusinessHoursMultipleContainer from './BusinessHoursMultipleContainer'; -import type BusinessHoursTimeZone from './BusinessHoursTimeZone'; import type ContactManager from './ContactManager'; import type CustomFieldsAdditionalFormContainer from './CustomFieldsAdditionalFormContainer'; import type DepartmentBusinessHours from './DepartmentBusinessHours'; @@ -29,7 +28,6 @@ declare module '../../../../client/views/omnichannel/additionalForms' { useEeTextAreaInput?: () => LazyExoticComponent; useBusinessHoursMultiple?: () => LazyExoticComponent; useEeTextInput?: () => LazyExoticComponent; - useBusinessHoursTimeZone?: () => LazyExoticComponent; useContactManager?: () => LazyExoticComponent; useCurrentChatTags?: () => LazyExoticComponent; @@ -54,7 +52,6 @@ hasLicense('livechat-enterprise').then((enabled) => { useEeTextAreaInput: () => useMemo(() => lazy(() => import('./EeTextAreaInput')), []), useBusinessHoursMultiple: () => useMemo(() => lazy(() => import('./BusinessHoursMultipleContainer')), []), useEeTextInput: () => useMemo(() => lazy(() => import('./EeTextInput')), []), - useBusinessHoursTimeZone: () => useMemo(() => lazy(() => import('./BusinessHoursTimeZone')), []), useContactManager: () => useMemo(() => lazy(() => import('./ContactManager')), []), useCurrentChatTags: () => useMemo(() => lazy(() => import('../tags/CurrentChatTags')), []), useDepartmentBusinessHours: () => useMemo(() => lazy(() => import('./DepartmentBusinessHours')), []), diff --git a/apps/meteor/ee/client/omnichannel/cannedResponses/components/cannedResponseForm.tsx b/apps/meteor/ee/client/omnichannel/cannedResponses/components/cannedResponseForm.tsx index 9560d61c28ae..5b9fb7433f7f 100644 --- a/apps/meteor/ee/client/omnichannel/cannedResponses/components/cannedResponseForm.tsx +++ b/apps/meteor/ee/client/omnichannel/cannedResponses/components/cannedResponseForm.tsx @@ -1,5 +1,5 @@ import { css } from '@rocket.chat/css-in-js'; -import { Box, Field, TextInput } from '@rocket.chat/fuselage'; +import { Box, Field, FieldLabel, FieldRow, FieldError, FieldDescription, TextInput } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { FC } from 'react'; import React from 'react'; @@ -36,7 +36,7 @@ const CannedResponseForm: FC<{ return ( <> - {t('Shortcut')} + {t('Shortcut')} - {errors.shortcut} + {errors.shortcut} - + {t('Message')} {previewState ? t('Editor') : t('Preview')} - + {previewState ? : } @@ -63,21 +63,21 @@ const CannedResponseForm: FC<{ {(isManager || isMonitor) && ( <> - {t('Sharing')} - {radioDescription} - + {t('Sharing')} + {radioDescription} + - + {scope === 'department' && ( - {t('Department')} + {t('Department')} - {errors.departmentId} + {errors.departmentId} )} diff --git a/apps/meteor/ee/client/omnichannel/components/RoomActivityIcon/index.tsx b/apps/meteor/ee/client/omnichannel/components/RoomActivityIcon/index.tsx new file mode 100644 index 000000000000..5db68f559fdb --- /dev/null +++ b/apps/meteor/ee/client/omnichannel/components/RoomActivityIcon/index.tsx @@ -0,0 +1,20 @@ +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { Icon } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { useIsRoomOverMacLimit } from '../../../../../client/hooks/omnichannel/useIsRoomOverMacLimit'; + +type RoomActivityIconProps = { + room: IOmnichannelRoom; +}; + +export const RoomActivityIcon = ({ room }: RoomActivityIconProps): ReactElement | null => { + const t = useTranslation(); + const isRoomOverMacLimit = useIsRoomOverMacLimit(room); + + return isRoomOverMacLimit ? ( + + ) : null; +}; diff --git a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.stories.tsx b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.stories.tsx index 1f5bd2e7c539..0c9f40f7c955 100644 --- a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.stories.tsx +++ b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.stories.tsx @@ -17,7 +17,7 @@ export default { export const Default: ComponentStory = (args) => ; Default.storyName = 'CannedResponse'; Default.args = { - canEdit: true, + allowEdit: true, data: { shortcut: 'test3 long long long long long long long long long', text: 'simple canned response test3 long long long long long long long long long long long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long long', diff --git a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.tsx b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.tsx index 05d6895d5dfc..90d6a4523cb7 100644 --- a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.tsx +++ b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.tsx @@ -15,7 +15,8 @@ import { import { useScopeDict } from '../../../hooks/useScopeDict'; const CannedResponse: FC<{ - canEdit: boolean; + allowEdit: boolean; + allowUse: boolean; data: { departmentName: ILivechatDepartment['name']; shortcut: IOmnichannelCannedResponse['shortcut']; @@ -26,7 +27,7 @@ const CannedResponse: FC<{ onClickBack: MouseEventHandler; onClickEdit: MouseEventHandler; onClickUse: MouseEventHandler; -}> = ({ canEdit, data: { departmentName, shortcut, text, scope: dataScope, tags }, onClickBack, onClickEdit, onClickUse }) => { +}> = ({ allowEdit, allowUse, data: { departmentName, shortcut, text, scope: dataScope, tags }, onClickBack, onClickEdit, onClickUse }) => { const t = useTranslation(); const scope = useScopeDict(dataScope, departmentName); @@ -84,8 +85,8 @@ const CannedResponse: FC<{ - {canEdit && } - } + diff --git a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponseList.tsx b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponseList.tsx index 1a41402368d5..782511de3c4e 100644 --- a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponseList.tsx +++ b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponseList.tsx @@ -30,6 +30,7 @@ const CannedResponseList: FC<{ setText: FormEventHandler; type: string; setType: Dispatch>; + isRoomOverMacLimit: boolean; onClickItem: (data: any) => void; onClickCreate: (e: MouseEvent) => void; onClickUse: (e: MouseEvent, text: string) => void; @@ -45,6 +46,7 @@ const CannedResponseList: FC<{ setText, type, setType, + isRoomOverMacLimit, onClickItem, onClickCreate, onClickUse, @@ -98,6 +100,7 @@ const CannedResponseList: FC<{ itemContent={(_index, data): ReactElement => ( { onClickItem(data); }} @@ -112,6 +115,7 @@ const CannedResponseList: FC<{ {cannedId && ( canned._id === (cannedId as unknown))} onClickBack={onClickItem} onClickUse={onClickUse} diff --git a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/Item.tsx b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/Item.tsx index 3c1dfa304f79..bcb6a7d9949f 100644 --- a/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/Item.tsx +++ b/apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/Item.tsx @@ -9,9 +9,10 @@ import { useScopeDict } from '../../../hooks/useScopeDict'; const Item: FC<{ data: IOmnichannelCannedResponse & { departmentName: ILivechatDepartment['name'] }; + allowUse?: boolean; onClickItem: (e: MouseEvent) => void; onClickUse: (e: MouseEvent, text: string) => void; -}> = ({ data, onClickItem, onClickUse }) => { +}> = ({ data, allowUse, onClickItem, onClickUse }) => { const t = useTranslation(); const scope = useScopeDict(data.scope, data.departmentName); @@ -47,7 +48,7 @@ const Item: FC<{ - + {((isSuccess && data?.monitors.length > 0) || queryHasChanged) && setText(text)} />} diff --git a/apps/meteor/ee/client/omnichannel/priorities/PriorityEditForm.tsx b/apps/meteor/ee/client/omnichannel/priorities/PriorityEditForm.tsx index 81d0fd72a2e6..042a6a7d2498 100644 --- a/apps/meteor/ee/client/omnichannel/priorities/PriorityEditForm.tsx +++ b/apps/meteor/ee/client/omnichannel/priorities/PriorityEditForm.tsx @@ -1,5 +1,5 @@ import type { ILivechatPriority, Serialized } from '@rocket.chat/core-typings'; -import { Field, Button, Box, ButtonGroup, Throbber } from '@rocket.chat/fuselage'; +import { Field, FieldError, Button, Box, ButtonGroup, Throbber } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; @@ -93,7 +93,7 @@ const PriorityEditForm = ({ data, onSave, onCancel }: PriorityEditFormProps): Re /> )} /> - {errors.name?.message} + {errors.name?.message} diff --git a/apps/meteor/ee/client/omnichannel/priorities/PriorityList.tsx b/apps/meteor/ee/client/omnichannel/priorities/PriorityList.tsx index 7fb940025035..029b8af38a2b 100644 --- a/apps/meteor/ee/client/omnichannel/priorities/PriorityList.tsx +++ b/apps/meteor/ee/client/omnichannel/priorities/PriorityList.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { Contextualbar, + ContextualbarTitle, ContextualbarHeader, ContextualbarClose, ContextualbarScrollableContent, @@ -24,7 +25,7 @@ const PriorityList = ({ priorityId, onClose, onSave }: PriorityListProps): React return ( - {t('Edit_Priority')} + {t('Edit_Priority')} diff --git a/apps/meteor/ee/client/omnichannel/reports/ReportsPage.tsx b/apps/meteor/ee/client/omnichannel/reports/ReportsPage.tsx index 3bbe82494bcc..147dff65ad97 100644 --- a/apps/meteor/ee/client/omnichannel/reports/ReportsPage.tsx +++ b/apps/meteor/ee/client/omnichannel/reports/ReportsPage.tsx @@ -1,14 +1,23 @@ import { Box } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; +import { usePermission, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; import Page from '../../../../client/components/Page'; +import NotAuthorizedPage from '../../../../client/views/notAuthorized/NotAuthorizedPage'; +import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; import { ResizeObserver } from './components/ResizeObserver'; import { AgentsSection, ChannelsSection, DepartmentsSection, StatusSection, TagsSection } from './sections'; const ReportsPage = () => { const t = useTranslation(); + const hasPermission = usePermission('view-livechat-reports'); + const isEnterprise = useHasLicenseModule('livechat-enterprise'); + + if (!hasPermission || !isEnterprise) { + return ; + } + return ( diff --git a/apps/meteor/ee/client/omnichannel/slaPolicies/SlaEdit.tsx b/apps/meteor/ee/client/omnichannel/slaPolicies/SlaEdit.tsx index 30bcd110a56e..83f2c4a36e72 100644 --- a/apps/meteor/ee/client/omnichannel/slaPolicies/SlaEdit.tsx +++ b/apps/meteor/ee/client/omnichannel/slaPolicies/SlaEdit.tsx @@ -1,5 +1,5 @@ import type { IOmnichannelServiceLevelAgreements, Serialized } from '@rocket.chat/core-typings'; -import { Field, TextInput, Button, Margins, Box, NumberInput } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, FieldError, TextInput, Button, Margins, Box, NumberInput } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -78,32 +78,32 @@ function SlaEdit({ data, isNew, slaId, reload, ...props }: SlaEditProps): ReactE return ( - {t('Name')}* - + {t('Name')}* + - - {errors.name?.message} + + {errors.name?.message} - {t('Description')} - + {t('Description')} + - + - {t('Estimated_wait_time_in_minutes')}* - + {t('Estimated_wait_time_in_minutes')}* + - - {errors.dueTimeInMinutes?.message} + + {errors.dueTimeInMinutes?.message} - + {!isNew && ( @@ -116,7 +116,7 @@ function SlaEdit({ data, isNew, slaId, reload, ...props }: SlaEditProps): ReactE - + ); diff --git a/apps/meteor/ee/client/omnichannel/tags/AutoCompleteTagsMultiple.js b/apps/meteor/ee/client/omnichannel/tags/AutoCompleteTagsMultiple.tsx similarity index 57% rename from apps/meteor/ee/client/omnichannel/tags/AutoCompleteTagsMultiple.js rename to apps/meteor/ee/client/omnichannel/tags/AutoCompleteTagsMultiple.tsx index 086d796152fc..f0baca991e2e 100644 --- a/apps/meteor/ee/client/omnichannel/tags/AutoCompleteTagsMultiple.js +++ b/apps/meteor/ee/client/omnichannel/tags/AutoCompleteTagsMultiple.tsx @@ -1,3 +1,4 @@ +import type { PaginatedMultiSelectOption } from '@rocket.chat/fuselage'; import { PaginatedMultiSelectFiltered } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; @@ -7,9 +8,21 @@ import { useRecordList } from '../../../../client/hooks/lists/useRecordList'; import { AsyncStatePhase } from '../../../../client/hooks/useAsyncState'; import { useTagsList } from '../../hooks/useTagsList'; -const AutoCompleteTagMultiple = (props) => { - const { value, onlyMyTags = false, onChange = () => {}, department, viewAll = false } = props; +type AutoCompleteTagsMultipleProps = { + value?: PaginatedMultiSelectOption[]; + onlyMyTags?: boolean; + onChange?: (value: PaginatedMultiSelectOption[]) => void; + department?: string; + viewAll?: boolean; +}; +const AutoCompleteTagsMultiple = ({ + value = [], + onlyMyTags = false, + onChange = () => undefined, + department, + viewAll = false, +}: AutoCompleteTagsMultipleProps) => { const t = useTranslation(); const [tagsFilter, setTagsFilter] = useState(''); @@ -24,16 +37,10 @@ const AutoCompleteTagMultiple = (props) => { const { phase: tagsPhase, items: tagsItems, itemCount: tagsTotal } = useRecordList(tagsList); - const sortedByName = tagsItems.sort((a, b) => { - if (a.name > b.name) { - return 1; - } - if (a.name < b.name) { - return -1; - } - - return 0; - }); + const tagsOptions = useMemo(() => { + const pending = value.filter(({ value }) => !tagsItems.find((tag) => tag.value === value)); + return [...tagsItems, ...pending]; + }, [tagsItems, value]); return ( { onChange={onChange} filter={tagsFilter} setFilter={setTagsFilter} - options={sortedByName} + options={tagsOptions} width='100%' flexShrink={0} flexGrow={0} placeholder={t('Select_an_option')} - endReached={tagsPhase === AsyncStatePhase.LOADING ? () => {} : (start) => loadMoreTags(start, Math.min(50, tagsTotal))} + endReached={ + tagsPhase === AsyncStatePhase.LOADING ? () => undefined : (start) => start && loadMoreTags(start, Math.min(50, tagsTotal)) + } /> ); }; -export default memo(AutoCompleteTagMultiple); +export default memo(AutoCompleteTagsMultiple); diff --git a/apps/meteor/ee/client/omnichannel/tags/CurrentChatTags.tsx b/apps/meteor/ee/client/omnichannel/tags/CurrentChatTags.tsx index 61c1d11af947..553d31b7479d 100644 --- a/apps/meteor/ee/client/omnichannel/tags/CurrentChatTags.tsx +++ b/apps/meteor/ee/client/omnichannel/tags/CurrentChatTags.tsx @@ -3,7 +3,7 @@ import React from 'react'; import AutoCompleteTagsMultiple from './AutoCompleteTagsMultiple'; -type CurrentChatTagsProps = { value: Array; handler: () => void; department?: string; viewAll?: boolean }; +type CurrentChatTagsProps = { value: Array<{ value: string; label: string }>; handler: () => void; department?: string; viewAll?: boolean }; const CurrentChatTags: FC = ({ value, handler, department, viewAll }) => ( diff --git a/apps/meteor/ee/client/omnichannel/tags/RemoveTagButton.tsx b/apps/meteor/ee/client/omnichannel/tags/RemoveTagButton.tsx deleted file mode 100644 index 478a92bf0413..000000000000 --- a/apps/meteor/ee/client/omnichannel/tags/RemoveTagButton.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { ILivechatTag } from '@rocket.chat/core-typings'; -import { IconButton } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useSetModal, useToastMessageDispatch, useRoute, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; - -import GenericModal from '../../../../client/components/GenericModal'; -import { GenericTableCell } from '../../../../client/components/GenericTable'; - -const RemoveTagButton = ({ _id, reload }: { _id: ILivechatTag['_id']; reload: () => void }) => { - const t = useTranslation(); - const setModal = useSetModal(); - const dispatchToastMessage = useToastMessageDispatch(); - const tagsRoute = useRoute('omnichannel-tags'); - const removeTag = useMethod('livechat:removeTag'); - - const handleDelete = useMutableCallback((e) => { - e.stopPropagation(); - const onDeleteAgent = async () => { - try { - await removeTag(_id); - dispatchToastMessage({ type: 'success', message: t('Tag_removed') }); - tagsRoute.push({}); - reload(); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } finally { - setModal(); - } - }; - - setModal( setModal()} confirmText={t('Delete')} />); - }); - - return ( - - - - ); -}; - -export default RemoveTagButton; diff --git a/apps/meteor/ee/client/omnichannel/tags/TagEdit.js b/apps/meteor/ee/client/omnichannel/tags/TagEdit.js deleted file mode 100644 index 82f9fc7e1481..000000000000 --- a/apps/meteor/ee/client/omnichannel/tags/TagEdit.js +++ /dev/null @@ -1,99 +0,0 @@ -import { Field, TextInput, Button, ButtonGroup, FieldGroup } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch, useRoute, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; -import React, { useMemo } from 'react'; - -import AutoCompleteDepartmentMultiple from '../../../../client/components/AutoCompleteDepartmentMultiple'; -import Page from '../../../../client/components/Page'; -import { useForm } from '../../../../client/hooks/useForm'; - -function TagEdit({ title, data, tagId, reload, currentDepartments, ...props }) { - const t = useTranslation(); - const tagsRoute = useRoute('omnichannel-tags'); - - const tag = data || {}; - - const { values, handlers, hasUnsavedChanges } = useForm({ - name: tag.name, - description: tag.description, - departments: - currentDepartments && currentDepartments.departments - ? currentDepartments.departments.map((dep) => ({ label: dep.name, value: dep._id })) - : [], - }); - - const { handleName, handleDescription, handleDepartments } = handlers; - const { name, description, departments } = values; - - const nameError = useMemo(() => (!name || name.length === 0 ? t('The_field_is_required', 'name') : undefined), [name, t]); - - const saveTag = useMethod('livechat:saveTag'); - - const dispatchToastMessage = useToastMessageDispatch(); - - const handleReturn = useMutableCallback(() => { - tagsRoute.push({}); - }); - - const canSave = useMemo(() => !nameError, [nameError]); - - const handleSave = useMutableCallback(async () => { - const tagData = { name, description }; - - if (!canSave) { - return dispatchToastMessage({ type: 'error', message: t('The_field_is_required') }); - } - - const finalDepartments = departments ? departments.map((dep) => dep.value) : ['']; - - try { - await saveTag(tagId, tagData, finalDepartments); - dispatchToastMessage({ type: 'success', message: t('Saved') }); - reload(); - tagsRoute.push({}); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - - return ( - - - - - - - - - - - - {t('Name')}* - - - - - - {t('Description')} - - - - - - {t('Departments')} - - - - - - - - - ); -} - -export default TagEdit; diff --git a/apps/meteor/ee/client/omnichannel/tags/TagEdit.tsx b/apps/meteor/ee/client/omnichannel/tags/TagEdit.tsx new file mode 100644 index 000000000000..4cff157eb6c7 --- /dev/null +++ b/apps/meteor/ee/client/omnichannel/tags/TagEdit.tsx @@ -0,0 +1,141 @@ +import type { ILivechatDepartment, ILivechatTag, Serialized } from '@rocket.chat/core-typings'; +import { Field, FieldLabel, FieldRow, FieldError, TextInput, Button, ButtonGroup, FieldGroup, Box } from '@rocket.chat/fuselage'; +import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useToastMessageDispatch, useRouter, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; +import React from 'react'; +import { useForm, Controller } from 'react-hook-form'; + +import AutoCompleteDepartmentMultiple from '../../../../client/components/AutoCompleteDepartmentMultiple'; +import { + ContextualbarScrollableContent, + ContextualbarFooter, + ContextualbarTitle, + Contextualbar, + ContextualbarHeader, + ContextualbarClose, +} from '../../../../client/components/Contextualbar'; +import { useRemoveTag } from './useRemoveTag'; + +type TagEditPayload = { + name: string; + description: string; + departments: { label: string; value: string }[]; +}; + +type TagEditProps = { + tagData?: ILivechatTag; + currentDepartments?: Serialized[]; +}; + +const TagEdit = ({ tagData, currentDepartments }: TagEditProps) => { + const t = useTranslation(); + const router = useRouter(); + const queryClient = useQueryClient(); + const handleDeleteTag = useRemoveTag(); + + const dispatchToastMessage = useToastMessageDispatch(); + const saveTag = useMethod('livechat:saveTag'); + + const { _id, name, description } = tagData || {}; + + const { + control, + formState: { isDirty, errors }, + handleSubmit, + } = useForm({ + mode: 'onBlur', + values: { + name: name || '', + description: description || '', + departments: currentDepartments?.map((dep) => ({ label: dep.name, value: dep._id })) || [], + }, + }); + + const handleSave = useMutableCallback(async ({ name, description, departments }: TagEditPayload) => { + const departmentsId = departments?.map((dep) => dep.value) || ['']; + + try { + await saveTag(_id as unknown as string, { name, description }, departmentsId); + dispatchToastMessage({ type: 'success', message: t('Saved') }); + queryClient.invalidateQueries(['livechat-tags']); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + router.navigate('/omnichannel/tags'); + } + }); + + const formId = useUniqueId(); + const nameField = useUniqueId(); + const descriptionField = useUniqueId(); + const departmentsField = useUniqueId(); + + return ( + + + {_id ? t('Edit_Tag') : t('New_Tag')} + router.navigate('/omnichannel/tags')}> + + + + + + + {t('Name')} + + + } + /> + + {errors?.name && ( + + {errors?.name?.message} + + )} + + + {t('Description')} + + } /> + + + + {t('Departments')} + + ( + + )} + /> + + + + + + + + + + + {_id && ( + + + + )} + + + ); +}; + +export default TagEdit; diff --git a/apps/meteor/ee/client/omnichannel/tags/TagEditWithData.js b/apps/meteor/ee/client/omnichannel/tags/TagEditWithData.js deleted file mode 100644 index cf545e0595e3..000000000000 --- a/apps/meteor/ee/client/omnichannel/tags/TagEditWithData.js +++ /dev/null @@ -1,38 +0,0 @@ -import { Callout } from '@rocket.chat/fuselage'; -import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; -import React from 'react'; - -import { FormSkeleton } from '../../../../client/components/Skeleton'; -import TagEdit from './TagEdit'; -import TagEditWithDepartmentData from './TagEditWithDepartmentData'; - -function TagEditWithData({ tagId, reload, title }) { - const getTag = useEndpoint('GET', '/v1/livechat/tags/:tagId', { tagId }); - const { data, isLoading, isError } = useQuery(['/v1/livechat/tags/:tagId', tagId], () => getTag(), { enabled: Boolean(tagId) }); - const t = useTranslation(); - - if (isLoading && tagId) { - return ; - } - - if (isError) { - return ( - - {t('Not_Available')} - - ); - } - - return ( - <> - {data && data.departments && data.departments.length > 0 ? ( - - ) : ( - - )} - - ); -} - -export default TagEditWithData; diff --git a/apps/meteor/ee/client/omnichannel/tags/TagEditWithData.tsx b/apps/meteor/ee/client/omnichannel/tags/TagEditWithData.tsx new file mode 100644 index 000000000000..95a902055ed6 --- /dev/null +++ b/apps/meteor/ee/client/omnichannel/tags/TagEditWithData.tsx @@ -0,0 +1,36 @@ +import type { ILivechatTag } from '@rocket.chat/core-typings'; +import { Callout } from '@rocket.chat/fuselage'; +import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; + +import { ContextualbarSkeleton } from '../../../../client/components/Contextualbar'; +import TagEdit from './TagEdit'; +import TagEditWithDepartmentData from './TagEditWithDepartmentData'; + +const TagEditWithData = ({ tagId }: { tagId: ILivechatTag['_id'] }) => { + const t = useTranslation(); + + const getTagById = useEndpoint('GET', '/v1/livechat/tags/:tagId', { tagId }); + const { data, isLoading, isError } = useQuery(['livechat-getTagById', tagId], async () => getTagById(), { refetchOnWindowFocus: false }); + + if (isLoading) { + return ; + } + + if (isError) { + return ( + + {t('Not_Available')} + + ); + } + + if (data?.departments && data.departments.length > 0) { + return ; + } + + return ; +}; + +export default TagEditWithData; diff --git a/apps/meteor/ee/client/omnichannel/tags/TagEditWithDepartmentData.tsx b/apps/meteor/ee/client/omnichannel/tags/TagEditWithDepartmentData.tsx index a6696ebdd485..4a65c31263c2 100644 --- a/apps/meteor/ee/client/omnichannel/tags/TagEditWithDepartmentData.tsx +++ b/apps/meteor/ee/client/omnichannel/tags/TagEditWithDepartmentData.tsx @@ -1,37 +1,27 @@ import type { ILivechatTag } from '@rocket.chat/core-typings'; import { Callout } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactElement, ReactNode } from 'react'; -import React, { useMemo } from 'react'; +import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; -import { FormSkeleton } from '../../../../client/components/Skeleton'; -import { AsyncStatePhase } from '../../../../client/hooks/useAsyncState'; -import { useEndpointData } from '../../../../client/hooks/useEndpointData'; +import { ContextualbarSkeleton } from '../../../../client/components/Contextualbar'; import TagEdit from './TagEdit'; -type TagEditWithDepartmentDataPropsType = { - data: ILivechatTag; - title: ReactNode; - tagId: ILivechatTag['_id']; - reload: () => void; -}; - -function TagEditWithDepartmentData({ data, title, ...props }: TagEditWithDepartmentDataPropsType): ReactElement { +const TagEditWithDepartmentData = ({ tagData }: { tagData: ILivechatTag }) => { const t = useTranslation(); - const { - value: currentDepartments, - phase: currentDepartmentsState, - error: currentDepartmentsError, - } = useEndpointData('/v1/livechat/department.listByIds', { - params: useMemo(() => ({ ids: data?.departments ? data.departments : [] }), [data]), - }); + const getDepartmentsById = useEndpoint('GET', '/v1/livechat/department.listByIds'); + const { data, isLoading, isError } = useQuery( + ['livechat-getDepartmentsById', tagData.departments], + async () => getDepartmentsById({ ids: tagData.departments }), + { refetchOnWindowFocus: false }, + ); - if ([currentDepartmentsState].includes(AsyncStatePhase.LOADING)) { - return ; + if (isLoading) { + return ; } - if (currentDepartmentsError) { + if (isError) { return ( {t('Not_Available')} @@ -39,7 +29,7 @@ function TagEditWithDepartmentData({ data, title, ...props }: TagEditWithDepartm ); } - return ; -} + return ; +}; export default TagEditWithDepartmentData; diff --git a/apps/meteor/ee/client/omnichannel/tags/TagsPage.tsx b/apps/meteor/ee/client/omnichannel/tags/TagsPage.tsx index 88d7da913048..c4af0a629940 100644 --- a/apps/meteor/ee/client/omnichannel/tags/TagsPage.tsx +++ b/apps/meteor/ee/client/omnichannel/tags/TagsPage.tsx @@ -1,34 +1,32 @@ import { Button, ButtonGroup } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useRoute, useTranslation } from '@rocket.chat/ui-contexts'; -import type { MutableRefObject } from 'react'; +import { useRouter, useTranslation, useRouteParameter } from '@rocket.chat/ui-contexts'; import React from 'react'; import Page from '../../../../client/components/Page'; +import TagEdit from './TagEdit'; +import TagEditWithData from './TagEditWithData'; import TagsTable from './TagsTable'; -const TagsPage = ({ reload }: { reload: MutableRefObject<() => void> }) => { +const TagsPage = () => { const t = useTranslation(); - const tagsRoute = useRoute('omnichannel-tags'); - - const handleClick = useMutableCallback(() => - tagsRoute.push({ - context: 'new', - }), - ); + const router = useRouter(); + const context = useRouteParameter('context'); + const id = useRouteParameter('id'); return ( - + - + + {context === 'edit' && id && } + {context === 'new' && } ); }; diff --git a/apps/meteor/ee/client/omnichannel/tags/TagsRoute.js b/apps/meteor/ee/client/omnichannel/tags/TagsRoute.js deleted file mode 100644 index f480c1251ccb..000000000000 --- a/apps/meteor/ee/client/omnichannel/tags/TagsRoute.js +++ /dev/null @@ -1,36 +0,0 @@ -import { useRouteParameter, usePermission, useTranslation } from '@rocket.chat/ui-contexts'; -import React, { useRef, useCallback } from 'react'; - -import NotAuthorizedPage from '../../../../client/views/notAuthorized/NotAuthorizedPage'; -import TagEdit from './TagEdit'; -import TagEditWithData from './TagEditWithData'; -import TagsPage from './TagsPage'; - -const TagsRoute = () => { - const t = useTranslation(); - const reload = useRef(() => null); - const canViewTags = usePermission('manage-livechat-tags'); - - const handleReload = useCallback(() => { - reload.current(); - }, []); - - const context = useRouteParameter('context'); - const id = useRouteParameter('id'); - - if (context === 'edit') { - return ; - } - - if (context === 'new') { - return ; - } - - if (!canViewTags) { - return ; - } - - return ; -}; - -export default TagsRoute; diff --git a/apps/meteor/ee/client/omnichannel/tags/TagsRoute.tsx b/apps/meteor/ee/client/omnichannel/tags/TagsRoute.tsx new file mode 100644 index 000000000000..4860b0aeab54 --- /dev/null +++ b/apps/meteor/ee/client/omnichannel/tags/TagsRoute.tsx @@ -0,0 +1,17 @@ +import { usePermission } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import NotAuthorizedPage from '../../../../client/views/notAuthorized/NotAuthorizedPage'; +import TagsPage from './TagsPage'; + +const TagsRoute = () => { + const canViewTags = usePermission('manage-livechat-tags'); + + if (!canViewTags) { + return ; + } + + return ; +}; + +export default TagsRoute; diff --git a/apps/meteor/ee/client/omnichannel/tags/TagsTable.tsx b/apps/meteor/ee/client/omnichannel/tags/TagsTable.tsx index e2fb3f6a67a8..3bb44996d22c 100644 --- a/apps/meteor/ee/client/omnichannel/tags/TagsTable.tsx +++ b/apps/meteor/ee/client/omnichannel/tags/TagsTable.tsx @@ -1,9 +1,8 @@ -import { Pagination } from '@rocket.chat/fuselage'; +import { IconButton, Pagination } from '@rocket.chat/fuselage'; import { useDebouncedValue, useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useTranslation, useEndpoint, useRouter } from '@rocket.chat/ui-contexts'; import { useQuery, hashQueryKey } from '@tanstack/react-query'; -import type { MutableRefObject } from 'react'; -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo, useState } from 'react'; import FilterByText from '../../../../client/components/FilterByText'; import GenericNoResults from '../../../../client/components/GenericNoResults'; @@ -18,9 +17,9 @@ import { } from '../../../../client/components/GenericTable'; import { usePagination } from '../../../../client/components/GenericTable/hooks/usePagination'; import { useSort } from '../../../../client/components/GenericTable/hooks/useSort'; -import RemoveTagButton from './RemoveTagButton'; +import { useRemoveTag } from './useRemoveTag'; -const TagsTable = ({ reload }: { reload: MutableRefObject<() => void> }) => { +const TagsTable = () => { const t = useTranslation(); const [filter, setFilter] = useState(''); const debouncedFilter = useDebouncedValue(filter, 500); @@ -31,6 +30,7 @@ const TagsTable = ({ reload }: { reload: MutableRefObject<() => void> }) => { const onRowClick = useMutableCallback((id) => router.navigate(`/omnichannel/tags/edit/${id}`)); const handleAddNew = useMutableCallback(() => router.navigate('/omnichannel/tags/new')); + const handleDeleteTag = useRemoveTag(); const query = useMemo( () => ({ @@ -45,15 +45,11 @@ const TagsTable = ({ reload }: { reload: MutableRefObject<() => void> }) => { ); const getTags = useEndpoint('GET', '/v1/livechat/tags'); - const { data, refetch, isSuccess, isLoading } = useQuery(['livechat-tags', query], async () => getTags(query)); + const { data, isSuccess, isLoading } = useQuery(['livechat-tags', query], async () => getTags(query), { refetchOnWindowFocus: false }); const [defaultQuery] = useState(hashQueryKey([query])); const queryHasChanged = defaultQuery !== hashQueryKey([query]); - useEffect(() => { - reload.current = refetch; - }, [reload, refetch]); - const headers = ( <> @@ -68,9 +64,7 @@ const TagsTable = ({ reload }: { reload: MutableRefObject<() => void> }) => { > {t('Description')} - - {t('Remove')} - + ); @@ -106,7 +100,17 @@ const TagsTable = ({ reload }: { reload: MutableRefObject<() => void> }) => { onRowClick(_id)} action qa-user-id={_id}> {name} {description} - + + { + e.stopPropagation(); + handleDeleteTag(_id); + }} + /> + ))} diff --git a/apps/meteor/ee/client/omnichannel/tags/useRemoveTag.tsx b/apps/meteor/ee/client/omnichannel/tags/useRemoveTag.tsx new file mode 100644 index 000000000000..d1a5c6096890 --- /dev/null +++ b/apps/meteor/ee/client/omnichannel/tags/useRemoveTag.tsx @@ -0,0 +1,34 @@ +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useSetModal, useToastMessageDispatch, useRouter, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; +import React from 'react'; + +import GenericModal from '../../../../client/components/GenericModal'; + +export const useRemoveTag = () => { + const t = useTranslation(); + const setModal = useSetModal(); + const dispatchToastMessage = useToastMessageDispatch(); + const removeTag = useMethod('livechat:removeTag'); + const queryClient = useQueryClient(); + const router = useRouter(); + + const handleDeleteTag = useMutableCallback((tagId) => { + const handleDelete = async () => { + try { + await removeTag(tagId); + dispatchToastMessage({ type: 'success', message: t('Tag_removed') }); + router.navigate('/omnichannel/tags'); + queryClient.invalidateQueries(['livechat-tags']); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + setModal(); + } + }; + + setModal( setModal()} confirmText={t('Delete')} />); + }); + + return handleDeleteTag; +}; diff --git a/apps/meteor/ee/client/omnichannel/units/RemoveUnitButton.tsx b/apps/meteor/ee/client/omnichannel/units/RemoveUnitButton.tsx deleted file mode 100644 index fb28be048781..000000000000 --- a/apps/meteor/ee/client/omnichannel/units/RemoveUnitButton.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { ILivechatUnitMonitor } from '@rocket.chat/core-typings'; -import { IconButton } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useSetModal, useToastMessageDispatch, useRoute, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; - -import GenericModal from '../../../../client/components/GenericModal'; -import { GenericTableCell } from '../../../../client/components/GenericTable'; - -const RemoveUnitButton = ({ _id, reload }: { _id: ILivechatUnitMonitor['unitId']; reload: () => void }) => { - const t = useTranslation(); - const setModal = useSetModal(); - const dispatchToastMessage = useToastMessageDispatch(); - - const removeUnit = useMethod('livechat:removeUnit'); - const unitsRoute = useRoute('omnichannel-units'); - - const handleDelete = useMutableCallback((e) => { - e.stopPropagation(); - const onDeleteAgent = async () => { - try { - await removeUnit(_id); - dispatchToastMessage({ type: 'success', message: t('Unit_removed') }); - unitsRoute.push({}); - reload(); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } finally { - setModal(); - } - }; - - setModal( setModal()} confirmText={t('Delete')} />); - }); - - return ( - - - - ); -}; - -export default RemoveUnitButton; diff --git a/apps/meteor/ee/client/omnichannel/units/UnitEdit.js b/apps/meteor/ee/client/omnichannel/units/UnitEdit.js deleted file mode 100644 index 2c172bcf38c6..000000000000 --- a/apps/meteor/ee/client/omnichannel/units/UnitEdit.js +++ /dev/null @@ -1,216 +0,0 @@ -import { Field, TextInput, Button, PaginatedMultiSelectFiltered, Select, ButtonGroup, FieldGroup } from '@rocket.chat/fuselage'; -import { useMutableCallback, useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch, useRoute, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; -import React, { useMemo, useState } from 'react'; - -import Page from '../../../../client/components/Page'; -import { useRecordList } from '../../../../client/hooks/lists/useRecordList'; -import { AsyncStatePhase } from '../../../../client/hooks/useAsyncState'; -import { useForm } from '../../../../client/hooks/useForm'; -import { useDepartmentsByUnitsList } from '../../../../client/views/hooks/useDepartmentsByUnitsList'; -import { useMonitorsList } from '../../../../client/views/hooks/useMonitorsList'; - -function UnitEdit({ title, data, unitId, isNew, unitMonitors, unitDepartments, reload, ...props }) { - const t = useTranslation(); - const unitsRoute = useRoute('omnichannel-units'); - const [monitorsFilter, setMonitorsFilter] = useState(''); - - const debouncedMonitorsFilter = useDebouncedValue(monitorsFilter, 500); - - const [departmentsFilter, setDepartmentsFilter] = useState(''); - - const debouncedDepartmentsFilter = useDebouncedValue(departmentsFilter, 500); - - const { itemsList: monitorsList, loadMoreItems: loadMoreMonitors } = useMonitorsList( - useMemo(() => ({ filter: debouncedMonitorsFilter }), [debouncedMonitorsFilter]), - ); - - const { phase: monitorsPhase, items: monitorsItems, itemCount: monitorsTotal } = useRecordList(monitorsList); - - const { itemsList: departmentsList, loadMoreItems: loadMoreDepartments } = useDepartmentsByUnitsList( - useMemo(() => ({ filter: debouncedDepartmentsFilter, unitId }), [debouncedDepartmentsFilter, unitId]), - ); - - const { phase: departmentsPhase, items: departmentsItems, itemCount: departmentsTotal } = useRecordList(departmentsList); - - const unit = data || {}; - - const currUnitMonitors = useMemo( - () => - unitMonitors && unitMonitors.monitors - ? unitMonitors.monitors.map(({ monitorId, username }) => ({ - value: monitorId, - label: username, - })) - : [], - [unitMonitors], - ); - const visibilityOpts = [ - ['public', t('Public')], - ['private', t('Private')], - ]; - - const currUnitDepartments = useMemo( - () => - unitDepartments && unitDepartments.departments && unitId - ? unitDepartments.departments.map(({ _id, name }) => ({ - value: _id, - label: name, - })) - : [], - [unitDepartments, unitId], - ); - - const { values, handlers, hasUnsavedChanges } = useForm({ - name: unit.name, - visibility: unit.visibility, - departments: currUnitDepartments, - monitors: currUnitMonitors, - }); - - const { handleName, handleVisibility, handleDepartments, handleMonitors } = handlers; - const { name, visibility, departments, monitors } = values; - - const departmentsOptions = useMemo(() => { - const pending = departments.filter(({ value }) => !departmentsItems.find((dep) => dep.value === value)); - return [...departmentsItems, ...pending]; - }, [departments, departmentsItems]); - - const monitorsOptions = useMemo(() => { - const pending = monitors.filter(({ value }) => !monitorsItems.find((mon) => mon.value === value)); - return [...monitorsItems, ...pending]; - }, [monitors, monitorsItems]); - - const nameError = useMemo(() => (!name || name.length === 0 ? t('The_field_is_required', t('name')) : undefined), [name, t]); - const visibilityError = useMemo( - () => (!visibility || visibility.length === 0 ? t('The_field_is_required', t('description')) : undefined), - [visibility, t], - ); - const departmentError = useMemo( - () => (!departments || departments.length === 0 ? t('The_field_is_required', t('departments')) : undefined), - [departments, t], - ); - const unitMonitorsError = useMemo( - () => (!monitors || monitors.length === 0 ? t('The_field_is_required', t('monitors')) : undefined), - [monitors, t], - ); - - const saveUnit = useMethod('livechat:saveUnit'); - - const dispatchToastMessage = useToastMessageDispatch(); - - const handleReturn = useMutableCallback(() => { - unitsRoute.push({}); - }); - - const canSave = useMemo( - () => !nameError && !visibilityError && !departmentError && !unitMonitorsError, - [nameError, visibilityError, departmentError, unitMonitorsError], - ); - - const handleSave = useMutableCallback(async () => { - const unitData = { name, visibility }; - const departmentsData = departments.map((department) => ({ departmentId: department.value })); - const monitorsData = monitors.map((monitor) => ({ - monitorId: monitor.value, - username: monitor.label, - })); - - if (!canSave) { - return dispatchToastMessage({ type: 'error', message: t('The_field_is_required') }); - } - - try { - await saveUnit(unitId, unitData, monitorsData, departmentsData); - dispatchToastMessage({ type: 'success', message: t('Saved') }); - reload(); - unitsRoute.push({}); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - - return ( - - - - - - - - - - - - {t('Name')}* - - - - - - {t('Visibility')}* - - + )} + /> + + {errors?.visibility && {errors?.visibility.message}} + + + + {t('Departments')} + + + ( + start && loadMoreDepartments(start, Math.min(50, departmentsTotal)) + } + aria-describedby={`${departmentsField}-error`} + aria-required={true} + aria-invalid={Boolean(errors?.departments)} + /> + )} + /> + + {errors?.departments && ( + + {errors?.departments.message} + + )} + + + + {t('Monitors')} + + + ( + start && loadMoreMonitors(start, Math.min(50, monitorsTotal)) + } + aria-describedby={`${monitorsField}-error`} + aria-required={true} + aria-invalid={Boolean(errors?.monitors)} + /> + )} + /> + + {errors?.monitors && ( + + {errors?.monitors.message} + + )} + + + + + + + + + + {_id && ( + + + + )} + + + ); +}; + +export default UnitEdit; diff --git a/apps/meteor/ee/client/omnichannel/units/UnitEditWithData.tsx b/apps/meteor/ee/client/omnichannel/units/UnitEditWithData.tsx index 66081ab07d0b..6f30453f62dc 100644 --- a/apps/meteor/ee/client/omnichannel/units/UnitEditWithData.tsx +++ b/apps/meteor/ee/client/omnichannel/units/UnitEditWithData.tsx @@ -1,39 +1,42 @@ +import type { IOmnichannelBusinessUnit } from '@rocket.chat/core-typings'; import { Callout } from '@rocket.chat/fuselage'; -import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; +import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; import React from 'react'; -import { FormSkeleton } from '../../../../client/components/Skeleton'; -import { AsyncStatePhase } from '../../../../client/hooks/useAsyncState'; -import { useEndpointData } from '../../../../client/hooks/useEndpointData'; +import { ContextualbarSkeleton } from '../../../../client/components/Contextualbar'; import UnitEdit from './UnitEdit'; -const UnitEditWithData: FC<{ - unitId: string; - title: string; - reload: () => void; -}> = function UnitEditWithData({ unitId, reload, title }) { - const { value: data, phase: state, error } = useEndpointData('/v1/livechat/units/:id', { keys: { id: unitId } }); +const UnitEditWithData = ({ unitId }: { unitId: IOmnichannelBusinessUnit['_id'] }) => { + const t = useTranslation(); - const { - value: unitMonitors, - phase: unitMonitorsState, - error: unitMonitorsError, - } = useEndpointData('/v1/livechat/units/:unitId/monitors', { keys: { unitId } }); + const getUnitById = useEndpoint('GET', '/v1/livechat/units/:id', { id: unitId }); + const getMonitorsByUnitId = useEndpoint('GET', '/v1/livechat/units/:unitId/monitors', { unitId }); + const getDepartmentsByUnitId = useEndpoint('GET', '/v1/livechat/units/:unitId/departments', { unitId }); const { - value: unitDepartments, - phase: unitDepartmentsState, - error: unitDepartmentsError, - } = useEndpointData('/v1/livechat/units/:unitId/departments', { keys: { unitId } }); - - const t = useTranslation(); - - if ([state, unitMonitorsState, unitDepartmentsState].includes(AsyncStatePhase.LOADING)) { - return ; + data: unitData, + isError, + isLoading, + } = useQuery(['livechat-getUnitById', unitId], async () => getUnitById(), { refetchOnWindowFocus: false }); + const { + data: unitMonitors, + isError: unitMonitorsError, + isLoading: unitMonitorsLoading, + } = useQuery(['livechat-getMonitorsByUnitId', unitId], async () => getMonitorsByUnitId({ unitId }), { refetchOnWindowFocus: false }); + const { + data: unitDepartments, + isError: unitDepartmentsError, + isLoading: unitDepartmentsLoading, + } = useQuery(['livechat-getDepartmentsByUnitId', unitId], async () => getDepartmentsByUnitId({ unitId }), { + refetchOnWindowFocus: false, + }); + + if (isLoading || unitMonitorsLoading || unitDepartmentsLoading) { + return ; } - if (error || unitMonitorsError || unitDepartmentsError) { + if (isError || unitMonitorsError || unitDepartmentsError) { return ( {t('Not_Available')} @@ -41,17 +44,7 @@ const UnitEditWithData: FC<{ ); } - return ( - - ); + return ; }; export default UnitEditWithData; diff --git a/apps/meteor/ee/client/omnichannel/units/UnitsPage.tsx b/apps/meteor/ee/client/omnichannel/units/UnitsPage.tsx index b372264190ad..10f1ee1c7753 100644 --- a/apps/meteor/ee/client/omnichannel/units/UnitsPage.tsx +++ b/apps/meteor/ee/client/omnichannel/units/UnitsPage.tsx @@ -1,36 +1,35 @@ import { Button, ButtonGroup } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useRoute, useTranslation } from '@rocket.chat/ui-contexts'; -import type { MutableRefObject } from 'react'; +import { useTranslation, useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; import React from 'react'; import Page from '../../../../client/components/Page'; +import UnitEdit from './UnitEdit'; +import UnitEditWithData from './UnitEditWithData'; import UnitsTable from './UnitsTable'; -const UnitsPage = ({ reload }: { reload: MutableRefObject<() => void> }) => { +const UnitsPage = () => { const t = useTranslation(); - const unitsRoute = useRoute('omnichannel-units'); + const router = useRouter(); - const handleClick = useMutableCallback(() => - unitsRoute.push({ - context: 'new', - }), - ); + const context = useRouteParameter('context'); + const id = useRouteParameter('id'); return ( - - + + {context === 'edit' && id && } + {context === 'new' && } ); }; diff --git a/apps/meteor/ee/client/omnichannel/units/UnitsRoute.js b/apps/meteor/ee/client/omnichannel/units/UnitsRoute.js deleted file mode 100644 index bc44f7be8513..000000000000 --- a/apps/meteor/ee/client/omnichannel/units/UnitsRoute.js +++ /dev/null @@ -1,38 +0,0 @@ -import { useRouteParameter, usePermission, useTranslation } from '@rocket.chat/ui-contexts'; -import React, { useRef, useCallback } from 'react'; - -import NotAuthorizedPage from '../../../../client/views/notAuthorized/NotAuthorizedPage'; -import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; -import UnitEdit from './UnitEdit'; -import UnitEditWithData from './UnitEditWithData'; -import UnitsPage from './UnitsPage'; - -const UnitsRoute = () => { - const t = useTranslation(); - const reload = useRef(() => null); - - const handleReload = useCallback(() => { - reload.current(); - }, []); - - const canViewUnits = usePermission('manage-livechat-units'); - const isEnterprise = useHasLicenseModule('livechat-enterprise'); - const context = useRouteParameter('context'); - const id = useRouteParameter('id'); - - if (context === 'edit' && id) { - return ; - } - - if (context === 'new') { - return ; - } - - if (!(isEnterprise && canViewUnits)) { - return ; - } - - return ; -}; - -export default UnitsRoute; diff --git a/apps/meteor/ee/client/omnichannel/units/UnitsRoute.tsx b/apps/meteor/ee/client/omnichannel/units/UnitsRoute.tsx new file mode 100644 index 000000000000..9afa82c171be --- /dev/null +++ b/apps/meteor/ee/client/omnichannel/units/UnitsRoute.tsx @@ -0,0 +1,19 @@ +import { usePermission } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import NotAuthorizedPage from '../../../../client/views/notAuthorized/NotAuthorizedPage'; +import { useHasLicenseModule } from '../../hooks/useHasLicenseModule'; +import UnitsPage from './UnitsPage'; + +const UnitsRoute = () => { + const canViewUnits = usePermission('manage-livechat-units'); + const isEnterprise = useHasLicenseModule('livechat-enterprise'); + + if (!(isEnterprise && canViewUnits)) { + return ; + } + + return ; +}; + +export default UnitsRoute; diff --git a/apps/meteor/ee/client/omnichannel/units/UnitsTable.tsx b/apps/meteor/ee/client/omnichannel/units/UnitsTable.tsx index 6784b53acd6b..673eb136fd73 100644 --- a/apps/meteor/ee/client/omnichannel/units/UnitsTable.tsx +++ b/apps/meteor/ee/client/omnichannel/units/UnitsTable.tsx @@ -1,9 +1,8 @@ -import { Pagination } from '@rocket.chat/fuselage'; +import { Pagination, IconButton } from '@rocket.chat/fuselage'; import { useDebouncedValue, useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useEndpoint, useRouter, useTranslation } from '@rocket.chat/ui-contexts'; import { useQuery, hashQueryKey } from '@tanstack/react-query'; -import type { MutableRefObject } from 'react'; -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo, useState } from 'react'; import FilterByText from '../../../../client/components/FilterByText'; import GenericNoResults from '../../../../client/components/GenericNoResults/GenericNoResults'; @@ -18,9 +17,9 @@ import { } from '../../../../client/components/GenericTable'; import { usePagination } from '../../../../client/components/GenericTable/hooks/usePagination'; import { useSort } from '../../../../client/components/GenericTable/hooks/useSort'; -import RemoveUnitButton from './RemoveUnitButton'; +import { useRemoveUnit } from './useRemoveUnit'; -const UnitsTable = ({ reload }: { reload: MutableRefObject<() => void> }) => { +const UnitsTable = () => { const t = useTranslation(); const [filter, setFilter] = useState(''); const debouncedFilter = useDebouncedValue(filter, 500); @@ -41,17 +40,14 @@ const UnitsTable = ({ reload }: { reload: MutableRefObject<() => void> }) => { ); const getUnits = useEndpoint('GET', '/v1/livechat/units'); - const { isSuccess, isLoading, data, refetch } = useQuery(['livechat-units', query], async () => getUnits(query)); + const { isSuccess, isLoading, data } = useQuery(['livechat-units', query], async () => getUnits(query)); const [defaultQuery] = useState(hashQueryKey([query])); const queryHasChanged = defaultQuery !== hashQueryKey([query]); - useEffect(() => { - reload.current = refetch; - }, [refetch, reload]); - const handleAddNew = useMutableCallback(() => router.navigate('/omnichannel/units/new')); const onRowClick = useMutableCallback((id) => () => router.navigate(`/omnichannel/units/edit/${id}`)); + const handleDelete = useRemoveUnit(); const headers = ( <> @@ -67,9 +63,7 @@ const UnitsTable = ({ reload }: { reload: MutableRefObject<() => void> }) => { > {t('Visibility')} - - {t('Remove')} - + ); @@ -105,7 +99,17 @@ const UnitsTable = ({ reload }: { reload: MutableRefObject<() => void> }) => { {name} {visibility} - + + { + e.stopPropagation(); + handleDelete(_id); + }} + /> + ))} diff --git a/apps/meteor/ee/client/omnichannel/units/useRemoveUnit.tsx b/apps/meteor/ee/client/omnichannel/units/useRemoveUnit.tsx new file mode 100644 index 000000000000..a4052c55b8e9 --- /dev/null +++ b/apps/meteor/ee/client/omnichannel/units/useRemoveUnit.tsx @@ -0,0 +1,35 @@ +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useSetModal, useToastMessageDispatch, useMethod, useTranslation, useRouter } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; +import React from 'react'; + +import GenericModal from '../../../../client/components/GenericModal'; + +export const useRemoveUnit = () => { + const t = useTranslation(); + const setModal = useSetModal(); + const router = useRouter(); + const dispatchToastMessage = useToastMessageDispatch(); + + const queryClient = useQueryClient(); + const removeUnit = useMethod('livechat:removeUnit'); + + const handleDelete = useMutableCallback((id) => { + const onDeleteAgent = async () => { + try { + await removeUnit(id); + dispatchToastMessage({ type: 'success', message: t('Unit_removed') }); + router.navigate('/omnichannel/units'); + queryClient.invalidateQueries(['livechat-units']); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + setModal(); + } + }; + + setModal( setModal()} confirmText={t('Delete')} />); + }); + + return handleDelete; +}; diff --git a/apps/meteor/ee/client/sidebar/footer/SidebarFooterWatermark.tsx b/apps/meteor/ee/client/sidebar/footer/SidebarFooterWatermark.tsx index 8fc2bfa5c94c..353fd213e526 100644 --- a/apps/meteor/ee/client/sidebar/footer/SidebarFooterWatermark.tsx +++ b/apps/meteor/ee/client/sidebar/footer/SidebarFooterWatermark.tsx @@ -3,17 +3,25 @@ import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; -import { useIsEnterprise } from '../../../../client/hooks/useIsEnterprise'; +import { useLicense } from '../../../../client/hooks/useLicense'; export const SidebarFooterWatermark = (): ReactElement | null => { const t = useTranslation(); - const { isLoading, isError, data } = useIsEnterprise(); + const response = useLicense(); - if (isError || isLoading || data?.isEnterprise) { + if (response.isLoading || response.isError) { return null; } + const license = response.data; + + if (license.activeModules.includes('hide-watermark') && !license.trial) { + return null; + } + + const [{ name: planName } = { name: 'Community' }] = license.tags ?? []; + return ( @@ -21,7 +29,7 @@ export const SidebarFooterWatermark = (): ReactElement | null => { {t('Powered_by_RocketChat')} - {t('Free_Edition')} + {[planName, license.trial ? 'trial' : ''].filter(Boolean).join(' ')} diff --git a/apps/meteor/ee/client/startup/readReceipt.ts b/apps/meteor/ee/client/startup/readReceipt.ts index 0efc779a3b49..938cc4b6133f 100644 --- a/apps/meteor/ee/client/startup/readReceipt.ts +++ b/apps/meteor/ee/client/startup/readReceipt.ts @@ -19,7 +19,7 @@ Meteor.startup(() => { id: 'receipt-detail', icon: 'info-circled', label: 'Info', - context: ['starred', 'message', 'message-mobile', 'threads'], + context: ['starred', 'message', 'message-mobile', 'threads', 'videoconf', 'videoconf-threads'], type: 'duplication', action(_, props) { const { message = messageArgs(this).msg } = props; diff --git a/apps/meteor/ee/client/views/admin/deviceManagement/DeviceManagementAdminRoute.tsx b/apps/meteor/ee/client/views/admin/deviceManagement/DeviceManagementAdminRoute.tsx index 566a54a597c3..4614a2c9157b 100644 --- a/apps/meteor/ee/client/views/admin/deviceManagement/DeviceManagementAdminRoute.tsx +++ b/apps/meteor/ee/client/views/admin/deviceManagement/DeviceManagementAdminRoute.tsx @@ -2,6 +2,7 @@ import { usePermission, useRouter, useSetModal, useCurrentModal, useTranslation import type { ReactElement } from 'react'; import React, { useEffect } from 'react'; +import { getURL } from '../../../../../app/utils/client/getURL'; import GenericUpsellModal from '../../../../../client/components/GenericUpsellModal'; import { useUpsellActions } from '../../../../../client/components/GenericUpsellModal/hooks'; import PageSkeleton from '../../../../../client/components/PageSkeleton'; @@ -25,7 +26,7 @@ const DeviceManagementAdminRoute = (): ReactElement => { setModal( { setModal( setModal(null)} diff --git a/apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx b/apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx index e067f777090f..eb504033e1e6 100644 --- a/apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx +++ b/apps/meteor/ee/client/views/admin/engagementDashboard/users/ActiveUsersSection.tsx @@ -127,7 +127,7 @@ const ActiveUsersSection = ({ timezone }: ActiveUsersSectionProps): ReactElement variation: diffDailyActiveUsers ?? 0, description: ( <> - {t('Daily_Active_Users')} + {t('Daily_Active_Users')} ), }, @@ -136,7 +136,7 @@ const ActiveUsersSection = ({ timezone }: ActiveUsersSectionProps): ReactElement variation: diffWeeklyActiveUsers ?? 0, description: ( <> - {t('Weekly_Active_Users')} + {t('Weekly_Active_Users')} ), }, @@ -203,7 +203,7 @@ const ActiveUsersSection = ({ timezone }: ActiveUsersSectionProps): ReactElement right: 0, left: 40, }} - colors={[colors.p200, colors.p300, colors.p500]} + colors={[colors.b200, colors.b300, colors.b500]} axisLeft={{ // TODO: Get it from theme tickSize: 0, diff --git a/apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx b/apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx index d8f13bb891a3..fa5664ebca27 100644 --- a/apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx +++ b/apps/meteor/ee/client/views/admin/engagementDashboard/users/UsersByTimeOfTheDaySection.tsx @@ -119,13 +119,13 @@ const UsersByTimeOfTheDaySection = ({ timezone }: UsersByTimeOfTheDaySectionProp type: 'quantize', colors: [ // TODO: Get it from theme - colors.p100, - colors.p200, - colors.p300, - colors.p400, - colors.p500, - colors.p600, - colors.p700, + colors.b100, + colors.b200, + colors.b300, + colors.b400, + colors.b500, + colors.b600, + colors.b700, ], }} emptyColor='transparent' diff --git a/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx b/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx index b595dd9c1fae..804893ae8458 100644 --- a/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx +++ b/apps/meteor/ee/client/views/admin/info/SeatsCard.tsx @@ -23,7 +23,7 @@ const SeatsCard = ({ seatsCap }: SeatsCardProps): ReactElement => { const isNearLimit = seatsCap && seatsCap.activeUsers / seatsCap.maxActiveUsers >= 0.8; - const color = isNearLimit ? colors.d500 : undefined; + const color = isNearLimit ? colors.r500 : undefined; return ( diff --git a/apps/meteor/ee/client/views/audit/components/AuditForm.tsx b/apps/meteor/ee/client/views/audit/components/AuditForm.tsx index 3888013d8732..f8fbb63e3903 100644 --- a/apps/meteor/ee/client/views/audit/components/AuditForm.tsx +++ b/apps/meteor/ee/client/views/audit/components/AuditForm.tsx @@ -1,5 +1,5 @@ import type { IAuditLog } from '@rocket.chat/core-typings'; -import { Box, Field, TextInput, Button, ButtonGroup } from '@rocket.chat/fuselage'; +import { Box, Field, FieldLabel, FieldRow, FieldError, TextInput, Button, ButtonGroup } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; import { useController } from 'react-hook-form'; @@ -40,18 +40,18 @@ const AuditForm = ({ type, onSubmit }: AuditFormProps) => {
- {t('Message')} - + {t('Message')} + - + - {t('Date')} - + {t('Date')} + - {dateRangeFieldState.error?.type === 'required' && {t('The_field_is_required', t('Date'))}} - {dateRangeFieldState.error?.type === 'validate' && {dateRangeFieldState.error.message}} - + {dateRangeFieldState.error?.type === 'required' && {t('The_field_is_required', t('Date'))}} + {dateRangeFieldState.error?.type === 'validate' && {dateRangeFieldState.error.message}} + diff --git a/apps/meteor/ee/client/views/audit/components/AuditLogTable.tsx b/apps/meteor/ee/client/views/audit/components/AuditLogTable.tsx index 6125275c3972..d51926fb574c 100644 --- a/apps/meteor/ee/client/views/audit/components/AuditLogTable.tsx +++ b/apps/meteor/ee/client/views/audit/components/AuditLogTable.tsx @@ -1,4 +1,4 @@ -import { Field } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; import { useTranslation, useMethod, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; @@ -55,10 +55,10 @@ const AuditLogTable = (): ReactElement => { return ( <> - {t('Date')} - + {t('Date')} + - + {isLoading && ( diff --git a/apps/meteor/ee/client/views/audit/components/tabs/DirectTab.tsx b/apps/meteor/ee/client/views/audit/components/tabs/DirectTab.tsx index 64472d0e1e91..58facbb3bd42 100644 --- a/apps/meteor/ee/client/views/audit/components/tabs/DirectTab.tsx +++ b/apps/meteor/ee/client/views/audit/components/tabs/DirectTab.tsx @@ -1,4 +1,4 @@ -import { Field } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; @@ -30,17 +30,17 @@ const DirectTab = ({ form: { control } }: DirectTabProps): ReactElement => { return ( - {t('Users')} - + {t('Users')} + - - {usersFieldState.error?.type === 'required' && {t('The_field_is_required', t('Users'))}} - {usersFieldState.error?.type === 'validate' && {usersFieldState.error.message}} + + {usersFieldState.error?.type === 'required' && {t('The_field_is_required', t('Users'))}} + {usersFieldState.error?.type === 'validate' && {usersFieldState.error.message}} ); }; diff --git a/apps/meteor/ee/client/views/audit/components/tabs/OmnichannelTab.tsx b/apps/meteor/ee/client/views/audit/components/tabs/OmnichannelTab.tsx index 9d4bf2c45cbf..772880fd6c21 100644 --- a/apps/meteor/ee/client/views/audit/components/tabs/OmnichannelTab.tsx +++ b/apps/meteor/ee/client/views/audit/components/tabs/OmnichannelTab.tsx @@ -1,4 +1,4 @@ -import { Field } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; @@ -30,21 +30,21 @@ const OmnichannelTab = ({ form: { control } }: OmnichannelTabProps): ReactElemen return ( <> - {t('Visitor')} - + {t('Visitor')} + - - {visitorFieldState.error?.type === 'required' && {t('The_field_is_required', t('Visitor'))}} - {visitorFieldState.error?.type === 'validate' && {visitorFieldState.error.message}} + + {visitorFieldState.error?.type === 'required' && {t('The_field_is_required', t('Visitor'))}} + {visitorFieldState.error?.type === 'validate' && {visitorFieldState.error.message}} - {t('Agent')} - + {t('Agent')} + { if (agentFieldState.error?.type === 'required') { @@ -57,9 +57,9 @@ const OmnichannelTab = ({ form: { control } }: OmnichannelTabProps): ReactElemen onChange={agentField.onChange} placeholder={t('Username_Placeholder')} /> - - {agentFieldState.error?.type === 'required' && {t('The_field_is_required', t('Agent'))}} - {agentFieldState.error?.type === 'validate' && {agentFieldState.error.message}} + + {agentFieldState.error?.type === 'required' && {t('The_field_is_required', t('Agent'))}} + {agentFieldState.error?.type === 'validate' && {agentFieldState.error.message}} ); diff --git a/apps/meteor/ee/client/views/audit/components/tabs/RoomsTab.tsx b/apps/meteor/ee/client/views/audit/components/tabs/RoomsTab.tsx index 551bd5bd5c0e..984789bc0e01 100644 --- a/apps/meteor/ee/client/views/audit/components/tabs/RoomsTab.tsx +++ b/apps/meteor/ee/client/views/audit/components/tabs/RoomsTab.tsx @@ -1,4 +1,4 @@ -import { Field } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; @@ -19,17 +19,17 @@ const RoomsTab = ({ form: { control } }: RoomsTabProps): ReactElement => { return ( - {t('Channel_name')} {/* TODO: should it be `Room_name`? */} - + {t('Channel_name')} {/* TODO: should it be `Room_name`? */} + - - {ridFieldState.error?.type === 'required' && {t('The_field_is_required', t('Channel_name'))}} - {ridFieldState.error?.type === 'validate' && {ridFieldState.error.message}} + + {ridFieldState.error?.type === 'required' && {t('The_field_is_required', t('Channel_name'))}} + {ridFieldState.error?.type === 'validate' && {ridFieldState.error.message}} ); }; diff --git a/apps/meteor/ee/client/views/audit/components/tabs/UsersTab.tsx b/apps/meteor/ee/client/views/audit/components/tabs/UsersTab.tsx index aeed1ae4aa8c..75e769baa71d 100644 --- a/apps/meteor/ee/client/views/audit/components/tabs/UsersTab.tsx +++ b/apps/meteor/ee/client/views/audit/components/tabs/UsersTab.tsx @@ -1,4 +1,4 @@ -import { Field } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; @@ -30,17 +30,17 @@ const UsersTab = ({ form: { control } }: UsersTabProps): ReactElement => { return ( - {t('Users')} - + {t('Users')} + - - {usersFieldState.error?.type === 'required' && {t('The_field_is_required', t('Users'))}} - {usersFieldState.error?.type === 'validate' && {usersFieldState.error.message}} + + {usersFieldState.error?.type === 'required' && {t('The_field_is_required', t('Users'))}} + {usersFieldState.error?.type === 'validate' && {usersFieldState.error.message}} ); }; diff --git a/apps/meteor/ee/client/voip/components/modals/WrapUpCallModal.tsx b/apps/meteor/ee/client/voip/components/modals/WrapUpCallModal.tsx index bb144fa750eb..31aedb04aa10 100644 --- a/apps/meteor/ee/client/voip/components/modals/WrapUpCallModal.tsx +++ b/apps/meteor/ee/client/voip/components/modals/WrapUpCallModal.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Field, Modal, TextAreaInput } from '@rocket.chat/fuselage'; +import { Box, Button, Field, FieldLabel, FieldRow, FieldHint, Modal, TextAreaInput } from '@rocket.chat/fuselage'; import { useSetModal, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { useEffect } from 'react'; @@ -52,11 +52,11 @@ export const WrapUpCallModal = ({ closeRoom }: WrapUpCallModalProps): ReactEleme - {t('Notes')} - + {t('Notes')} + - - {t('These_notes_will_be_available_in_the_call_summary')} + + {t('These_notes_will_be_available_in_the_call_summary')} void} /> diff --git a/apps/meteor/ee/client/voip/modal/DialPad/DialPadModal.tsx b/apps/meteor/ee/client/voip/modal/DialPad/DialPadModal.tsx index de409bf13f07..20d94e0b9e4b 100644 --- a/apps/meteor/ee/client/voip/modal/DialPad/DialPadModal.tsx +++ b/apps/meteor/ee/client/voip/modal/DialPad/DialPadModal.tsx @@ -1,5 +1,5 @@ import { css } from '@rocket.chat/css-in-js'; -import { Field, Modal, IconButton } from '@rocket.chat/fuselage'; +import { Field, FieldError, Modal, IconButton } from '@rocket.chat/fuselage'; import type { ReactElement } from 'react'; import React from 'react'; @@ -51,9 +51,9 @@ const DialPadModal = ({ initialValue, errorMessage: initialErrorMessage, handleC isButtonDisabled={isButtonDisabled} handleOnChange={handleOnChange} /> - + {inputError} - + diff --git a/apps/meteor/ee/client/voip/modals/DeviceSettingsModal.tsx b/apps/meteor/ee/client/voip/modals/DeviceSettingsModal.tsx index 4bda542fdb63..70202a42348b 100644 --- a/apps/meteor/ee/client/voip/modals/DeviceSettingsModal.tsx +++ b/apps/meteor/ee/client/voip/modals/DeviceSettingsModal.tsx @@ -1,5 +1,5 @@ import type { SelectOption } from '@rocket.chat/fuselage'; -import { Modal, Field, Select, Button, Box } from '@rocket.chat/fuselage'; +import { Modal, Field, FieldLabel, FieldRow, Select, Button, Box } from '@rocket.chat/fuselage'; import { useTranslation, useAvailableDevices, @@ -77,8 +77,8 @@ const DeviceSettingsModal = (): ReactElement => { )} - {t('Microphone')} - + {t('Microphone')} + { )} /> - + diff --git a/apps/meteor/ee/server/api/licenses.ts b/apps/meteor/ee/server/api/licenses.ts index cfd657a1f0e9..a2e7a75b072a 100644 --- a/apps/meteor/ee/server/api/licenses.ts +++ b/apps/meteor/ee/server/api/licenses.ts @@ -1,15 +1,19 @@ import { License } from '@rocket.chat/license'; import { Settings, Users } from '@rocket.chat/models'; +import { isLicensesInfoProps } from '@rocket.chat/rest-typings'; import { check } from 'meteor/check'; import { API } from '../../../app/api/server/api'; import { hasPermissionAsync } from '../../../app/authorization/server/functions/hasPermission'; +import { apiDeprecationLogger } from '../../../app/lib/server/lib/deprecationWarningLogger'; API.v1.addRoute( 'licenses.get', { authRequired: true }, { async get() { + apiDeprecationLogger.endpoint(this.request.route, '7.0.0', this.response, ' Use licenses.info instead.'); + if (!(await hasPermissionAsync(this.userId, 'view-privileged-setting'))) { return API.v1.unauthorized(); } @@ -22,6 +26,21 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'licenses.info', + { authRequired: true, validateParams: isLicensesInfoProps }, + { + async get() { + const unrestrictedAccess = await hasPermissionAsync(this.userId, 'view-privileged-setting'); + const loadCurrentValues = unrestrictedAccess && Boolean(this.queryParams.loadValues); + + const license = await License.getInfo({ limits: unrestrictedAccess, license: unrestrictedAccess, currentValues: loadCurrentValues }); + + return API.v1.success({ license }); + }, + }, +); + API.v1.addRoute( 'licenses.add', { authRequired: true }, @@ -65,8 +84,9 @@ API.v1.addRoute( { authOrAnonRequired: true }, { get() { - const isEnterpriseEdtion = License.hasValidLicense(); - return API.v1.success({ isEnterprise: isEnterpriseEdtion }); + apiDeprecationLogger.endpoint(this.request.route, '7.0.0', this.response, ' Use licenses.info instead.'); + const isEnterpriseEdition = License.hasValidLicense(); + return API.v1.success({ isEnterprise: isEnterpriseEdition }); }, }, ); diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index f356f3e45a18..b6f2f7a9c2ae 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -165,8 +165,7 @@ export class AppsRestApi { } result = await request.json(); } catch (e: any) { - orchestrator.getRocketChatLogger().error('Error getting the categories from the Marketplace:', e.response.data); - return API.v1.internalError(); + return handleError('Unable to access Marketplace. Does the server has access to the internet?', e); } return API.v1.success(result); diff --git a/apps/meteor/ee/server/apps/communication/uikit.ts b/apps/meteor/ee/server/apps/communication/uikit.ts index a7f84eab619b..61dee0a1857f 100644 --- a/apps/meteor/ee/server/apps/communication/uikit.ts +++ b/apps/meteor/ee/server/apps/communication/uikit.ts @@ -1,6 +1,6 @@ -import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata'; -import { UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit'; +import type { UiKitCoreAppPayload } from '@rocket.chat/core-services'; import { UiKitCoreApp } from '@rocket.chat/core-services'; +import type { OperationParams, UrlParams } from '@rocket.chat/rest-typings'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; @@ -91,41 +91,58 @@ const corsOptions: cors.CorsOptions = { apiServer.use('/api/apps/ui.interaction/', cors(corsOptions), router); // didn't have the rateLimiter option -const getPayloadForType = (type: UIKitIncomingInteractionType, req: Request) => { - if (type === UIKitIncomingInteractionType.BLOCK) { - const { type, actionId, triggerId, mid, rid, payload, container } = req.body; +type UiKitUserInteractionRequest = Request< + UrlParams<'/apps/ui.interaction/:id'>, + any, + OperationParams<'POST', '/apps/ui.interaction/:id'> & { + visitor?: { + id: string; + username: string; + name?: string; + department?: string; + updatedAt?: Date; + token: string; + phone?: { phoneNumber: string }[] | null; + visitorEmails?: { address: string }[]; + livechatData?: Record; + status?: 'online' | 'away' | 'offline' | 'busy' | 'disabled'; + }; + } +>; - const { visitor } = req.body; - const { user } = req; +const getCoreAppPayload = (req: UiKitUserInteractionRequest): UiKitCoreAppPayload => { + const { id: appId } = req.params; - const room = rid; // orch.getConverters().get('rooms').convertById(rid); - const message = mid; + if (req.body.type === 'blockAction') { + const { user } = req; + const { type, actionId, triggerId, payload, container, visitor } = req.body; + const message = 'mid' in req.body ? req.body.mid : undefined; + const room = 'rid' in req.body ? req.body.rid : undefined; return { + appId, type, - container, actionId, - message, triggerId, + container, + message, payload, user, visitor, room, - } as const; + }; } - if (type === UIKitIncomingInteractionType.VIEW_CLOSED) { + if (req.body.type === 'viewClosed') { + const { user } = req; const { type, - actionId, payload: { view, isCleared }, } = req.body; - const { user } = req; - return { + appId, type, - actionId, user, payload: { view, @@ -134,12 +151,12 @@ const getPayloadForType = (type: UIKitIncomingInteractionType, req: Request) => }; } - if (type === UIKitIncomingInteractionType.VIEW_SUBMIT) { - const { type, actionId, triggerId, payload } = req.body; - + if (req.body.type === 'viewSubmit') { const { user } = req; + const { type, actionId, triggerId, payload } = req.body; return { + appId, type, actionId, triggerId, @@ -151,24 +168,18 @@ const getPayloadForType = (type: UIKitIncomingInteractionType, req: Request) => throw new Error('Type not supported'); }; -router.post('/:appId', async (req, res, next) => { - const { appId } = req.params; +router.post('/:id', async (req: UiKitUserInteractionRequest, res, next) => { + const { id: appId } = req.params; - const isCore = await UiKitCoreApp.isRegistered(appId); - if (!isCore) { + const isCoreApp = await UiKitCoreApp.isRegistered(appId); + if (!isCoreApp) { return next(); } - // eslint-disable-next-line prefer-destructuring - const type: UIKitIncomingInteractionType = req.body.type; - try { - const payload = { - ...getPayloadForType(type, req), - appId, - }; + const payload = getCoreAppPayload(req); - const result = await (UiKitCoreApp as any)[type](payload); // TO-DO: fix type + const result = await UiKitCoreApp[payload.type](payload); // Using ?? to always send something in the response, even if the app had no result. res.send(result ?? {}); @@ -178,16 +189,24 @@ router.post('/:appId', async (req, res, next) => { } }); -const appsRoutes = - (orch: AppServerOrchestrator) => - async (req: Request, res: Response): Promise => { - const { appId } = req.params; +export class AppUIKitInteractionApi { + orch: AppServerOrchestrator; + + constructor(orch: AppServerOrchestrator) { + this.orch = orch; + + router.post('/:id', this.routeHandler); + } - const { type } = req.body; + private routeHandler = async (req: UiKitUserInteractionRequest, res: Response): Promise => { + const { orch } = this; + const { id: appId } = req.params; - switch (type) { - case UIKitIncomingInteractionType.BLOCK: { - const { type, actionId, triggerId, mid, rid, payload, container } = req.body; + switch (req.body.type) { + case 'blockAction': { + const { type, actionId, triggerId, payload, container } = req.body; + const mid = 'mid' in req.body ? req.body.mid : undefined; + const rid = 'rid' in req.body ? req.body.rid : undefined; const { visitor } = req.body; const room = await orch.getConverters()?.get('rooms').convertById(rid); @@ -208,7 +227,7 @@ const appsRoutes = }; try { - const eventInterface = !visitor ? AppInterface.IUIKitInteractionHandler : AppInterface.IUIKitLivechatInteractionHandler; + const eventInterface = !visitor ? 'IUIKitInteractionHandler' : 'IUIKitLivechatInteractionHandler'; const result = await orch.triggerEvent(eventInterface, action); @@ -220,10 +239,9 @@ const appsRoutes = break; } - case UIKitIncomingInteractionType.VIEW_CLOSED: { + case 'viewClosed': { const { type, - actionId, payload: { view, isCleared }, } = req.body; @@ -232,7 +250,6 @@ const appsRoutes = const action = { type, appId, - actionId, user, payload: { view, @@ -251,7 +268,7 @@ const appsRoutes = break; } - case UIKitIncomingInteractionType.VIEW_SUBMIT: { + case 'viewSubmit': { const { type, actionId, triggerId, payload } = req.body; const user = orch.getConverters()?.get('users').convertToApp(req.user); @@ -276,7 +293,7 @@ const appsRoutes = break; } - case UIKitIncomingInteractionType.ACTION_BUTTON: { + case 'actionButton': { const { type, actionId, @@ -302,7 +319,7 @@ const appsRoutes = tmid, payload: { context, - ...(msgText && { message: msgText }), + ...(msgText ? { message: msgText } : {}), }, }; @@ -324,13 +341,4 @@ const appsRoutes = // TODO: validate payloads per type }; - -export class AppUIKitInteractionApi { - orch: AppServerOrchestrator; - - constructor(orch: AppServerOrchestrator) { - this.orch = orch; - - router.post('/:appId', appsRoutes(orch)); - } } diff --git a/apps/meteor/ee/server/index.ts b/apps/meteor/ee/server/index.ts index 9b56239ad046..f5b385c9a805 100644 --- a/apps/meteor/ee/server/index.ts +++ b/apps/meteor/ee/server/index.ts @@ -1,5 +1,3 @@ -import './startup'; - import '../app/license/server/index'; import '../app/api-enterprise/server/index'; import '../app/authorization/server/index'; diff --git a/apps/meteor/ee/server/lib/ldap/Manager.ts b/apps/meteor/ee/server/lib/ldap/Manager.ts index deb6cdcec666..6c04574ad557 100644 --- a/apps/meteor/ee/server/lib/ldap/Manager.ts +++ b/apps/meteor/ee/server/lib/ldap/Manager.ts @@ -1,6 +1,6 @@ import { Team } from '@rocket.chat/core-services'; import type { ILDAPEntry, IUser, IRoom, IRole, IImportUser, IImportRecord } from '@rocket.chat/core-typings'; -import { Users as UsersRaw, Roles, Subscriptions as SubscriptionsRaw, Rooms } from '@rocket.chat/models'; +import { Users, Roles, Subscriptions as SubscriptionsRaw, Rooms } from '@rocket.chat/models'; import type ldapjs from 'ldapjs'; import type { @@ -271,10 +271,12 @@ export class LDAPEEManager extends LDAPManager { logger.debug(`Channel '${channel}' doesn't exist, creating it.`); const roomOwner = settings.get('LDAP_Sync_User_Data_Channels_Admin') || ''; - // #ToDo: Remove typecastings when createRoom is converted to ts. - const room = await createRoom('c', channel, roomOwner, [], false, false, { + + const user = await Users.findOneByUsernameIgnoringCase(roomOwner); + + const room = await createRoom('c', channel, user, [], false, false, { customFields: { ldap: true }, - } as any); + }); if (!room?.rid) { logger.error(`Unable to auto-create channel '${channel}' during ldap sync.`); return; @@ -574,7 +576,7 @@ export class LDAPEEManager extends LDAPManager { } private static async updateExistingUsers(ldap: LDAPConnection, converter: LDAPDataConverter): Promise { - const users = await UsersRaw.findLDAPUsers().toArray(); + const users = await Users.findLDAPUsers().toArray(); for await (const user of users) { const ldapUser = await this.findLDAPUser(ldap, user); @@ -586,7 +588,7 @@ export class LDAPEEManager extends LDAPManager { } private static async updateUserAvatars(ldap: LDAPConnection): Promise { - const users = await UsersRaw.findLDAPUsers().toArray(); + const users = await Users.findLDAPUsers().toArray(); for await (const user of users) { const ldapUser = await this.findLDAPUser(ldap, user); if (!ldapUser) { @@ -615,7 +617,7 @@ export class LDAPEEManager extends LDAPManager { } private static async logoutDeactivatedUsers(ldap: LDAPConnection): Promise { - const users = await UsersRaw.findConnectedLDAPUsers().toArray(); + const users = await Users.findConnectedLDAPUsers().toArray(); for await (const user of users) { const ldapUser = await this.findLDAPUser(ldap, user); @@ -624,7 +626,7 @@ export class LDAPEEManager extends LDAPManager { } if (this.isUserDeactivated(ldapUser)) { - await UsersRaw.unsetLoginTokens(user._id); + await Users.unsetLoginTokens(user._id); } } } diff --git a/apps/meteor/ee/server/lib/oauth/Manager.ts b/apps/meteor/ee/server/lib/oauth/Manager.ts index b24d7436a784..b75c8aa9a7a5 100644 --- a/apps/meteor/ee/server/lib/oauth/Manager.ts +++ b/apps/meteor/ee/server/lib/oauth/Manager.ts @@ -1,6 +1,6 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; -import { Roles, Rooms } from '@rocket.chat/models'; +import { Roles, Rooms, Users } from '@rocket.chat/models'; import { addUserToRoom } from '../../../../app/lib/server/functions/addUserToRoom'; import { createRoom } from '../../../../app/lib/server/functions/createRoom'; @@ -20,6 +20,12 @@ export class OAuthEEManager { if (channelsMap && user && identity && groupClaimName) { const groupsFromSSO = identity[groupClaimName] || []; + const userChannelAdmin = await Users.findOneByUsernameIgnoringCase(channelsAdmin); + if (!userChannelAdmin) { + logger.error(`could not create channel, user not found: ${channelsAdmin}`); + return; + } + for await (const ssoGroup of Object.keys(channelsMap)) { if (typeof ssoGroup === 'string') { let channels = channelsMap[ssoGroup]; @@ -30,7 +36,7 @@ export class OAuthEEManager { const name = await getValidRoomName(channel.trim(), undefined, { allowDuplicates: true }); let room = await Rooms.findOneByNonValidatedName(name); if (!room) { - const createdRoom = await createRoom('c', channel, channelsAdmin, [], false, false); + const createdRoom = await createRoom('c', channel, userChannelAdmin, [], false, false); if (!createdRoom?.rid) { logger.error(`could not create channel ${channel}`); return; diff --git a/apps/meteor/ee/server/models/raw/LivechatRooms.ts b/apps/meteor/ee/server/models/raw/LivechatRooms.ts index 5c3bbb1296e0..3295af1b6179 100644 --- a/apps/meteor/ee/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/ee/server/models/raw/LivechatRooms.ts @@ -11,8 +11,6 @@ import type { FindCursor, UpdateResult, Document, FindOptions, Db, Collection, F import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; import { LivechatRoomsRaw } from '../../../../server/models/raw/LivechatRooms'; -import { queriesLogger } from '../../../app/livechat-enterprise/server/lib/logger'; -import { addQueryRestrictionsToRoomsModel } from '../../../app/livechat-enterprise/server/lib/query.helper'; declare module '@rocket.chat/model-typings' { interface ILivechatRoomsModel { @@ -271,25 +269,14 @@ export class LivechatRoomsRawEE extends LivechatRoomsRaw implements ILivechatRoo ], }; const update = { $set: { departmentAncestors: [unitId] } }; - queriesLogger.debug({ msg: `LivechatRoomsRawEE.associateRoomsWithDepartmentToUnit - association step`, query, update }); - const associationResult = await this.updateMany(query, update); - queriesLogger.debug({ msg: `LivechatRoomsRawEE.associateRoomsWithDepartmentToUnit - association step`, result: associationResult }); + await this.updateMany(query, update); const queryToDisassociateOldRoomsConnectedToUnit = { departmentAncestors: unitId, departmentId: { $nin: departments }, }; const updateToDisassociateRooms = { $unset: { departmentAncestors: 1 } }; - queriesLogger.debug({ - msg: `LivechatRoomsRawEE.associateRoomsWithDepartmentToUnit - disassociation step`, - query: queryToDisassociateOldRoomsConnectedToUnit, - update: updateToDisassociateRooms, - }); - const disassociationResult = await this.updateMany(queryToDisassociateOldRoomsConnectedToUnit, updateToDisassociateRooms); - queriesLogger.debug({ - msg: `LivechatRoomsRawEE.associateRoomsWithDepartmentToUnit - disassociation step`, - result: disassociationResult, - }); + await this.updateMany(queryToDisassociateOldRoomsConnectedToUnit, updateToDisassociateRooms); } async removeUnitAssociationFromRooms(unitId: string): Promise { @@ -297,9 +284,7 @@ export class LivechatRoomsRawEE extends LivechatRoomsRaw implements ILivechatRoo departmentAncestors: unitId, }; const update = { $unset: { departmentAncestors: 1 } }; - queriesLogger.debug({ msg: `LivechatRoomsRawEE.removeUnitAssociationFromRooms`, query, update }); - const result = await this.updateMany(query, update); - queriesLogger.debug({ msg: `LivechatRoomsRawEE.removeUnitAssociationFromRooms`, result }); + await this.updateMany(query, update); } async updateDepartmentAncestorsById(rid: string, departmentAncestors?: string[]) { @@ -310,35 +295,6 @@ export class LivechatRoomsRawEE extends LivechatRoomsRaw implements ILivechatRoo return this.updateOne(query, update); } - /** @deprecated Use updateOne or updateMany instead */ - async update(...args: Parameters) { - const [query, ...restArgs] = args; - const restrictedQuery = await addQueryRestrictionsToRoomsModel(query); - queriesLogger.debug({ msg: 'LivechatRoomsRawEE.update', query: restrictedQuery }); - return super.update(restrictedQuery, ...restArgs); - } - - async updateOne(...args: [...Parameters, { bypassUnits?: boolean }?]) { - const [query, update, opts, extraOpts] = args; - if (extraOpts?.bypassUnits) { - // When calling updateOne from a service, we cannot call the meteor code inside the query restrictions - // So the solution now is to pass a bypassUnits flag to the updateOne method which prevents checking - // units restrictions on the query, but just for the query the service is actually using - // We need to find a way of remove the meteor dependency when fetching units, and then, we can remove this flag - return super.updateOne(query, update, opts); - } - const restrictedQuery = await addQueryRestrictionsToRoomsModel(query); - queriesLogger.debug({ msg: 'LivechatRoomsRawEE.updateOne', query: restrictedQuery }); - return super.updateOne(restrictedQuery, update, opts); - } - - async updateMany(...args: Parameters) { - const [query, ...restArgs] = args; - const restrictedQuery = await addQueryRestrictionsToRoomsModel(query); - queriesLogger.debug({ msg: 'LivechatRoomsRawEE.updateMany', query: restrictedQuery }); - return super.updateMany(restrictedQuery, ...restArgs); - } - getConversationsBySource(start: Date, end: Date, extraQuery: Filter): AggregationCursor { return this.col.aggregate( [ diff --git a/apps/meteor/ee/server/models/raw/LivechatUnit.ts b/apps/meteor/ee/server/models/raw/LivechatUnit.ts index b49cbb959df1..fcabf12fa4f8 100644 --- a/apps/meteor/ee/server/models/raw/LivechatUnit.ts +++ b/apps/meteor/ee/server/models/raw/LivechatUnit.ts @@ -4,7 +4,6 @@ import { LivechatUnitMonitors, LivechatDepartment, LivechatRooms } from '@rocket import type { FindOptions, Filter, FindCursor, Db, FilterOperators, UpdateResult, DeleteResult, Document, UpdateFilter } from 'mongodb'; import { BaseRaw } from '../../../../server/models/raw/BaseRaw'; -import { queriesLogger } from '../../../app/livechat-enterprise/server/lib/logger'; import { getUnitsFromUser } from '../../../app/livechat-enterprise/server/lib/units'; const addQueryRestrictions = async (originalQuery: Filter = {}) => { @@ -40,7 +39,6 @@ export class LivechatUnitRaw extends BaseRaw implement options: FindOptions, ): Promise> { const query = await addQueryRestrictions(originalQuery); - queriesLogger.debug({ msg: 'LivechatUnit.find', query }); return this.col.find(query, options) as FindCursor; } @@ -50,20 +48,9 @@ export class LivechatUnitRaw extends BaseRaw implement options: FindOptions, ): Promise { const query = await addQueryRestrictions(originalQuery); - queriesLogger.debug({ msg: 'LivechatUnit.findOne', query }); return this.col.findOne(query, options); } - async update( - originalQuery: Filter, - update: Filter, - options: FindOptions, - ): Promise { - const query = await addQueryRestrictions(originalQuery); - queriesLogger.debug({ msg: 'LivechatUnit.update', query }); - return this.col.updateOne(query, update, options); - } - remove(query: Filter): Promise { return this.deleteMany(query); } diff --git a/apps/meteor/ee/server/services/CHANGELOG.md b/apps/meteor/ee/server/services/CHANGELOG.md index cb58d45affa3..77b48a0d1f92 100644 --- a/apps/meteor/ee/server/services/CHANGELOG.md +++ b/apps/meteor/ee/server/services/CHANGELOG.md @@ -1,5 +1,45 @@ # rocketchat-services +## 1.1.9 + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [5cee21468e] +- Updated dependencies [2db32f0d4a] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [aaefe865a7] +- Updated dependencies [357a3a50fa] +- Updated dependencies [f556518fa1] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [9496f1eb97] +- Updated dependencies [d45365436e] +- Updated dependencies [93d4912e17] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/rest-typings@6.4.0 + - @rocket.chat/model-typings@0.1.0 + - @rocket.chat/core-services@0.2.0 + - @rocket.chat/models@0.0.15 + +## 1.1.9-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/rest-typings@6.4.0-rc.5 + - @rocket.chat/core-services@0.2.0-rc.5 + - @rocket.chat/model-typings@0.1.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + ## 1.1.8-rc.4 ### Patch Changes diff --git a/apps/meteor/ee/server/services/package.json b/apps/meteor/ee/server/services/package.json index 98658d1ab681..ea6660730c63 100644 --- a/apps/meteor/ee/server/services/package.json +++ b/apps/meteor/ee/server/services/package.json @@ -1,7 +1,7 @@ { "name": "rocketchat-services", "private": true, - "version": "1.1.8-rc.4", + "version": "1.1.9", "description": "Rocket.Chat Authorization service", "main": "index.js", "scripts": { diff --git a/apps/meteor/ee/server/startup/maxRoomsPerGuest.ts b/apps/meteor/ee/server/startup/maxRoomsPerGuest.ts index 5731ca0d1deb..bfcb1ba5fa8b 100644 --- a/apps/meteor/ee/server/startup/maxRoomsPerGuest.ts +++ b/apps/meteor/ee/server/startup/maxRoomsPerGuest.ts @@ -8,7 +8,7 @@ callbacks.add( 'beforeAddedToRoom', async ({ user }) => { if (user.roles?.includes('guest')) { - if (await License.shouldPreventAction('roomsPerGuest', { userId: user._id })) { + if (await License.shouldPreventAction('roomsPerGuest', 0, { userId: user._id })) { throw new Meteor.Error('error-max-rooms-per-guest-reached', i18n.t('error-max-rooms-per-guest-reached')); } } diff --git a/apps/meteor/ee/server/startup/seatsCap.ts b/apps/meteor/ee/server/startup/seatsCap.ts index f6d42823cb97..e72852052acc 100644 --- a/apps/meteor/ee/server/startup/seatsCap.ts +++ b/apps/meteor/ee/server/startup/seatsCap.ts @@ -33,7 +33,7 @@ callbacks.add( callbacks.add( 'beforeUserImport', async ({ userCount }) => { - if (await License.shouldPreventAction('activeUsers', {}, userCount)) { + if (await License.shouldPreventAction('activeUsers', userCount)) { throw new Meteor.Error('error-license-user-limit-reached', i18n.t('error-license-user-limit-reached')); } }, diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index 46a27357f546..4d59f52e9cd6 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -1,5 +1,3 @@ -import type { UrlWithParsedQuery } from 'url'; - import type { IMessage, IRoom, @@ -10,7 +8,6 @@ import type { ILivechatInquiryRecord, ILivechatVisitor, VideoConference, - ParsedUrl, OEmbedMeta, OEmbedUrlContent, Username, @@ -20,6 +17,8 @@ import type { InquiryWithAgentInfo, ILivechatTagRecord, TransferData, + AtLeast, + UserStatus, } from '@rocket.chat/core-typings'; import type { FilterOperators } from 'mongodb'; @@ -54,12 +53,12 @@ interface EventLikeCallbackSignatures { 'livechat.saveRoom': (room: IRoom) => void; 'livechat:afterReturnRoomAsInquiry': (params: { room: IRoom }) => void; 'livechat.setUserStatusLivechat': (params: { userId: IUser['_id']; status: OmnichannelAgentStatus }) => void; - 'livechat.agentStatusChanged': (params: { userId: IUser['_id']; status: OmnichannelAgentStatus }) => void; + 'livechat.agentStatusChanged': (params: { userId: IUser['_id']; status: UserStatus }) => void; 'livechat.onNewAgentCreated': (agentId: string) => void; 'livechat.afterTakeInquiry': (inq: InquiryWithAgentInfo, agent: { agentId: string; username: string }) => void; 'livechat.afterAgentRemoved': (params: { agent: Pick }) => void; 'afterAddedToRoom': (params: { user: IUser; inviter?: IUser }, room: IRoom) => void; - 'beforeAddedToRoom': (params: { user: IUser; inviter: IUser }) => void; + 'beforeAddedToRoom': (params: { user: AtLeast; inviter: IUser }) => void; 'afterCreateDirectRoom': (params: IRoom, second: { members: IUser[]; creatorId: IUser['_id'] }) => void; 'beforeDeleteRoom': (params: IRoom) => void; 'beforeJoinDefaultChannels': (user: IUser) => void; @@ -167,24 +166,13 @@ type ChainedCallbackSignatures = { BusinessHourBehaviorClass: { new (): IBusinessHourBehavior }; }; 'renderMessage': (message: T) => T; - 'oembed:beforeGetUrlContent': (data: { - urlObj: Omit & { host?: unknown; search?: unknown }; - parsedUrl: ParsedUrl; - }) => { - urlObj: UrlWithParsedQuery; - parsedUrl: ParsedUrl; + 'oembed:beforeGetUrlContent': (data: { urlObj: URL }) => { + urlObj: URL; }; - 'oembed:afterParseContent': (data: { - url: string; - meta: OEmbedMeta; - headers: { [k: string]: string }; - parsedUrl: ParsedUrl; - content: OEmbedUrlContent; - }) => { + 'oembed:afterParseContent': (data: { url: string; meta: OEmbedMeta; headers: { [k: string]: string }; content: OEmbedUrlContent }) => { url: string; meta: OEmbedMeta; headers: { [k: string]: string }; - parsedUrl: ParsedUrl; content: OEmbedUrlContent; }; 'livechat.beforeListTags': () => ILivechatTag[]; diff --git a/apps/meteor/lib/callbacks/callbacksBase.ts b/apps/meteor/lib/callbacks/callbacksBase.ts index e6681df78321..405cc5da80e6 100644 --- a/apps/meteor/lib/callbacks/callbacksBase.ts +++ b/apps/meteor/lib/callbacks/callbacksBase.ts @@ -170,7 +170,7 @@ export class Callbacks< this.setCallbacks(hook, hooks); } - run(hook: Hook, ...args: Parameters): void; + run(hook: Hook, ...args: Parameters): Promise; run( hook: Hook, diff --git a/apps/meteor/lib/errors/CloudWorkspaceAccessError.ts b/apps/meteor/lib/errors/CloudWorkspaceAccessError.ts new file mode 100644 index 000000000000..4cea63a01f09 --- /dev/null +++ b/apps/meteor/lib/errors/CloudWorkspaceAccessError.ts @@ -0,0 +1,8 @@ +import { CloudWorkspaceError } from './CloudWorkspaceError'; + +export class CloudWorkspaceAccessError extends CloudWorkspaceError { + constructor(message: string) { + super(message); + this.name = CloudWorkspaceAccessError.name; + } +} diff --git a/apps/meteor/lib/errors/CloudWorkspaceConnectionError.ts b/apps/meteor/lib/errors/CloudWorkspaceConnectionError.ts new file mode 100644 index 000000000000..8b4edcf8f588 --- /dev/null +++ b/apps/meteor/lib/errors/CloudWorkspaceConnectionError.ts @@ -0,0 +1,8 @@ +import { CloudWorkspaceError } from './CloudWorkspaceError'; + +export class CloudWorkspaceConnectionError extends CloudWorkspaceError { + constructor(message: string) { + super(message); + this.name = CloudWorkspaceConnectionError.name; + } +} diff --git a/apps/meteor/lib/errors/CloudWorkspaceError.ts b/apps/meteor/lib/errors/CloudWorkspaceError.ts new file mode 100644 index 000000000000..d843c42ea520 --- /dev/null +++ b/apps/meteor/lib/errors/CloudWorkspaceError.ts @@ -0,0 +1,6 @@ +export class CloudWorkspaceError extends Error { + constructor(message: string) { + super(message); + this.name = CloudWorkspaceError.name; + } +} diff --git a/apps/meteor/lib/errors/CloudWorkspaceLicenseError.ts b/apps/meteor/lib/errors/CloudWorkspaceLicenseError.ts new file mode 100644 index 000000000000..96c9a28be82c --- /dev/null +++ b/apps/meteor/lib/errors/CloudWorkspaceLicenseError.ts @@ -0,0 +1,8 @@ +import { CloudWorkspaceError } from './CloudWorkspaceError'; + +export class CloudWorkspaceLicenseError extends CloudWorkspaceError { + constructor(message: string) { + super(message); + this.name = CloudWorkspaceLicenseError.name; + } +} diff --git a/apps/meteor/lib/errors/CloudWorkspaceRegistrationError.ts b/apps/meteor/lib/errors/CloudWorkspaceRegistrationError.ts new file mode 100644 index 000000000000..aecec757acee --- /dev/null +++ b/apps/meteor/lib/errors/CloudWorkspaceRegistrationError.ts @@ -0,0 +1,8 @@ +import { CloudWorkspaceError } from './CloudWorkspaceError'; + +export class CloudWorkspaceRegistrationError extends CloudWorkspaceError { + constructor(message: string) { + super(message); + this.name = CloudWorkspaceRegistrationError.name; + } +} diff --git a/apps/meteor/package.json b/apps/meteor/package.json index b59552d1fcc5..62c906c8f89a 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -236,11 +236,11 @@ "@rocket.chat/favicon": "workspace:^", "@rocket.chat/forked-matrix-appservice-bridge": "^4.0.1", "@rocket.chat/forked-matrix-bot-sdk": "^0.6.0-beta.2", - "@rocket.chat/fuselage": "^0.32.1", + "@rocket.chat/fuselage": "^0.36.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/fuselage-polyfills": "next", "@rocket.chat/fuselage-toastbar": "next", - "@rocket.chat/fuselage-tokens": "next", + "@rocket.chat/fuselage-tokens": "^0.32.0", "@rocket.chat/fuselage-ui-kit": "workspace:^", "@rocket.chat/gazzodown": "workspace:^", "@rocket.chat/i18n": "workspace:^", @@ -251,14 +251,14 @@ "@rocket.chat/license": "workspace:^", "@rocket.chat/log-format": "workspace:^", "@rocket.chat/logger": "workspace:^", - "@rocket.chat/logo": "^0.31.27", + "@rocket.chat/logo": "^0.31.28", "@rocket.chat/memo": "next", "@rocket.chat/message-parser": "next", "@rocket.chat/model-typings": "workspace:^", "@rocket.chat/models": "workspace:^", "@rocket.chat/mp3-encoder": "0.24.0", "@rocket.chat/omnichannel-services": "workspace:^", - "@rocket.chat/onboarding-ui": "^0.32.1", + "@rocket.chat/onboarding-ui": "0.33.0", "@rocket.chat/password-policies": "workspace:^", "@rocket.chat/pdf-worker": "workspace:^", "@rocket.chat/poplib": "workspace:^", @@ -277,6 +277,7 @@ "@rocket.chat/ui-theming": "workspace:^", "@rocket.chat/ui-video-conf": "workspace:^", "@rocket.chat/web-ui-registration": "workspace:^", + "@slack/bolt": "^3.14.0", "@slack/rtm-api": "^6.0.0", "@tanstack/react-query": "^4.16.1", "@types/cookie": "^0.5.1", @@ -289,7 +290,7 @@ "@xmldom/xmldom": "^0.8.8", "adm-zip": "0.5.10", "ajv": "^8.11.0", - "ajv-formats": "^2.1.1", + "ajv-formats": "~2.1.1", "apn": "2.2.0", "archiver": "^3.1.1", "asterisk-manager": "^0.2.0", @@ -417,6 +418,7 @@ "stream-buffers": "^3.0.2", "strict-uri-encode": "^2.0.0", "string-strip-html": "^7.0.3", + "suretype": "~2.4.1", "tar-stream": "^1.6.2", "textarea-caret": "^3.1.0", "tinykeys": "^1.4.0", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/ar.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/ar.i18n.json index d6b7e5a0739b..392b4a99b3ad 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/ar.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/ar.i18n.json @@ -4299,10 +4299,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "يمنع هذا الإعداد كل المثيلات من إرسال تغييرات الحالة للمستخدمين إلى عملائهم مع الاحتفاظ بحالة تواجد كل المستخدمين من التحميل الأول!", "Troubleshoot_Disable_Sessions_Monitor": "تعطيل شاشة مراقبة الجلسات", "Troubleshoot_Disable_Sessions_Monitor_Alert": "يوقف هذا الإعداد معالجة جلسات المستخدم، ما يتسبب في توقف الإحصاءات عن العمل بشكل صحيح!", - "Troubleshoot_Disable_Statistics_Generator": "تعطيل منشئ الإحصاءات", - "Troubleshoot_Disable_Statistics_Generator_Alert": "يوقف هذا الإعداد معالجة كل الإحصاءات، ما يجعل صفحة المعلومات قديمة حتى ينقر شخص ما على زر التحديث وقد يتسبب في فقد معلومات أخرى حول النظام!", - "Troubleshoot_Disable_Workspace_Sync": "تعطيل مزامنة مساحة العمل", - "Troubleshoot_Disable_Workspace_Sync_Alert": "يوقف هذا الإعداد مزامنة هذا الخادم مع سحابة Rocket.Chat وقد يتسبب في حدوث مشاكل مع تراخيص السوق والمؤسسة!", "True": "صحيح", "Try_now": "المحاولة الآن", "Try_searching_in_the_marketplace_instead": "محاولة البحث في السوق بدلاً من ذلك", @@ -4855,8 +4851,6 @@ "onboarding.page.requestTrial.subtitle": "جرب أفضل خطة إصدار Enterprise لمدة 30 يومًا مجانًا", "onboarding.page.magicLinkEmail.title": "أرسلنا لك رابط تسجيل الدخول عبر البريد الإلكتروني", "onboarding.page.magicLinkEmail.subtitle": "انقر فوق الرابط الموجود في البريد الإلكتروني الذي أرسلناه لك للتو لتسجيل الدخول إلى مساحة العمل الخاصة بك. <1>ستنتهي صلاحية الرابط خلال 30 دقيقة.", - "onboarding.page.organizationInfoPage.title": "بعض التفاصيل الإضافية...", - "onboarding.page.organizationInfoPage.subtitle": "ستساعدنا هذه على تخصيص مساحة العمل الخاصة بك.", "onboarding.form.adminInfoForm.title": "معلومات المسؤول", "onboarding.form.adminInfoForm.subtitle": "نحتاج إلى هذا لإنشاء ملف شخصي مسؤول داخل مساحة العمل الخاصة بك", "onboarding.form.adminInfoForm.fields.fullName.label": "الاسم الكامل", @@ -4885,10 +4879,8 @@ "onboarding.form.registeredServerForm.included.externalProviders": "التكامل مع مقدمي الخدمات الخارجيين (WhatsApp وFacebook وTelegram وTwitter)", "onboarding.form.registeredServerForm.included.apps": "الوصول إلى تطبيقات السوق", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "البريد الإلكتروني لحساب السحابة", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "لتسجيل الخادم الخاص بك، نحتاج إلى توصيله بحسابك السحابي. إذا كان لديك حساب سابقًا، فسنقوم بربطه تلقائيًا. وإن لم يكن لديك، فسيتم إنشاء حساب جديد", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "يرجى إدخال بريدك الإلكتروني", "onboarding.form.registeredServerForm.keepInformed": "أبقني على اطلاع بالأخبار والأحداث", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "يعني التسجيل موافقتي على تلقي تحديثات المنتج والأمان ذات الصلة", "onboarding.form.standaloneServerForm.title": "تأكيد الخادم المستقل", "onboarding.form.standaloneServerForm.servicesUnavailable": "لن تكون بعض الخدمات متاحة أو ستتطلب إعدادًا يدويًا", "onboarding.form.standaloneServerForm.publishOwnApp": "لإرسال الإشعارات، تحتاج إلى تجميع تطبيقك الخاص ونشره على Google Play وApp Store", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/ca.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/ca.i18n.json index b6e15bbf6f66..9f0ef2e27a74 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/ca.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/ca.i18n.json @@ -4227,10 +4227,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "Aquesta configuració evita que totes les instàncies enviïn els canvis d'estat dels usuaris als clients, mantenint tots els usuaris amb el seu estat de presència des de la primera càrrega!", "Troubleshoot_Disable_Sessions_Monitor": "Desactiva el monitor de sessions", "Troubleshoot_Disable_Sessions_Monitor_Alert": "Aquesta configuració deté el processament de les sessions de visita de l'LiveChat causant que les estadístiques deixin de funcionar!", - "Troubleshoot_Disable_Statistics_Generator": "Desactivar el generador d'estadístiques", - "Troubleshoot_Disable_Statistics_Generator_Alert": "Aquest ajust deté el processament de totes les estadístiques fent que la pàgina d'informació quedi desactualitzada fins que algú faci clic al botó d'actualització i pot causar que falti altra informació en el sistema!", - "Troubleshoot_Disable_Workspace_Sync": "Desactiva la sincronització de l'espai de treball", - "Troubleshoot_Disable_Workspace_Sync_Alert": "¡Este ajuste detiene la sincronización de este servidor con la nube de Rocket.Chat y puede causar problemas con el mercado y las licencias de las empresas!", "True": "Sí", "Try_now": "Prova-ho ara", "Tuesday": "dimarts", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/cs.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/cs.i18n.json index ff081e3a5eeb..c2fbddd26d29 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/cs.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/cs.i18n.json @@ -3561,10 +3561,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "Toto nastavení zakáže všem instancím odesílat změny stavu uživatelů a ponechat si nastavení při prvním načtení", "Troubleshoot_Disable_Sessions_Monitor": "Zakázat monitor sessions", "Troubleshoot_Disable_Sessions_Monitor_Alert": "Toto nastavení přestane zpracovávat uživatelské sessions a statistiky tak přestanou správně fungovat!", - "Troubleshoot_Disable_Statistics_Generator": "Zakázat generování statistik", - "Troubleshoot_Disable_Statistics_Generator_Alert": "Toto nastavení přestane zpracovávat statistiky, takže stránka s informacemi zůstane neaktuální dokud někdo nevynutí aktualizaci. Způsobuje neaktuálnost dat napříč systémem!", - "Troubleshoot_Disable_Workspace_Sync": "Zakázat synchronizaci pracovního prostoru", - "Troubleshoot_Disable_Workspace_Sync_Alert": "Toto nastavení zakáže synchronizaci s Rocket.chat cloud a může způsobit problémy s marketplace a enterprise licencemi!", "True": "Ano", "Try_now": "Zkusit nyní", "Tuesday": "Úterý", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/da.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/da.i18n.json index 317faac59164..66e3bb1f035e 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/da.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/da.i18n.json @@ -3582,10 +3582,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "Denne indstilling forhindrer alle instancer fra at sende statusændringerne for brugerne til deres klienter, hvilket gør, at alle brugere vil have deres status vedr. tilstedeværelse fra de blev loadet i starten!", "Troubleshoot_Disable_Sessions_Monitor": "Deaktivér sessions-monitor", "Troubleshoot_Disable_Sessions_Monitor_Alert": "Denne indstilling stopper behandlingen af brugersessioner og får statistikkerne til at stoppe med at virke korrekt!", - "Troubleshoot_Disable_Statistics_Generator": "Deaktivér statistik-generator", - "Troubleshoot_Disable_Statistics_Generator_Alert": "Denne indstilling stopper behandlingen af alle statistikker, hvilket gør at informationssiden forældes, indtil nogen klikker på opdateringsknappen og kan også forårsage andre manglende oplysninger rundt omkring i systemet!", - "Troubleshoot_Disable_Workspace_Sync": "Deaktivér synkronisering af Workspace", - "Troubleshoot_Disable_Workspace_Sync_Alert": "Denne indstilling stopper synkroniseringen af denne server med Rocket.Chat's cloud og kan forårsage problemer med marketplace og enteprise-licenser!", "True": "Sandt", "Try_now": "Forsøg nu", "Tuesday": "tirsdag", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/de.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/de.i18n.json index 47649dd17b2d..52054a3ebdad 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/de.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/de.i18n.json @@ -4821,10 +4821,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "Diese Einstellung sorgt dafür, dass keine Instanz mehr die Statusänderungen der Benutzer an ihre Clients sendet, sodass die Benutzer den Präsenzstatus behalten, den sie beim ersten Laden hatten!", "Troubleshoot_Disable_Sessions_Monitor": "Sitzungsmonitor deaktivieren", "Troubleshoot_Disable_Sessions_Monitor_Alert": "Diese Einstellung stoppt die Verarbeitung von Benutzersitzungen, was dazu führt, dass die Statistiken nicht mehr ordnungsgemäß funktionieren!", - "Troubleshoot_Disable_Statistics_Generator": "Statistikgenerator deaktivieren", - "Troubleshoot_Disable_Statistics_Generator_Alert": "Diese Einstellung stoppt die Verarbeitung der gesamten Statistik, sodass die Infoseite so lange veraltet ist, bis jemand die Aktualisierungschaltfläche anklickt. Außerdem kann es sein, dass andere Systeminformationen fehlen!", - "Troubleshoot_Disable_Workspace_Sync": "Arbeitsbereichsynchronisierung deaktivieren", - "Troubleshoot_Disable_Workspace_Sync_Alert": "Diese Einstellung stoppt die Synchronisierung des Servers mit der Rocket.Chat-Cloud und kann Probleme mit Marktplatz- und Unternehmenslizenzen verursachen!", "True": "Ja", "Try_now": "Jetzt versuchen", "Try_searching_in_the_marketplace_instead": "Versuchen Sie stattdessen den Marktplatz zu durchsuchen", @@ -4953,7 +4949,7 @@ "User__username__unmuted_in_room__roomName__": "Stummschaltung von Benutzer {{username}} in Raum {{roomName}} aufgehoben", "User_added": "Benutzer hinzugefügt", "User_added_by": "Der Benutzer {{user_added}} wurde von {{user_by}} hinzugefügt", - "User_added_to": "__user_added_ hinzugefügt", + "User_added_to": "hinzugefügt {{user_added}}", "User_added_successfully": "Benutzer erfolgreich hinzugefügt", "User_and_group_mentions_only": "Nur Benutzer- und Gruppenerwähnungen", "User_cant_be_empty": "Benutzer darf nicht leer sein", @@ -5466,8 +5462,6 @@ "onboarding.page.requestTrial.subtitle": "Testen Sie unseren besten Enterprise Edition-Plan 30 Tage lang gratis", "onboarding.page.magicLinkEmail.title": "Wir haben Ihnen einen Anmeldelink gesendet", "onboarding.page.magicLinkEmail.subtitle": "Klicken Sie auf den Link, in der gerade an Sie versandten E-Mail, um sich bei Ihrem Arbeitsbereich anzumelden. <1>Der Link verfällt in 30 Minuten.", - "onboarding.page.organizationInfoPage.title": "Ein paar zusätzliche Details...", - "onboarding.page.organizationInfoPage.subtitle": "Diese helfen uns, Ihren Arbeitsbereich zu personalisieren.", "onboarding.form.adminInfoForm.title": "Admin-Info", "onboarding.form.adminInfoForm.subtitle": "Das ist erforderlich, um ein Admin-Profil in Ihrem Arbeitsbereich zu erstellen", "onboarding.form.adminInfoForm.fields.fullName.label": "Vollständiger Name", @@ -5496,10 +5490,8 @@ "onboarding.form.registeredServerForm.included.externalProviders": "Integration mit externen Anbietern (WhatsApp, Facebook, Telegram, Twitter)", "onboarding.form.registeredServerForm.included.apps": "Zugriff auf Marktplatz-Apps", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "Cloud-Konto-E-Mail", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "Um Ihren Server zu registrieren, müssen wir ihn mit Ihrem Cloud-Konto verbinden. Wenn Sie bereits eines haben, werden wir es automatisch verknüpfen. Andernfalls wird ein neues Konto erstellt", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "Bitte geben Sie Ihre E-Mail-Adresse ein", "onboarding.form.registeredServerForm.keepInformed": "Informieren Sie mich über Neuigkeiten und Ereignisse", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "Mit der Registrierung stimme ich zu, dass ich relevante Produkt- und Sicherheits-Updates erhalte", "onboarding.form.standaloneServerForm.title": "Stand-alone-Server-Bestätigung", "onboarding.form.standaloneServerForm.servicesUnavailable": "Einige der Services werden nicht verfügbar sein oder erfordern eine manuelle Einrichtung", "onboarding.form.standaloneServerForm.publishOwnApp": "Um Push-Benachrichtigungen zu senden, müssen Sie Ihre eigene App kompilieren und in Google Play und im App Store veröffentlichen", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/es.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/es.i18n.json index 50a7b8d63873..e44f564b381b 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/es.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/es.i18n.json @@ -1719,6 +1719,7 @@ "error-logged-user-not-in-room": "No estás en la sala \"%s\"", "error-max-guests-number-reached": "Has alcanzado la cantidad máxima de usuarios invitados que permite tu licencia. Escribe a sale@rocket.chat para obtener una nueva licencia.", "error-max-number-simultaneous-chats-reached": "Se ha alcanzado el máximo de chats simultáneos por agente.", + "error-mac-limit-reached": "Se ha alcanzado el máximo de contactos activos por mes para este espacio de trabajo.", "error-message-deleting-blocked": "La eliminación de mensajes está bloqueada", "error-message-editing-blocked": "La edición de mensajes está bloqueada", "error-message-size-exceeded": "El tamaño de mensaje excede el máximo: Message_MaxAllowedSize", @@ -4268,10 +4269,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "Este ajuste evita que todas las instancias envíen los cambios de estado de los usuarios a sus clientes, lo que mantiene todos los usuarios con su estado de presencia desde la primera carga.", "Troubleshoot_Disable_Sessions_Monitor": "Deshabilitar supervisor de sesiones", "Troubleshoot_Disable_Sessions_Monitor_Alert": "Este ajuste detiene el procesamiento de las sesiones de visita de Omnichannel, lo que provoca que las estadísticas dejen de funcionar correctamente.", - "Troubleshoot_Disable_Statistics_Generator": "Deshabilitar generador de estadísticas", - "Troubleshoot_Disable_Statistics_Generator_Alert": "Este ajuste detiene el procesamiento de todas las estadísticas, lo que provoca que la página de información quede desactualizada hasta que alguien haga clic en el botón para actualizar. Además, puede causar que falte otra información en el sistema.", - "Troubleshoot_Disable_Workspace_Sync": "Deshabilitar sincronización de espacio de trabajo", - "Troubleshoot_Disable_Workspace_Sync_Alert": "Este ajuste detiene la sincronización de este servidor con la nube de Rocket.Chat y puede problemas con las licencias de empresas y Marketplace.", "True": "Verdadero", "Try_now": "Intentar ahora", "Try_searching_in_the_marketplace_instead": "Prueba a buscar en Marketplace en su lugar", @@ -4805,8 +4802,6 @@ "onboarding.page.requestTrial.subtitle": "Prueba nuestro mejor plan Enterprise Edition gratis durante 30 días", "onboarding.page.magicLinkEmail.title": "Te hemos enviado un enlace de inicio de sesión por correo electrónico", "onboarding.page.magicLinkEmail.subtitle": "Haz clic en el enlace del mensaje que acabamos de enviarte para iniciar sesión en tu espacio de trabajo. <1>El enlace caducará en 30 minutos.", - "onboarding.page.organizationInfoPage.title": "Unos detalles más...", - "onboarding.page.organizationInfoPage.subtitle": "Esto nos ayudará a personalizar tu espacio de trabajo.", "onboarding.form.adminInfoForm.title": "Información de administrador", "onboarding.form.adminInfoForm.subtitle": "Necesitamos esto para crear un perfil de administrador en tu espacio de trabajo", "onboarding.form.adminInfoForm.fields.fullName.label": "Nombre completo", @@ -4835,10 +4830,8 @@ "onboarding.form.registeredServerForm.included.externalProviders": "Integración con proveedores externos (WhatsApp, Facebook, Telegram, Twitter)", "onboarding.form.registeredServerForm.included.apps": "Acceso al Marketplace de aplicaciones", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "Cuenta de correo electrónico en la nube", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "Para registrar el servidor, necesitamos conectarlo a tu cuenta en la nube. Si ya tienes una, la vincularemos automáticamente. De lo contrario, se creará una cuenta nueva", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "Introduce tu correo electrónico", "onboarding.form.registeredServerForm.keepInformed": "Recibir información sobre noticias y eventos", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "Al registrarme, acepto recibir actualizaciones sobre seguridad y productos relevantes", "onboarding.form.standaloneServerForm.title": "Confirmación de servidor independiente", "onboarding.form.standaloneServerForm.servicesUnavailable": "Algunos servicios no estarán disponibles o requerirán configuración manual", "onboarding.form.standaloneServerForm.publishOwnApp": "Para enviarte notificaciones push, debes compilar y publicar tu propia aplicación en Google Play y App Store", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json index 34c7ca9fdda4..f482e42419a4 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/fi.i18n.json @@ -30,6 +30,7 @@ "A_secure_and_highly_private_self-managed_solution_for_conference_calls": "Suojattu ja vahvasti yksityinen itsepalveluratkaisu neuvottelupuheluille.", "A_workspace_admin_needs_to_install_and_configure_a_conference_call_app": "Työtilan järjestelmänvalvojan on asennettava ja määritettävä neuvottelupuhelusovellus.", "An_app_needs_to_be_installed_and_configured": "Sovellus on asennettavaa ja määritettävä.", + "Accessibility_and_Appearance": "Helppokäyttöisyys ja ulkoasu", "Accept_Call": "Hyväksy puhelu", "Accept": "Hyväksy", "Accept_incoming_livechat_requests_even_if_there_are_no_online_agents": "Hyväksy saapuvat monikanavapyynnöt, vaikka agentteja ei ole paikalla", @@ -4917,10 +4918,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "Tämä asetus estää kaikkia instansseja lähettämästä käyttäjien tilamuutoksia asiakkailleen, jolloin kaikki käyttäjät pysyvät läsnäolotilassaan ensimmäisestä latauksesta lähtien!", "Troubleshoot_Disable_Sessions_Monitor": "Istuntojen valvonnan poistaminen käytöstä", "Troubleshoot_Disable_Sessions_Monitor_Alert": "Tämä asetus pysäyttää käyttäjäistuntojen käsittelyn, jolloin tilastot eivät enää toimi oikein!", - "Troubleshoot_Disable_Statistics_Generator": "Poista tilastogeneraattori käytöstä", - "Troubleshoot_Disable_Statistics_Generator_Alert": "Tämä asetus pysäyttää kaikkien tilastojen käsittelyn, jolloin infosivu on vanhentunut, kunnes joku klikkaa päivityspainiketta, ja se voi aiheuttaa muita puuttuvia tietoja järjestelmästä!", - "Troubleshoot_Disable_Workspace_Sync": "Työtilan synkronoinnin poistaminen käytöstä", - "Troubleshoot_Disable_Workspace_Sync_Alert": "Tämä asetus estää tämän palvelimen synkronoinnin Rocket.Chatin pilven kanssa ja saattaa aiheuttaa ongelmia markkinapaikan ja enteprise-lisenssien kanssa!", "True": "Tosi", "Try_now": "Kokeile nyt", "Try_searching_in_the_marketplace_instead": "Kokeile sen sijaan etsiä Kauppapaikalta", @@ -5603,8 +5600,6 @@ "onboarding.page.requestTrial.subtitle": "Kokeile parasta yritysversion sopimustamme 30 päivää maksutta", "onboarding.page.magicLinkEmail.title": "Lähetimme sinulle kirjautumislinkin sähköpostitse", "onboarding.page.magicLinkEmail.subtitle": "Klikkaa juuri lähettämässämme sähköpostiviestissä olevaa linkkiä kirjautuaksesi työtilaasi. <1>Linkki päättyy 30 minuutin kuluttua.", - "onboarding.page.organizationInfoPage.title": "Muutama yksityiskohta vielä...", - "onboarding.page.organizationInfoPage.subtitle": "Nämä auttavat meitä muokkaamaan työtilasi yksilölliseksi.", "onboarding.form.adminInfoForm.title": "Admin Info", "onboarding.form.adminInfoForm.subtitle": "Tarvitsemme tätä luodaksemme ylläpitäjäprofiilin työtilaasi", "onboarding.form.adminInfoForm.fields.fullName.label": "Koko nimi", @@ -5633,12 +5628,10 @@ "onboarding.form.registeredServerForm.included.externalProviders": "Integrointi ulkoisten palveluntarjoajien kanssa (WhatsApp, Facebook, Telegram, Twitter)", "onboarding.form.registeredServerForm.included.apps": "Pääsy kauppapaikan sovelluksiin", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "Cloud-tilin sähköposti", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "Rekisteröidäksemme palvelimesi meidän on yhdistettävä se pilvitiliisi. Jos sinulla on jo sellainen - yhdistämme sen automaattisesti. Muussa tapauksessa luodaan uusi tili", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "Kirjoita sähköpostiosoitteesi", "onboarding.form.registeredServerForm.keepInformed": "Pidä minut ajan tasalla uutisista ja tapahtumista", "onboarding.form.registeredServerForm.registerLater": "Rekisteröidy myöhemmin", "onboarding.form.registeredServerForm.notConnectedToInternet": "Palvelin ei ole yhteydessä internetiin, joten työtila on rekisteröitävä offline-tilassa.", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "Rekisteröitymällä suostun vastaanottamaan asiaankuuluvia tuote- ja tietoturvapäivityksiä", "onboarding.form.standaloneServerForm.title": "Itsenäisen palvelimen vahvistus", "onboarding.form.standaloneServerForm.servicesUnavailable": "Jotkin palvelut eivät ole käytettävissä tai vaativat manuaalista asennusta", "onboarding.form.standaloneServerForm.publishOwnApp": "Jotta voit lähettää push-ilmoituksia, sinun on koottava ja julkaistava oma sovelluksesi Google Play- ja App Store -sovelluksissa", @@ -5757,4 +5750,4 @@ "Uninstall_grandfathered_app": "Poistetaanko {{appName}}?", "App_will_lose_grandfathered_status": "**Tämä {{context}}sovellus menettää aikaisemmin käytetössä olleen sovelluksen tilansa.** \n \nYhteisöversion työtiloissa voi olla käytössä enintään {{limit}} {{context}} sovellusta. aikaisemmin Aikaisemmin käytössä olleet sovellukset lasketaan mukaan rajoitukseen, mutta rajoitusta ei sovelleta niihin.", "Theme_Appearence": "Teeman ulkoasu" -} \ No newline at end of file +} diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/fr.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/fr.i18n.json index 08641b831f94..f460fc0b61de 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/fr.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/fr.i18n.json @@ -4300,10 +4300,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "Ce paramètre empêche toutes les instances d'envoyer les changements de statut des utilisateurs à leurs clients ; le statut de présence du premier chargement est donc conservé !", "Troubleshoot_Disable_Sessions_Monitor": "Désactiver le moniteur de sessions", "Troubleshoot_Disable_Sessions_Monitor_Alert": "Ce paramètre arrête le traitement des sessions utilisateur, ce qui empêche les statistiques de fonctionner correctement !", - "Troubleshoot_Disable_Statistics_Generator": "Désactiver le générateur de statistiques", - "Troubleshoot_Disable_Statistics_Generator_Alert": "Ce paramètre arrête le traitement de toutes les statistiques, ce qui rend la page d'informations obsolète jusqu'à ce que quelqu'un clique sur le bouton d'actualisation ; d'autres informations peuvent être manquantes dans le système !", - "Troubleshoot_Disable_Workspace_Sync": "Désactiver la synchronisation de l'espace de travail", - "Troubleshoot_Disable_Workspace_Sync_Alert": "Ce paramètre arrête la synchronisation de ce serveur avec le cloud Rocket.Chat et peut entraîner des problèmes avec les licences marketplace et entreprise !", "True": "Vrai", "Try_now": "Essayer maintenant", "Try_searching_in_the_marketplace_instead": "Essayez plutôt de chercher sur le marketplace", @@ -4855,8 +4851,6 @@ "onboarding.page.requestTrial.subtitle": "Essayez notre meilleur forfait Enterprise Edition gratuitement pendant 30 jours", "onboarding.page.magicLinkEmail.title": "Nous vous avons envoyé un lien de connexion par e-mail", "onboarding.page.magicLinkEmail.subtitle": "Cliquez sur le lien dans l'e-mail que nous venons de vous envoyer pour vous connecter à votre espace de travail. <1>Le lien expirera dans 30 minutes.", - "onboarding.page.organizationInfoPage.title": "Quelques détails supplémentaires...", - "onboarding.page.organizationInfoPage.subtitle": "Ceux-ci nous aideront à personnaliser votre espace de travail.", "onboarding.form.adminInfoForm.title": "Infos sur l'administrateur", "onboarding.form.adminInfoForm.subtitle": "Nous en avons besoin pour créer un profil d'administrateur dans votre espace de travail", "onboarding.form.adminInfoForm.fields.fullName.label": "Nom complet", @@ -4885,10 +4879,8 @@ "onboarding.form.registeredServerForm.included.externalProviders": "Intégration avec des fournisseurs externes (WhatsApp, Facebook, Telegram, Twitter)", "onboarding.form.registeredServerForm.included.apps": "Accès aux applications du marketplace", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "E-mail du compte cloud", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "Pour enregistrer votre serveur, nous devons le connecter à votre compte cloud. Si vous en avez déjà un, nous l'associerons automatiquement. Sinon, un nouveau compte sera créé", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "Entrez votre adresse e-mail", "onboarding.form.registeredServerForm.keepInformed": "Me tenir informé des actualités et des événements", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "En m'inscrivant, j'accepte de recevoir des mises à jour pertinentes concernant les produits et la sécurité", "onboarding.form.standaloneServerForm.title": "Confirmation du serveur autonome", "onboarding.form.standaloneServerForm.servicesUnavailable": "Certains services ne seront pas disponibles ou nécessiteront une configuration manuelle", "onboarding.form.standaloneServerForm.publishOwnApp": "Pour envoyer des notifications push, vous devez compiler et publier votre propre application sur Google Play et App Store", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/gl.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/gl.i18n.json index 5272cd9e1d3f..9ee622195a6a 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/gl.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/gl.i18n.json @@ -352,8 +352,6 @@ "onboarding.page.requestTrial.subtitle": "Proba o noso mellor plana de empresas durante 30 días de balde", "onboarding.page.magicLinkEmail.title": "Enviámosche por correo electrónico un link de inicio de sesión", "onboarding.page.magicLinkEmail.subtitle": "Fai clic na ligazón do correo electrónico que che acabamos de enviar para iniciar sesión no teu espazo de traballo. <1>A ligazón caducará en 30 minutos.", - "onboarding.page.organizationInfoPage.title": "Algúns detalles máis...", - "onboarding.page.organizationInfoPage.subtitle": "Estes axudaranos a personalizar o teu espazo de traballo.", "onboarding.form.adminInfoForm.title": "Información administrativa", "onboarding.form.adminInfoForm.subtitle": "Necesitamos isto para crear un perfil de administrador dentro do teu espazo de traballo", "onboarding.form.adminInfoForm.fields.fullName.label": "Nome completo", @@ -382,10 +380,8 @@ "onboarding.form.registeredServerForm.included.externalProviders": "Integración con provedores externos (WhatsApp, Facebook, Telegram, Twitter)", "onboarding.form.registeredServerForm.included.apps": "Acceso a aplicacións do mercado", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "Correo electrónico da conta na nube", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "Para rexistrar o teu servidor, necesitamos conectalo á túa conta na nube. Se xa tes un, vincularémolo automaticamente. En caso contrario, crearase unha nova conta", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "Introduce o teu correo electrónico", "onboarding.form.registeredServerForm.keepInformed": "Mantéñame informado sobre novidades e eventos", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "Ao rexistrarme, acepto recibir actualizacións de produtos e seguridade relevantes", "onboarding.form.standaloneServerForm.title": "Confirmación do servidor autónomo", "onboarding.form.standaloneServerForm.servicesUnavailable": "Algúns dos servizos non estarán dispoñibles ou requirirán unha configuración manual", "onboarding.form.standaloneServerForm.publishOwnApp": "Para enviar notificacións push, debes compilar e publicar a túa propia aplicación en Google Play e App Store", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/hu.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/hu.i18n.json index e54f69c81052..1177a356dc71 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/hu.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/hu.i18n.json @@ -4736,10 +4736,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "Ez a beállítás megakadályozza az összes példányt abban, hogy elküldjék a felhasználók állapotváltozásait az ügyfeleiknek, megtartva az összes felhasználót az első betöltésből származó jelenléti állapotával!", "Troubleshoot_Disable_Sessions_Monitor": "Munkamenetek megfigyelőjének letiltása", "Troubleshoot_Disable_Sessions_Monitor_Alert": "Ez a beállítás leállítja a felhasználók munkameneteinek feldolgozását, ami a statisztikák megfelelő működésének megszűnését okozza!", - "Troubleshoot_Disable_Statistics_Generator": "Statisztika-előállító letiltása", - "Troubleshoot_Disable_Statistics_Generator_Alert": "Ez a beállítás leállítja az összes statisztika feldolgozását, ami az információs oldalt elavulttá teszi, amíg valaki nem kattint a frissítés gombra, valamint más információk hiányát is okozhatja a rendszerben!", - "Troubleshoot_Disable_Workspace_Sync": "Munkaterület szinkronizálásának letiltása", - "Troubleshoot_Disable_Workspace_Sync_Alert": "Ez a beállítás leállítja ennek a kiszolgálónak a Rocket.Chat felhőjével való szinkronizálását, és problémákat okozhat a piactérrel és a vállalati licencekkel!", "True": "Igaz", "Try_now": "Próbálja most", "Try_searching_in_the_marketplace_instead": "Próbáljon inkább a piactéren keresni", @@ -5393,8 +5389,6 @@ "onboarding.page.requestTrial.subtitle": "Próbálja ki a legjobb vállalati kiadású előfizetéses csomagunkat 30 napig ingyen", "onboarding.page.magicLinkEmail.title": "Elküldünk Önnek egy bejelentkezési hivatkozást e-mailben", "onboarding.page.magicLinkEmail.subtitle": "Kattintson a most elküldött levélben lévő hivatkozásra, hogy bejelentkezhessen a munkaterületére. <1>A hivatkozás 30 percen belül lejár.", - "onboarding.page.organizationInfoPage.title": "Néhány további részlet…", - "onboarding.page.organizationInfoPage.subtitle": "Ezek segítenek nekünk személyre szabni a munkaterületét.", "onboarding.form.adminInfoForm.title": "Adminisztrátor-információk", "onboarding.form.adminInfoForm.subtitle": "Erre azért van szükségünk, hogy létrehozzunk egy adminisztrátori profilt a munkaterületén belül", "onboarding.form.adminInfoForm.fields.fullName.label": "Teljes név", @@ -5423,10 +5417,8 @@ "onboarding.form.registeredServerForm.included.externalProviders": "Integráció külső szolgáltatókkal (WhatsApp, Facebook, Telegram, Twitter)", "onboarding.form.registeredServerForm.included.apps": "Hozzáférés a piactér alkalmazásaihoz", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "Felhős fiók e-mail-címe", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "A kiszolgálója regisztrálásához csatlakoztatnunk kell azt a felhős fiókjához. Ha már rendelkezik ilyennel, akkor automatikusan összekapcsoljuk. Ellenkező esetben új fiók kerül létrehozásra.", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "Adja meg az e-mail-címét", "onboarding.form.registeredServerForm.keepInformed": "Tájékoztassanak a hírekről és az eseményekről", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "A regisztrációval beleegyezem, hogy megkapom a vonatkozó termék- és biztonsági frissítéseket", "onboarding.form.standaloneServerForm.title": "Egyedülálló kiszolgáló megerősítése", "onboarding.form.standaloneServerForm.servicesUnavailable": "Néhány szolgáltatás nem lesz elérhető, vagy kézi beállítást igényel", "onboarding.form.standaloneServerForm.publishOwnApp": "A leküldéses értesítések küldéséhez saját alkalmazást kell összeállítania és közzétennie a Google Play és az App Store áruházakban", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/ja.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/ja.i18n.json index 0bf0eb5063ae..31973b5da92a 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/ja.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/ja.i18n.json @@ -4259,10 +4259,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "この設定は、すべてのインスタンスがユーザーのステータス変更をクライアントに送信することを防ぎ、すべてのユーザーを最初のロードからのプレゼンスステータスで維持します!", "Troubleshoot_Disable_Sessions_Monitor": "セッションモニターを無効にする", "Troubleshoot_Disable_Sessions_Monitor_Alert": "この設定により、ユーザーセッションの処理が停止し、統計が正しく機能しなくなります!", - "Troubleshoot_Disable_Statistics_Generator": "統計ジェネレーターを無効にする", - "Troubleshoot_Disable_Statistics_Generator_Alert": "この設定では、更新ボタンがクリックされるまですべての統計処理が停止され、情報ページの情報が最新ではなくなり、システムに関するその他の情報が失われる可能性があります。", - "Troubleshoot_Disable_Workspace_Sync": "ワークスペース同期を無効にする", - "Troubleshoot_Disable_Workspace_Sync_Alert": "この設定により、このサーバーとRocket.Chatのクラウドとの同期が停止し、マーケットプレイスとエンタープライズライセンスで問題が発生する可能性があります!", "True": "はい", "Try_now": "今すぐ再試行", "Try_searching_in_the_marketplace_instead": "代わりにマーケットプレイスを検索してみてください", @@ -4805,8 +4801,6 @@ "onboarding.page.requestTrial.subtitle": "30日間の最上位のEnterprise Editionプランを無料でお試しください", "onboarding.page.magicLinkEmail.title": "ログインリンクをメールで送信しました", "onboarding.page.magicLinkEmail.subtitle": "送信されたメールのリンクをクリックし、ワークスペースにサインインしてください。 <1>リンクの有効期間は30分です。", - "onboarding.page.organizationInfoPage.title": "その他の詳細...", - "onboarding.page.organizationInfoPage.subtitle": "これにより、ワークスペースをパーソナライズできます。", "onboarding.form.adminInfoForm.title": "管理者情報", "onboarding.form.adminInfoForm.subtitle": "これはワークスペース内に管理プロファイルを作成するために必要です", "onboarding.form.adminInfoForm.fields.fullName.label": "氏名", @@ -4835,10 +4829,8 @@ "onboarding.form.registeredServerForm.included.externalProviders": "外部プロバイダー(WhatsApp、Facebook、Telegram、Twitter)との統合", "onboarding.form.registeredServerForm.included.apps": "マーケットプレイスアプリにアクセス", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "クラウドアカウントメール", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "サーバーを登録するには、サーバーをクラウドアカウントに接続する必要があります。アカウントをすでにお持ちの場合は、自動的にリンクします。それ以外の場合は、新しいアカウントが作成されます", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "メールアドレスを入力してください", "onboarding.form.registeredServerForm.keepInformed": "ニュースとイベントの情報を受け取る", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "登録すると、関連する製品とセキュリティの更新を受け取ることに同意したものとみなされます", "onboarding.form.standaloneServerForm.title": "スタンドアロンサーバーの確認", "onboarding.form.standaloneServerForm.servicesUnavailable": "一部のサービスは利用できないか、手動で設定する必要があります", "onboarding.form.standaloneServerForm.publishOwnApp": "プッシュ通知を送信するには、独自のアプリをコンパイルしてGoogle PlayとApp Storeに公開する必要があります", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/ka-GE.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/ka-GE.i18n.json index 15514a726e5b..6dd72917fe9d 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/ka-GE.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/ka-GE.i18n.json @@ -3303,10 +3303,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "ეს პარამეტრი ყვენა ინსტანციისთვის თიშავს მომხმარებლის სტატუსის გადაგზავნას კლიენტებისთვის და ტოვებს ყველა მომხმარებელს იმ სტატუსით რომლითაც იყო პირველი ჩატვირთვისას.", "Troubleshoot_Disable_Sessions_Monitor": "გამორთეთ სესიების მონიტორი", "Troubleshoot_Disable_Sessions_Monitor_Alert": "ეს პარამეტრი თიშავს მომხმარებლის სესიების დამუშავებას და იწვევს სტატისტიკის არასწორ მუშაობას", - "Troubleshoot_Disable_Statistics_Generator": "გამორთეთ სტატისტიკის გენერატორი", - "Troubleshoot_Disable_Statistics_Generator_Alert": "ეს პარამეტრი სტატისტიკის დამუშავებას თიშავს სრულად და გვერდი ხდება ვადაგასული ვიდრე ვინმე განახლების ღილაკს არ დააჭერს, ამან შეიძლება გამოიწვიოს ზოგი ინფორმაციის დაკარგვა", - "Troubleshoot_Disable_Workspace_Sync": "გამორთეთ სამუშაო ადგილის სინქრონიზაცია", - "Troubleshoot_Disable_Workspace_Sync_Alert": "ეს პარამეტრი თიშავს ამ სერვერის Rocket.Chat's clou-თან სინქრონიზაციას და შეიძლება გამოიწვიოს პრობლემები მარკეტში და საწარმო ლიცენზიებში!", "True": "მართალია", "Try_now": "სცადე ახლა", "Tuesday": "სამშაბათი", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/ko.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/ko.i18n.json index 34f7608c9bed..2574c0b288b8 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/ko.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/ko.i18n.json @@ -3624,10 +3624,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "이 설정은 모든 인스턴스가 사용자의 상태 변경 사항을 클라이언트로 보내지 않으며, 설정 시, 모든 사용자의 상태가 처음 로딩상태로 유지됩니다.!", "Troubleshoot_Disable_Sessions_Monitor": "세션 모니터링 사용중지", "Troubleshoot_Disable_Sessions_Monitor_Alert": "이 설정은 사용자 세션 처리를 중단하는 것입니다. 설정 시 통계가 올바르게 작동하지 않을 수 있습니다. ", - "Troubleshoot_Disable_Statistics_Generator": "통계 생성 사용중지", - "Troubleshoot_Disable_Statistics_Generator_Alert": "이 설정은 누군가가 새로 고침 버튼을 클릭 할 때까지 이전 정보 페이지를 생성하는 모든 통계 처리를 중지하는 것입니다. 설정 시 시스템 주변에 다른 정보가 누락 될 수 있습니다!", - "Troubleshoot_Disable_Workspace_Sync": " Workspace 동기화 사용중지", - "Troubleshoot_Disable_Workspace_Sync_Alert": "이 설정은 서버와 Rocket.Chat의 클라우드 동기화를 중지하는 것입니다. 설정 시 Marketplace 및 기업 라이선스에 문제가 발생할 수 있습니다. ", "True": "설정됨", "Try_now": "지금 시도", "Tuesday": "화요일", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/nl.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/nl.i18n.json index f611b39aebd4..eff6811050c1 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/nl.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/nl.i18n.json @@ -4290,10 +4290,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "Deze instelling voorkomt dat alle instanties de statuswijzingen van de gebruikers naar hun clients sturen, waarbij alle gebruikers hun aanwezigheidsstatus behouden van de eerste lading!", "Troubleshoot_Disable_Sessions_Monitor": "Schakel sessies monitor uit", "Troubleshoot_Disable_Sessions_Monitor_Alert": "Deze instelling stopt de verwerking van gebruikerssessies waardoor de statistieken niet meer correct werken!", - "Troubleshoot_Disable_Statistics_Generator": "Schakel statistieken generator uit", - "Troubleshoot_Disable_Statistics_Generator_Alert": "Deze instelling stopt de verwerking van alle statistieken waardoor de info-pagina verouderd raakt totdat iemand op de Vernieuwen knop klikt, en kan ontbrekende informatie in het systeem verzoorzaken!", - "Troubleshoot_Disable_Workspace_Sync": "Schakel Workspace Sync uit", - "Troubleshoot_Disable_Workspace_Sync_Alert": "Deze instelling stopt de synchronisatie van deze server met Rocket.Chat's cloud en kan problemen veroorzaken met marktplaats- en bedrijfslicenties!", "True": "Waar", "Try_now": "Probeer nu", "Try_searching_in_the_marketplace_instead": "Probeer in plaats daarvan in de Marketplace te zoeken", @@ -4843,8 +4839,6 @@ "onboarding.page.requestTrial.subtitle": "Probeer gratis onze beste Enterprise Edition-abonnement gedurende 30 dagen", "onboarding.page.magicLinkEmail.title": "We hebben je een inloglink gemaild.", "onboarding.page.magicLinkEmail.subtitle": "Klik op de link in de e-mail die we u zojuist hebben gestuurd om u aan te melden bij uw werkruimte. <1>De link verloopt over 30 minuten.", - "onboarding.page.organizationInfoPage.title": "Nog een paar details...", - "onboarding.page.organizationInfoPage.subtitle": "Deze zullen ons helpen om uw werkruimte te personaliseren.", "onboarding.form.adminInfoForm.title": "Admin info", "onboarding.form.adminInfoForm.subtitle": "We hebben dit nodig om een beheerdersprofiel in uw werkruimte te maken", "onboarding.form.adminInfoForm.fields.fullName.label": "Volledige naam", @@ -4873,10 +4867,8 @@ "onboarding.form.registeredServerForm.included.externalProviders": "Integratie met externe providers (WhatsApp, Facebook, Telegram, Twitter)", "onboarding.form.registeredServerForm.included.apps": "Toegang tot Marketplace-apps", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "E-mailadres van cloudaccount", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "Om uw server te registreren, moeten we deze verbinden met uw cloudaccount. Als u er al een heeft, zullen we deze automatisch koppelen. Anders wordt er een nieuwe account aangemaakt", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "Voer uw e-mailadres in", "onboarding.form.registeredServerForm.keepInformed": "Hou me op de hoogte van nieuws en evenementen", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "Door te registreren ga ik akkoord met het ontvangen van relevante product- en beveiligingsupdates", "onboarding.form.standaloneServerForm.title": "Standalone serverbevestiging", "onboarding.form.standaloneServerForm.servicesUnavailable": "Sommige diensten zullen niet beschikbaar zijn of vereisen handmatige configuratie", "onboarding.form.standaloneServerForm.publishOwnApp": "Om pushmeldingen te verzenden, moet u uw eigen app compileren en publiceren in Google Play en App Store", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/pl.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/pl.i18n.json index 0c4b85e588e0..80ab48d383c0 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/pl.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/pl.i18n.json @@ -4667,10 +4667,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "To ustawienie zapobiega wysyłaniu przez wszystkie instancje zmian statusu użytkowników do ich klientów, utrzymując status wszystkich użytkowników taki jak przy pierwszym załadowaniu!", "Troubleshoot_Disable_Sessions_Monitor": "Wyłącz monitor sesji", "Troubleshoot_Disable_Sessions_Monitor_Alert": "To ustawienie zatrzymuje przetwarzanie sesji użytkowników, co spowoduje niepoprawne działanie statystyk!", - "Troubleshoot_Disable_Statistics_Generator": "Wyłącz generator statystyk", - "Troubleshoot_Disable_Statistics_Generator_Alert": "To ustawienie zatrzymuje przetwarzanie wszystkich statystyk powodując, że strona informacyjna stanie się nieaktualna dopóki nie zostanie naciśnięty przycisk odświeżania i może wywołać utratę innych informacji w całym systemie!", - "Troubleshoot_Disable_Workspace_Sync": "Wyłączenie Workspace Sync", - "Troubleshoot_Disable_Workspace_Sync_Alert": "To ustawienie zatrzymuje synchronizację tego serwera z chmurą Rocket.Chat co może wywołać problemy z marketplace i licencjami korporacyjnymi!", "True": "Tak", "Try_now": "Spróbuj teraz", "Try_searching_in_the_marketplace_instead": "Zamiast tego spróbuj poszukać w Marketplace", @@ -5304,8 +5300,6 @@ "onboarding.page.requestTrial.subtitle": "Wypróbuj nasz najlepszy plan Enterprise Edition przez 30 dni za darmo", "onboarding.page.magicLinkEmail.title": "Wysłaliśmy Ci link do logowania", "onboarding.page.magicLinkEmail.subtitle": "Kliknij link w wiadomości e-mail, którą właśnie do Ciebie wysłaliśmy, aby zalogować się do swojego obszaru roboczego. <1>Link wygaśnie za 30 minut.", - "onboarding.page.organizationInfoPage.title": "Jeszcze kilka szczegółów...", - "onboarding.page.organizationInfoPage.subtitle": "Pomogą nam one spersonalizować Twoje miejsce pracy.", "onboarding.form.adminInfoForm.title": "Admin Info", "onboarding.form.adminInfoForm.subtitle": "Potrzebujemy tego, aby utworzyć profil administratora w twoim obszarze roboczym", "onboarding.form.adminInfoForm.fields.fullName.label": "Pełna nazwa", @@ -5334,10 +5328,8 @@ "onboarding.form.registeredServerForm.included.externalProviders": "Integracja z zewnętrznymi dostawcami (WhatsApp, Facebook, Telegram, Twitter)", "onboarding.form.registeredServerForm.included.apps": "Dostęp do aplikacji w Marketplace", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "E-mail konta w chmurze", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "Aby zarejestrować Twój serwer, musimy połączyć go z Twoim kontem w chmurze. Jeśli już je posiadasz - połączymy je automatycznie. W przeciwnym razie, zostanie utworzone nowe konto", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "Proszę wpisać swój adres e-mail", "onboarding.form.registeredServerForm.keepInformed": "Informuj mnie o nowościach i wydarzeniach", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "Rejestrując się, wyrażam zgodę na otrzymywanie odpowiednich aktualizacji produktów i zabezpieczeń", "onboarding.form.standaloneServerForm.title": "Potwierdzenie serwera standalone", "onboarding.form.standaloneServerForm.servicesUnavailable": "Niektóre z usług będą niedostępne lub będą wymagały ręcznej konfiguracji", "onboarding.form.standaloneServerForm.publishOwnApp": "W celu wysyłania powiadomień push należy skompilować i opublikować własną aplikację w Google Play i App Store", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/ru.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/ru.i18n.json index 8f71b6f49ab2..48557a3a146e 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/ru.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/ru.i18n.json @@ -4463,10 +4463,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "Эта настройка не позволяет всем инстансам отправлять изменения статуса пользователей своим клиентам, сохраняя всех пользователей со статусом присутствия с первой загрузки!", "Troubleshoot_Disable_Sessions_Monitor": "Отключить монитор сессий", "Troubleshoot_Disable_Sessions_Monitor_Alert": "Эта настройка останавливает обработку пользовательских сессий, в результате чего статистика перестает работать корректно!", - "Troubleshoot_Disable_Statistics_Generator": "Отключить генератор статистики", - "Troubleshoot_Disable_Statistics_Generator_Alert": "Эта настройка останавливает обработку всей статистики, делая информационную страницу устаревшей до тех пор, пока кто-нибудь не нажмет кнопку обновления, это может привести к появлению другой недостающей информации в системе!", - "Troubleshoot_Disable_Workspace_Sync": "Отключить синхронизацию рабочего пространства", - "Troubleshoot_Disable_Workspace_Sync_Alert": "Эта настройка останавливает синхронизацию данного сервера с Rocket.Chat Cloud и может привести к проблемам с корпоративами лицензиями и в магазине приложений!", "True": "Да", "Try_now": "Попробуйте сейчас", "Try_searching_in_the_marketplace_instead": "Попробуйте выполнить поиск в магазине", @@ -5041,8 +5037,6 @@ "onboarding.page.requestTrial.subtitle": "Воспользуйтесь нашим лучшим тарифным планом Enterprise Edition в течение 30 дней бесплатно", "onboarding.page.magicLinkEmail.title": "Мы отправили вам ссылку для входа в систему в электронном письме", "onboarding.page.magicLinkEmail.subtitle": "Нажмите на ссылку в электронном письме, чтобы войти в свое рабочее пространство. <1>Срок действия ссылки истечет через 30 минут.", - "onboarding.page.organizationInfoPage.title": "Дополнительные сведения...", - "onboarding.page.organizationInfoPage.subtitle": "Это поможет нам персонализировать ваше рабочее пространство.", "onboarding.form.adminInfoForm.title": "Информация об администраторе", "onboarding.form.adminInfoForm.subtitle": "Это необходимо для создания профиля администратора в вашем рабочем пространстве", "onboarding.form.adminInfoForm.fields.fullName.label": "Полное имя", @@ -5071,10 +5065,8 @@ "onboarding.form.registeredServerForm.included.externalProviders": "Интеграция с внешними поставщиками (WhatsApp, Facebook, Telegram, Twitter)", "onboarding.form.registeredServerForm.included.apps": "Доступ к приложениям магазина", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "Адрес электронной почты учетной записи в облаке", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "Чтобы зарегистрировать сервер, необходимо подключить его к учетной записи облака. Если у вас уже есть такая учетная запись, мы свяжем ее автоматически. В противном случае будет создана новая учетная запись", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "Введите адрес электронной почты", "onboarding.form.registeredServerForm.keepInformed": "Сообщайте мне новости и информацию о событиях", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "Регистрируясь, я соглашаюсь получать соответствующие обновления продуктов и системы безопасности", "onboarding.form.standaloneServerForm.title": "Подтверждение автономного сервера", "onboarding.form.standaloneServerForm.servicesUnavailable": "Некоторые сервисы будут недоступны или потребуется ручная настройка", "onboarding.form.standaloneServerForm.publishOwnApp": "Чтобы отправлять push-уведомления, необходимо создать и опубликовать собственное приложение в Google Play и App Store", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/sv.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/sv.i18n.json index bea75b85c976..5cf724dd49c1 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/sv.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/sv.i18n.json @@ -4922,10 +4922,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "Med den här inställningen förhindras alla instanser att skicka ändringar av användarnas status till deras klienter, vilket gör att alla användare behåller sin närvarostatus från den första inläsningen.", "Troubleshoot_Disable_Sessions_Monitor": "Inaktivera sessionsövervakning", "Troubleshoot_Disable_Sessions_Monitor_Alert": "Med den här inställningen stoppas bearbetningen av användarsessioner, vilket gör att statistikfunktionen slutar fungera som den ska.", - "Troubleshoot_Disable_Statistics_Generator": "Inaktivera generering av statistik", - "Troubleshoot_Disable_Statistics_Generator_Alert": "Med den här inställningen stoppas bearbetningen av all statistik. Det gör att informationssidan blir inaktuell tills någon klickar på uppdateringsknappen och kan leda till att annan information saknas i systemet.", - "Troubleshoot_Disable_Workspace_Sync": "Inaktivera synkronisering av arbetsyta", - "Troubleshoot_Disable_Workspace_Sync_Alert": "Den här inställningen stoppar serverns synkronisering med Rocket.Chat-molnet och kan orsaka problem med Marketplace och Enterprise-licenser.", "True": "Sant", "Try_now": "Pröva nu", "Try_searching_in_the_marketplace_instead": "Pröva att söka i Marketplace istället", @@ -5608,8 +5604,6 @@ "onboarding.page.requestTrial.subtitle": "Prova på vårt bästa Enterprise Edition-abonnemang i 30 dagar utan kostnad", "onboarding.page.magicLinkEmail.title": "Vi har skickat en inloggningslänk via e-post ", "onboarding.page.magicLinkEmail.subtitle": "Logga in på arbetsytan genom att klicka på länken i e-postmeddelandet vi precis skickade till dig. <1>Länken upphör att gälla om 30 minuter.", - "onboarding.page.organizationInfoPage.title": "Några uppgifter till...", - "onboarding.page.organizationInfoPage.subtitle": "Vi behöver dem för att anpassa arbetsytan.", "onboarding.form.adminInfoForm.title": "Information om administratör", "onboarding.form.adminInfoForm.subtitle": "Vi behöver skapa en administratörsprofil i din arbetsyta", "onboarding.form.adminInfoForm.fields.fullName.label": "Fullständigt namn", @@ -5638,12 +5632,10 @@ "onboarding.form.registeredServerForm.included.externalProviders": "Integrering med externa leverantörer (WhatsApp, Facebook, Telegram, Twitter)", "onboarding.form.registeredServerForm.included.apps": "Åtkomst till appar i Marketplace", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "E-postadress för molnkonto", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "För att registrera servern behöver vi ansluta den till ditt molnkonto. Om du har ett kopplar vi det automatiskt. Annars skapas ett nytt konto", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "Ange din e-postadress", "onboarding.form.registeredServerForm.keepInformed": "Håll mig informerad om nyheter och händelser", "onboarding.form.registeredServerForm.registerLater": "Registrera dig senare", "onboarding.form.registeredServerForm.notConnectedToInternet": "Servern är inte ansluten till internet, så du måste göra en offline-registrering för den här arbetsytan.", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "Genom att registrera mig godkänner jag att ta emot relevanta produkt- och säkerhetsuppdateringar", "onboarding.form.standaloneServerForm.title": "Bekräftelse av fristående server", "onboarding.form.standaloneServerForm.servicesUnavailable": "Vissa av tjänsterna kommer att vara otillgängliga eller måste ställas in manuellt", "onboarding.form.standaloneServerForm.publishOwnApp": "Om du ska kunna skicka pushmeddelanden måste du kompilera och publicera din egen app på Google Play och App Store", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/zh-TW.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/zh-TW.i18n.json index 5ad5c0eb1d2b..24dedfcf33e7 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/zh-TW.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/zh-TW.i18n.json @@ -4068,10 +4068,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "這個設定可防止所有實務將使用者的狀態更改發送到其客戶端,從而使所有使用者保持首次載入的狀態!", "Troubleshoot_Disable_Sessions_Monitor": "停用 Sessions 監視器", "Troubleshoot_Disable_Sessions_Monitor_Alert": "這個設定將停止處理使用者 sessions,將會導致統計資料無法正常工作!", - "Troubleshoot_Disable_Statistics_Generator": "停用統計資料產生器", - "Troubleshoot_Disable_Statistics_Generator_Alert": "這個設定將停止處理所有統計資料,將會使資料頁面過時,直到有人點擊“重整”按鈕,並可能導致系統周圍缺少其他資料!", - "Troubleshoot_Disable_Workspace_Sync": "停用工作區同步", - "Troubleshoot_Disable_Workspace_Sync_Alert": "這個設定將停止該伺服器與 Rocket.Chat 的雲端同步,並可能導致商店和企業授權出現問題!", "True": "是", "Try_now": "現在再試", "Tuesday": "星期二", @@ -4553,8 +4549,6 @@ "onboarding.page.requestTrial.subtitle": "試用我們最棒的企業版方案,30 天免費", "onboarding.page.magicLinkEmail.title": "我們已透過電子郵件傳送登入連結給您", "onboarding.page.magicLinkEmail.subtitle": "按一下我們剛傳送給您的電子郵件中的連結,即可登入您的工作空間。<1>該連結將在 30 分鐘後到期。", - "onboarding.page.organizationInfoPage.title": "更多詳細資料...", - "onboarding.page.organizationInfoPage.subtitle": "這些資料將可協助我們個人化您的工作空間。", "onboarding.form.adminInfoForm.title": "管理員資訊", "onboarding.form.adminInfoForm.subtitle": "我們需要此資訊以在您的工作空間內建立管理員個人資料", "onboarding.form.adminInfoForm.fields.fullName.label": "全名", @@ -4583,10 +4577,8 @@ "onboarding.form.registeredServerForm.included.externalProviders": "與外部提供者 (WhatsApp、Facebook、Telegram、Twitter) 整合", "onboarding.form.registeredServerForm.included.apps": "存取市集應用程式", "onboarding.form.registeredServerForm.fields.accountEmail.inputLabel": "雲端帳戶電子郵件", - "onboarding.form.registeredServerForm.fields.accountEmail.tooltipLabel": "若要註冊您的伺服器,我們需要將伺服器連線至您的雲端帳戶。如果您已有雲端帳戶,我們將會自動為您連線。否則,將需要建立新的帳戶", "onboarding.form.registeredServerForm.fields.accountEmail.inputPlaceholder": "請輸入您的電子郵件", "onboarding.form.registeredServerForm.keepInformed": "在有新聞與活動消息時通知我", - "onboarding.form.registeredServerForm.agreeToReceiveUpdates": "註冊即代表我同意收到相關產品與安全性更新資訊", "onboarding.form.standaloneServerForm.title": "獨立伺服器確認", "onboarding.form.standaloneServerForm.servicesUnavailable": "部分服務將無法使用或是需要手動設定", "onboarding.form.standaloneServerForm.publishOwnApp": "若要傳送推播通知,您必須對您所擁有的應用程式進行編碼,並將應用程式發佈至 Google Play 和 App Store", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/zh.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/zh.i18n.json index df4642f4b0df..283944175541 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/zh.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/zh.i18n.json @@ -3717,10 +3717,6 @@ "Troubleshoot_Disable_Presence_Broadcast_Alert": "这个设置可以防止所有的实例将用户的状态变化发送给他们的客户端,使所有的用户保持他们第一次加载时的存在状态。", "Troubleshoot_Disable_Sessions_Monitor": "禁用会话监控", "Troubleshoot_Disable_Sessions_Monitor_Alert": "这个设置停止了对用户会话的处理,将导致统计工作无法正常进行!", - "Troubleshoot_Disable_Statistics_Generator": "禁用统计生成器", - "Troubleshoot_Disable_Statistics_Generator_Alert": "这个设置会停止处理所有的统计数据,使信息页面过时,直到有人点击刷新按钮,并可能导致系统中的其他信息缺失!", - "Troubleshoot_Disable_Workspace_Sync": "禁用工作区同步", - "Troubleshoot_Disable_Workspace_Sync_Alert": "该设置会停止同步该服务器到 Rocket.Chat 云端,并可能导致市场和企业许可证出现问题!", "True": "是", "Try_now": "立即尝试", "Tuesday": "星期二", diff --git a/apps/meteor/packages/rocketchat-mongo-config/server/index.js b/apps/meteor/packages/rocketchat-mongo-config/server/index.js index 80e664a9c209..65464a31095c 100644 --- a/apps/meteor/packages/rocketchat-mongo-config/server/index.js +++ b/apps/meteor/packages/rocketchat-mongo-config/server/index.js @@ -34,20 +34,19 @@ if (Object.keys(mongoConnectionOptions).length > 0) { process.env.HTTP_FORWARDED_COUNT = process.env.HTTP_FORWARDED_COUNT || '1'; -// Send emails to a "fake" stream instead of print them in console in case MAIL_URL or SMTP is not configured -if (process.env.NODE_ENV !== 'development') { - const { sendAsync } = Email; +// Just print to logs if in TEST_MODE due to a bug in Meteor 2.5: TypeError: Cannot read property '_syncSendMail' of null +if (process.env.TEST_MODE === 'true') { + Email.sendAsync = function _sendAsync(options) { + console.log('Email.sendAsync', options); + }; +} else if (process.env.NODE_ENV !== 'development') { + // Send emails to a "fake" stream instead of print them in console in case MAIL_URL or SMTP is not configured const stream = new PassThrough(); stream.on('data', () => {}); stream.on('end', () => {}); - Email.sendAsync = function _sendAsync(options) { - return sendAsync.call(this, { stream, ...options }); - }; -} -// Just print to logs if in TEST_MODE due to a bug in Meteor 2.5: TypeError: Cannot read property '_syncSendMail' of null -if (process.env.TEST_MODE === 'true') { + const { sendAsync } = Email; Email.sendAsync = function _sendAsync(options) { - console.log('Email.sendAsync', options); + return sendAsync.call(this, { stream, ...options }); }; } diff --git a/apps/meteor/public/images/high-contrast-upsell-modal.png b/apps/meteor/public/images/high-contrast-upsell-modal.png deleted file mode 100644 index b761a1b0b76c..000000000000 Binary files a/apps/meteor/public/images/high-contrast-upsell-modal.png and /dev/null differ diff --git a/apps/meteor/server/configureLogLevel.ts b/apps/meteor/server/configureLogLevel.ts new file mode 100644 index 000000000000..b328d79a023a --- /dev/null +++ b/apps/meteor/server/configureLogLevel.ts @@ -0,0 +1,8 @@ +import type { LogLevelSetting } from '@rocket.chat/logger'; +import { logLevel } from '@rocket.chat/logger'; +import { Settings } from '@rocket.chat/models'; + +const LogLevel = await Settings.getValueById('Log_Level'); +if (LogLevel) { + logLevel.emit('changed', LogLevel as LogLevelSetting); +} diff --git a/apps/meteor/server/cron/statistics.ts b/apps/meteor/server/cron/statistics.ts index 27c1fc064e25..44dcb554824c 100644 --- a/apps/meteor/server/cron/statistics.ts +++ b/apps/meteor/server/cron/statistics.ts @@ -1,5 +1,6 @@ import { cronJobs } from '@rocket.chat/cron'; import type { Logger } from '@rocket.chat/logger'; +import { Statistics } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Meteor } from 'meteor/meteor'; @@ -8,9 +9,7 @@ import { settings } from '../../app/settings/server'; import { statistics } from '../../app/statistics/server'; async function generateStatistics(logger: Logger): Promise { - const cronStatistics: Record = await statistics.save(); - - cronStatistics.host = Meteor.absoluteUrl(); + const cronStatistics = await statistics.save(); if (!settings.get('Statistics_reporting')) { return; @@ -20,11 +19,20 @@ async function generateStatistics(logger: Logger): Promise { const token = await getWorkspaceAccessToken(); const headers = { ...(token && { Authorization: `Bearer ${token}` }) }; - await fetch('https://collector.rocket.chat/', { + const response = await fetch('https://collector.rocket.chat/', { method: 'POST', - body: cronStatistics, + body: { + ...cronStatistics, + host: Meteor.absoluteUrl(), + }, headers, }); + + const { statsToken } = await response.json(); + + if (statsToken != null) { + await Statistics.updateOne({ _id: cronStatistics._id }, { $set: { statsToken } }); + } } catch (error) { /* error*/ logger.warn('Failed to send usage report'); @@ -37,5 +45,7 @@ export async function statsCron(logger: Logger): Promise { const now = new Date(); - await cronJobs.add(name, `12 ${now.getHours()} * * *`, async () => generateStatistics(logger)); + await cronJobs.add(name, `12 ${now.getHours()} * * *`, async () => { + await generateStatistics(logger); + }); } diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index 44302ae9ff91..ebdd9cdcac01 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -12,7 +12,6 @@ import type { ParsedMail, Attachment } from 'mailparser'; import stripHtml from 'string-strip-html'; import { FileUpload } from '../../../app/file-upload/server'; -import { Livechat } from '../../../app/livechat/server/lib/Livechat'; import { Livechat as LivechatTyped } from '../../../app/livechat/server/lib/LivechatTyped'; import { QueueManager } from '../../../app/livechat/server/lib/QueueManager'; import { settings } from '../../../app/settings/server'; @@ -25,33 +24,21 @@ const language = settings.get('Language') || 'en'; const t = (s: string): string => i18n.t(s, { lng: language }); async function getGuestByEmail(email: string, name: string, department = ''): Promise { - logger.debug(`Attempt to register a guest for ${email} on department: ${department}`); const guest = await LivechatVisitors.findOneGuestByEmailAddress(email); if (guest) { - logger.debug(`Guest with email ${email} found with id ${guest._id}`); if (guest.department !== department) { - logger.debug({ - msg: 'Switching departments for guest', - guest, - previousDepartment: guest.department, - newDepartment: department, - }); if (!department) { await LivechatVisitors.removeDepartmentById(guest._id); delete guest.department; return guest; } await LivechatTyped.setDepartmentForGuest({ token: guest.token, department }); - return LivechatVisitors.findOneById(guest._id, {}); + return LivechatVisitors.findOneEnabledById(guest._id, {}); } return guest; } - logger.debug({ - msg: 'Creating a new Omnichannel guest for visitor with email', - email, - }); const userId = await LivechatTyped.registerGuest({ token: Random.id(), name: name || email, @@ -59,8 +46,9 @@ async function getGuestByEmail(email: string, name: string, department = ''): Pr department, }); - const newGuest = await LivechatVisitors.findOneById(userId); + const newGuest = await LivechatVisitors.findOneEnabledById(userId); logger.debug(`Guest ${userId} for visitor ${email} created`); + if (newGuest) { return newGuest; } @@ -111,7 +99,7 @@ async function uploadAttachment(attachmentParam: Attachment, rid: string, visito } export async function onEmailReceived(email: ParsedMail, inbox: string, department = ''): Promise { - logger.debug(`New email conversation received on inbox ${inbox}. Will be assigned to department ${department}`); + logger.info(`New email conversation received on inbox ${inbox}. Will be assigned to department ${department}`); if (!email.from?.value?.[0]?.address) { return; } @@ -119,19 +107,13 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme const references = typeof email.references === 'string' ? [email.references] : email.references; const initialRef = [email.messageId, email.inReplyTo].filter(Boolean) as string[]; const thread = (references?.length ? references : []).flatMap((t: string) => t.split(',')).concat(initialRef); - - logger.debug(`Received new email conversation with thread ${thread} on inbox ${inbox} from ${email.from.value[0].address}`); - - logger.debug(`Fetching guest for visitor ${email.from.value[0].address}`); const guest = await getGuestByEmail(email.from.value[0].address, email.from.value[0].name, department); if (!guest) { - logger.debug(`No visitor found for ${email.from.value[0].address}`); + logger.error(`No visitor found for ${email.from.value[0].address}`); return; } - logger.debug(`Guest ${guest._id} obtained. Attempting to find or create a room on department ${department}`); - let room: IOmnichannelRoom | null = await LivechatRooms.findOneByVisitorTokenAndEmailThreadAndDepartment( guest.token, thread, @@ -146,7 +128,6 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme }); if (room?.closedAt) { - logger.debug(`Room ${room?._id} is closed. Reopening`); room = await QueueManager.unarchiveRoom(room); } @@ -166,9 +147,7 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme const rid = room?._id ?? Random.id(); const msgId = Random.id(); - logger.debug(`Sending email message to room ${rid} for visitor ${guest._id}. Conversation assigned to department ${department}`); - - Livechat.sendMessage({ + LivechatTyped.sendMessage({ guest, message: { _id: msgId, @@ -242,7 +221,7 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme try { attachments.push(await uploadAttachment(attachment, rid, guest.token)); } catch (err) { - Livechat.logger.error({ msg: 'Error uploading attachment from email', err }); + logger.error({ msg: 'Error uploading attachment from email', err }); } } @@ -259,7 +238,7 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme room && (await LivechatRooms.updateEmailThreadByRoomId(room._id, thread)); }) .catch((err) => { - Livechat.logger.error({ + logger.error({ msg: 'Error receiving email', err, }); diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts index dccf8315acd3..685c7f9e96dd 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts @@ -43,15 +43,15 @@ const sendErrorReplyMessage = async (error: string, options: any) => { return sendMessage(user, message, { _id: options.rid }); }; -const sendSuccessReplyMessage = async (options: any) => { - if (!options?.rid || !options?.msgId) { +const sendSuccessReplyMessage = async (options: { room: IOmnichannelRoom; msgId: string; sender: string }) => { + if (!options?.room?._id || !options?.msgId) { return; } const message = { groupable: false, msg: `@${options.sender} Attachment was sent successfully`, _id: String(Date.now()), - rid: options.rid, + rid: options.room._id, ts: new Date(), }; @@ -60,7 +60,7 @@ const sendSuccessReplyMessage = async (options: any) => { return; } - return sendMessage(user, message, { _id: options.rid }); + return sendMessage(user, message, options.room); }; async function sendEmail(inbox: Inbox, mail: Mail.Options, options?: any): Promise<{ messageId: string }> { @@ -75,7 +75,7 @@ async function sendEmail(inbox: Inbox, mail: Mail.Options, options?: any): Promi ...mail, }) .then((info) => { - logger.info('Message sent: %s', info.messageId); + logger.info({ msg: 'Message sent', info }); return info; }) .catch(async (err) => { @@ -92,7 +92,6 @@ async function sendEmail(inbox: Inbox, mail: Mail.Options, options?: any): Promi slashCommands.add({ command: 'sendEmailAttachment', callback: async ({ command, params }: SlashCommandCallbackParams<'sendEmailAttachment'>) => { - logger.debug('sendEmailAttachment command: ', command, params); if (command !== 'sendEmailAttachment' || !Match.test(params, String)) { return; } @@ -175,7 +174,7 @@ slashCommands.add({ return sendSuccessReplyMessage({ msgId: message._id, sender: message.u.username, - rid: room._id, + room, }); }, options: { @@ -318,7 +317,6 @@ export async function sendTestEmailToInbox(emailInboxRecord: IEmailInbox, user: throw new Error('user-without-verified-email'); } - logger.info(`Sending testing email to ${address}`); void sendEmail(inbox, { to: address, subject: 'Test of inbox configuration', diff --git a/apps/meteor/server/lib/dataExport/sendFile.ts b/apps/meteor/server/lib/dataExport/sendFile.ts index c4f75bd48bff..54c691127d9a 100644 --- a/apps/meteor/server/lib/dataExport/sendFile.ts +++ b/apps/meteor/server/lib/dataExport/sendFile.ts @@ -64,9 +64,11 @@ export const sendFile = async (data: ExportFile, user: IUser): Promise => await exportMessages(); + const promises: Promise[] = []; for await (const attachmentData of fullFileList) { - await copyFileUpload(attachmentData, assetsPath); + promises.push(copyFileUpload(attachmentData, assetsPath)); } + await Promise.all(promises); const exportFile = `${baseDir}-export.zip`; await makeZipFile(exportPath, exportFile); diff --git a/apps/meteor/server/lib/dataExport/uploadZipFile.ts b/apps/meteor/server/lib/dataExport/uploadZipFile.ts index e6a76472db7f..5fe9ea2d57dd 100644 --- a/apps/meteor/server/lib/dataExport/uploadZipFile.ts +++ b/apps/meteor/server/lib/dataExport/uploadZipFile.ts @@ -1,5 +1,5 @@ import { createReadStream } from 'fs'; -import { open, stat } from 'fs/promises'; +import { stat } from 'fs/promises'; import type { IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; @@ -28,9 +28,7 @@ export const uploadZipFile = async (filePath: string, userId: IUser['_id'], expo name: newFileName, }; - const { fd } = await open(filePath); - - const stream = createReadStream('', { fd }); // @todo once upgrades to Node.js v16.x, use createReadStream from fs.promises.open + const stream = createReadStream(filePath); const userDataStore = FileUpload.getStore('UserDataFiles'); diff --git a/apps/meteor/server/lib/getSubscriptionAutotranslateDefaultConfig.ts b/apps/meteor/server/lib/getSubscriptionAutotranslateDefaultConfig.ts index 13540246f0e6..92e76d8c2ec1 100644 --- a/apps/meteor/server/lib/getSubscriptionAutotranslateDefaultConfig.ts +++ b/apps/meteor/server/lib/getSubscriptionAutotranslateDefaultConfig.ts @@ -1,28 +1,23 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import { Settings } from '@rocket.chat/models'; +import type { AtLeast, IUser } from '@rocket.chat/core-typings'; -export const getSubscriptionAutotranslateDefaultConfig = async ( - user: IUser, -): Promise< +import { settings } from '../../app/settings/server'; + +export function getSubscriptionAutotranslateDefaultConfig(user: AtLeast): | { autoTranslate: boolean; autoTranslateLanguage: string; } - | undefined -> => { - const [autoEnableSetting, languageSetting] = await Promise.all([ - Settings.findOneById('AutoTranslate_AutoEnableOnJoinRoom'), - Settings.findOneById('Language'), - ]); - const { language: userLanguage } = user.settings?.preferences || {}; - - if (!autoEnableSetting?.value) { + | undefined { + if (!settings.get('AutoTranslate_AutoEnableOnJoinRoom')) { return; } - if (!userLanguage || userLanguage === 'default' || languageSetting?.value === userLanguage) { + const languageSetting = settings.get('Language'); + + const { language: userLanguage } = user.settings?.preferences || {}; + if (!userLanguage || userLanguage === 'default' || languageSetting === userLanguage) { return; } return { autoTranslate: true, autoTranslateLanguage: userLanguage }; -}; +} diff --git a/apps/meteor/server/lib/migrations.ts b/apps/meteor/server/lib/migrations.ts index da3aeec761e6..f70b5bcca9ff 100644 --- a/apps/meteor/server/lib/migrations.ts +++ b/apps/meteor/server/lib/migrations.ts @@ -292,9 +292,24 @@ export async function migrateDatabase(targetVersion: 'latest' | number, subcomma return true; } -export const onFreshInstall = - (await getControl()).version !== 0 - ? async (): Promise => { - /* noop */ - } - : (fn: () => unknown): unknown => fn(); +export async function onServerVersionChange(cb: () => Promise): Promise { + const result = await Migrations.findOneAndUpdate( + { + _id: 'upgrade', + }, + { + $set: { + hash: Info.commit.hash, + }, + }, + { + upsert: true, + }, + ); + + if (result.value?.hash === Info.commit.hash) { + return; + } + + await cb(); +} diff --git a/apps/meteor/server/lib/readMessages.ts b/apps/meteor/server/lib/readMessages.ts index 00bf04bd3449..d7c8cf559288 100644 --- a/apps/meteor/server/lib/readMessages.ts +++ b/apps/meteor/server/lib/readMessages.ts @@ -6,7 +6,7 @@ import { callbacks } from '../../lib/callbacks'; export async function readMessages(rid: IRoom['_id'], uid: IUser['_id'], readThreads: boolean): Promise { await callbacks.run('beforeReadMessages', rid, uid); - const projection = { ls: 1, tunread: 1, alert: 1 }; + const projection = { ls: 1, tunread: 1, alert: 1, ts: 1 }; const sub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid, { projection }); if (!sub) { throw new Error('error-invalid-subscription'); @@ -19,5 +19,6 @@ export async function readMessages(rid: IRoom['_id'], uid: IUser['_id'], readThr await NotificationQueue.clearQueueByUserId(uid); - callbacks.runAsync('afterReadMessages', rid, { uid, lastSeen: sub.ls }); + const lastSeen = sub.ls || sub.ts; + callbacks.runAsync('afterReadMessages', rid, { uid, lastSeen }); } diff --git a/apps/meteor/server/lib/roles/addUserRoles.ts b/apps/meteor/server/lib/roles/addUserRoles.ts index 395056903ae4..a064553f5cb4 100644 --- a/apps/meteor/server/lib/roles/addUserRoles.ts +++ b/apps/meteor/server/lib/roles/addUserRoles.ts @@ -1,6 +1,6 @@ import { MeteorError } from '@rocket.chat/core-services'; import type { IRole, IUser, IRoom } from '@rocket.chat/core-typings'; -import { Users, Roles } from '@rocket.chat/models'; +import { Roles } from '@rocket.chat/models'; import { validateRoleList } from './validateRoleList'; @@ -9,11 +9,6 @@ export const addUserRolesAsync = async (userId: IUser['_id'], roleIds: IRole['_i return false; } - const user = await Users.findOneById(userId, { projection: { _id: 1 } }); - if (!user) { - throw new MeteorError('error-invalid-user', 'Invalid user'); - } - if (!(await validateRoleList(roleIds))) { throw new MeteorError('error-invalid-role', 'Invalid role'); } diff --git a/apps/meteor/server/lib/rooms/roomTypes/livechat.ts b/apps/meteor/server/lib/rooms/roomTypes/livechat.ts index 88393088541e..92d722ac2bb0 100644 --- a/apps/meteor/server/lib/rooms/roomTypes/livechat.ts +++ b/apps/meteor/server/lib/rooms/roomTypes/livechat.ts @@ -39,7 +39,7 @@ roomCoordinator.add(LivechatRoomType, { }, async getMsgSender(senderId) { - return LivechatVisitors.findOneById(senderId); + return LivechatVisitors.findOneEnabledById(senderId); }, getReadReceiptsExtraData(message) { diff --git a/apps/meteor/server/main.ts b/apps/meteor/server/main.ts index 5579261911f5..b9418fe43830 100644 --- a/apps/meteor/server/main.ts +++ b/apps/meteor/server/main.ts @@ -1,4 +1,5 @@ import './models/startup'; +import './configureLogLevel'; import './settings/index'; import '../ee/server/models/startup'; import './services/startup'; @@ -9,9 +10,10 @@ import './importPackages'; import '../imports/startup/server'; import '../app/lib/server/startup'; +import '../ee/server/startup'; +import './startup'; import '../ee/server'; import './lib/pushConfig'; -import './startup'; import './configuration/accounts_meld'; import './configuration/ldap'; import './methods/OEmbedCacheCleanup'; diff --git a/apps/meteor/server/methods/addAllUserToRoom.ts b/apps/meteor/server/methods/addAllUserToRoom.ts index acba1bed406b..11232908b847 100644 --- a/apps/meteor/server/methods/addAllUserToRoom.ts +++ b/apps/meteor/server/methods/addAllUserToRoom.ts @@ -56,7 +56,7 @@ Meteor.methods({ continue; } await callbacks.run('beforeJoinRoom', user, room); - const autoTranslateConfig = await getSubscriptionAutotranslateDefaultConfig(user); + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(user); await Subscriptions.createWithRoomAndUser(room, user, { ts: now, open: true, diff --git a/apps/meteor/server/methods/createDirectMessage.ts b/apps/meteor/server/methods/createDirectMessage.ts index d92c7e46292e..ccbfe8916cae 100644 --- a/apps/meteor/server/methods/createDirectMessage.ts +++ b/apps/meteor/server/methods/createDirectMessage.ts @@ -104,7 +104,11 @@ export async function createDirectMessage( } catch (error) { throw new Meteor.Error((error as any)?.message); } - const { _id: rid, inserted, ...room } = await createRoom('d', undefined, undefined, roomUsers as IUser[], false, undefined, {}, options); + const { + _id: rid, + inserted, + ...room + } = await createRoom<'d'>('d', undefined, undefined, roomUsers as IUser[], false, undefined, {}, options); return { // @ts-expect-error - room type is already defined in the `createRoom` return type diff --git a/apps/meteor/server/methods/removeUserFromRoom.ts b/apps/meteor/server/methods/removeUserFromRoom.ts index ea5bfa9edcff..2f29b1f55039 100644 --- a/apps/meteor/server/methods/removeUserFromRoom.ts +++ b/apps/meteor/server/methods/removeUserFromRoom.ts @@ -4,7 +4,7 @@ import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { getUsersInRole } from '../../app/authorization/server'; +import { canAccessRoomAsync, getUsersInRole } from '../../app/authorization/server'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { hasRoleAsync } from '../../app/authorization/server/functions/hasRole'; import { RoomMemberActions } from '../../definition/IRoomTypeConfig'; @@ -35,8 +35,6 @@ export const removeUserFromRoomMethod = async (fromId: string, data: { rid: stri }); } - const removedUser = await Users.findOneByUsernameIgnoringCase(data.username); - const fromUser = await Users.findOneById(fromId); if (!fromUser) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { @@ -44,13 +42,25 @@ export const removeUserFromRoomMethod = async (fromId: string, data: { rid: stri }); } - const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, removedUser._id, { - projection: { _id: 1 }, - }); - if (!subscription) { - throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { - method: 'removeUserFromRoom', + // did this way so a ctrl-f would find the permission being used + const kickAnyUserPermission = room.t === 'c' ? 'kick-user-from-any-c-room' : 'kick-user-from-any-p-room'; + + const canKickAnyUser = await hasPermissionAsync(fromId, kickAnyUserPermission); + if (!canKickAnyUser && !(await canAccessRoomAsync(room, fromUser))) { + throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); + } + + const removedUser = await Users.findOneByUsernameIgnoringCase(data.username); + + if (!canKickAnyUser) { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, removedUser._id, { + projection: { _id: 1 }, }); + if (!subscription) { + throw new Meteor.Error('error-user-not-in-room', 'User is not in this room', { + method: 'removeUserFromRoom', + }); + } } if (await hasRoleAsync(removedUser._id, 'owner', room._id)) { diff --git a/apps/meteor/server/methods/saveUserPreferences.ts b/apps/meteor/server/methods/saveUserPreferences.ts index 71abe7bea3b1..814627a745bc 100644 --- a/apps/meteor/server/methods/saveUserPreferences.ts +++ b/apps/meteor/server/methods/saveUserPreferences.ts @@ -86,6 +86,7 @@ export const saveUserPreferences = async (settings: Partial, us fontSize: Match.Optional(String), omnichannelTranscriptEmail: Match.Optional(Boolean), omnichannelTranscriptPDF: Match.Optional(Boolean), + omnichannelHideConversationAfterClosing: Match.Optional(Boolean), notifyCalendarEvents: Match.Optional(Boolean), enableMobileRinging: Match.Optional(Boolean), mentionsWithSymbol: Match.Optional(Boolean), diff --git a/apps/meteor/server/models/CloudAnnouncements.ts b/apps/meteor/server/models/CloudAnnouncements.ts new file mode 100644 index 000000000000..4f6692d67fc9 --- /dev/null +++ b/apps/meteor/server/models/CloudAnnouncements.ts @@ -0,0 +1,6 @@ +import { registerModel } from '@rocket.chat/models'; + +import { db } from '../database/utils'; +import { CloudAnnouncementsRaw } from './raw/CloudAnnouncements'; + +registerModel('ICloudAnnouncementsModel', new CloudAnnouncementsRaw(db)); diff --git a/apps/meteor/server/models/raw/CloudAnnouncements.ts b/apps/meteor/server/models/raw/CloudAnnouncements.ts new file mode 100644 index 000000000000..21b4304b2bd5 --- /dev/null +++ b/apps/meteor/server/models/raw/CloudAnnouncements.ts @@ -0,0 +1,11 @@ +import type { Cloud } from '@rocket.chat/core-typings'; +import type { ICloudAnnouncementsModel } from '@rocket.chat/model-typings'; +import type { Db } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class CloudAnnouncementsRaw extends BaseRaw implements ICloudAnnouncementsModel { + constructor(db: Db) { + super(db, 'cloud_announcements'); + } +} diff --git a/apps/meteor/server/models/raw/LivechatRooms.ts b/apps/meteor/server/models/raw/LivechatRooms.ts index 38eab9056586..974c2b5cb570 100644 --- a/apps/meteor/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/server/models/raw/LivechatRooms.ts @@ -8,6 +8,7 @@ import type { ILivechatPriority, IOmnichannelServiceLevelAgreements, ReportResult, + MACStats, } from '@rocket.chat/core-typings'; import { UserStatus } from '@rocket.chat/core-typings'; import type { ILivechatRoomsModel } from '@rocket.chat/model-typings'; @@ -74,6 +75,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive { key: { departmentId: 1, ts: 1 }, partialFilterExpression: { departmentId: { $exists: true }, t: 'l' } }, { key: { 'tags.0': 1, 'ts': 1 }, partialFilterExpression: { 'tags.0': { $exists: true }, 't': 'l' } }, { key: { servedBy: 1, ts: 1 }, partialFilterExpression: { servedBy: { $exists: true }, t: 'l' } }, + { key: { 'v.activity': 1, 'ts': 1 }, partialFilterExpression: { 'v.activity': { $exists: true }, 't': 'l' } }, ]; } @@ -1516,11 +1518,6 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive { $set: { pdfTranscriptRequested: true }, }, - {}, - // @ts-expect-error - extra arg not on base types - { - bypassUnits: true, - }, ); } @@ -1532,11 +1529,6 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive { $unset: { pdfTranscriptRequested: 1 }, }, - {}, - // @ts-expect-error - extra arg not on base types - { - bypassUnits: true, - }, ); } @@ -1548,11 +1540,6 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive { $set: { pdfTranscriptFileId: fileId }, }, - {}, - // @ts-expect-error - extra arg not on base types - { - bypassUnits: true, - }, ); } @@ -2448,6 +2435,140 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive return this.updateOne(query, update); } + markVisitorActiveForPeriod(rid: string, period: string): Promise { + const query = { + _id: rid, + }; + + const update = { + $addToSet: { + 'v.activity': period, + }, + }; + + return this.updateOne(query, update); + } + + async getMACStatisticsForPeriod(period: string): Promise { + return this.col + .aggregate([ + { + $match: { + 't': 'l', + 'v.activity': period, + }, + }, + { + $group: { + _id: { + source: { + $ifNull: ['$source.alias', '$source.type'], + }, + }, + contactsCount: { + $addToSet: '$v._id', + }, + conversationsCount: { + $sum: 1, + }, + }, + }, + { + $group: { + _id: null, + sources: { + $push: { + source: '$_id.source', + contactsCount: { + $size: '$contactsCount', + }, + conversationsCount: '$conversationsCount', + }, + }, + totalContactsCount: { + $sum: { + $size: '$contactsCount', + }, + }, + totalConversationsCount: { + $sum: '$conversationsCount', + }, + }, + }, + { + $project: { + _id: 0, + contactsCount: '$totalContactsCount', + conversationsCount: '$totalConversationsCount', + sources: 1, + }, + }, + ]) + .toArray(); + } + + async getMACStatisticsBetweenDates(start: Date, end: Date): Promise { + return this.col + .aggregate([ + { + $match: { + 't': 'l', + 'v.activity': { $exists: true }, + 'ts': { + $gte: start, + $lt: end, + }, + }, + }, + { + $group: { + _id: { + source: { + $ifNull: ['$source.alias', '$source.type'], + }, + }, + contactsCount: { + $addToSet: '$v._id', + }, + conversationsCount: { + $sum: 1, + }, + }, + }, + { + $group: { + _id: null, + sources: { + $push: { + source: '$_id.source', + contactsCount: { + $size: '$contactsCount', + }, + conversationsCount: '$conversationsCount', + }, + }, + totalContactsCount: { + $sum: { + $size: '$contactsCount', + }, + }, + totalConversationsCount: { + $sum: '$conversationsCount', + }, + }, + }, + { + $project: { + _id: 0, + contactsCount: '$totalContactsCount', + conversationsCount: '$totalConversationsCount', + sources: 1, + }, + }, + ]) + .toArray(); + } + async unsetAllPredictedVisitorAbandonment(): Promise { throw new Error('Method not implemented.'); } diff --git a/apps/meteor/server/models/raw/LivechatVisitors.ts b/apps/meteor/server/models/raw/LivechatVisitors.ts index 2df2ae09882b..7b478bab43d6 100644 --- a/apps/meteor/server/models/raw/LivechatVisitors.ts +++ b/apps/meteor/server/models/raw/LivechatVisitors.ts @@ -32,6 +32,8 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL { key: { username: 1 } }, { key: { 'contactMananger.username': 1 }, sparse: true }, { key: { 'livechatData.$**': 1 } }, + { key: { activity: 1 }, partialFilterExpression: { activity: { $exists: true } } }, + { key: { disabled: 1 }, partialFilterExpression: { disabled: { $exists: true } } }, ]; } @@ -63,9 +65,29 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.find(query, options); } + findEnabled(query: Filter, options?: FindOptions): FindCursor { + return this.find( + { + ...query, + disabled: { $ne: true }, + }, + options, + ); + } + + findOneEnabledById(_id: string, options?: FindOptions): Promise { + const query = { + _id, + disabled: { $ne: true }, + }; + + return this.findOne(query, options); + } + findVisitorByToken(token: string): FindCursor { const query = { token, + disabled: { $ne: true }, }; return this.find(query); @@ -81,6 +103,7 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL getVisitorsBetweenDate({ start, end, department }: { start: Date; end: Date; department?: string }): FindCursor { const query = { + disabled: { $ne: true }, _updatedAt: { $gte: new Date(start), $lt: new Date(end), @@ -166,7 +189,7 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL options?: FindOptions, ): Promise>> { if (!emailOrPhone && !nameOrUsername && allowedCustomFields.length === 0) { - return this.findPaginated({}, options); + return this.findPaginated({ disabled: { $ne: true } }, options); } const query: Filter = { @@ -193,6 +216,7 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL : []), ...allowedCustomFields.map((c: string) => ({ [`livechatData.${c}`]: nameOrUsername })), ], + disabled: { $ne: true }, }; return this.findPaginated(query, options); @@ -204,7 +228,9 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL customFields?: { [key: string]: RegExp }, ): Promise { const query = Object.assign( - {}, + { + disabled: { $ne: true }, + }, { ...(email && { visitorEmails: { address: email } }), ...(phone && { phone: { phoneNumber: phone } }), @@ -212,7 +238,7 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL }, ); - if (Object.keys(query).length === 0) { + if (Object.keys(query).length === 1) { return null; } @@ -365,6 +391,60 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL }, ); } + + isVisitorActiveOnPeriod(visitorId: string, period: string): Promise { + const query = { + _id: visitorId, + activity: period, + }; + + return this.findOne(query, { projection: { _id: 1 } }).then(Boolean); + } + + markVisitorActiveForPeriod(visitorId: string, period: string): Promise { + const query = { + _id: visitorId, + }; + + const update = { + $push: { + activity: { + $each: [period], + $slice: -12, + }, + }, + }; + + return this.updateOne(query, update); + } + + disableById(_id: string): Promise { + return this.updateOne( + { _id }, + { + $set: { disabled: true }, + $unset: { + department: 1, + contactManager: 1, + token: 1, + visitorEmails: 1, + phone: 1, + name: 1, + livechatData: 1, + lastChat: 1, + ip: 1, + host: 1, + userAgent: 1, + }, + }, + ); + } + + countVisitorsOnPeriod(period: string): Promise { + return this.countDocuments({ + activity: period, + }); + } } type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; diff --git a/apps/meteor/server/models/raw/Rooms.ts b/apps/meteor/server/models/raw/Rooms.ts index 673fd83f7800..8b8a6637284c 100644 --- a/apps/meteor/server/models/raw/Rooms.ts +++ b/apps/meteor/server/models/raw/Rooms.ts @@ -678,6 +678,16 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { ); } + findE2ERoomById(roomId: IRoom['_id'], options: FindOptions = {}): Promise { + return this.findOne( + { + _id: roomId, + encrypted: true, + }, + options, + ); + } + findRoomsInsideTeams(autoJoin = false): FindCursor { return this.find({ teamId: { $exists: true }, diff --git a/apps/meteor/server/models/raw/Settings.ts b/apps/meteor/server/models/raw/Settings.ts index 3a5d150c0158..1154b7dfe630 100644 --- a/apps/meteor/server/models/raw/Settings.ts +++ b/apps/meteor/server/models/raw/Settings.ts @@ -69,6 +69,25 @@ export class SettingsRaw extends BaseRaw implements ISettingsModel { return this.updateOne(query, update); } + async resetValueById( + _id: string, + value?: (ISetting['value'] extends undefined ? never : ISetting['value']) | null, + ): Promise { + if (value == null) { + const record = await this.findOneById(_id); + if (record) { + const prop = record.valueSource || 'packageValue'; + value = record[prop]; + } + } + + if (value == null) { + return; + } + + return this.updateValueById(_id, value); + } + async incrementValueById(_id: ISetting['_id'], value = 1): Promise { return this.updateOne( { diff --git a/apps/meteor/server/models/raw/Statistics.ts b/apps/meteor/server/models/raw/Statistics.ts index 1ad0ab993910..bad44ee07c23 100644 --- a/apps/meteor/server/models/raw/Statistics.ts +++ b/apps/meteor/server/models/raw/Statistics.ts @@ -25,4 +25,25 @@ export class StatisticsRaw extends BaseRaw implements IStatisticsModel { ).toArray(); return records?.[0]; } + + async findMonthlyPeakConnections() { + const oneMonthAgo = new Date(); + oneMonthAgo.setDate(oneMonthAgo.getDate() - 30); + oneMonthAgo.setHours(0, 0, 0, 0); + + return this.findOne>( + { + createdAt: { $gte: oneMonthAgo }, + }, + { + sort: { + dailyPeakConnections: -1, + }, + projection: { + dailyPeakConnections: 1, + createdAt: 1, + }, + }, + ); + } } diff --git a/apps/meteor/server/models/raw/Subscriptions.ts b/apps/meteor/server/models/raw/Subscriptions.ts index 4b42367bad05..a7b9bb347511 100644 --- a/apps/meteor/server/models/raw/Subscriptions.ts +++ b/apps/meteor/server/models/raw/Subscriptions.ts @@ -1,4 +1,13 @@ -import type { IRole, IRoom, ISubscription, IUser, RocketChatRecordDeleted, RoomType, SpotlightUser } from '@rocket.chat/core-typings'; +import type { + AtLeast, + IRole, + IRoom, + ISubscription, + IUser, + RocketChatRecordDeleted, + RoomType, + SpotlightUser, +} from '@rocket.chat/core-typings'; import type { ISubscriptionsModel } from '@rocket.chat/model-typings'; import { Rooms, Users } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; @@ -17,6 +26,7 @@ import type { IndexDescription, UpdateFilter, InsertOneResult, + InsertManyResult, } from 'mongodb'; import { getDefaultSubscriptionPref } from '../../../app/utils/lib/getDefaultSubscriptionPref'; @@ -594,6 +604,14 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateOne(query, update); } + disableAutoTranslateByRoomId(roomId: IRoom['_id']): Promise { + const query = { + rid: roomId, + }; + + return this.updateMany(query, { $unset: { autoTranslate: 1 } }); + } + updateAutoTranslateLanguageById(_id: string, autoTranslateLanguage: string): Promise { const query = { _id, @@ -1605,6 +1623,38 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return result; } + async createWithRoomAndManyUsers( + room: IRoom, + users: { user: AtLeast; extraData: Record }[] = [], + ): Promise> { + const subscriptions = users.map(({ user, extraData }) => ({ + open: false, + alert: false, + unread: 0, + userMentions: 0, + groupMentions: 0, + ts: room.ts, + rid: room._id, + name: room.name, + fname: room.fname, + ...(room.customFields && { customFields: room.customFields }), + t: room.t, + u: { + _id: user._id, + username: user.username, + name: user.name, + }, + ...(room.prid && { prid: room.prid }), + ...getDefaultSubscriptionPref(user), + ...extraData, + })); + + // @ts-expect-error - types not good :( + const result = await this.insertMany(subscriptions); + + return result; + } + // REMOVE async removeByUserId(userId: string): Promise { const query = { diff --git a/apps/meteor/server/models/raw/Users.js b/apps/meteor/server/models/raw/Users.js index 0663bbdcda28..c8cee8f2f6bf 100644 --- a/apps/meteor/server/models/raw/Users.js +++ b/apps/meteor/server/models/raw/Users.js @@ -384,6 +384,10 @@ export class UsersRaw extends BaseRaw { } findOneByUsernameIgnoringCase(username, options) { + if (!username) { + throw new Error('invalid username'); + } + const query = { username }; return this.findOne(query, { @@ -1488,6 +1492,18 @@ export class UsersRaw extends BaseRaw { ); } + addRoomByUserIds(uids, rid) { + return this.updateMany( + { + _id: { $in: uids }, + __rooms: { $ne: rid }, + }, + { + $addToSet: { __rooms: rid }, + }, + ); + } + removeRoomByRoomIds(rids) { return this.updateMany( { @@ -2171,7 +2187,6 @@ export class UsersRaw extends BaseRaw { { active: true, type: { $nin: ['app'] }, - roles: { $ne: ['guest'] }, _id: { $in: ids }, }, options, diff --git a/apps/meteor/server/models/startup.ts b/apps/meteor/server/models/startup.ts index 14b26e0f188f..d355d1febd16 100644 --- a/apps/meteor/server/models/startup.ts +++ b/apps/meteor/server/models/startup.ts @@ -68,3 +68,4 @@ import './Imports'; import './AppsTokens'; import './CronHistory'; import './Migrations'; +import './CloudAnnouncements'; diff --git a/apps/meteor/server/modules/core-apps/banner.module.ts b/apps/meteor/server/modules/core-apps/banner.module.ts index bc850fea2078..fac891e5ea73 100644 --- a/apps/meteor/server/modules/core-apps/banner.module.ts +++ b/apps/meteor/server/modules/core-apps/banner.module.ts @@ -1,18 +1,24 @@ import { Banner } from '@rocket.chat/core-services'; -import type { IUiKitCoreApp } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; export class BannerModule implements IUiKitCoreApp { appId = 'banner-core'; // when banner view is closed we need to dissmiss that banner for that user - async viewClosed(payload: any): Promise { + async viewClosed(payload: UiKitCoreAppPayload) { const { - payload: { - view: { viewId: bannerId }, - }, - user: { _id: userId }, + payload: { view: { viewId: bannerId } = {} }, + user: { _id: userId } = {}, } = payload; + if (!userId) { + throw new Error('invalid user'); + } + + if (!bannerId) { + throw new Error('invalid banner'); + } + return Banner.dismiss(userId, bannerId); } } diff --git a/apps/meteor/server/modules/core-apps/nps.module.ts b/apps/meteor/server/modules/core-apps/nps.module.ts index 68ebeffd97c2..6e8965122df3 100644 --- a/apps/meteor/server/modules/core-apps/nps.module.ts +++ b/apps/meteor/server/modules/core-apps/nps.module.ts @@ -1,4 +1,4 @@ -import type { IUiKitCoreApp } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; import { Banner, NPS } from '@rocket.chat/core-services'; import { createModal } from './nps/createModal'; @@ -6,15 +6,19 @@ import { createModal } from './nps/createModal'; export class Nps implements IUiKitCoreApp { appId = 'nps-core'; - async blockAction(payload: any): Promise { + async blockAction(payload: UiKitCoreAppPayload) { const { triggerId, actionId, - container: { id: viewId }, + container: { id: viewId } = {}, payload: { value: score, blockId: npsId }, user, } = payload; + if (!viewId || !triggerId || !user || !npsId) { + throw new Error('Invalid payload'); + } + const bannerId = viewId.replace(`${npsId}-`, ''); return createModal({ @@ -23,13 +27,13 @@ export class Nps implements IUiKitCoreApp { appId: this.appId, npsId, triggerId, - score, + score: String(score), user, }); } - async viewSubmit(payload: any): Promise { - if (!payload.payload?.view?.state) { + async viewSubmit(payload: UiKitCoreAppPayload) { + if (!payload.payload?.view?.state || !payload.payload?.view?.id) { throw new Error('Invalid payload'); } @@ -37,7 +41,7 @@ export class Nps implements IUiKitCoreApp { payload: { view: { state, id: viewId }, }, - user: { _id: userId, roles }, + user: { _id: userId, roles } = {}, } = payload; const [npsId] = Object.keys(state); @@ -51,11 +55,15 @@ export class Nps implements IUiKitCoreApp { await NPS.vote({ npsId, userId, - comment, + comment: String(comment), roles, - score, + score: Number(score), }); + if (!userId) { + throw new Error('invalid user'); + } + await Banner.dismiss(userId, bannerId); return true; diff --git a/apps/meteor/server/modules/core-apps/videoconf.module.ts b/apps/meteor/server/modules/core-apps/videoconf.module.ts index b0425f6ffd55..694a0fac9b8e 100644 --- a/apps/meteor/server/modules/core-apps/videoconf.module.ts +++ b/apps/meteor/server/modules/core-apps/videoconf.module.ts @@ -1,4 +1,4 @@ -import type { IUiKitCoreApp } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, UiKitCoreAppPayload } from '@rocket.chat/core-services'; import { VideoConf } from '@rocket.chat/core-services'; import { i18n } from '../../lib/i18n'; @@ -6,14 +6,18 @@ import { i18n } from '../../lib/i18n'; export class VideoConfModule implements IUiKitCoreApp { appId = 'videoconf-core'; - async blockAction(payload: any): Promise { + async blockAction(payload: UiKitCoreAppPayload) { const { triggerId, actionId, payload: { blockId: callId }, - user: { _id: userId }, + user: { _id: userId } = {}, } = payload; + if (!callId) { + throw new Error('invalid call'); + } + if (actionId === 'join') { await VideoConf.join(userId, callId, {}); } diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index f21081e43d0a..5d6dcbbad30f 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -29,6 +29,9 @@ export class ListenersModule { constructor(service: IServiceClass, notifications: NotificationsModule) { const logger = new Logger('ListenersModule'); + service.onEvent('license.sync', () => notifications.notifyAllInThisInstance('license')); + service.onEvent('license.actions', () => notifications.notifyAllInThisInstance('license')); + service.onEvent('emoji.deleteCustom', (emoji) => { notifications.notifyLoggedInThisInstance('deleteEmojiCustom', { emojiData: emoji, @@ -471,5 +474,13 @@ export class ListenersModule { notifications.streamApps.emitWithoutBroadcast('actions/changed'); notifications.streamApps.emitWithoutBroadcast('apps', ['actions/changed', []]); }); + + service.onEvent('mac.limitReached', () => { + notifications.notifyLoggedInThisInstance('mac.limit', { limitReached: true }); + }); + + service.onEvent('mac.limitRestored', () => { + notifications.notifyLoggedInThisInstance('mac.limit', { limitReached: false }); + }); } } diff --git a/apps/meteor/server/services/banner/service.ts b/apps/meteor/server/services/banner/service.ts index 4dc0dbbec494..d20b9e780875 100644 --- a/apps/meteor/server/services/banner/service.ts +++ b/apps/meteor/server/services/banner/service.ts @@ -26,7 +26,7 @@ export class BannerService extends ServiceClassInternal implements IBannerServic return true; } - async create(doc: Optional): Promise { + async create(doc: Optional): Promise { const bannerId = doc._id || uuidv4(); doc.view.appId = 'banner-core'; diff --git a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Room.ts b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Room.ts index 018a5f87704c..c4aee8bcf2aa 100644 --- a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Room.ts +++ b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Room.ts @@ -58,7 +58,12 @@ export class RocketChatRoomAdapter { .trim() .replace(/ /g, '-'), ); - const { rid, _id } = await createRoom(federatedRoom.getRoomType(), roomName, usernameOrId); + const owner = await Users.findOneByUsernameIgnoringCase(usernameOrId); + if (!owner) { + throw new Error('Cannot create a room without a creator'); + } + + const { rid, _id } = await createRoom(federatedRoom.getRoomType(), roomName, owner); const roomId = rid || _id; await MatrixBridgedRoom.createOrUpdateByLocalRoomId( roomId, @@ -90,10 +95,16 @@ export class RocketChatRoomAdapter { const readonly = false; const excludeSelf = false; const extraData = undefined; + + const owner = await Users.findOneByUsernameIgnoringCase(usernameOrId); + if (!owner) { + throw new Error('Cannot create a room without a creator'); + } + const { rid, _id } = await createRoom( federatedRoom.getRoomType(), federatedRoom.getDisplayName(), - usernameOrId, + owner, federatedRoom.getMembersUsernames(), excludeSelf, readonly, diff --git a/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts b/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts index 02a3c29eedf3..8e4c06941c81 100644 --- a/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts +++ b/apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts @@ -1,5 +1,5 @@ import { Banner } from '@rocket.chat/core-services'; -import type { UiKitBannerPayload, IBanner, BannerPlatform } from '@rocket.chat/core-typings'; +import type { UiKit, IBanner, BannerPlatform } from '@rocket.chat/core-typings'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { getWorkspaceAccessToken } from '../../../app/cloud/server'; @@ -10,7 +10,7 @@ type NpsSurveyData = { id: string; platform: BannerPlatform[]; roles: string[]; - survey: UiKitBannerPayload; + survey: UiKit.BannerView; createdAt: Date; startAt: Date; expireAt: Date; diff --git a/apps/meteor/server/services/nps/notification.ts b/apps/meteor/server/services/nps/notification.ts index 91ed3c7d2671..692b9bc6291f 100644 --- a/apps/meteor/server/services/nps/notification.ts +++ b/apps/meteor/server/services/nps/notification.ts @@ -1,5 +1,3 @@ -import { BlockType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; -import { TextObjectType } from '@rocket.chat/apps-engine/definition/uikit/blocks/Objects'; import type { IBanner } from '@rocket.chat/core-typings'; import { BannerPlatform } from '@rocket.chat/core-typings'; import moment from 'moment'; @@ -27,10 +25,10 @@ export const getBannerForAdmins = (expireAt: Date): Omit => { appId: '', blocks: [ { - type: BlockType.SECTION, + type: 'section', blockId: 'attention', text: { - type: TextObjectType.PLAINTEXT, + type: 'plain_text', text: i18n.t('NPS_survey_is_scheduled_to-run-at__date__for_all_users', { date: moment(expireAt).format('YYYY-MM-DD'), lng, diff --git a/apps/meteor/server/services/omnichannel-voip/service.ts b/apps/meteor/server/services/omnichannel-voip/service.ts index 532bb5d245e9..3e492a4d6514 100644 --- a/apps/meteor/server/services/omnichannel-voip/service.ts +++ b/apps/meteor/server/services/omnichannel-voip/service.ts @@ -32,10 +32,8 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn // handle agent disconnections this.onEvent('watch.pbxevents', async ({ data }) => { - this.logger.debug(`Get event watch.pbxevents on service`); const extension = data.agentExtension; if (!extension) { - this.logger.debug(`No agent extension associated with the event. Skipping`); return; } switch (data.event) { @@ -53,12 +51,12 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn this.logger.info(`Processing hangup event for call with agent on extension ${extension}`); const agent = await Users.findOneByExtension(extension); if (!agent) { - this.logger.debug(`No agent found with extension ${extension}. Event won't proceed`); + this.logger.error(`No agent found with extension ${extension}. Event won't proceed`); return; } const currentRoom = await VoipRoom.findOneByAgentId(agent._id); if (!currentRoom) { - this.logger.debug(`No active call found for agent ${agent._id}`); + this.logger.error(`No active call found for agent ${agent._id}`); return; } this.logger.debug(`Notifying agent ${agent._id} of hangup on room ${currentRoom._id}`); @@ -69,7 +67,7 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn this.logger.info(`Processing disconnection event for agent with extension ${extension}`); const agent = await Users.findOneByExtension(extension); if (!agent) { - this.logger.debug(`No agent found with extension ${extension}. Event won't proceed`); + this.logger.error(`No agent found with extension ${extension}. Event won't proceed`); // this should not even be possible, but just in case return; } @@ -96,8 +94,6 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn const { _id, department: departmentId } = guest; const newRoomAt = new Date(); - this.logger.debug(`Creating Voip room for visitor ${_id}`); - /** * This is a peculiar case for outbound. In case of outbound, * the room is created as soon as the remote use accepts a call. @@ -182,7 +178,6 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn _updatedAt: newRoomAt, }; - this.logger.debug(`Room created for visitor ${_id}`); return (await VoipRoom.insertOne(room)).insertedId; } @@ -234,11 +229,9 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn direction: IVoipRoom['direction'], options: FindOptions = {}, ): Promise { - this.logger.debug(`Attempting to find or create a room for visitor ${guest._id}`); let room = await VoipRoom.findOneById(rid, options); let newRoom = false; if (room && !room.open) { - this.logger.debug(`Last room for visitor ${guest._id} closed. Creating new one`); room = null; } if (room == null) { @@ -246,10 +239,8 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn const roomId = await this.createVoipRoom(rid, name, agent, guest, direction); room = await VoipRoom.findOneVoipRoomById(roomId); newRoom = true; - this.logger.debug(`Room obtained for visitor ${guest._id} -> ${room?._id}`); } if (!room) { - this.logger.debug(`Visitor ${guest._id} trying to access another visitor's room`); throw new Error('cannot-access-room'); } return { @@ -281,7 +272,6 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn sysMessageId: 'voip-call-wrapup' | 'voip-call-ended-unexpectedly' = 'voip-call-wrapup', options?: { comment?: string; tags?: string[] }, ): Promise { - this.logger.debug(`Attempting to close room ${room._id}`); if (!room || room.t !== 'v' || !room.open) { return false; } @@ -298,8 +288,6 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn // For now, this data will be appended as a metric on room closing await this.setCallWaitingQueueTimers(room); - this.logger.debug(`Room ${room._id} closed and timers set`); - this.logger.debug(`Room ${room._id} was closed at ${closeInfo.closedAt} (duration ${closeInfo.callDuration})`); await VoipRoom.closeByRoomId(room._id, closeInfo); return true; @@ -452,8 +440,6 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn }, }; - this.logger.debug(`Handling event ${event} on room ${room._id}`); - if ( isVoipRoom(room) && room.open && @@ -461,7 +447,6 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn // Check if call exists by looking if we have pbx events of it (await PbxEvents.findOneByUniqueId(room.callUniqueId)) ) { - this.logger.debug(`Room is valid. Sending event ${event}`); await sendMessage(user, message, room); } else { this.logger.warn({ msg: 'Invalid room type or event type', type: room.t, event }); diff --git a/apps/meteor/server/services/omnichannel/queue.ts b/apps/meteor/server/services/omnichannel/queue.ts index 684c10161a94..8bb2e86f2313 100644 --- a/apps/meteor/server/services/omnichannel/queue.ts +++ b/apps/meteor/server/services/omnichannel/queue.ts @@ -1,4 +1,5 @@ import type { InquiryWithAgentInfo, IOmnichannelQueue } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { LivechatInquiry } from '@rocket.chat/models'; import { dispatchAgentDelegated } from '../../../app/livechat/server/lib/Helper'; @@ -19,25 +20,28 @@ export class OmnichannelQueue implements IOmnichannelQueue { return timeout < 1 ? DEFAULT_RACE_TIMEOUT : timeout * 1000; } + public isRunning() { + return this.running; + } + async start() { - queueLogger.debug('Starting queue'); if (this.running) { - queueLogger.debug('Queue already running'); return; } const activeQueues = await this.getActiveQueues(); queueLogger.debug(`Active queues: ${activeQueues.length}`); - this.running = true; + + queueLogger.info('Service started'); return this.execute(); } async stop() { - queueLogger.debug('Stopping queue'); await LivechatInquiry.unlockAll(); this.running = false; + queueLogger.info('Service stopped'); } private async getActiveQueues() { @@ -62,7 +66,7 @@ export class OmnichannelQueue implements IOmnichannelQueue { const queue = await this.nextQueue(); const queueDelayTimeout = this.delay(); - queueLogger.debug(`Executing queue ${queue || 'Public'} with timeout of ${queueDelayTimeout}`); + queueLogger.info(`Executing queue ${queue || 'Public'} with timeout of ${queueDelayTimeout}`); setTimeout(this.checkQueue.bind(this, queue), queueDelayTimeout); } @@ -95,12 +99,16 @@ export class OmnichannelQueue implements IOmnichannelQueue { } } - shouldStart() { + async shouldStart() { if (!settings.get('Livechat_enabled')) { void this.stop(); return; } + if (await License.shouldPreventAction('monthlyActiveContacts')) { + return; + } + const routingSupportsAutoAssign = RoutingManager.getConfig()?.autoAssignAgent; queueLogger.debug({ msg: 'Routing method supports auto assignment', diff --git a/apps/meteor/server/services/omnichannel/service.ts b/apps/meteor/server/services/omnichannel/service.ts index 7f35de104e1c..158d97ccee1d 100644 --- a/apps/meteor/server/services/omnichannel/service.ts +++ b/apps/meteor/server/services/omnichannel/service.ts @@ -1,8 +1,10 @@ import { ServiceClassInternal } from '@rocket.chat/core-services'; import type { IOmnichannelService } from '@rocket.chat/core-services'; -import type { IOmnichannelQueue } from '@rocket.chat/core-typings'; +import type { AtLeast, IOmnichannelQueue, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import moment from 'moment'; -import { Livechat } from '../../../app/livechat/server'; +import { Livechat } from '../../../app/livechat/server/lib/LivechatTyped'; import { RoutingManager } from '../../../app/livechat/server/lib/RoutingManager'; import { settings } from '../../../app/settings/server'; import { OmnichannelQueue } from './queue'; @@ -12,6 +14,8 @@ export class OmnichannelService extends ServiceClassInternal implements IOmnicha private queueWorker: IOmnichannelQueue; + private macLimitReached = false; + constructor() { super(); this.queueWorker = new OmnichannelQueue(); @@ -34,9 +38,31 @@ export class OmnichannelService extends ServiceClassInternal implements IOmnicha settings.watch('Livechat_enabled', (enabled) => { void (enabled && RoutingManager.isMethodSet() ? this.queueWorker.shouldStart() : this.queueWorker.stop()); }); + + License.onLimitReached('monthlyActiveContacts', async (): Promise => { + if (this.macLimitReached) { + // Dupe events + return; + } + + this.macLimitReached = true; + void this.api?.broadcast('mac.limitReached'); + this.queueWorker.isRunning() && (await this.queueWorker.stop()); + }); + + License.onValidateLicense(async (): Promise => { + this.macLimitReached = false; + void this.api?.broadcast('mac.limitRestored'); + RoutingManager.isMethodSet() && (await this.queueWorker.shouldStart()); + }); } getQueueWorker(): IOmnichannelQueue { return this.queueWorker; } + + async isWithinMACLimit(room: AtLeast): Promise { + const currentMonth = moment.utc().format('YYYY-MM'); + return room.v?.activity?.includes(currentMonth) || !(await License.shouldPreventAction('monthlyActiveContacts')); + } } diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 61b5bfeee504..7b9b85cecbd0 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -23,15 +23,13 @@ export class RoomService extends ServiceClassInternal implements IRoomService { throw new Error('no-permission'); } - const user = await Users.findOneById>(uid, { - projection: { username: 1 }, - }); + const user = await Users.findOneById(uid); if (!user?.username) { throw new Error('User not found'); } // TODO convert `createRoom` function to "raw" and move to here - return createRoom(type, name, user.username, members, false, readOnly, extraData, options) as unknown as IRoom; + return createRoom(type, name, user, members, false, readOnly, extraData, options) as unknown as IRoom; } async createDirectMessage({ to, from }: { to: string; from: string }): Promise<{ rid: string }> { diff --git a/apps/meteor/server/services/startup.ts b/apps/meteor/server/services/startup.ts index 968415620558..28ab5a35b553 100644 --- a/apps/meteor/server/services/startup.ts +++ b/apps/meteor/server/services/startup.ts @@ -25,7 +25,7 @@ import { SAUMonitorService } from './sauMonitor/service'; import { SettingsService } from './settings/service'; import { TeamService } from './team/service'; import { TranslationService } from './translation/service'; -import { UiKitCoreApp } from './uikit-core-app/service'; +import { UiKitCoreAppService } from './uikit-core-app/service'; import { UploadService } from './upload/service'; import { VideoConfService } from './video-conference/service'; import { VoipService } from './voip/service'; @@ -47,7 +47,7 @@ api.registerService(new VoipService(db)); api.registerService(new OmnichannelService()); api.registerService(new OmnichannelVoipService()); api.registerService(new TeamService()); -api.registerService(new UiKitCoreApp()); +api.registerService(new UiKitCoreAppService()); api.registerService(new PushService()); api.registerService(new DeviceManagementService()); api.registerService(new VideoConfService()); diff --git a/apps/meteor/server/services/uikit-core-app/service.ts b/apps/meteor/server/services/uikit-core-app/service.ts index a9eddf69ce81..a842a4854c6a 100644 --- a/apps/meteor/server/services/uikit-core-app/service.ts +++ b/apps/meteor/server/services/uikit-core-app/service.ts @@ -1,9 +1,9 @@ import { ServiceClassInternal } from '@rocket.chat/core-services'; -import type { IUiKitCoreApp, IUiKitCoreAppService } from '@rocket.chat/core-services'; +import type { IUiKitCoreApp, IUiKitCoreAppService, UiKitCoreAppPayload } from '@rocket.chat/core-services'; -const registeredApps = new Map(); +const registeredApps = new Map(); -const getAppModule = (appId: string): any => { +const getAppModule = (appId: string) => { const module = registeredApps.get(appId); if (typeof module === 'undefined') { @@ -17,14 +17,14 @@ export const registerCoreApp = (module: IUiKitCoreApp): void => { registeredApps.set(module.appId, module); }; -export class UiKitCoreApp extends ServiceClassInternal implements IUiKitCoreAppService { +export class UiKitCoreAppService extends ServiceClassInternal implements IUiKitCoreAppService { protected name = 'uikit-core-app'; async isRegistered(appId: string): Promise { return registeredApps.has(appId); } - async blockAction(payload: any): Promise { + async blockAction(payload: UiKitCoreAppPayload) { const { appId } = payload; const service = getAppModule(appId); @@ -35,7 +35,7 @@ export class UiKitCoreApp extends ServiceClassInternal implements IUiKitCoreAppS return service.blockAction?.(payload); } - async viewClosed(payload: any): Promise { + async viewClosed(payload: UiKitCoreAppPayload) { const { appId } = payload; const service = getAppModule(appId); @@ -46,7 +46,7 @@ export class UiKitCoreApp extends ServiceClassInternal implements IUiKitCoreAppS return service.viewClosed?.(payload); } - async viewSubmit(payload: any): Promise { + async viewSubmit(payload: UiKitCoreAppPayload) { const { appId } = payload; const service = getAppModule(appId); diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 77cdc1cbd8e0..818280fd4d31 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1,4 +1,3 @@ -import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; import type { AppVideoConfProviderManager } from '@rocket.chat/apps-engine/server/managers'; import type { IVideoConfService, VideoConferenceJoinOptions } from '@rocket.chat/core-services'; import { api, ServiceClassInternal } from '@rocket.chat/core-services'; @@ -20,6 +19,7 @@ import type { VideoConferenceCapabilities, VideoConferenceCreateData, Optional, + UiKit, } from '@rocket.chat/core-typings'; import { VideoConferenceStatus, @@ -136,7 +136,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return this.joinCall(call, user || undefined, options); } - public async getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise { + public async getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise { const call = await VideoConferenceModel.findOneById(callId); if (!call) { throw new Error('invalid-call'); @@ -162,7 +162,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf }); if (blocks?.length) { - return blocks; + return blocks as UiKit.LayoutBlock[]; } return [ @@ -173,7 +173,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf type: 'mrkdwn', text: `**${i18n.t('Video_Conference_Url')}**: ${call.url}`, }, - } as IBlock, + }, ]; } @@ -618,6 +618,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf caller: call.createdBy, avatar: getUserAvatarURL(call.createdBy.username), status: call.status, + callId: call._id, }, userId: calleeId, notId: PushNotification.getNotificationId(`${call.rid}|${call._id}`), diff --git a/apps/meteor/server/settings/accounts.ts b/apps/meteor/server/settings/accounts.ts index ccc87b0ffd24..ba031c9210d5 100644 --- a/apps/meteor/server/settings/accounts.ts +++ b/apps/meteor/server/settings/accounts.ts @@ -282,13 +282,6 @@ export const createAccountSettings = () => await this.add('Accounts_EmailVerification', false, { type: 'boolean', public: true, - enableQuery: { - _id: 'SMTP_Host', - value: { - $exists: true, - $ne: '', - }, - }, }); await this.add('Accounts_Verify_Email_For_External_Accounts', true, { type: 'boolean', diff --git a/apps/meteor/server/settings/misc.ts b/apps/meteor/server/settings/misc.ts index 127d0e6e97ba..fa7b6bbde3d0 100644 --- a/apps/meteor/server/settings/misc.ts +++ b/apps/meteor/server/settings/misc.ts @@ -1,11 +1,70 @@ -import { Random } from '@rocket.chat/random'; +import crypto from 'crypto'; -import { settingsRegistry } from '../../app/settings/server'; +import { Logger } from '@rocket.chat/logger'; +import { Settings } from '@rocket.chat/models'; +import { v4 as uuidv4 } from 'uuid'; + +import { settingsRegistry, settings } from '../../app/settings/server'; + +const logger = new Logger('FingerPrint'); + +const generateFingerprint = function () { + const siteUrl = settings.get('Site_Url'); + const dbConnectionString = process.env.MONGO_URL; + + const fingerprint = `${siteUrl}${dbConnectionString}`; + return crypto.createHash('sha256').update(fingerprint).digest('base64'); +}; + +const updateFingerprint = async function (fingerprint: string, verified: boolean) { + await Settings.updateValueById('Deployment_FingerPrint_Hash', fingerprint); + + await Settings.updateValueById('Deployment_FingerPrint_Verified', verified); +}; + +const verifyFingerPrint = async function () { + const DeploymentFingerPrintRecordHash = await Settings.getValueById('Deployment_FingerPrint_Hash'); + + const fingerprint = generateFingerprint(); + + if (!DeploymentFingerPrintRecordHash) { + logger.info('Generating fingerprint for the first time', fingerprint); + await updateFingerprint(fingerprint, true); + return; + } + + if (DeploymentFingerPrintRecordHash === fingerprint) { + return; + } + + if (process.env.AUTO_ACCEPT_FINGERPRINT === 'true') { + logger.info('Updating fingerprint as AUTO_ACCEPT_FINGERPRINT is true', fingerprint); + await updateFingerprint(fingerprint, true); + } + + logger.warn('Updating fingerprint as pending for admin verification', fingerprint); + await updateFingerprint(fingerprint, false); +}; + +settings.watch('Site_Url', () => { + void verifyFingerPrint(); +}); // Insert server unique id if it doesn't exist export const createMiscSettings = async () => { - await settingsRegistry.add('uniqueID', process.env.DEPLOYMENT_ID || Random.id(), { + await settingsRegistry.add('uniqueID', process.env.DEPLOYMENT_ID || uuidv4(), { + public: true, + }); + + await settingsRegistry.add('Deployment_FingerPrint_Hash', '', { + public: false, + readonly: true, + }); + + await settingsRegistry.add('Deployment_FingerPrint_Verified', false, { + type: 'boolean', public: true, + readonly: true, }); await settingsRegistry.add('Initial_Channel_Created', false, { diff --git a/apps/meteor/server/settings/omnichannel.ts b/apps/meteor/server/settings/omnichannel.ts index fe5d27c1e677..cc9da5474862 100644 --- a/apps/meteor/server/settings/omnichannel.ts +++ b/apps/meteor/server/settings/omnichannel.ts @@ -778,7 +778,7 @@ await settingsRegistry.addGroup('SMS', async function () { i18nLabel: 'Mobex_sms_gateway_password', }); await this.add('SMS_Mobex_from_number', '', { - type: 'int', + type: 'string', enableQuery: { _id: 'SMS_Service', value: 'mobex', diff --git a/apps/meteor/server/settings/setup-wizard.ts b/apps/meteor/server/settings/setup-wizard.ts index 62da3f1471cf..3d2472cc84d0 100644 --- a/apps/meteor/server/settings/setup-wizard.ts +++ b/apps/meteor/server/settings/setup-wizard.ts @@ -34,6 +34,7 @@ export const createSetupWSettings = () => step: 2, order: 1, }, + public: true, }); await this.add('Industry', '', { type: 'select', @@ -1270,7 +1271,7 @@ export const createSetupWSettings = () => secret: true, }); - await this.add('Cloud_Workspace_Client_Secret_Expires_At', '', { + await this.add('Cloud_Workspace_Client_Secret_Expires_At', 0, { type: 'int', hidden: true, readonly: true, diff --git a/apps/meteor/server/settings/slackbridge.ts b/apps/meteor/server/settings/slackbridge.ts index ea16f07db42a..bf3b8c090c9a 100644 --- a/apps/meteor/server/settings/slackbridge.ts +++ b/apps/meteor/server/settings/slackbridge.ts @@ -8,18 +8,90 @@ export const createSlackBridgeSettings = () => public: true, }); - await this.add('SlackBridge_APIToken', '', { - type: 'string', - multiline: true, + await this.add('SlackBridge_UseLegacy', true, { + type: 'boolean', enableQuery: { _id: 'SlackBridge_Enabled', value: true, }, + i18nLabel: 'SlackBridge_UseLegacy', + i18nDescription: 'SlackBridge_UseLegacy_Description', + public: true, + packageValue: true, + }); + + await this.add('SlackBridge_APIToken', '', { + type: 'string', + multiline: true, + enableQuery: [ + { + _id: 'SlackBridge_UseLegacy', + value: true, + }, + { + _id: 'SlackBridge_Enabled', + value: true, + }, + ], i18nLabel: 'SlackBridge_APIToken', i18nDescription: 'SlackBridge_APIToken_Description', secret: true, }); + await this.add('SlackBridge_BotToken', '', { + type: 'string', + multiline: true, + enableQuery: [ + { + _id: 'SlackBridge_UseLegacy', + value: false, + }, + { + _id: 'SlackBridge_Enabled', + value: true, + }, + ], + i18nLabel: 'SlackBridge_BotToken', + i18nDescription: 'SlackBridge_BotToken_Description', + secret: true, + }); + + await this.add('SlackBridge_SigningSecret', '', { + type: 'string', + multiline: true, + enableQuery: [ + { + _id: 'SlackBridge_UseLegacy', + value: false, + }, + { + _id: 'SlackBridge_Enabled', + value: true, + }, + ], + i18nLabel: 'SlackBridge_SigningSecret', + i18nDescription: 'SlackBridge_SigningSecret_Description', + secret: true, + }); + + await this.add('SlackBridge_AppToken', '', { + type: 'string', + multiline: true, + enableQuery: [ + { + _id: 'SlackBridge_UseLegacy', + value: false, + }, + { + _id: 'SlackBridge_Enabled', + value: true, + }, + ], + i18nLabel: 'SlackBridge_AppToken', + i18nDescription: 'SlackBridge_AppToken_Description', + secret: true, + }); + await this.add('SlackBridge_FileUpload_Enabled', true, { type: 'boolean', enableQuery: { diff --git a/apps/meteor/server/startup/appcache.js b/apps/meteor/server/startup/appcache.ts similarity index 100% rename from apps/meteor/server/startup/appcache.js rename to apps/meteor/server/startup/appcache.ts diff --git a/apps/meteor/server/startup/cloudRegistration.ts b/apps/meteor/server/startup/cloudRegistration.ts new file mode 100644 index 000000000000..e69d4446d6ec --- /dev/null +++ b/apps/meteor/server/startup/cloudRegistration.ts @@ -0,0 +1,20 @@ +import { Settings } from '@rocket.chat/models'; + +export async function ensureCloudWorkspaceRegistered(): Promise { + const cloudWorkspaceClientId = await Settings.getValueById('Cloud_Workspace_Client_Id'); + const cloudWorkspaceClientSecret = await Settings.getValueById('Cloud_Workspace_Client_Secret'); + const showSetupWizard = await Settings.getValueById('Show_Setup_Wizard'); + + // skip if both fields are already set, which means the workspace is already registered + if (!!cloudWorkspaceClientId && !!cloudWorkspaceClientSecret) { + return; + } + + // skip if the setup wizard still not completed + if (showSetupWizard !== 'completed') { + return; + } + + // otherwise, set the setup wizard to in_progress forcing admins to complete the registration + await Settings.updateValueById('Show_Setup_Wizard', 'in_progress'); +} diff --git a/apps/meteor/server/startup/migrations/xrun.js b/apps/meteor/server/startup/migrations/xrun.js deleted file mode 100644 index bd3d19a7cbee..000000000000 --- a/apps/meteor/server/startup/migrations/xrun.js +++ /dev/null @@ -1,9 +0,0 @@ -import { upsertPermissions } from '../../../app/authorization/server/functions/upsertPermissions'; -import { migrateDatabase, onFreshInstall } from '../../lib/migrations'; - -const { MIGRATION_VERSION = 'latest' } = process.env; - -const [version, ...subcommands] = MIGRATION_VERSION.split(','); - -await migrateDatabase(version === 'latest' ? version : parseInt(version), subcommands); -await onFreshInstall(upsertPermissions); diff --git a/apps/meteor/server/startup/migrations/xrun.ts b/apps/meteor/server/startup/migrations/xrun.ts new file mode 100644 index 000000000000..c560d488187c --- /dev/null +++ b/apps/meteor/server/startup/migrations/xrun.ts @@ -0,0 +1,15 @@ +import { upsertPermissions } from '../../../app/authorization/server/functions/upsertPermissions'; +import { migrateDatabase, onServerVersionChange } from '../../lib/migrations'; +import { ensureCloudWorkspaceRegistered } from '../cloudRegistration'; + +const { MIGRATION_VERSION = 'latest' } = process.env; + +const [version, ...subcommands] = MIGRATION_VERSION.split(','); + +await migrateDatabase(version === 'latest' ? version : parseInt(version), subcommands); + +// perform operations when the server is starting with a different version +await onServerVersionChange(async () => { + await upsertPermissions(); + await ensureCloudWorkspaceRegistered(); +}); diff --git a/apps/meteor/tests/data/api-data.js b/apps/meteor/tests/data/api-data.js index 25e89c2ef99a..b311af16e764 100644 --- a/apps/meteor/tests/data/api-data.js +++ b/apps/meteor/tests/data/api-data.js @@ -1,7 +1,7 @@ import supertest from 'supertest'; -import { publicChannelName, privateChannelName } from './channel.js'; -import { roleNameUsers, roleNameSubscriptions, roleScopeUsers, roleScopeSubscriptions, roleDescription } from './role.js'; +import { publicChannelName, privateChannelName } from './channel'; +import { roleNameUsers, roleNameSubscriptions, roleScopeUsers, roleScopeSubscriptions, roleDescription } from './role'; import { username, email, adminUsername, adminPassword } from './user'; const apiUrl = process.env.TEST_API_URL || 'http://localhost:3000'; @@ -13,10 +13,10 @@ export function wait(cb, time) { return () => setTimeout(cb, time); } -export const apiUsername = `api${username}`; -export const apiEmail = `api${email}`; -export const apiPublicChannelName = `api${publicChannelName}`; -export const apiPrivateChannelName = `api${privateChannelName}`; +export const apiUsername = `api${username}-${Date.now()}`; +export const apiEmail = `api${email}-${Date.now()}`; +export const apiPublicChannelName = `api${publicChannelName}-${Date.now()}`; +export const apiPrivateChannelName = `api${privateChannelName}-${Date.now()}`; export const apiRoleNameUsers = `api${roleNameUsers}`; export const apiRoleNameSubscriptions = `api${roleNameSubscriptions}`; @@ -25,7 +25,6 @@ export const apiRoleScopeSubscriptions = `${roleScopeSubscriptions}`; export const apiRoleDescription = `api${roleDescription}`; export const reservedWords = ['admin', 'administrator', 'system', 'user']; -export const targetUser = {}; export const channel = {}; export const group = {}; export const message = {}; diff --git a/apps/meteor/tests/data/channel.js b/apps/meteor/tests/data/channel.ts similarity index 100% rename from apps/meteor/tests/data/channel.js rename to apps/meteor/tests/data/channel.ts diff --git a/apps/meteor/tests/data/custom-fields.js b/apps/meteor/tests/data/custom-fields.js index 2509dddf5d84..e2e175429b4c 100644 --- a/apps/meteor/tests/data/custom-fields.js +++ b/apps/meteor/tests/data/custom-fields.js @@ -1,4 +1,4 @@ -import { getCredentials, request, api, credentials } from './api-data.js'; +import { credentials, request, api } from './api-data.js'; export const customFieldText = { type: 'text', @@ -7,18 +7,12 @@ export const customFieldText = { maxLength: 10, }; -export function setCustomFields(customFields, done) { - getCredentials((error) => { - if (error) { - return done(error); - } +export function setCustomFields(customFields) { + const stringified = customFields ? JSON.stringify(customFields) : ''; - const stringified = customFields ? JSON.stringify(customFields) : ''; - - request.post(api('settings/Accounts_CustomFields')).set(credentials).send({ value: stringified }).expect(200).end(done); - }); + return request.post(api('settings/Accounts_CustomFields')).set(credentials).send({ value: stringified }).expect(200); } -export function clearCustomFields(done = () => {}) { - setCustomFields(null, done); +export function clearCustomFields() { + return setCustomFields(null); } diff --git a/apps/meteor/tests/data/interactions.js b/apps/meteor/tests/data/interactions.ts similarity index 100% rename from apps/meteor/tests/data/interactions.js rename to apps/meteor/tests/data/interactions.ts diff --git a/apps/meteor/tests/data/livechat/department.ts b/apps/meteor/tests/data/livechat/department.ts index e11324a47a46..8aba28addfcf 100644 --- a/apps/meteor/tests/data/livechat/department.ts +++ b/apps/meteor/tests/data/livechat/department.ts @@ -2,9 +2,9 @@ import { faker } from '@faker-js/faker'; import { expect } from 'chai'; import type { ILivechatDepartment, IUser, LivechatDepartmentDTO } from '@rocket.chat/core-typings'; import { api, credentials, methodCall, request } from '../api-data'; -import { IUserCredentialsHeader, password } from '../user'; -import { login } from '../users.helper'; -import { createAgent, makeAgentAvailable } from './rooms'; +import { IUserCredentialsHeader } from '../user'; +import { createAnOnlineAgent } from './users'; +import { WithRequiredProperty } from './utils'; export const NewDepartmentData = ((): Partial => ({ enabled: true, @@ -59,29 +59,19 @@ new Promise((resolve, reject) => { export const createDepartmentWithAnOnlineAgent = async (): Promise<{department: ILivechatDepartment, agent: { credentials: IUserCredentialsHeader; - user: IUser; + user: WithRequiredProperty; }}> => { - // TODO moving here for tests - const username = `user.test.${Date.now()}`; - const email = `${username}@rocket.chat`; - const { body } = await request - .post(api('users.create')) - .set(credentials) - .send({ email, name: username, username, password }); - const agent = body.user; - const createdUserCredentials = await login(agent.username, password); - await createAgent(agent.username); - await makeAgentAvailable(createdUserCredentials); + const { user, credentials } = await createAnOnlineAgent(); const department = await createDepartmentWithMethod() as ILivechatDepartment; - await addOrRemoveAgentFromDepartment(department._id, {agentId: agent._id, username: (agent.username as string)}, true); + await addOrRemoveAgentFromDepartment(department._id, {agentId: user._id, username: user.username}, true); return { department, agent: { - credentials: createdUserCredentials, - user: agent, + credentials, + user, } }; }; diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts index c2658c73af8d..5efb279dcb18 100644 --- a/apps/meteor/tests/data/livechat/rooms.ts +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -185,6 +185,9 @@ export const getLivechatRoomInfo = (roomId: string): Promise = }); }; +/** + * @summary Sends message as visitor +*/ export const sendMessage = (roomId: string, message: string, visitorToken: string): Promise => { return new Promise((resolve, reject) => { request diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 7a5dc23b4cc0..38fb176faaa4 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -1,6 +1,6 @@ import { faker } from "@faker-js/faker"; import type { IUser } from "@rocket.chat/core-typings"; -import { password } from "../user"; +import { IUserCredentialsHeader, password } from "../user"; import { createUser, login } from "../users.helper"; import { createAgent, makeAgentAvailable } from "./rooms"; import { api, credentials, request } from "../api-data"; @@ -29,3 +29,24 @@ export const removeAgent = async (userId: string): Promise => { .set(credentials) .expect(200); } + +export const createAnOnlineAgent = async (): Promise<{ + credentials: IUserCredentialsHeader; + user: IUser & { username: string }; +}> => { + const username = `user.test.${Date.now()}`; + const email = `${username}@rocket.chat`; + const { body } = await request + .post(api('users.create')) + .set(credentials) + .send({ email, name: username, username, password }); + const agent = body.user; + const createdUserCredentials = await login(agent.username, password); + await createAgent(agent.username); + await makeAgentAvailable(createdUserCredentials); + + return { + credentials: createdUserCredentials, + user: agent, + }; +} diff --git a/apps/meteor/tests/data/livechat/utils.ts b/apps/meteor/tests/data/livechat/utils.ts index 89b6af709fbf..b6fd3a4bf6b3 100644 --- a/apps/meteor/tests/data/livechat/utils.ts +++ b/apps/meteor/tests/data/livechat/utils.ts @@ -1,6 +1,10 @@ export type DummyResponse = E extends 'wrapped' ? { body: { [k: string]: T } } : { body: T }; +export type WithRequiredProperty = Type & { + [Property in Key]-?: Type[Property]; +}; + export const sleep = (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/apps/meteor/tests/data/role.js b/apps/meteor/tests/data/role.ts similarity index 100% rename from apps/meteor/tests/data/role.js rename to apps/meteor/tests/data/role.ts diff --git a/apps/meteor/tests/data/uploads.helper.ts b/apps/meteor/tests/data/uploads.helper.ts index 194b19df34c8..29c7a143484c 100644 --- a/apps/meteor/tests/data/uploads.helper.ts +++ b/apps/meteor/tests/data/uploads.helper.ts @@ -5,7 +5,7 @@ import { after, before, it } from 'mocha'; import { api, request, credentials } from './api-data.js'; import { password } from './user'; import { createUser, login } from './users.helper'; -import { imgURL } from './interactions.js'; +import { imgURL } from './interactions'; import { updateSetting } from './permissions.helper'; import { createRoom } from './rooms.helper'; import { createVisitor } from './livechat/rooms'; diff --git a/apps/meteor/tests/data/users.helper.js b/apps/meteor/tests/data/users.helper.js index 92425902cb5b..d69b0413ae0b 100644 --- a/apps/meteor/tests/data/users.helper.js +++ b/apps/meteor/tests/data/users.helper.js @@ -1,3 +1,4 @@ +import { UserStatus } from '@rocket.chat/core-typings'; import { api, credentials, request } from './api-data'; import { password } from './user'; @@ -33,15 +34,9 @@ export const login = (username, password) => }); }); -export const deleteUser = (user) => - new Promise((resolve) => { - request - .post(api('users.delete')) - .set(credentials) - .send({ - userId: user._id, - }) - .end(resolve); +export const deleteUser = async (user) => + request.post(api('users.delete')).set(credentials).send({ + userId: user._id, }); export const getUserByUsername = (username) => @@ -89,3 +84,9 @@ export const setUserActiveStatus = (userId, activeStatus = true) => }) .end(resolve); }); + +export const setUserStatus = (overrideCredentials = credentials, status = UserStatus.ONLINE) => + request.post(api('users.setStatus')).set(overrideCredentials).send({ + message: '', + status, + }); diff --git a/apps/meteor/tests/e2e/administration-menu.spec.ts b/apps/meteor/tests/e2e/administration-menu.spec.ts index e105b4a2a0d4..4e630c60e16e 100644 --- a/apps/meteor/tests/e2e/administration-menu.spec.ts +++ b/apps/meteor/tests/e2e/administration-menu.spec.ts @@ -25,7 +25,7 @@ test.describe.serial('administration-menu', () => { test.skip(!IS_EE, 'Enterprise only'); await poHomeDiscussion.sidenav.openAdministrationByLabel('Workspace'); - await expect(page).toHaveURL('admin/workspace'); + await expect(page).toHaveURL('admin/info'); }); test('expect open omnichannel page', async ({ page }) => { diff --git a/apps/meteor/tests/e2e/administration.spec.ts b/apps/meteor/tests/e2e/administration.spec.ts index b439258429f8..3006432d417d 100644 --- a/apps/meteor/tests/e2e/administration.spec.ts +++ b/apps/meteor/tests/e2e/administration.spec.ts @@ -16,7 +16,7 @@ test.describe.parallel('administration', () => { test.describe('Workspace', () => { test.beforeEach(async ({ page }) => { - await page.goto('/admin/workspace'); + await page.goto('/admin/info'); }); test('expect download info as JSON', async ({ page }) => { diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 0c49ea7c7bbf..7cc04efba3a0 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -154,8 +154,7 @@ test.describe.serial('e2e-encryption', () => { await expect(page).toHaveURL(`/group/${channelName}`); - await poHomeChannel.toastSuccess.locator('button >> i.rcx-icon--name-cross.rcx-icon').click(); - await page.mouse.move(0, 0); + await poHomeChannel.dismissToast(); await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); @@ -168,6 +167,7 @@ test.describe.serial('e2e-encryption', () => { await expect(poHomeChannel.tabs.btnDisableE2E).toBeVisible(); await poHomeChannel.tabs.btnDisableE2E.click({ force: true }); + await poHomeChannel.dismissToast(); await page.waitForTimeout(1000); await poHomeChannel.content.sendMessage('hello world not encrypted'); @@ -178,6 +178,7 @@ test.describe.serial('e2e-encryption', () => { await poHomeChannel.tabs.kebab.click({ force: true }); await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); await poHomeChannel.tabs.btnEnableE2E.click({ force: true }); + await poHomeChannel.dismissToast(); await page.waitForTimeout(1000); await poHomeChannel.content.sendMessage('hello world encrypted again'); @@ -197,7 +198,7 @@ test.describe.serial('e2e-encryption', () => { await expect(poHomeChannel.toastSuccess).toBeVisible(); - await poHomeChannel.toastSuccess.locator('button >> i.rcx-icon--name-cross.rcx-icon').click(); + await poHomeChannel.dismissToast(); await poHomeChannel.tabs.kebab.click({ force: true }); await expect(poHomeChannel.tabs.btnEnableE2E).toBeVisible(); diff --git a/apps/meteor/tests/e2e/message-actions.spec.ts b/apps/meteor/tests/e2e/message-actions.spec.ts index f6093053fde6..7cfa089326b2 100644 --- a/apps/meteor/tests/e2e/message-actions.spec.ts +++ b/apps/meteor/tests/e2e/message-actions.spec.ts @@ -40,6 +40,8 @@ test.describe.serial('message-actions', () => { await page.locator('[data-qa-id="edit-message"]').click(); await page.locator('[name="msg"]').fill('this message was edited'); await page.keyboard.press('Enter'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('this message was edited'); }); test('expect message is deleted', async ({ page }) => { @@ -47,6 +49,9 @@ test.describe.serial('message-actions', () => { await poHomeChannel.content.openLastMessageMenu(); await page.locator('[data-qa-id="delete-message"]').click(); await page.locator('#modal-root .rcx-button-group--align-end .rcx-button--danger').click(); + await expect(poHomeChannel.content.lastUserMessage.locator('[data-qa-type="message-body"]:has-text("Message to delete")')).toHaveCount( + 0, + ); }); test('expect quote the message', async ({ page }) => { @@ -64,6 +69,9 @@ test.describe.serial('message-actions', () => { await poHomeChannel.content.sendMessage('Message to star'); await poHomeChannel.content.openLastMessageMenu(); await page.locator('[data-qa-id="star-message"]').click(); + await page.getByRole('button').and(page.getByTitle('Options')).click(); + await page.locator('[data-key="starred-messages"]').click(); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('Message to star'); }); test('expect copy the message', async ({ page }) => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts index 3c74065a9a84..dc31f54be934 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-transfer-to-another-agents.spec.ts @@ -65,6 +65,7 @@ test.describe('omnichannel-transfer-to-another-agent', () => { await agent2.poHomeOmnichannel.sidenav.switchStatus('offline'); await agent1.poHomeOmnichannel.content.btnForwardChat.click(); + await agent1.poHomeOmnichannel.content.inputModalAgentUserName.click(); await agent1.poHomeOmnichannel.content.inputModalAgentUserName.type('user2'); await expect(agent1.page.locator('text=Empty')).toBeVisible(); @@ -76,8 +77,9 @@ test.describe('omnichannel-transfer-to-another-agent', () => { await agent1.poHomeOmnichannel.sidenav.getSidebarItemByName(newVisitor.name).click(); await agent1.poHomeOmnichannel.content.btnForwardChat.click(); + await agent1.poHomeOmnichannel.content.inputModalAgentUserName.click(); await agent1.poHomeOmnichannel.content.inputModalAgentUserName.type('user2'); - await agent1.page.locator('.rcx-option .rcx-option__wrapper >> text="user2"').click(); + await agent1.page.locator('.rcx-option .rcx-option__wrapper >> text="user2 (@user2)"').click(); await agent1.poHomeOmnichannel.content.inputModalAgentForwardComment.type('any_comment'); await agent1.poHomeOmnichannel.content.btnModalConfirm.click(); await expect(agent1.poHomeOmnichannel.toastSuccess).toBeVisible(); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts index 4cf3b82b2c66..9db221723ebe 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts @@ -29,6 +29,7 @@ test.describe.serial('Omnichannel Triggers', () => { const { page } = await createAuxContext(browser, Users.user1, '/omnichannel/triggers'); agent = { page, poHomeOmnichannel: new HomeOmnichannel(page) }; + await page.emulateMedia({ reducedMotion: 'reduce' }); }); test.beforeEach(async ({ page, api }) => { @@ -39,6 +40,7 @@ test.describe.serial('Omnichannel Triggers', () => { await Promise.all([ api.delete('/livechat/users/agent/user1'), api.delete('/livechat/users/manager/user1'), + api.delete(`/livechat/triggers/${triggersName}`), api.post('/settings/Livechat_clear_local_storage_when_chat_ended', { value: false }), ]); await agent.page.close(); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index cb8c8b089095..2ba8cd6428d9 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -197,7 +197,7 @@ export class HomeContent { } get inputModalAgentUserName(): Locator { - return this.page.locator('#modal-root input:nth-child(1)'); + return this.page.locator('#modal-root input[placeholder="Username, name or e-mail"]'); } get inputModalAgentForwardComment(): Locator { @@ -237,16 +237,8 @@ export class HomeContent { async openLastThreadMessageMenu(): Promise { await this.page.locator('//main//aside >> [data-qa-type="message"]').last().hover(); - await this.page - .locator('//main//aside >> [data-qa-type="message"]') - .last() - .locator('role=button[name="More"]') - .waitFor(); - await this.page - .locator('//main//aside >> [data-qa-type="message"]') - .last() - .locator('role=button[name="More"]') - .click(); + await this.page.locator('//main//aside >> [data-qa-type="message"]').last().locator('role=button[name="More"]').waitFor(); + await this.page.locator('//main//aside >> [data-qa-type="message"]').last().locator('role=button[name="More"]').click(); } async toggleAlsoSendThreadToChannel(isChecked: boolean): Promise { diff --git a/apps/meteor/tests/e2e/page-objects/home-channel.ts b/apps/meteor/tests/e2e/page-objects/home-channel.ts index d2f43c5cfec5..24403b22b845 100644 --- a/apps/meteor/tests/e2e/page-objects/home-channel.ts +++ b/apps/meteor/tests/e2e/page-objects/home-channel.ts @@ -34,4 +34,10 @@ export class HomeChannel { await expect(this.page.locator('role=main >> .rcx-skeleton')).toHaveCount(0); await expect(this.page.locator('role=main >> role=list')).not.toHaveAttribute('aria-busy', 'true'); } + + async dismissToast() { + // this is a workaround for when the toast is blocking the click of the button + await this.toastSuccess.locator('button >> i.rcx-icon--name-cross.rcx-icon').click(); + await this.page.mouse.move(0, 0); + } } diff --git a/apps/meteor/tests/e2e/threads.spec.ts b/apps/meteor/tests/e2e/threads.spec.ts index 6d6b8ee6f122..d9e181d2706b 100644 --- a/apps/meteor/tests/e2e/threads.spec.ts +++ b/apps/meteor/tests/e2e/threads.spec.ts @@ -104,6 +104,7 @@ test.describe.serial('Threads', () => { await page.locator('[data-qa-id="edit-message"]').click(); await page.locator('[name="msg"]').last().fill('this message was edited'); await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('this message was edited'); }); test('expect quote the thread message', async ({ page }) => { @@ -118,6 +119,9 @@ test.describe.serial('Threads', () => { test('expect star the thread message', async ({ page }) => { await poHomeChannel.content.openLastThreadMessageMenu(); await page.locator('[data-qa-id="star-message"]').click(); + await page.getByRole('button').and(page.getByTitle('Options')).click(); + await page.locator('[data-key="starred-messages"]').click(); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('this is a message for reply'); }); test('expect copy the message', async ({ page }) => { diff --git a/apps/meteor/tests/end-to-end/api/00-autotranslate.js b/apps/meteor/tests/end-to-end/api/00-autotranslate.js index 48bb021ce388..7695718bd01f 100644 --- a/apps/meteor/tests/end-to-end/api/00-autotranslate.js +++ b/apps/meteor/tests/end-to-end/api/00-autotranslate.js @@ -4,7 +4,7 @@ import { before, describe, after, it } from 'mocha'; import { getCredentials, api, request, credentials } from '../../data/api-data.js'; import { sendSimpleMessage } from '../../data/chat.helper'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; -import { createRoom } from '../../data/rooms.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { password } from '../../data/user'; import { createUser, login } from '../../data/users.helper.js'; @@ -71,6 +71,18 @@ describe('AutoTranslate', function () { }); }); describe('[/autotranslate.saveSettings', () => { + let testGroupId; + before(async () => { + await updateSetting('E2E_Enable', true); + await updateSetting('E2E_Enabled_Default_PrivateRooms', true); + const res = await createRoom({ type: 'p', name: `e2etest-autotranslate-${Date.now()}` }); + testGroupId = res.body.group._id; + }); + after(async () => { + await updateSetting('E2E_Enabled_Default_PrivateRooms', false); + await updateSetting('E2E_Enable', false); + await deleteRoom({ type: 'p', roomId: testGroupId }); + }); it('should throw an error when the "AutoTranslate_Enabled" setting is disabled', (done) => { updateSetting('AutoTranslate_Enabled', false).then(() => { request @@ -223,6 +235,23 @@ describe('AutoTranslate', function () { }) .end(done); }); + it('should throw an error when E2E encryption is enabled', async () => { + await request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({ + roomId: testGroupId, + field: 'autoTranslate', + defaultLanguage: 'en', + value: true, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-e2e-enabled'); + }); + }); it('should return success when the setting is saved correctly', (done) => { request .post(api('autotranslate.saveSettings')) diff --git a/apps/meteor/tests/end-to-end/api/01-users.js b/apps/meteor/tests/end-to-end/api/01-users.js index d99fa68a036f..eaafc97527a3 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -5,23 +5,12 @@ import { expect } from 'chai'; import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; import { sleep } from '../../../lib/utils/sleep'; -import { - getCredentials, - api, - request, - credentials, - apiEmail, - apiUsername, - targetUser, - log, - wait, - reservedWords, -} from '../../data/api-data.js'; +import { getCredentials, api, request, credentials, apiEmail, apiUsername, log, wait, reservedWords } from '../../data/api-data.js'; import { MAX_BIO_LENGTH, MAX_NICKNAME_LENGTH } from '../../data/constants.ts'; import { customFieldText, clearCustomFields, setCustomFields } from '../../data/custom-fields.js'; -import { imgURL } from '../../data/interactions.js'; +import { imgURL } from '../../data/interactions'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; -import { createRoom } from '../../data/rooms.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { adminEmail, preferences, password, adminUsername } from '../../data/user'; import { createUser, login, deleteUser, getUserStatus, getUserByUsername } from '../../data/users.helper.js'; @@ -39,11 +28,48 @@ async function joinChannel(userCredentials, roomId) { }); } +const targetUser = {}; + describe('[Users]', function () { this.retries(0); before((done) => getCredentials(done)); + before('should create a new user', async () => { + await request + .post(api('users.create')) + .set(credentials) + .send({ + email: apiEmail, + name: apiUsername, + username: apiUsername, + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', apiUsername); + expect(res.body).to.have.nested.property('user.emails[0].address', apiEmail); + expect(res.body).to.have.nested.property('user.active', true); + expect(res.body).to.have.nested.property('user.name', apiUsername); + expect(res.body).to.not.have.nested.property('user.e2e'); + + expect(res.body).to.not.have.nested.property('user.customFields'); + + targetUser._id = res.body.user._id; + targetUser.username = res.body.user.username; + }); + }); + + after(async () => { + await deleteUser(targetUser); + }); + it('enabling E2E in server and generating keys to user...', async () => { await updateSetting('E2E_Enable', true); await request @@ -71,145 +97,101 @@ describe('[Users]', function () { }); describe('[/users.create]', () => { - before((done) => clearCustomFields(done)); - after((done) => clearCustomFields(done)); + before(async () => clearCustomFields()); + after(async () => clearCustomFields()); + + it('should create a new user with custom fields', async () => { + await setCustomFields({ customFieldText }); + + const username = `customField_${apiUsername}`; + const email = `customField_${apiEmail}`; + const customFields = { customFieldText: 'success' }; + + let user; - it('should create a new user', async () => { await request .post(api('users.create')) .set(credentials) .send({ - email: apiEmail, - name: apiUsername, - username: apiUsername, + email, + name: username, + username, password, active: true, roles: ['user'], joinDefaultChannels: true, verified: true, + customFields, }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('user.username', apiUsername); - expect(res.body).to.have.nested.property('user.emails[0].address', apiEmail); + expect(res.body).to.have.nested.property('user.username', username); + expect(res.body).to.have.nested.property('user.emails[0].address', email); expect(res.body).to.have.nested.property('user.active', true); - expect(res.body).to.have.nested.property('user.name', apiUsername); + expect(res.body).to.have.nested.property('user.name', username); + expect(res.body).to.have.nested.property('user.customFields.customFieldText', 'success'); expect(res.body).to.not.have.nested.property('user.e2e'); - expect(res.body).to.not.have.nested.property('user.customFields'); - - targetUser._id = res.body.user._id; - targetUser.username = res.body.user.username; + user = res.body.user; }); - await request - .post(api('login')) - .send({ - user: apiUsername, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200); + await deleteUser(user); }); - it('should create a new user with custom fields', (done) => { - setCustomFields({ customFieldText }, (error) => { - if (error) { - return done(error); - } - - const username = `customField_${apiUsername}`; - const email = `customField_${apiEmail}`; - const customFields = { customFieldText: 'success' }; - + function failCreateUser(name) { + it(`should not create a new user if username is the reserved word ${name}`, (done) => { request .post(api('users.create')) .set(credentials) .send({ - email, - name: username, - username, + email: `create_user_fail_${apiEmail}`, + name: `create_user_fail_${apiUsername}`, + username: name, password, active: true, roles: ['user'], joinDefaultChannels: true, verified: true, - customFields, }) .expect('Content-Type', 'application/json') - .expect(200) + .expect(400) .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('user.username', username); - expect(res.body).to.have.nested.property('user.emails[0].address', email); - expect(res.body).to.have.nested.property('user.active', true); - expect(res.body).to.have.nested.property('user.name', username); - expect(res.body).to.have.nested.property('user.customFields.customFieldText', 'success'); - expect(res.body).to.not.have.nested.property('user.e2e'); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `${name} is blocked and can't be used! [error-blocked-username]`); }) .end(done); }); - }); + } - function failCreateUser(name) { - it(`should not create a new user if username is the reserved word ${name}`, (done) => { - request + function failUserWithCustomField(field) { + it(`should not create a user if a custom field ${field.reason}`, async () => { + await setCustomFields({ customFieldText }); + + const customFields = {}; + customFields[field.name] = field.value; + + await request .post(api('users.create')) .set(credentials) .send({ - email: `create_user_fail_${apiEmail}`, - name: `create_user_fail_${apiUsername}`, - username: name, + email: `customField_fail_${apiEmail}`, + name: `customField_fail_${apiUsername}`, + username: `customField_fail_${apiUsername}`, password, active: true, roles: ['user'], joinDefaultChannels: true, verified: true, + customFields, }) .expect('Content-Type', 'application/json') .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', `${name} is blocked and can't be used! [error-blocked-username]`); - }) - .end(done); - }); - } - - function failUserWithCustomField(field) { - it(`should not create a user if a custom field ${field.reason}`, (done) => { - setCustomFields({ customFieldText }, (error) => { - if (error) { - return done(error); - } - - const customFields = {}; - customFields[field.name] = field.value; - - request - .post(api('users.create')) - .set(credentials) - .send({ - email: `customField_fail_${apiEmail}`, - name: `customField_fail_${apiUsername}`, - username: `customField_fail_${apiUsername}`, - password, - active: true, - roles: ['user'], - joinDefaultChannels: true, - verified: true, - customFields, - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'error-user-registration-custom-field'); - }) - .end(done); - }); + expect(res.body).to.have.property('errorType', 'error-user-registration-custom-field'); + }); }); } @@ -226,12 +208,16 @@ describe('[Users]', function () { }); describe('users default roles configuration', () => { + const users = []; + before(async () => { await updateSetting('Accounts_Registration_Users_Default_Roles', 'user,admin'); }); after(async () => { await updateSetting('Accounts_Registration_Users_Default_Roles', 'user'); + + await Promise.all(users.map((user) => deleteUser(user))); }); it('should create a new user with default roles', (done) => { @@ -256,6 +242,8 @@ describe('[Users]', function () { expect(res.body).to.have.nested.property('user.active', true); expect(res.body).to.have.nested.property('user.name', username); expect(res.body.user.roles).to.have.members(['user', 'admin']); + + users.push(res.body.user); }) .end(done); }); @@ -283,6 +271,8 @@ describe('[Users]', function () { expect(res.body).to.have.nested.property('user.active', true); expect(res.body).to.have.nested.property('user.name', username); expect(res.body.user.roles).to.have.members(['guest']); + + users.push(res.body.user); }) .end(done); }); @@ -292,6 +282,10 @@ describe('[Users]', function () { describe('[/users.register]', () => { const email = `email@email${Date.now()}.com`; const username = `myusername${Date.now()}`; + let user; + + after(async () => deleteUser(user)); + it('should register new user', (done) => { request .post(api('users.register')) @@ -308,6 +302,7 @@ describe('[Users]', function () { expect(res.body).to.have.nested.property('user.username', username); expect(res.body).to.have.nested.property('user.active', true); expect(res.body).to.have.nested.property('user.name', 'name'); + user = res.body.user; }) .end(done); }); @@ -331,9 +326,11 @@ describe('[Users]', function () { }); describe('[/users.info]', () => { - after(() => { - updatePermission('view-other-user-channels', ['admin']); - updatePermission('view-full-other-user-info', ['admin']); + after(async () => { + await Promise.all([ + updatePermission('view-other-user-channels', ['admin']), + updatePermission('view-full-other-user-info', ['admin']), + ]); }); it('should return an error when the user does not exist', (done) => { @@ -476,26 +473,30 @@ describe('[Users]', function () { }); it('should correctly route users that have `ufs` in their username', async () => { + const ufsUsername = `ufs-${Date.now()}`; + let user; + await request .post(api('users.create')) .set(credentials) .send({ - email: 'me@email.com', + email: `me-${Date.now()}@email.com`, name: 'testuser', - username: 'ufs', + username: ufsUsername, password: '1234', }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); + user = res.body.user; }); await request .get(api('users.info')) .set(credentials) .query({ - username: 'ufs', + username: ufsUsername, }) .expect('Content-Type', 'application/json') .expect(200) @@ -503,9 +504,11 @@ describe('[Users]', function () { expect(res.body).to.have.property('success', true); expect(res.body.user).to.have.property('type', 'user'); expect(res.body.user).to.have.property('name', 'testuser'); - expect(res.body.user).to.have.property('username', 'ufs'); + expect(res.body.user).to.have.property('username', ufsUsername); expect(res.body.user).to.have.property('active', true); }); + + await deleteUser(user); }); }); describe('[/users.getPresence]', () => { @@ -549,10 +552,10 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('full', true); - expect(res.body) - .to.have.property('users') - .to.have.property('0') - .to.deep.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); + + const user = res.body.users.find((user) => user.username === 'rocket.cat'); + + expect(user).to.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); }) .end(done); }); @@ -583,10 +586,10 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('full', true); - expect(res.body) - .to.have.property('users') - .to.have.property('0') - .to.deep.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); + + const user = res.body.users.find((user) => user.username === 'rocket.cat'); + + expect(user).to.have.all.keys('_id', 'avatarETag', 'username', 'name', 'status', 'utcOffset'); }) .end(done); }); @@ -599,68 +602,59 @@ describe('[Users]', function () { let user2; let user2Credentials; - before((done) => { - const createDeactivatedUser = async () => { - const username = `deactivated_${Date.now()}${apiUsername}`; - const email = `deactivated_+${Date.now()}${apiEmail}`; - - const userData = { - email, - name: username, - username, - password, - active: false, - }; - - deactivatedUser = await createUser(userData); - - expect(deactivatedUser).to.not.be.null; - expect(deactivatedUser).to.have.nested.property('username', username); - expect(deactivatedUser).to.have.nested.property('emails[0].address', email); - expect(deactivatedUser).to.have.nested.property('active', false); - expect(deactivatedUser).to.have.nested.property('name', username); - expect(deactivatedUser).to.not.have.nested.property('e2e'); + before(async () => { + const username = `deactivated_${Date.now()}${apiUsername}`; + const email = `deactivated_+${Date.now()}${apiEmail}`; + + const userData = { + email, + name: username, + username, + password, + active: false, }; - createDeactivatedUser().then(done); - }); - before((done) => - setCustomFields({ customFieldText }, async (error) => { - if (error) { - return done(error); - } - - const username = `customField_${Date.now()}${apiUsername}`; - const email = `customField_+${Date.now()}${apiEmail}`; - const customFields = { customFieldText: 'success' }; + deactivatedUser = await createUser(userData); - const userData = { - email, - name: username, - username, - password, - active: true, - roles: ['user'], - joinDefaultChannels: true, - verified: true, - customFields, - }; + expect(deactivatedUser).to.not.be.null; + expect(deactivatedUser).to.have.nested.property('username', username); + expect(deactivatedUser).to.have.nested.property('emails[0].address', email); + expect(deactivatedUser).to.have.nested.property('active', false); + expect(deactivatedUser).to.have.nested.property('name', username); + expect(deactivatedUser).to.not.have.nested.property('e2e'); + }); - user = await createUser(userData); + before(async () => { + await setCustomFields({ customFieldText }); + + const username = `customField_${Date.now()}${apiUsername}`; + const email = `customField_+${Date.now()}${apiEmail}`; + const customFields = { customFieldText: 'success' }; + + const userData = { + email, + name: username, + username, + password, + active: true, + roles: ['user'], + joinDefaultChannels: true, + verified: true, + customFields, + }; - expect(user).to.not.be.null; - expect(user).to.have.nested.property('username', username); - expect(user).to.have.nested.property('emails[0].address', email); - expect(user).to.have.nested.property('active', true); - expect(user).to.have.nested.property('name', username); - expect(user).to.have.nested.property('customFields.customFieldText', 'success'); - expect(user).to.not.have.nested.property('e2e'); + user = await createUser(userData); - return done(); - }), - ); + expect(user).to.not.be.null; + expect(user).to.have.nested.property('username', username); + expect(user).to.have.nested.property('emails[0].address', email); + expect(user).to.have.nested.property('active', true); + expect(user).to.have.nested.property('name', username); + expect(user).to.have.nested.property('customFields.customFieldText', 'success'); + expect(user).to.not.have.nested.property('e2e'); + }); - after((done) => clearCustomFields(done)); + after(async () => clearCustomFields()); before(async () => { user2 = await createUser({ joinDefaultChannels: false }); @@ -668,6 +662,8 @@ describe('[Users]', function () { }); after(async () => { + await deleteUser(deactivatedUser); + await deleteUser(user); await deleteUser(user2); user2 = undefined; @@ -1281,26 +1277,23 @@ describe('[Users]', function () { }); }); - it('should update the user name when the required permission is applied', (done) => { - updatePermission('edit-other-user-info', ['admin']).then(() => { - updateSetting('Accounts_AllowUsernameChange', false).then(() => { - request - .post(api('users.update')) - .set(credentials) - .send({ - userId: targetUser._id, - data: { - username: 'fake.name', - }, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + it('should update the user name when the required permission is applied', async () => { + await Promise.all([updatePermission('edit-other-user-info', ['admin']), updateSetting('Accounts_AllowUsernameChange', false)]); + + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + username: `fake.name.${Date.now()}`, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); }); - }); }); it('should return an error when trying update user real name and it is not allowed', (done) => { @@ -2297,6 +2290,8 @@ describe('[Users]', function () { .end(done); }); + after(async () => deleteUser(targetUser)); + it('should return an username suggestion', (done) => { request .get(api('users.getUsernameSuggestion')) @@ -2326,7 +2321,7 @@ describe('[Users]', function () { const testUsername = `test-username-123456-${+new Date()}`; let targetUser; let userCredentials; - it('register a new user...', (done) => { + before((done) => { request .post(api('users.register')) .set(credentials) @@ -2343,7 +2338,7 @@ describe('[Users]', function () { }) .end(done); }); - it('Login...', (done) => { + before((done) => { request .post(api('login')) .send({ @@ -2360,6 +2355,8 @@ describe('[Users]', function () { .end(done); }); + after(async () => deleteUser(targetUser)); + it('should return true if the username is the same user username set', (done) => { request .get(api('users.checkUsernameAvailability')) @@ -2410,7 +2407,7 @@ describe('[Users]', function () { const testUsername = `testuser${+new Date()}`; let targetUser; let userCredentials; - it('register a new user...', (done) => { + before((done) => { request .post(api('users.register')) .set(credentials) @@ -2427,7 +2424,7 @@ describe('[Users]', function () { }) .end(done); }); - it('Login...', (done) => { + before((done) => { request .post(api('login')) .send({ @@ -2444,6 +2441,8 @@ describe('[Users]', function () { .end(done); }); + after(async () => deleteUser(targetUser)); + it('Enable "Accounts_AllowDeleteOwnAccount" setting...', (done) => { request .post('/api/v1/settings/Accounts_AllowDeleteOwnAccount') @@ -2472,23 +2471,22 @@ describe('[Users]', function () { .end(done); }); - it('should delete user own account when the SHA256 hash is in upper case', (done) => { - createUser().then((user) => { - login(user.username, password).then((createdUserCredentials) => { - request - .post(api('users.deleteOwnAccount')) - .set(createdUserCredentials) - .send({ - password: crypto.createHash('sha256').update(password, 'utf8').digest('hex').toUpperCase(), - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + it('should delete user own account when the SHA256 hash is in upper case', async () => { + const user = await createUser(); + const createdUserCredentials = await login(user.username, password); + await request + .post(api('users.deleteOwnAccount')) + .set(createdUserCredentials) + .send({ + password: crypto.createHash('sha256').update(password, 'utf8').digest('hex').toUpperCase(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); }); - }); + + await deleteUser(user); }); it('should return an error when trying to delete user own account if user is the last room owner', async () => { @@ -2542,6 +2540,9 @@ describe('[Users]', function () { expect(res.body).to.have.property('error', '[user-last-owner]'); expect(res.body).to.have.property('errorType', 'user-last-owner'); }); + + await deleteRoom({ type: 'c', roomId: room._id }); + await deleteUser(user); }); it('should delete user own account if the user is the last room owner and `confirmRelinquish` is set to `true`', async () => { @@ -2594,6 +2595,8 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); }); + await deleteRoom({ type: 'c', roomId: room._id }); + await deleteUser(user); }); it('should assign a new owner to the room if the last room owner is deleted', async () => { @@ -2661,6 +2664,8 @@ describe('[Users]', function () { expect(res.body.roles[0].roles).to.eql(['owner']); expect(res.body.roles[0].u).to.have.property('_id', credentials['X-User-Id']); }); + await deleteRoom({ type: 'c', roomId: room._id }); + await deleteUser(user); }); }); @@ -2763,6 +2768,8 @@ describe('[Users]', function () { expect(res.body).to.have.property('error', '[user-last-owner]'); expect(res.body).to.have.property('errorType', 'user-last-owner'); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should delete user account if the user is the last room owner and `confirmRelinquish` is set to `true`', async () => { @@ -2813,6 +2820,8 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should delete user account when logged user has "delete-user" permission', async () => { @@ -2893,6 +2902,8 @@ describe('[Users]', function () { expect(res.body.roles[0].roles).to.eql(['owner']); expect(res.body.roles[0].u).to.have.property('_id', credentials['X-User-Id']); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); }); @@ -3241,6 +3252,8 @@ describe('[Users]', function () { expect(res.body).to.have.property('error', '[user-last-owner]'); expect(res.body).to.have.property('errorType', 'user-last-owner'); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should set other user status to inactive if the user is the last owner of a room and `confirmRelinquish` is set to `true`', async () => { @@ -3305,6 +3318,8 @@ describe('[Users]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should set other user as room owner if the last owner of a room is deactivated and `confirmRelinquish` is set to `true`', async () => { @@ -3396,6 +3411,8 @@ describe('[Users]', function () { expect(res.body.roles[1].roles).to.eql(['owner']); expect(res.body.roles[1].u).to.have.property('_id', credentials['X-User-Id']); }); + + await deleteRoom({ type: 'c', roomId: room._id }); }); it('should return an error when trying to set other user active status and has not the necessary permission(edit-other-user-active-status)', (done) => { @@ -3464,6 +3481,8 @@ describe('[Users]', function () { expect(user).to.have.property('roles'); expect(user.roles).to.be.an('array').of.length(2); expect(user.roles).to.include('user', 'livechat-agent'); + + await deleteUser(testUser); }); }); @@ -3516,6 +3535,10 @@ describe('[Users]', function () { .end(done); }); + after(async () => { + await deleteUser(testUser); + }); + it('should fail to deactivate if user doesnt have edit-other-user-active-status permission', (done) => { updatePermission('edit-other-user-active-status', []).then(() => { request @@ -3563,7 +3586,7 @@ describe('[Users]', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('count', 2); + expect(res.body).to.have.property('count', 1); }) .end(done); }); @@ -3690,7 +3713,7 @@ describe('[Users]', function () { updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user']); }); - describe('[without permission]', () => { + describe('[without permission]', function () { let user; let userCredentials; let user2; @@ -3711,6 +3734,12 @@ describe('[Users]', function () { roomId = await createChannel(userCredentials, `channel.autocomplete.${Date.now()}`); }); + after(async () => { + await deleteRoom({ type: 'c', roomId }); + await deleteUser(user); + await deleteUser(user2); + }); + it('should return an empty list when the user does not have any subscription', (done) => { request .get(api('users.autocomplete?selector={}')) @@ -4126,6 +4155,10 @@ describe('[Users]', function () { .then(() => done()); }); + after(async () => { + await deleteUser(testUser); + }); + it('should list both channels', (done) => { request .get(api('users.listTeams')) @@ -4151,14 +4184,19 @@ describe('[Users]', function () { describe('[/users.logout]', () => { let user; let otherUser; + let userCredentials; + before(async () => { user = await createUser(); otherUser = await createUser(); }); + before(async () => { + userCredentials = await login(user.username, password); + }); + after(async () => { await deleteUser(user); await deleteUser(otherUser); - user = undefined; }); it('should throw unauthorized error to user w/o "logout-other-user" permission', (done) => { @@ -4187,7 +4225,7 @@ describe('[Users]', function () { it('should logout the requester', (done) => { updatePermission('logout-other-user', []).then(() => { - request.post(api('users.logout')).set(credentials).expect('Content-Type', 'application/json').expect(200).end(done); + request.post(api('users.logout')).set(userCredentials).expect('Content-Type', 'application/json').expect(200).end(done); }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/03-groups.js b/apps/meteor/tests/end-to-end/api/03-groups.js index cccc3eb27738..3941df1366eb 100644 --- a/apps/meteor/tests/end-to-end/api/03-groups.js +++ b/apps/meteor/tests/end-to-end/api/03-groups.js @@ -29,8 +29,8 @@ describe('[Groups]', function () { before((done) => getCredentials(done)); - before('/groups.create', (done) => { - request + before(async () => { + await request .post(api('groups.create')) .set(credentials) .send({ @@ -46,10 +46,21 @@ describe('[Groups]', function () { expect(res.body).to.have.nested.property('group.msgs', 0); group._id = res.body.group._id; group.name = res.body.group.name; + }); + }); + + after(async () => { + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomId: group._id, }) - .end(done); + .expect('Content-Type', 'application/json') + .expect(200); }); - describe('[/groups.create]', () => { + + describe('/groups.create', () => { let guestUser; let room; @@ -60,80 +71,89 @@ describe('[Groups]', function () { await deleteUser(guestUser); }); - it('should not add guest users to more rooms than defined in the license', async function () { - // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, - // ideally we should have a single CI job that adds a license and runs both CE and EE tests. - if (!process.env.IS_EE) { - this.skip(); - } - const promises = []; + describe('guest users', () => { + it('should not add guest users to more rooms than defined in the license', async function () { + // TODO this is not the right way to do it. We're doing this way for now just because we have separate CI jobs for EE and CE, + // ideally we should have a single CI job that adds a license and runs both CE and EE tests. + if (!process.env.IS_EE) { + this.skip(); + } + const promises = []; + + for (let i = 0; i < maxRoomsPerGuest; i++) { + promises.push( + createRoom({ + type: 'p', + name: `channel.test.${Date.now()}-${Math.random()}`, + members: [guestUser.username], + }), + ); + } + await Promise.all(promises); - for (let i = 0; i < maxRoomsPerGuest; i++) { - promises.push( - createRoom({ - type: 'p', + await request + .post(api('groups.create')) + .set(credentials) + .send({ name: `channel.test.${Date.now()}-${Math.random()}`, members: [guestUser.username], - }), - ); - } - await Promise.all(promises); + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + room = res.body.group; + }); - request - .post(api('groups.create')) - .set(credentials) - .send({ - name: `channel.test.${Date.now()}-${Math.random()}`, - members: [guestUser.username], - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - room = res.body.group; - }) - .then(() => { - request - .get(api('groups.members')) - .set(credentials) - .query({ - roomId: room._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('members').and.to.be.an('array'); - expect(res.body.members).to.have.lengthOf(1); - }); - }); + await request + .get(api('groups.members')) + .set(credentials) + .query({ + roomId: room._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('members').and.to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + }); + }); }); - }); - describe('/groups.create (encrypted)', () => { - it('should create a new encrypted group', async () => { - await request - .post(api('groups.create')) - .set(credentials) - .send({ - name: `encrypted-${apiPrivateChannelName}`, - extraData: { - encrypted: true, - }, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('group.name', `encrypted-${apiPrivateChannelName}`); - expect(res.body).to.have.nested.property('group.t', 'p'); - expect(res.body).to.have.nested.property('group.msgs', 0); - expect(res.body).to.have.nested.property('group.encrypted', true); - }); + + describe('validate E2E rooms', () => { + it('should create a new encrypted group', async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `encrypted-${apiPrivateChannelName}`, + extraData: { + encrypted: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group.name', `encrypted-${apiPrivateChannelName}`); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + expect(res.body).to.have.nested.property('group.encrypted', true); + }); + }); }); - it('should create the encrypted room by default', async () => { - await updateSetting('E2E_Enabled_Default_PrivateRooms', true); - try { + describe('E2E enabled by default', () => { + before(async () => { + await Promise.all([updateSetting('E2E_Enable', true), updateSetting('E2E_Enabled_Default_PrivateRooms', true)]); + }); + + after(async () => { + await Promise.all([updateSetting('E2E_Enable', false), updateSetting('E2E_Enabled_Default_PrivateRooms', false)]); + }); + + it('should create the encrypted room by default', async () => { await request .post(api('groups.create')) .set(credentials) @@ -149,30 +169,43 @@ describe('[Groups]', function () { expect(res.body).to.have.nested.property('group.msgs', 0); expect(res.body).to.have.nested.property('group.encrypted', true); }); - } finally { - await updateSetting('E2E_Enabled_Default_PrivateRooms', false); - } + }); }); }); - describe('[/groups.info]', () => { + + describe('/groups.info', () => { let testGroup = {}; let groupMessage = {}; - it('creating new group...', (done) => { - request + + const newGroupInfoName = `info-private-channel-test-${Date.now()}`; + + before('creating new group...', async () => { + await request .post(api('groups.create')) .set(credentials) .send({ - name: apiPrivateChannelName, + name: newGroupInfoName, }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { testGroup = res.body.group; + }); + }); + + after('deleting group...', async () => { + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: newGroupInfoName, }) - .end(done); + .expect('Content-Type', 'application/json') + .expect(200); }); - it('should return group basic structure', (done) => { - request + + it('should return group basic structure', async () => { + await request .get(api('groups.info')) .set(credentials) .query({ @@ -183,14 +216,14 @@ describe('[Groups]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('group._id'); - expect(res.body).to.have.nested.property('group.name', apiPrivateChannelName); + expect(res.body).to.have.nested.property('group.name', newGroupInfoName); expect(res.body).to.have.nested.property('group.t', 'p'); expect(res.body).to.have.nested.property('group.msgs', 0); - }) - .end(done); + }); }); - it('sending a message...', (done) => { - request + + it('sending a message...', async () => { + await request .post(api('chat.sendMessage')) .set(credentials) .send({ @@ -204,11 +237,11 @@ describe('[Groups]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); groupMessage = res.body.message; - }) - .end(done); + }); }); - it('REACTing with last message', (done) => { - request + + it('REACTing with last message', async () => { + await request .post(api('chat.react')) .set(credentials) .send({ @@ -219,11 +252,11 @@ describe('[Groups]', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - }) - .end(done); + }); }); - it('STARring last message', (done) => { - request + + it('STARring last message', async () => { + await request .post(api('chat.starMessage')) .set(credentials) .send({ @@ -233,11 +266,11 @@ describe('[Groups]', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - }) - .end(done); + }); }); - it('PINning last message', (done) => { - request + + it('PINning last message', async () => { + await request .post(api('chat.pinMessage')) .set(credentials) .send({ @@ -247,11 +280,11 @@ describe('[Groups]', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - }) - .end(done); + }); }); - it('should return group structure with "lastMessage" object including pin, reaction and star(should be an array) infos', (done) => { - request + + it('should return group structure with "lastMessage" object including pin, reaction and star(should be an array) infos', async () => { + await request .get(api('groups.info')) .set(credentials) .query({ @@ -269,11 +302,10 @@ describe('[Groups]', function () { expect(group.lastMessage).to.have.property('pinnedAt').and.to.be.a('string'); expect(group.lastMessage).to.have.property('pinnedBy').and.to.be.an('object'); expect(group.lastMessage).to.have.property('starred').and.to.be.an('array'); - }) - .end(done); + }); }); - it('should return all groups messages where the last message of array should have the "star" array with USERS star ONLY', (done) => { - request + it('should return all groups messages where the last message of array should have the "star" array with USERS star ONLY', async () => { + await request .get(api('groups.messages')) .set(credentials) .query({ @@ -288,11 +320,11 @@ describe('[Groups]', function () { const lastMessage = messages.filter((message) => message._id === groupMessage._id)[0]; expect(lastMessage).to.have.property('starred').and.to.be.an('array'); expect(lastMessage.starred[0]._id).to.be.equal(adminUsername); - }) - .end(done); + }); }); - it('should return all groups messages where the last message of array should have the "star" array with USERS star ONLY even requested with count and offset params', (done) => { - request + + it('should return all groups messages where the last message of array should have the "star" array with USERS star ONLY even requested with count and offset params', async () => { + await request .get(api('groups.messages')) .set(credentials) .query({ @@ -309,178 +341,321 @@ describe('[Groups]', function () { const lastMessage = messages.filter((message) => message._id === groupMessage._id)[0]; expect(lastMessage).to.have.property('starred').and.to.be.an('array'); expect(lastMessage.starred[0]._id).to.be.equal(adminUsername); - }) - .end(done); + }); }); }); - it('/groups.invite', async () => { - const roomInfo = await getRoomInfo(group._id); - return request - .post(api('groups.invite')) - .set(credentials) - .send({ - roomId: group._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('group._id'); - expect(res.body).to.have.nested.property('group.name', apiPrivateChannelName); - expect(res.body).to.have.nested.property('group.t', 'p'); - expect(res.body).to.have.nested.property('group.msgs', roomInfo.group.msgs + 1); - }); - }); + describe('/groups.invite', async () => { + let roomInfo = {}; - it('/groups.addModerator', (done) => { - request - .post(api('groups.addModerator')) - .set(credentials) - .send({ - roomId: group._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + before(async () => { + roomInfo = await getRoomInfo(group._id); + }); + + it('should invite user to group', async () => { + await request + .post(api('groups.invite')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', apiPrivateChannelName); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', roomInfo.group.msgs + 1); + }); + }); }); - it('/groups.removeModerator', (done) => { - request - .post(api('groups.removeModerator')) - .set(credentials) - .send({ - roomId: group._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + describe('/groups.addModerator', () => { + it('should make user a moderator', (done) => { + request + .post(api('groups.addModerator')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); }); - it('/groups.addOwner', (done) => { - request - .post(api('groups.addOwner')) - .set(credentials) - .send({ - roomId: group._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + describe('/groups.removeModerator', () => { + it('should remove user from moderator', (done) => { + request + .post(api('groups.removeModerator')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); }); - it('/groups.removeOwner', (done) => { - request - .post(api('groups.removeOwner')) - .set(credentials) - .send({ - roomId: group._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + describe('/groups.addOwner', () => { + it('should add user as owner', (done) => { + request + .post(api('groups.addOwner')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); }); - it('/groups.addLeader', (done) => { - request - .post(api('groups.addLeader')) - .set(credentials) - .send({ - roomId: group._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.a.property('success', true); - }) - .end(done); + describe('/groups.removeOwner', () => { + it('should remove user from owner', (done) => { + request + .post(api('groups.removeOwner')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); }); - it('/groups.removeLeader', (done) => { - request - .post(api('groups.removeLeader')) - .set(credentials) - .send({ - roomId: group._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + describe('/groups.addLeader', () => { + it('should add user as leader', (done) => { + request + .post(api('groups.addLeader')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.a.property('success', true); + }) + .end(done); + }); }); - it('/groups.kick', (done) => { - request - .post(api('groups.kick')) - .set(credentials) - .send({ - roomId: group._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + describe('/groups.removeLeader', () => { + it('should remove user from leader', (done) => { + request + .post(api('groups.removeLeader')) + .set(credentials) + .send({ + roomId: group._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); }); - it('/groups.invite', async () => { - const roomInfo = await getRoomInfo(group._id); + describe('/groups.kick', () => { + let testUserModerator; + let credsModerator; + let testUserOwner; + let credsOwner; + let testUserMember; + let groupTest; - return request - .post(api('groups.invite')) - .set(credentials) - .send({ - roomId: group._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('group._id'); - expect(res.body).to.have.nested.property('group.name', apiPrivateChannelName); - expect(res.body).to.have.nested.property('group.t', 'p'); - expect(res.body).to.have.nested.property('group.msgs', roomInfo.group.msgs + 1); - }); - }); + const inviteUser = async (userId) => { + await request + .post(api('groups.invite')) + .set(credsOwner) + .send({ + roomId: groupTest._id, + userId, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }; + + before(async () => { + // had to do them in serie because calling them with Promise.all was failing some times + testUserModerator = await createUser(); + testUserOwner = await createUser(); + testUserMember = await createUser(); + + credsModerator = await login(testUserModerator.username, password); + credsOwner = await login(testUserOwner.username, password); + + await request + .post(api('groups.create')) + .set(credsOwner) + .send({ + name: `kick-test-group-${Date.now()}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', 0); + groupTest = res.body.group; + }); + + await inviteUser(testUserModerator._id); + + await request + .post(api('groups.addModerator')) + .set(credsOwner) + .send({ + roomId: groupTest._id, + userId: testUserModerator._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + after(async () => { + await Promise.all([ + request + .post(api('groups.delete')) + .set(credsOwner) + .send({ + roomId: groupTest._id, + }) + .expect('Content-Type', 'application/json') + .expect(200), + // updatePermission('kick-user-from-any-p-room', []), + updatePermission('remove-user', ['admin', 'owner', 'moderator']), + deleteUser(testUserModerator), + deleteUser(testUserOwner), + deleteUser(testUserMember), + ]); + }); + + it("should return an error when user is not a member of the group and doesn't have permission", async () => { + await request + .post(api('groups.kick')) + .set(credentials) + .send({ + roomId: groupTest._id, + userId: testUserMember._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + }); + + it('should allow a moderator to remove user from group', async () => { + await inviteUser(testUserMember._id); + + await request + .post(api('groups.kick')) + .set(credsModerator) + .send({ + roomId: groupTest._id, + userId: testUserMember._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it('should allow an owner to remove user from group', async () => { + await inviteUser(testUserMember._id); + + await request + .post(api('groups.kick')) + .set(credsOwner) + .send({ + roomId: groupTest._id, + userId: testUserMember._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it.skip('should kick user from group if not a member of the room but has the required permission', async () => { + await updatePermission('kick-user-from-any-p-room', ['admin']); + await inviteUser(testUserMember._id); + + await request + .post(api('groups.kick')) + .set(credentials) + .send({ + roomId: group._id, + userId: testUserMember._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it("should return an error when the owner doesn't have the required permission", async () => { + await updatePermission('remove-user', ['admin', 'moderator']); + await inviteUser(testUserMember._id); + + await request + .post(api('groups.kick')) + .set(credsOwner) + .send({ + roomId: groupTest._id, + userId: testUserMember._id, + }) + .expect('Content-Type', 'application/json') - it('/groups.addOwner', (done) => { - request - .post(api('groups.addOwner')) - .set(credentials) - .send({ - roomId: group._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + }); + }); + + it('should return an error when trying to kick the last owner from a group', async () => { + await updatePermission('kick-user-from-any-p-room', ['admin']); + + await request + .post(api('groups.kick')) + .set(credentials) + .send({ + roomId: groupTest._id, + userId: testUserOwner._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-you-are-last-owner'); + }); + }); + + it('should return an error when trying to kick user that does not exist'); + it('should return an error when trying to kick user from a group that does not exist'); + it('should return an error when trying to kick user from a group that the user is not in the room'); }); describe('/groups.setDescription', () => { @@ -623,114 +798,124 @@ describe('[Groups]', function () { }); }); - it('/groups.archive', (done) => { - request - .post(api('groups.archive')) - .set(credentials) - .send({ - roomId: group._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + describe('/groups.archive', () => { + it('should archive the group', (done) => { + request + .post(api('groups.archive')) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); }); - it('/groups.unarchive', (done) => { - request - .post(api('groups.unarchive')) - .set(credentials) - .send({ - roomId: group._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + describe('/groups.unarchive', () => { + it('should unarchive the group', (done) => { + request + .post(api('groups.unarchive')) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); }); - it('/groups.close', (done) => { - request - .post(api('groups.close')) - .set(credentials) - .send({ - roomId: group._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); + describe('/groups.close', () => { + it('should close the group', (done) => { + request + .post(api('groups.close')) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); - it('/groups.close', (done) => { - request - .post(api('groups.close')) - .set(credentials) - .send({ - roomName: apiPrivateChannelName, - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', `The private group, ${apiPrivateChannelName}, is already closed to the sender`); - }) - .end(done); + it('should return an error when trying to close a private group that is already closed', (done) => { + request + .post(api('groups.close')) + .set(credentials) + .send({ + roomName: apiPrivateChannelName, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `The private group, ${apiPrivateChannelName}, is already closed to the sender`); + }) + .end(done); + }); }); - it('/groups.open', (done) => { - request - .post(api('groups.open')) - .set(credentials) - .send({ - roomId: group._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + describe('/groups.open', () => { + it('should open the group', (done) => { + request + .post(api('groups.open')) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); }); - it('/groups.list', (done) => { - request - .get(api('groups.list')) - .set(credentials) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('count'); - expect(res.body).to.have.property('total'); - expect(res.body).to.have.property('groups').and.to.be.an('array'); - }) - .end(done); - }); + describe('/groups.list', () => { + it('should list the groups the caller is part of', (done) => { + request + .get(api('groups.list')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('groups').and.to.be.an('array'); + }) + .end(done); + }); - it('/groups.list should return a list of zero length if not a member of any group', async () => { - const user = await createUser(); - const newCreds = await login(user.username, password); - request - .get(api('groups.list')) - .set(newCreds) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('count').and.to.equal(0); - expect(res.body).to.have.property('total').and.to.equal(0); - expect(res.body).to.have.property('groups').and.to.be.an('array').and.that.has.lengthOf(0); - }); + it('should return a list of zero length if not a member of any group', async () => { + const user = await createUser(); + const newCreds = await login(user.username, password); + await request + .get(api('groups.list')) + .set(newCreds) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('count').and.to.equal(0); + expect(res.body).to.have.property('total').and.to.equal(0); + expect(res.body).to.have.property('groups').and.to.be.an('array').and.that.has.lengthOf(0); + }); + }); }); - describe('[/groups.online]', () => { + describe('/groups.online', () => { const createUserAndChannel = async (setAsOnline = true) => { const testUser = await createUser(); const testUserCredentials = await login(testUser.username, password); @@ -812,7 +997,7 @@ describe('[Groups]', function () { const { room } = await createUserAndChannel(); - return request + await request .get(api('groups.online')) .set(outsiderCredentials) .query(`query={"_id": "${room._id}"}`) @@ -823,6 +1008,7 @@ describe('[Groups]', function () { }); }); }); + describe('/groups.members', () => { it('should return group members when searching by roomId', (done) => { request @@ -864,7 +1050,7 @@ describe('[Groups]', function () { }); }); - describe('[/groups.files]', async () => { + describe('/groups.files', async () => { await testFileUploads('groups.files', group); }); @@ -899,192 +1085,204 @@ describe('[Groups]', function () { }); }); - it('/groups.counters', (done) => { - request - .get(api('groups.counters')) - .set(credentials) - .query({ - roomId: group._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('joined', true); - expect(res.body).to.have.property('members'); - expect(res.body).to.have.property('unreads'); - expect(res.body).to.have.property('unreadsFrom'); - expect(res.body).to.have.property('msgs'); - expect(res.body).to.have.property('latest'); - expect(res.body).to.have.property('userMentions'); - }) - .end(done); + describe('/groups.counters', () => { + it('should return group counters', (done) => { + request + .get(api('groups.counters')) + .set(credentials) + .query({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('joined', true); + expect(res.body).to.have.property('members'); + expect(res.body).to.have.property('unreads'); + expect(res.body).to.have.property('unreadsFrom'); + expect(res.body).to.have.property('msgs'); + expect(res.body).to.have.property('latest'); + expect(res.body).to.have.property('userMentions'); + }) + .end(done); + }); }); - it('/groups.rename', async () => { - const roomInfo = await getRoomInfo(group._id); + describe('/groups.rename', async () => { + let roomInfo; + before(async () => { + roomInfo = await getRoomInfo(group._id); + }); - return request - .post(api('groups.rename')) - .set(credentials) - .send({ - roomId: group._id, - name: `EDITED${apiPrivateChannelName}`, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('group._id'); - expect(res.body).to.have.nested.property('group.name', `EDITED${apiPrivateChannelName}`); - expect(res.body).to.have.nested.property('group.t', 'p'); - expect(res.body).to.have.nested.property('group.msgs', roomInfo.group.msgs + 1); - }); + it('should return the group rename with an additional message', async () => { + await request + .post(api('groups.rename')) + .set(credentials) + .send({ + roomId: group._id, + name: `EDITED${apiPrivateChannelName}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + expect(res.body).to.have.nested.property('group.name', `EDITED${apiPrivateChannelName}`); + expect(res.body).to.have.nested.property('group.t', 'p'); + expect(res.body).to.have.nested.property('group.msgs', roomInfo.group.msgs + 1); + }); + }); }); describe('/groups.getIntegrations', () => { let integrationCreatedByAnUser; - let userCredentials; let createdGroup; - before((done) => { - createRoom({ name: `test-integration-group-${Date.now()}`, type: 'p' }).end((err, res) => { - createdGroup = res.body.group; - createUser().then((createdUser) => { - const user = createdUser; - login(user.username, password).then((credentials) => { - userCredentials = credentials; - updatePermission('manage-incoming-integrations', ['user']).then(() => { - updatePermission('manage-own-incoming-integrations', ['user']).then(() => { - createIntegration( - { - type: 'webhook-incoming', - name: 'Incoming test', - enabled: true, - alias: 'test', - username: 'rocket.cat', - scriptEnabled: false, - overrideDestinationChannelEnabled: true, - channel: `#${createdGroup.name}`, - }, - userCredentials, - ).then((integration) => { - integrationCreatedByAnUser = integration; - done(); - }); - }); - }); - }); - }); - }); + + before(async () => { + const resRoom = await createRoom({ name: `test-integration-group-${Date.now()}`, type: 'p' }); + + createdGroup = resRoom.body.group; + + const user = await createUser(); + + const userCredentials = await login(user.username, password); + + await Promise.all([ + updatePermission('manage-incoming-integrations', ['user']), + updatePermission('manage-own-incoming-integrations', ['user']), + ]); + + integrationCreatedByAnUser = await createIntegration( + { + type: 'webhook-incoming', + name: 'Incoming test', + enabled: true, + alias: 'test', + username: 'rocket.cat', + scriptEnabled: false, + overrideDestinationChannelEnabled: true, + channel: `#${createdGroup.name}`, + }, + userCredentials, + ); }); - after((done) => { - removeIntegration(integrationCreatedByAnUser._id, 'incoming').then(done); + after(async () => { + await removeIntegration(integrationCreatedByAnUser._id, 'incoming'); + + await Promise.all([ + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-outgoing-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + updatePermission('manage-own-outgoing-integrations', ['admin']), + ]); }); - it('should return the list of integrations of create group and it should contain the integration created by user when the admin DOES have the permission', (done) => { - updatePermission('manage-incoming-integrations', ['admin']).then(() => { - request - .get(api('groups.getIntegrations')) - .set(credentials) - .query({ - roomId: createdGroup._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - const integrationCreated = res.body.integrations.find( - (createdIntegration) => createdIntegration._id === integrationCreatedByAnUser._id, - ); - expect(integrationCreated).to.be.an('object'); - expect(integrationCreated._id).to.be.equal(integrationCreatedByAnUser._id); - expect(res.body).to.have.property('offset'); - expect(res.body).to.have.property('total'); - }) - .end(done); - }); + it('should return the list of integrations of create group and it should contain the integration created by user when the admin DOES have the permission', async () => { + await updatePermission('manage-incoming-integrations', ['admin']); + + await request + .get(api('groups.getIntegrations')) + .set(credentials) + .query({ + roomId: createdGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const integrationCreated = res.body.integrations.find( + (createdIntegration) => createdIntegration._id === integrationCreatedByAnUser._id, + ); + expect(integrationCreated).to.be.an('object'); + expect(integrationCreated._id).to.be.equal(integrationCreatedByAnUser._id); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + }); }); - it('should return the list of integrations created by the user only', (done) => { - updatePermission('manage-own-incoming-integrations', ['admin']).then(() => { - updatePermission('manage-incoming-integrations', []).then(() => { - request - .get(api('groups.getIntegrations')) - .set(credentials) - .query({ - roomId: createdGroup._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - const integrationCreated = res.body.integrations.find( - (createdIntegration) => createdIntegration._id === integrationCreatedByAnUser._id, - ); - expect(integrationCreated).to.be.equal(undefined); - expect(res.body).to.have.property('offset'); - expect(res.body).to.have.property('total'); - }) - .end(done); + it('should return the list of integrations created by the user only', async () => { + await Promise.all([ + updatePermission('manage-own-incoming-integrations', ['admin']), + updatePermission('manage-incoming-integrations', []), + ]); + + await request + .get(api('groups.getIntegrations')) + .set(credentials) + .query({ + roomId: createdGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const integrationCreated = res.body.integrations.find( + (createdIntegration) => createdIntegration._id === integrationCreatedByAnUser._id, + ); + expect(integrationCreated).to.be.equal(undefined); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); }); - }); }); - it('should return unauthorized error when the user does not have any integrations permissions', (done) => { - updatePermission('manage-incoming-integrations', []).then(() => { - updatePermission('manage-own-incoming-integrations', []).then(() => { - updatePermission('manage-outgoing-integrations', []).then(() => { - updatePermission('manage-own-outgoing-integrations', []).then(() => { - request - .get(api('groups.getIntegrations')) - .set(credentials) - .query({ - roomId: createdGroup._id, - }) - .expect('Content-Type', 'application/json') - .expect(403) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'unauthorized'); - }) - .end(done); - }); - }); + it('should return unauthorized error when the user does not have any integrations permissions', async () => { + await Promise.all([ + updatePermission('manage-incoming-integrations', []), + updatePermission('manage-outgoing-integrations', []), + updatePermission('manage-own-incoming-integrations', []), + updatePermission('manage-own-outgoing-integrations', []), + ]); + + await request + .get(api('groups.getIntegrations')) + .set(credentials) + .query({ + roomId: createdGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'unauthorized'); }); - }); }); }); - it('/groups.setReadOnly', (done) => { - request - .post(api('groups.setReadOnly')) - .set(credentials) - .send({ - roomId: group._id, - readOnly: true, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + describe('/groups.setReadOnly', () => { + it('should set the group as read only', (done) => { + request + .post(api('groups.setReadOnly')) + .set(credentials) + .send({ + roomId: group._id, + readOnly: true, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); }); - it.skip('/groups.leave', (done) => { - request - .post(api('groups.leave')) - .set(credentials) - .send({ - roomId: group._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + describe.skip('/groups.leave', () => { + it('should allow the user to leave the group', (done) => { + request + .post(api('groups.leave')) + .set(credentials) + .send({ + roomId: group._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); }); describe('/groups.setAnnouncement', () => { @@ -1123,39 +1321,101 @@ describe('[Groups]', function () { }); describe('/groups.setType', () => { - it('should change the type of the group to a channel', (done) => { - request + let roomTypeId; + + before(async () => { + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `channel.type.${Date.now()}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + + roomTypeId = res.body.group._id; + }); + }); + + after(async () => { + await request + .post(api('channels.delete')) + .set(credentials) + .send({ + roomId: roomTypeId, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + + it('should change the type of the group to a channel', async () => { + await request .post(api('groups.setType')) .set(credentials) .send({ - roomId: group._id, + roomId: roomTypeId, type: 'c', }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - }) - .end(done); + expect(res.body).to.have.nested.property('group.t', 'c'); + }); }); }); - describe('/groups.setCustomFields:', () => { + describe('/groups.setCustomFields', () => { let cfchannel; - it('create group with customFields', (done) => { + let groupWithoutCustomFields; + + before('create group with customFields', async () => { const customFields = { field0: 'value0' }; - request + + await request .post(api('groups.create')) .set(credentials) .send({ name: `channel.cf.${Date.now()}`, customFields, }) - .end((err, res) => { + .expect((res) => { cfchannel = res.body.group; - done(); }); + + await request + .post(api('groups.create')) + .set(credentials) + .send({ + name: `channel.cf.${Date.now()}`, + }) + .expect((res) => { + groupWithoutCustomFields = res.body.group; + }); + }); + + after('delete group with customFields', async () => { + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: cfchannel.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: groupWithoutCustomFields.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); }); + it('get customFields using groups.info', (done) => { request .get(api('groups.info')) @@ -1173,7 +1433,7 @@ describe('[Groups]', function () { }); it('change customFields', async () => { const customFields = { field9: 'value9' }; - return request + await request .post(api('groups.setCustomFields')) .set(credentials) .send({ @@ -1206,39 +1466,14 @@ describe('[Groups]', function () { }) .end(done); }); - it('delete group with customFields', (done) => { - request - .post(api('groups.delete')) - .set(credentials) - .send({ - roomName: cfchannel.name, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - it('create group without customFields', (done) => { - request - .post(api('groups.create')) - .set(credentials) - .send({ - name: `channel.cf.${Date.now()}`, - }) - .end((err, res) => { - cfchannel = res.body.group; - done(); - }); - }); + it('set customFields with one nested field', async () => { const customFields = { field1: 'value1' }; - return request + await request .post(api('groups.setCustomFields')) .set(credentials) .send({ - roomId: cfchannel._id, + roomId: groupWithoutCustomFields._id, customFields, }) .expect('Content-Type', 'application/json') @@ -1246,7 +1481,7 @@ describe('[Groups]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('group._id'); - expect(res.body).to.have.nested.property('group.name', cfchannel.name); + expect(res.body).to.have.nested.property('group.name', groupWithoutCustomFields.name); expect(res.body).to.have.nested.property('group.t', 'p'); expect(res.body).to.have.nested.property('group.customFields.field1', 'value1'); }); @@ -1254,11 +1489,11 @@ describe('[Groups]', function () { it('set customFields with multiple nested fields', async () => { const customFields = { field2: 'value2', field3: 'value3', field4: 'value4' }; - return request + await request .post(api('groups.setCustomFields')) .set(credentials) .send({ - roomName: cfchannel.name, + roomName: groupWithoutCustomFields.name, customFields, }) .expect('Content-Type', 'application/json') @@ -1266,7 +1501,7 @@ describe('[Groups]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('group._id'); - expect(res.body).to.have.nested.property('group.name', cfchannel.name); + expect(res.body).to.have.nested.property('group.name', groupWithoutCustomFields.name); expect(res.body).to.have.nested.property('group.t', 'p'); expect(res.body).to.have.nested.property('group.customFields.field2', 'value2'); expect(res.body).to.have.nested.property('group.customFields.field3', 'value3'); @@ -1277,11 +1512,11 @@ describe('[Groups]', function () { it('set customFields to empty object', async () => { const customFields = {}; - return request + await request .post(api('groups.setCustomFields')) .set(credentials) .send({ - roomName: cfchannel.name, + roomName: groupWithoutCustomFields.name, customFields, }) .expect('Content-Type', 'application/json') @@ -1289,7 +1524,7 @@ describe('[Groups]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('group._id'); - expect(res.body).to.have.nested.property('group.name', cfchannel.name); + expect(res.body).to.have.nested.property('group.name', groupWithoutCustomFields.name); expect(res.body).to.have.nested.property('group.t', 'p'); expect(res.body).to.have.not.nested.property('group.customFields.field2', 'value2'); expect(res.body).to.have.not.nested.property('group.customFields.field3', 'value3'); @@ -1303,7 +1538,7 @@ describe('[Groups]', function () { .post(api('groups.setCustomFields')) .set(credentials) .send({ - roomName: cfchannel.name, + roomName: groupWithoutCustomFields.name, customFields, }) .expect('Content-Type', 'application/json') @@ -1313,37 +1548,25 @@ describe('[Groups]', function () { }) .end(done); }); - it('delete group with empty customFields', (done) => { - request - .post(api('groups.delete')) - .set(credentials) - .send({ - roomName: cfchannel.name, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); }); describe('/groups.delete', () => { let testGroup; - it('/groups.create', (done) => { - request + before(async () => { + await request .post(api('groups.create')) .set(credentials) .send({ name: `group.test.${Date.now()}`, }) - .end((err, res) => { + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { testGroup = res.body.group; - done(); }); }); - it('/groups.delete', (done) => { + + it('should delete group', (done) => { request .post(api('groups.delete')) .set(credentials) @@ -1357,7 +1580,8 @@ describe('[Groups]', function () { }) .end(done); }); - it('/groups.info', (done) => { + + it('should return group not found', (done) => { request .get(api('groups.info')) .set(credentials) @@ -1376,18 +1600,31 @@ describe('[Groups]', function () { describe('/groups.roles', () => { let testGroup; - it('/groups.create', (done) => { - request + before(async () => { + await request .post(api('groups.create')) .set(credentials) .send({ name: `group.roles.test.${Date.now()}`, }) - .end((err, res) => { + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { testGroup = res.body.group; - done(); }); }); + + after(async () => { + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: testGroup.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + it('/groups.invite', (done) => { request .post(api('groups.invite')) @@ -1451,18 +1688,31 @@ describe('[Groups]', function () { describe('/groups.moderators', () => { let testGroup; - it('/groups.create', (done) => { - request + before(async () => { + await request .post(api('groups.create')) .set(credentials) .send({ name: `group.roles.test.${Date.now()}`, }) - .end((err, res) => { + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { testGroup = res.body.group; - done(); }); }); + + after(async () => { + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: testGroup.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); + it('/groups.invite', (done) => { request .post(api('groups.invite')) @@ -1503,17 +1753,35 @@ describe('[Groups]', function () { describe('/groups.setEncrypted', () => { let testGroup; - it('/groups.create', (done) => { - request + + before(async () => { + await request .post(api('groups.create')) .set(credentials) .send({ name: `group.encrypted.test.${Date.now()}`, }) - .end((err, res) => { + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id'); + testGroup = res.body.group; - done(); }); + + await updateSetting('E2E_Enable', true); + }); + + after(async () => { + await updateSetting('E2E_Enable', false); + + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: testGroup.name, + }) + .expect('Content-Type', 'application/json') + .expect(200); }); it('should return an error when passing no boolean param', (done) => { @@ -1584,16 +1852,28 @@ describe('[Groups]', function () { }); describe('/groups.convertToTeam', () => { - before((done) => { - request + let newGroup; + + before(async () => { + await request .post(api('groups.create')) .set(credentials) .send({ name: `group-${Date.now()}` }) .expect(200) .expect((response) => { - this.newGroup = response.body.group; + newGroup = response.body.group; + }); + }); + + after(async () => { + await request + .post(api('groups.delete')) + .set(credentials) + .send({ + roomName: newGroup.name, }) - .then(() => done()); + .expect('Content-Type', 'application/json') + .expect(200); }); it('should fail to convert group if lacking edit-room permission', (done) => { @@ -1602,7 +1882,7 @@ describe('[Groups]', function () { request .post(api('groups.convertToTeam')) .set(credentials) - .send({ roomId: this.newGroup._id }) + .send({ roomId: newGroup._id }) .expect(403) .expect((res) => { expect(res.body).to.have.a.property('success', false); @@ -1618,7 +1898,7 @@ describe('[Groups]', function () { request .post(api('groups.convertToTeam')) .set(credentials) - .send({ roomId: this.newGroup._id }) + .send({ roomId: newGroup._id }) .expect(403) .expect((res) => { expect(res.body).to.have.a.property('success', false); @@ -1634,7 +1914,7 @@ describe('[Groups]', function () { request .post(api('groups.convertToTeam')) .set(credentials) - .send({ roomId: this.newGroup._id }) + .send({ roomId: newGroup._id }) .expect(200) .expect((res) => { expect(res.body).to.have.a.property('success', true); @@ -1652,7 +1932,7 @@ describe('[Groups]', function () { request .post(api('groups.convertToTeam')) .set(credentials) - .send({ roomId: this.newGroup._id }) + .send({ roomId: newGroup._id }) .expect(400) .expect((res) => { expect(res.body).to.have.a.property('success', false); @@ -1693,6 +1973,7 @@ describe('[Groups]', function () { expect(res.body).to.have.property('success', true); }); }); + after(async () => { await updateSetting('UI_Use_Real_Name', false); diff --git a/apps/meteor/tests/end-to-end/api/09-rooms.js b/apps/meteor/tests/end-to-end/api/09-rooms.js index 5a534fe2674d..533c0b63da44 100644 --- a/apps/meteor/tests/end-to-end/api/09-rooms.js +++ b/apps/meteor/tests/end-to-end/api/09-rooms.js @@ -4,9 +4,10 @@ import path from 'path'; import { expect } from 'chai'; import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; +import { sleep } from '../../../lib/utils/sleep'; import { getCredentials, api, request, credentials } from '../../data/api-data.js'; import { sendSimpleMessage, deleteMessage } from '../../data/chat.helper'; -import { imgURL } from '../../data/interactions.js'; +import { imgURL } from '../../data/interactions'; import { updateEEPermission, updatePermission, updateSetting } from '../../data/permissions.helper'; import { closeRoom, createRoom } from '../../data/rooms.helper'; import { password } from '../../data/user'; @@ -1543,54 +1544,56 @@ describe('[Rooms]', function () { roomId = result.body.room.rid; }); - it('should update group name if user changes username', (done) => { - updateSetting('UI_Use_Real_Name', false).then(() => { - request - .post(api('users.update')) - .set(credentials) - .send({ - userId: testUser._id, - data: { - username: `changed.username.${testUser.username}`, - }, - }) - .end(() => { - request - .get(api('subscriptions.getOne')) - .set(credentials) - .query({ roomId }) - .end((err, res) => { - const { subscription } = res.body; - expect(subscription.name).to.equal(`rocket.cat,changed.username.${testUser.username}`); - done(); - }); - }); - }); + it('should update group name if user changes username', async () => { + await updateSetting('UI_Use_Real_Name', false); + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + username: `changed.username.${testUser.username}`, + }, + }); + + // need to wait for the username update finish + await sleep(300); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId }) + .send() + .expect((res) => { + const { subscription } = res.body; + expect(subscription.name).to.equal(`rocket.cat,changed.username.${testUser.username}`); + }); }); - it('should update group name if user changes name', (done) => { - updateSetting('UI_Use_Real_Name', true).then(() => { - request - .post(api('users.update')) - .set(credentials) - .send({ - userId: testUser._id, - data: { - name: `changed.name.${testUser.username}`, - }, - }) - .end(() => { - request - .get(api('subscriptions.getOne')) - .set(credentials) - .query({ roomId }) - .end((err, res) => { - const { subscription } = res.body; - expect(subscription.fname).to.equal(`changed.name.${testUser.username}, Rocket.Cat`); - done(); - }); - }); - }); + it('should update group name if user changes name', async () => { + await updateSetting('UI_Use_Real_Name', true); + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + name: `changed.name.${testUser.username}`, + }, + }); + + // need to wait for the name update finish + await sleep(300); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId }) + .send() + .expect((res) => { + const { subscription } = res.body; + expect(subscription.fname).to.equal(`changed.name.${testUser.username}, Rocket.Cat`); + }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/10-subscriptions.js b/apps/meteor/tests/end-to-end/api/10-subscriptions.js index f547895eb8a4..531291a99216 100644 --- a/apps/meteor/tests/end-to-end/api/10-subscriptions.js +++ b/apps/meteor/tests/end-to-end/api/10-subscriptions.js @@ -236,7 +236,8 @@ describe('[Subscriptions]', function () { before(async () => { user = await createUser({ username: 'testthread123', password: 'testthread123' }); threadUserCredentials = await login('testthread123', 'testthread123'); - request + + const res = await request .post(api('chat.sendMessage')) .set(threadUserCredentials) .send({ @@ -244,14 +245,13 @@ describe('[Subscriptions]', function () { rid: testChannel._id, msg: 'Starting a Thread', }, - }) - .end((_, res) => { - threadId = res.body.message._id; }); + + threadId = res.body.message._id; }); - after((done) => { - deleteUser(user).then(done); + after(async () => { + await deleteUser(user); }); it('should mark threads as read', async () => { diff --git a/apps/meteor/tests/end-to-end/api/14-assets.js b/apps/meteor/tests/end-to-end/api/14-assets.js index 4e9c61b53301..8248e8c04f09 100644 --- a/apps/meteor/tests/end-to-end/api/14-assets.js +++ b/apps/meteor/tests/end-to-end/api/14-assets.js @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { before, describe, it } from 'mocha'; import { getCredentials, api, request, credentials } from '../../data/api-data.js'; -import { imgURL } from '../../data/interactions.js'; +import { imgURL } from '../../data/interactions'; describe('[Assets]', function () { this.retries(0); diff --git a/apps/meteor/tests/end-to-end/api/20-licenses.js b/apps/meteor/tests/end-to-end/api/20-licenses.js index 993428d34409..9088e4e9e1d9 100644 --- a/apps/meteor/tests/end-to-end/api/20-licenses.js +++ b/apps/meteor/tests/end-to-end/api/20-licenses.js @@ -105,6 +105,52 @@ describe('licenses', function () { }); }); + describe('[/licenses.info]', () => { + it('should fail if not logged in', (done) => { + request + .get(api('licenses.info')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }) + .end(done); + }); + + it('should return limited information if user is unauthorized', (done) => { + request + .get(api('licenses.info')) + .set(unauthorizedUserCredentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('license').and.to.be.an('object'); + expect(res.body.license).to.not.have.property('license'); + expect(res.body.license).to.have.property('tags').and.to.be.an('array'); + }) + .end(done); + }); + + it('should return unrestricted info if user is logged in and is authorized', (done) => { + request + .get(api('licenses.info')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('license').and.to.be.an('object'); + if (process.env.IS_EE) { + expect(res.body.license).to.have.property('license').and.to.be.an('object'); + } + expect(res.body.license).to.have.property('tags').and.to.be.an('array'); + }) + + .end(done); + }); + }); + describe('[/licenses.isEnterprise]', () => { it('should fail if not logged in', (done) => { request diff --git a/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts b/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts index 83efe2c96aa2..044cdf498a88 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts @@ -1,4 +1,4 @@ -import type { ILivechatAgent, ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; +import { UserStatus, type ILivechatAgent, type ILivechatDepartment, type IUser } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { after, before, describe, it } from 'mocha'; import type { Response } from 'supertest'; @@ -16,7 +16,7 @@ import { } from '../../../data/livechat/rooms'; import { updatePermission, updateSetting } from '../../../data/permissions.helper'; import { password } from '../../../data/user'; -import { createUser, deleteUser, getMe, login } from '../../../data/users.helper'; +import { createUser, deleteUser, getMe, login, setUserStatus } from '../../../data/users.helper'; describe('LIVECHAT - Agents', function () { this.retries(0); @@ -94,6 +94,88 @@ describe('LIVECHAT - Agents', function () { expect(agentRecentlyCreated?._id).to.be.equal(agent._id); }); }); + it('should return an array of available agents', async () => { + await updatePermission('edit-omnichannel-contact', ['admin']); + await updatePermission('transfer-livechat-guest', ['admin']); + await updatePermission('manage-livechat-agents', ['admin']); + + await request + .get(api('livechat/users/agent')) + .set(credentials) + .expect('Content-Type', 'application/json') + .query({ onlyAvailable: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.users.every((u: { statusLivechat: string }) => u.statusLivechat === 'available')).to.be.true; + }); + }); + it('should return an array of available/unavailable agents when onlyAvailable is false', async () => { + await request + .get(api('livechat/users/agent')) + .set(credentials) + .expect('Content-Type', 'application/json') + .query({ onlyAvailable: false }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect( + res.body.users.every( + (u: { statusLivechat: string }) => !u.statusLivechat || ['available', 'not-available'].includes(u.statusLivechat), + ), + ).to.be.true; + }); + }); + + it('should return offline agents when showIdleAgents is true', async () => { + await setUserStatus(agent2.credentials, UserStatus.OFFLINE); + await request + .get(api('livechat/users/agent')) + .set(credentials) + .expect('Content-Type', 'application/json') + .query({ showIdleAgents: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect( + res.body.users.every( + (u: { status: UserStatus }) => + !u.status || [UserStatus.ONLINE, UserStatus.OFFLINE, UserStatus.AWAY, UserStatus.BUSY].includes(u.status), + ), + ).to.be.true; + }); + }); + + it('should return only online agents when showIdleAgents is false', async () => { + await setUserStatus(agent2.credentials, UserStatus.ONLINE); + await request + .get(api('livechat/users/agent')) + .set(credentials) + .expect('Content-Type', 'application/json') + .query({ showIdleAgents: false }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.users).to.be.an('array'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('count'); + expect(res.body.users.every((u: { status: UserStatus }) => u.status !== UserStatus.OFFLINE)).to.be.true; + }); + }); + it('should return an array of managers', async () => { await updatePermission('view-livechat-manager', ['admin']); await updatePermission('manage-livechat-agents', ['admin']); diff --git a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts index 61a2719d9cba..e17484d25d83 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts @@ -1,12 +1,30 @@ +import { faker } from '@faker-js/faker'; +import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; import { expect } from 'chai'; import { before, describe, it } from 'mocha'; +import moment from 'moment'; import type { Response } from 'supertest'; import { getCredentials, api, request, credentials } from '../../../data/api-data'; -import { updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { addOrRemoveAgentFromDepartment, createDepartmentWithAnOnlineAgent } from '../../../data/livechat/department'; +import { + closeOmnichannelRoom, + placeRoomOnHold, + sendAgentMessage, + sendMessage, + startANewLivechatRoomAndTakeIt, +} from '../../../data/livechat/rooms'; +import { createAnOnlineAgent } from '../../../data/livechat/users'; +import { sleep } from '../../../data/livechat/utils'; +import { removePermissionFromAllRoles, restorePermissionToRoles, updateSetting } from '../../../data/permissions.helper'; +import type { IUserCredentialsHeader } from '../../../data/user'; +import { IS_EE } from '../../../e2e/config/constants'; describe('LIVECHAT - dashboards', function () { this.retries(0); + // This test is expected to take more time since we're simulating real time conversations to verify analytics + this.timeout(60000); before((done) => getCredentials(done)); @@ -14,6 +32,106 @@ describe('LIVECHAT - dashboards', function () { await updateSetting('Livechat_enabled', true); }); + let department: ILivechatDepartment; + const agents: { + credentials: IUserCredentialsHeader; + user: IUser & { username: string }; + }[] = []; + let avgClosedRoomChatDuration = 0; + + const inactivityTimeout = 3; + + const TOTAL_MESSAGES = { + min: 5, + max: 10, + }; + const DELAY_BETWEEN_MESSAGES = { + min: 1000, + max: (inactivityTimeout - 1) * 1000, + }; + const TOTAL_ROOMS = 7; + + const simulateRealtimeConversation = async (chatInfo: Awaited>[]) => { + const promises = chatInfo.map(async (info) => { + const { room, visitor } = info; + + // send a few messages + const numberOfMessages = Random.between(TOTAL_MESSAGES.min, TOTAL_MESSAGES.max); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of Array(numberOfMessages - 1).keys()) { + // flip a coin to decide who will send the message + const willSendFromAgent = Random.between(0, 1) === 1; + + if (willSendFromAgent) { + await sendAgentMessage(room._id); + } else { + await sendMessage(room._id, faker.lorem.sentence(), visitor.token); + } + + const delay = Random.between(DELAY_BETWEEN_MESSAGES.min, DELAY_BETWEEN_MESSAGES.max); + await sleep(delay); + } + + // Last message is always from visitor so that the chat doesn't get abandoned due to + // "Livechat_visitor_inactivity_timeout" setting + await sendMessage(room._id, faker.lorem.sentence(), visitor.token); + }); + + await Promise.all(promises); + }; + + before(async () => { + if (!IS_EE) { + return; + } + + await updateSetting('Livechat_visitor_inactivity_timeout', inactivityTimeout); + await updateSetting('Livechat_enable_business_hours', false); + + // create dummy test data for further tests + const { department: createdDept, agent: agent1 } = await createDepartmentWithAnOnlineAgent(); + department = createdDept; + + console.log('department', department.name); + + const agent2 = await createAnOnlineAgent(); + await addOrRemoveAgentFromDepartment(department._id, { agentId: agent2.user._id, username: agent2.user.username }, true); + agents.push(agent1); + agents.push(agent2); + + const roomCreationStart = moment(); + // start a few chats + const promises = Array.from(Array(TOTAL_ROOMS).keys()).map((i) => { + // 2 rooms by agent 1 + if (i < 2) { + return startANewLivechatRoomAndTakeIt({ departmentId: department._id, agent: agent1.credentials }); + } + return startANewLivechatRoomAndTakeIt({ departmentId: department._id, agent: agent2.credentials }); + }); + + const results = await Promise.all(promises); + + const chatInfo = results.map((result) => ({ room: result.room, visitor: result.visitor })); + + // simulate messages being exchanged between agents and visitors + await simulateRealtimeConversation(chatInfo); + + // put a chat on hold + await sendAgentMessage(chatInfo[1].room._id); + await placeRoomOnHold(chatInfo[1].room._id); + // close a chat + await closeOmnichannelRoom(chatInfo[4].room._id); + const room5ChatDuration = moment().diff(roomCreationStart, 'seconds'); + // close an abandoned chat + await sendAgentMessage(chatInfo[5].room._id); + await sleep(inactivityTimeout * 1000); // wait for the chat to be considered abandoned + await closeOmnichannelRoom(chatInfo[5].room._id); + const room6ChatDuration = moment().diff(roomCreationStart, 'seconds'); + + avgClosedRoomChatDuration = (room5ChatDuration + room6ChatDuration) / 2; + }); + describe('livechat/analytics/dashboards/conversation-totalizers', () => { const expectedMetrics = [ 'Total_conversations', @@ -25,7 +143,7 @@ describe('LIVECHAT - dashboards', function () { 'Total_visitors', ]; it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/conversation-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -33,7 +151,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an array of conversation totalizers', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/conversation-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -47,12 +165,49 @@ describe('LIVECHAT - dashboards', function () { ); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/conversation-totalizers')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('totalizers'); + expect(result.body.totalizers).to.be.an('array'); + expect(result.body.totalizers).to.have.lengthOf(5); + + const expectedResult = [ + { title: 'Total_conversations', value: 7 }, + { title: 'Open_conversations', value: 4 }, + { title: 'On_Hold_conversations', value: 1 }, + // { title: 'Total_messages', value: 60 }, + { title: 'Total_visitors', value: 7 }, + ]; + + expectedResult.forEach((expected) => { + const resultItem = result.body.totalizers.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + + const minMessages = TOTAL_MESSAGES.min * TOTAL_ROOMS; + + const totalMessages = result.body.totalizers.find((item: any) => item.title === 'Total_messages'); + expect(totalMessages).to.not.be.undefined; + const totalMessagesValue = parseInt(totalMessages.value); + expect(totalMessagesValue).to.be.greaterThanOrEqual(minMessages); + }); }); describe('livechat/analytics/dashboards/productivity-totalizers', () => { const expectedMetrics = ['Avg_response_time', 'Avg_first_response_time', 'Avg_reaction_time', 'Avg_of_waiting_time']; it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/productivity-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -60,7 +215,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an array of productivity totalizers', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/productivity-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -74,12 +229,41 @@ describe('LIVECHAT - dashboards', function () { ); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/productivity-totalizers')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + + // const expected = [ + // // There's a bug in the code for calculation of these 3 values. + // // Due to which it always return 0 + // { title: 'Avg_response_time', value: '00:00:00' }, + // { title: 'Avg_first_response_time', value: '00:00:00' }, + // { title: 'Avg_reaction_time', value: '00:00:00' }, + + // { title: 'Avg_of_waiting_time', value: '00:00:03' }, // approx 3, 5 delta + // ]; + + const avgWaitingTime = result.body.totalizers.find((item: any) => item.title === 'Avg_of_waiting_time'); + expect(avgWaitingTime).to.not.be.undefined; + + const avgWaitingTimeValue = moment.duration(avgWaitingTime.value).asSeconds(); + expect(avgWaitingTimeValue).to.be.closeTo(DELAY_BETWEEN_MESSAGES.max / 1000, 5); + }); }); describe('livechat/analytics/dashboards/chats-totalizers', () => { const expectedMetrics = ['Total_abandoned_chats', 'Avg_of_abandoned_chats', 'Avg_of_chat_duration_time']; it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/chats-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -87,7 +271,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an array of chats totalizers', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/chats-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -101,12 +285,45 @@ describe('LIVECHAT - dashboards', function () { ); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/chats-totalizers')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const expected = [ + { title: 'Total_abandoned_chats', value: 1 }, + { title: 'Avg_of_abandoned_chats', value: '14%' }, + // { title: 'Avg_of_chat_duration_time', value: '00:00:01' }, + ]; + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('totalizers'); + expect(result.body.totalizers).to.be.an('array'); + + expected.forEach((expected) => { + const resultItem = result.body.totalizers.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + + const resultAverageChatDuration = result.body.totalizers.find((item: any) => item.title === 'Avg_of_chat_duration_time'); + expect(resultAverageChatDuration).to.not.be.undefined; + + const resultAverageChatDurationValue = moment.duration(resultAverageChatDuration.value).asSeconds(); + expect(resultAverageChatDurationValue).to.be.closeTo(avgClosedRoomChatDuration, 5); // Keep a margin of 3 seconds + }); }); describe('livechat/analytics/dashboards/agents-productivity-totalizers', () => { const expectedMetrics = ['Busiest_time', 'Avg_of_available_service_time', 'Avg_of_service_time']; it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get( api('livechat/analytics/dashboards/agents-productivity-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z'), @@ -116,7 +333,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an array of agents productivity totalizers', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get( api('livechat/analytics/dashboards/agents-productivity-totalizers?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z'), @@ -132,11 +349,40 @@ describe('LIVECHAT - dashboards', function () { ); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/agents-productivity-totalizers')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + // [ + // { title: 'Busiest_time', value: '- -' }, + // { title: 'Avg_of_available_service_time', value: '00:00:00' }, + // { title: 'Avg_of_service_time', value: '00:00:16' } approx 17, 6 delta + // ], + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('totalizers'); + expect(result.body.totalizers).to.be.an('array'); + + const avgServiceTime = result.body.totalizers.find((item: any) => item.title === 'Avg_of_service_time'); + + expect(avgServiceTime).to.not.be.undefined; + const avgServiceTimeValue = moment.duration(avgServiceTime.value).asSeconds(); + const minChatDuration = (DELAY_BETWEEN_MESSAGES.min * TOTAL_MESSAGES.min) / 1000; + const maxChatDuration = (DELAY_BETWEEN_MESSAGES.max * TOTAL_MESSAGES.max) / 1000; + expect(avgServiceTimeValue).to.be.closeTo((minChatDuration + maxChatDuration) / 2, 10); + }); }); describe('livechat/analytics/dashboards/charts/chats', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/chats?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -144,7 +390,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an array of productivity totalizers', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/chats?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -157,11 +403,35 @@ describe('LIVECHAT - dashboards', function () { expect(res.body).to.have.property('queued'); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/chats')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const expected = { + open: 4, + closed: 2, + queued: 0, + onhold: 1, + }; + + expect(result.body).to.have.property('success', true); + + Object.entries(expected).forEach(([key, value]) => { + expect(result.body).to.have.property(key, value); + }); + }); }); describe('livechat/analytics/dashboards/charts/chats-per-agent', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/chats-per-agent?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -169,7 +439,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an object with open and closed chats by agent', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/chats-per-agent?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -179,11 +449,39 @@ describe('LIVECHAT - dashboards', function () { expect(res.body).to.have.property('success', true); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/chats-per-agent')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const expected = { + agent0: { open: 1, closed: 0, onhold: 1 }, + agent1: { open: 3, closed: 2 }, + }; + + expect(result.body).to.have.property('success', true); + + const agent0 = result.body[agents[0].user.username as string]; + const agent1 = result.body[agents[1].user.username as string]; + + Object.entries(expected.agent0).forEach(([key, value]) => { + expect(agent0).to.have.property(key, value); + }); + Object.entries(expected.agent1).forEach(([key, value]) => { + expect(agent1).to.have.property(key, value); + }); + }); }); describe('livechat/analytics/dashboards/charts/agents-status', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/agents-status')) .set(credentials) @@ -191,7 +489,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an object with agents status metrics', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/agents-status')) .set(credentials) @@ -205,11 +503,36 @@ describe('LIVECHAT - dashboards', function () { expect(res.body).to.have.property('available'); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/agents-status')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + // TODO: We can improve tests further by creating some agents with different status + const expected = { + offline: 0, + away: 0, + busy: 0, + available: 2, + }; + + expect(result.body).to.have.property('success', true); + + Object.entries(expected).forEach(([key, value]) => { + expect(result.body).to.have.property(key, value); + }); + }); }); describe('livechat/analytics/dashboards/charts/chats-per-department', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/chats-per-department?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -217,7 +540,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an object with open and closed chats by department', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/chats-per-department?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -227,11 +550,34 @@ describe('LIVECHAT - dashboards', function () { expect(res.body).to.have.property('success', true); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/chats-per-department')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + const expected = { + department0: { open: 5, closed: 2 }, + }; + + expect(result.body).to.have.property('success', true); + + const department0 = result.body[department.name]; + + Object.entries(expected.department0).forEach(([key, value]) => { + expect(department0).to.have.property(key, value); + }); + }); }); describe('livechat/analytics/dashboards/charts/timings', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/timings?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -239,7 +585,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an object with open and closed chats by department', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/dashboards/charts/timings?start=2019-10-25T15:08:17.248Z&end=2019-12-08T15:08:17.248Z')) .set(credentials) @@ -258,11 +604,52 @@ describe('LIVECHAT - dashboards', function () { expect(res.body.chatDuration).to.have.property('longest'); }); }); + (IS_EE ? it : it.skip)('should return data with correct values', async () => { + const start = moment().subtract(1, 'days').toISOString(); + const end = moment().toISOString(); + + const result = await request + .get(api('livechat/analytics/dashboards/charts/timings')) + .query({ start, end, departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + + // const expected = { + // response: { avg: 0, longest: 0.207 }, // avg between delayBetweenMessage.min and delayBetweenMessage.max + // reaction: { avg: 0, longest: 0.221 }, // avg between delayBetweenMessage.min and delayBetweenMessage.max + // chatDuration: { avg: 0, longest: 0.18 }, // avg should be about avgClosedRoomChatDuration, and longest should be greater than avgClosedRoomChatDuration and within delta of 20 + // success: true, + // }; + + const maxChatDuration = (DELAY_BETWEEN_MESSAGES.max * TOTAL_MESSAGES.max) / 1000; + + const responseValues = result.body.response; + expect(responseValues).to.have.property('avg'); + expect(responseValues).to.have.property('longest'); + expect(responseValues.avg).to.be.closeTo((DELAY_BETWEEN_MESSAGES.min + DELAY_BETWEEN_MESSAGES.max) / 2000, 5); + expect(responseValues.longest).to.be.lessThan(maxChatDuration); + + const reactionValues = result.body.reaction; + expect(reactionValues).to.have.property('avg'); + expect(reactionValues).to.have.property('longest'); + expect(reactionValues.avg).to.be.closeTo((DELAY_BETWEEN_MESSAGES.min + DELAY_BETWEEN_MESSAGES.max) / 2000, 5); + expect(reactionValues.longest).to.be.lessThan(maxChatDuration); + + const chatDurationValues = result.body.chatDuration; + expect(chatDurationValues).to.have.property('avg'); + expect(chatDurationValues).to.have.property('longest'); + expect(chatDurationValues.avg).to.be.closeTo(avgClosedRoomChatDuration, 5); + expect(chatDurationValues.longest).to.be.greaterThan(avgClosedRoomChatDuration); + expect(chatDurationValues.longest).to.be.lessThan(avgClosedRoomChatDuration + 20); + }); }); describe('livechat/analytics/agent-overview', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/agent-overview')) .query({ from: '2020-01-01', to: '2020-01-02', name: 'Total_conversations' }) @@ -271,7 +658,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an "invalid-chart-name error" when the chart name is empty', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/agent-overview')) .query({ from: '2020-01-01', to: '2020-01-02', name: '' }) @@ -305,11 +692,37 @@ describe('LIVECHAT - dashboards', function () { expect(result.body.head).to.be.an('array'); expect(result.body.data).to.be.an('array'); }); + (IS_EE ? it : it.skip)('should return agent overview data with correct values', async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: yesterday, to: today, name: 'Total_conversations', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + expect(result.body.data).to.have.lengthOf(2); + + const user1Data = result.body.data.find((data: any) => data.name === agents[0].user.username); + const user2Data = result.body.data.find((data: any) => data.name === agents[1].user.username); + + expect(user1Data).to.not.be.undefined; + expect(user2Data).to.not.be.undefined; + + expect(user1Data).to.have.property('value', '28.57%'); + expect(user2Data).to.have.property('value', '71.43%'); + }); }); describe('livechat/analytics/overview', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { - await updatePermission('view-livechat-manager', []); + await removePermissionFromAllRoles('view-livechat-manager'); await request .get(api('livechat/analytics/overview')) .query({ from: '2020-01-01', to: '2020-01-02', name: 'Conversations' }) @@ -318,7 +731,7 @@ describe('LIVECHAT - dashboards', function () { .expect(403); }); it('should return an "invalid-chart-name error" when the chart name is empty', async () => { - await updatePermission('view-livechat-manager', ['admin']); + await restorePermissionToRoles('view-livechat-manager'); await request .get(api('livechat/analytics/overview')) .query({ from: '2020-01-01', to: '2020-01-02', name: '' }) @@ -351,5 +764,43 @@ describe('LIVECHAT - dashboards', function () { expect(result.body[0]).to.have.property('title', 'Total_conversations'); expect(result.body[0]).to.have.property('value', 0); }); + (IS_EE ? it : it.skip)('should return analytics overview data with correct values', async () => { + const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD'); + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/overview')) + .query({ from: yesterday, to: today, name: 'Conversations', departmentId: department._id }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.be.an('array'); + + const expectedResult = [ + { title: 'Total_conversations', value: 7 }, + { title: 'Open_conversations', value: 4 }, + { title: 'On_Hold_conversations', value: 1 }, + // { title: 'Total_messages', value: 6 }, + // { title: 'Busiest_day', value: moment().format('dddd') }, // TODO: need to check y this return a day before + { title: 'Conversations_per_day', value: '3.50' }, + { title: 'Busiest_time', value: '- -' }, + ]; + + expectedResult.forEach((expected) => { + const resultItem = result.body.find((item: any) => item.title === expected.title); + expect(resultItem).to.not.be.undefined; + expect(resultItem).to.have.property('value', expected.value); + }); + + const minMessages = TOTAL_MESSAGES.min * TOTAL_ROOMS; + const maxMessages = TOTAL_MESSAGES.max * TOTAL_ROOMS; + + const totalMessages = result.body.find((item: any) => item.title === 'Total_messages'); + expect(totalMessages).to.not.be.undefined; + const totalMessagesValue = parseInt(totalMessages.value); + expect(totalMessagesValue).to.be.greaterThanOrEqual(minMessages); + expect(totalMessagesValue).to.be.lessThanOrEqual(maxMessages); + }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts index a3ca544de20c..55ef4402da39 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts @@ -2,7 +2,8 @@ import { faker } from '@faker-js/faker'; import type { ILivechatVisitor } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { before, describe, it } from 'mocha'; -import type { Response } from 'supertest'; +import moment from 'moment'; +import { type Response } from 'supertest'; import { getCredentials, api, request, credentials } from '../../../data/api-data'; import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; @@ -334,6 +335,27 @@ describe('LIVECHAT - visitors', function () { }); }); + it('should return visitor activity field when visitor was active on month', async () => { + // Activity is determined by a conversation in which an agent has engaged (sent a message) + // For a visitor to be considered active, they must have had a conversation in the last 30 days + const period = moment().format('YYYY-MM'); + const { visitor, room } = await startANewLivechatRoomAndTakeIt(); + // agent should send a message on the room + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: room._id, + msg: 'test', + }, + }); + + const activeVisitor = await getLivechatVisitorByToken(visitor.token); + expect(activeVisitor).to.have.property('activity'); + expect(activeVisitor.activity).to.include(period); + }); + it("should return a 'error-removing-visitor' error when removeGuest's result is false", async () => { await request .delete(api('livechat/visitor/123')) diff --git a/apps/meteor/tests/end-to-end/api/livechat/22-monitors.ts b/apps/meteor/tests/end-to-end/api/livechat/22-monitors.ts new file mode 100644 index 000000000000..d4c2fd59211c --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/22-monitors.ts @@ -0,0 +1,201 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, it, describe } from 'mocha'; + +import { getCredentials, api, request } from '../../../data/api-data'; +import { addOrRemoveAgentFromDepartment, createDepartment } from '../../../data/livechat/department'; +import { + createAgent, + createLivechatRoom, + createManager, + createVisitor, + getLivechatRoomInfo, + makeAgentAvailable, +} from '../../../data/livechat/rooms'; +import { createMonitor, createUnit } from '../../../data/livechat/units'; +import { updateSetting, updatePermission } from '../../../data/permissions.helper'; +import { password } from '../../../data/user'; +import { createUser, login, setUserActiveStatus } from '../../../data/users.helper'; +import { IS_EE } from '../../../e2e/config/constants'; + +type TestUser = { user: IUser; credentials: { 'X-Auth-Token': string; 'X-User-Id': string } }; + +(IS_EE ? describe : describe.skip)('Omnichannel - Monitors', () => { + let manager: TestUser; + let monitor: TestUser; + let noUnitDepartment: ILivechatDepartment; + let unitDepartment: ILivechatDepartment; + + before((done) => getCredentials(done)); + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await setUserActiveStatus('rocketchat.internal.admin.test', true); + await createAgent(); + await makeAgentAvailable(); + }); + before(async () => { + const user: IUser = await createUser(); + const userCredentials = await login(user.username, password); + if (!user.username) { + throw new Error('user not created'); + } + await createManager(user.username); + + manager = { + user, + credentials: userCredentials, + }; + }); + before(async () => { + const user: IUser = await createUser(); + const userCredentials = await login(user.username, password); + if (!user.username) { + throw new Error('user not created'); + } + await createMonitor(user.username); + + monitor = { + user, + credentials: userCredentials, + }; + }); + before(async () => { + noUnitDepartment = await createDepartment(); + unitDepartment = await createDepartment(); + + await createUnit(monitor.user._id, monitor.user.username!, [unitDepartment._id]); + }); + before(async () => { + await updatePermission('transfer-livechat-guest', ['admin', 'livechat-manager', 'livechat-agent', 'livechat-monitor']); + }); + + describe('Monitors & Rooms', () => { + it('should not return a room of a department that the monitor is not assigned to', async () => { + const visitor = await createVisitor(noUnitDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request + .get(api('livechat/rooms')) + .set(monitor.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('rooms').that.is.an('array'); + expect(body.rooms.find((r: any) => r._id === room._id)).to.not.exist; + }); + it('should return a room of a department the monitor is assigned to', async () => { + const visitor = await createVisitor(unitDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request + .get(api('livechat/rooms')) + .set(monitor.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('rooms').that.is.an('array'); + expect(body.rooms.find((r: any) => r._id === room._id)).to.exist; + }); + }); + + describe('Monitors & Departments', () => { + it('should not return a department that the monitor is not assigned to', async () => { + const { body } = await request + .get(api('livechat/department')) + .query({ onlyMyDepartments: true }) + .set(monitor.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('departments').that.is.an('array'); + expect(body.departments.find((d: any) => d._id === noUnitDepartment._id)).to.not.exist; + }); + it('should return a department that the monitor is assigned to', async () => { + const { body } = await request + .get(api('livechat/department')) + .query({ onlyMyDepartments: true }) + .set(monitor.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('departments').that.is.an('array'); + expect(body.departments.length).to.be.equal(1); + expect(body.departments.find((d: any) => d._id === unitDepartment._id)).to.exist; + }); + it('should return both created departments to a manager', async () => { + const { body } = await request + .get(api('livechat/department')) + .query({ onlyMyDepartments: true, sort: '{ "_updatedAt": 1 }' }) + .set(manager.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('departments').that.is.an('array'); + expect(body.departments.find((d: any) => d._id === noUnitDepartment._id)).to.exist; + expect(body.departments.find((d: any) => d._id === unitDepartment._id)).to.exist; + }); + it('should not return a department when monitor is only assigned as agent there', async () => { + await createAgent(monitor.user.username!); + await addOrRemoveAgentFromDepartment( + noUnitDepartment._id, + { agentId: monitor.user._id, username: monitor.user.username!, count: 0, order: 0 }, + true, + ); + + const { body } = await request + .get(api('livechat/department')) + .query({ onlyMyDepartments: true }) + .set(monitor.credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('departments').that.is.an('array'); + expect(body.departments.length).to.be.equal(1); + expect(body.departments.find((d: any) => d._id === noUnitDepartment._id)).to.not.exist; + }); + }); + + describe('Monitors & Forward', () => { + it('should successfully forward a room to another agent', async () => { + const visitor = await createVisitor(unitDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request + .post(api('livechat/room.forward')) + .set(monitor.credentials) + .send({ + roomId: room._id, + userId: 'rocketchat.internal.admin.test', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + + const room2 = await getLivechatRoomInfo(room._id); + + expect(room2).to.have.property('servedBy').that.is.an('object'); + expect(room2.servedBy).to.have.property('_id', 'rocketchat.internal.admin.test'); + }); + it('should successfully forward a room to a department', async () => { + const visitor = await createVisitor(noUnitDepartment._id); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request + .post(api('livechat/room.forward')) + .set(monitor.credentials) + .send({ + roomId: room._id, + departmentId: unitDepartment._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.property('success', true); + + const room2 = await getLivechatRoomInfo(room._id); + expect(room2.departmentId).to.be.equal(unitDepartment._id); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/livechat/23-mac.ts b/apps/meteor/tests/end-to-end/api/livechat/23-mac.ts new file mode 100644 index 000000000000..4249086c5c80 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/23-mac.ts @@ -0,0 +1,69 @@ +import type { ILivechatVisitor } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, describe, it } from 'mocha'; +import moment from 'moment'; + +import { api, getCredentials, request, credentials } from '../../../data/api-data'; +import { + createVisitor, + createLivechatRoom, + createAgent, + makeAgentAvailable, + sendAgentMessage, + getLivechatRoomInfo, +} from '../../../data/livechat/rooms'; +import { IS_EE } from '../../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('MAC', () => { + before((done) => getCredentials(done)); + + before(async () => { + await createAgent(); + await makeAgentAvailable(); + }); + + describe('MAC rooms', () => { + let visitor: ILivechatVisitor; + it('Should create an innactive room by default', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + expect(room).to.be.an('object'); + expect(room.v.activity).to.be.undefined; + }); + + it('should mark room as active when agent sends a message', async () => { + visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + await sendAgentMessage(room._id); + + const updatedRoom = await getLivechatRoomInfo(room._id); + + expect(updatedRoom).to.have.nested.property('v.activity').and.to.be.an('array'); + }); + + it('should mark multiple rooms as active when they come from same visitor', async () => { + const room = await createLivechatRoom(visitor.token); + + await sendAgentMessage(room._id); + + const updatedRoom = await getLivechatRoomInfo(room._id); + + expect(updatedRoom).to.have.nested.property('v.activity').and.to.be.an('array'); + }); + + it('visitor should be marked as active for period', async () => { + const { body } = await request + .get(api(`livechat/visitors.info?visitorId=${visitor._id}`)) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(body).to.have.nested.property('visitor').and.to.be.an('object'); + expect(body.visitor).to.have.nested.property('activity').and.to.be.an('array'); + expect(body.visitor.activity).to.have.lengthOf(1); + expect(body.visitor.activity[0]).to.equal(moment.utc().format('YYYY-MM')); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts b/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts new file mode 100644 index 000000000000..b91165fb3ca9 --- /dev/null +++ b/apps/meteor/tests/unit/server/users/saveUserIdentity.spec.ts @@ -0,0 +1,134 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +// Create stubs for dependencies +const stubs = { + findOneUserById: sinon.stub(), + updateUsernameAndMessageOfMentionByIdAndOldUsername: sinon.stub(), + updateUsernameOfEditByUserId: sinon.stub(), + updateAllUsernamesByUserId: sinon.stub(), + updateDirectNameAndFnameByName: sinon.stub(), + updateUserReferences: sinon.stub(), + setUsername: sinon.stub(), + setRealName: sinon.stub(), + validateName: sinon.stub(), + FileUpload: sinon.stub(), +}; + +const { saveUserIdentity } = proxyquire.noCallThru().load('../../../../app/lib/server/functions/saveUserIdentity', { + '@rocket.chat/models': { + Users: { + findOneById: stubs.findOneUserById, + }, + Messages: { + updateUsernameAndMessageOfMentionByIdAndOldUsername: stubs.updateUsernameAndMessageOfMentionByIdAndOldUsername, + updateUsernameOfEditByUserId: stubs.updateUsernameOfEditByUserId, + updateAllUsernamesByUserId: stubs.updateAllUsernamesByUserId, + }, + Subscriptions: { + updateDirectNameAndFnameByName: stubs.updateDirectNameAndFnameByName, + }, + VideoConference: { + updateUserReferences: stubs.updateUserReferences, + }, + }, + 'meteor/meteor': { + 'Meteor': sinon.stub(), + '@global': true, + }, + '../../../../app/file-upload/server': { + FileUpload: stubs.FileUpload, + }, + '../../../../app/lib/server/functions/setRealName': { + _setRealName: stubs.setRealName, + }, + '../../../../app/lib/server/functions/setUsername': { + _setUsername: stubs.setUsername, + }, + '../../../../app/lib/server/functions/updateGroupDMsName': { + updateGroupDMsName: sinon.stub(), + }, + '../../../../app/lib/server/functions/validateName': { + validateName: stubs.validateName, + }, +}); + +describe('Users - saveUserIdentity', () => { + beforeEach(() => { + // Reset stubs before each test + Object.values(stubs).forEach((stub) => stub.reset()); + }); + + it('should return false if _id is not provided', async () => { + const result = await saveUserIdentity({ _id: undefined }); + + expect(stubs.findOneUserById.called).to.be.false; + expect(result).to.be.false; + }); + + it('should return false if user does not exist', async () => { + stubs.findOneUserById.returns(undefined); + const result = await saveUserIdentity({ _id: 'valid_id' }); + + expect(stubs.findOneUserById.calledWith('valid_id')).to.be.true; + expect(result).to.be.false; + }); + + it('should return false if username is not allowed', async () => { + stubs.findOneUserById.returns({ username: 'oldUsername' }); + stubs.validateName.returns(false); + const result = await saveUserIdentity({ _id: 'valid_id', username: 'admin' }); + + expect(stubs.validateName.calledWith('admin')).to.be.true; + expect(result).to.be.false; + }); + + it('should return false if username is invalid or unavailable', async () => { + stubs.findOneUserById.returns({ username: 'oldUsername' }); + stubs.validateName.returns(true); + stubs.setUsername.returns(false); + const result = await saveUserIdentity({ _id: 'valid_id', username: 'invalidUsername' }); + + expect(stubs.validateName.calledWith('invalidUsername')).to.be.true; + expect(stubs.setUsername.calledWith('valid_id', 'invalidUsername', { username: 'oldUsername' })).to.be.true; + expect(result).to.be.false; + }); + + it("should not update the username if it's not changed", async () => { + stubs.findOneUserById.returns({ username: 'oldUsername', name: 'oldName' }); + stubs.validateName.returns(true); + stubs.setUsername.returns(true); + await saveUserIdentity({ _id: 'valid_id', username: 'oldUsername', name: 'oldName' }); + + expect(stubs.validateName.called).to.be.false; + expect(stubs.setUsername.called).to.be.false; + expect(stubs.updateUsernameOfEditByUserId.called).to.be.false; + expect(stubs.updateAllUsernamesByUserId.called).to.be.false; + expect(stubs.updateUsernameAndMessageOfMentionByIdAndOldUsername.called).to.be.false; + expect(stubs.updateDirectNameAndFnameByName.called).to.be.false; + expect(stubs.updateUserReferences.called).to.be.false; + }); + + it('should return false if _setName fails', async () => { + stubs.findOneUserById.returns({ name: 'oldName' }); + stubs.setRealName.returns(false); + const result = await saveUserIdentity({ _id: 'valid_id', name: 'invalidName' }); + + expect(stubs.setRealName.calledWith('valid_id', 'invalidName', { name: 'oldName' })).to.be.true; + expect(result).to.be.false; + }); + + it('should update Subscriptions and VideoConference if name changes', async () => { + stubs.findOneUserById.returns({ name: 'oldName', username: 'oldUsername' }); + stubs.setRealName.returns(true); + const result = await saveUserIdentity({ _id: 'valid_id', name: 'name', username: 'oldUsername' }); + + expect(stubs.setUsername.called).to.be.false; + expect(stubs.setRealName.called).to.be.true; + expect(stubs.updateUsernameOfEditByUserId.called).to.be.false; + expect(stubs.updateDirectNameAndFnameByName.called).to.be.true; + expect(stubs.updateUserReferences.called).to.be.true; + expect(result).to.be.true; + }); +}); diff --git a/ee/apps/account-service/CHANGELOG.md b/ee/apps/account-service/CHANGELOG.md index 2b8f68d82542..81958cf94cc6 100644 --- a/ee/apps/account-service/CHANGELOG.md +++ b/ee/apps/account-service/CHANGELOG.md @@ -1,5 +1,45 @@ # @rocket.chat/account-service +## 0.2.9 + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [5cee21468e] +- Updated dependencies [2db32f0d4a] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [aaefe865a7] +- Updated dependencies [357a3a50fa] +- Updated dependencies [f556518fa1] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [9496f1eb97] +- Updated dependencies [d45365436e] +- Updated dependencies [93d4912e17] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/rest-typings@6.4.0 + - @rocket.chat/model-typings@0.1.0 + - @rocket.chat/core-services@0.2.0 + - @rocket.chat/models@0.0.15 + +## 0.2.9-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/rest-typings@6.4.0-rc.5 + - @rocket.chat/core-services@0.2.0-rc.5 + - @rocket.chat/model-typings@0.1.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + ## 0.2.8-rc.4 ### Patch Changes diff --git a/ee/apps/account-service/package.json b/ee/apps/account-service/package.json index 5828b25ebcd3..9038b8cf2ef0 100644 --- a/ee/apps/account-service/package.json +++ b/ee/apps/account-service/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/account-service", "private": true, - "version": "0.2.8-rc.4", + "version": "0.2.9", "description": "Rocket.Chat Account service", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/ee/apps/authorization-service/CHANGELOG.md b/ee/apps/authorization-service/CHANGELOG.md index c88f11bdaca0..e1f254d06053 100644 --- a/ee/apps/authorization-service/CHANGELOG.md +++ b/ee/apps/authorization-service/CHANGELOG.md @@ -1,5 +1,45 @@ # @rocket.chat/authorization-service +## 0.2.9 + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [5cee21468e] +- Updated dependencies [2db32f0d4a] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [aaefe865a7] +- Updated dependencies [357a3a50fa] +- Updated dependencies [f556518fa1] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [9496f1eb97] +- Updated dependencies [d45365436e] +- Updated dependencies [93d4912e17] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/rest-typings@6.4.0 + - @rocket.chat/model-typings@0.1.0 + - @rocket.chat/core-services@0.2.0 + - @rocket.chat/models@0.0.15 + +## 0.2.9-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/rest-typings@6.4.0-rc.5 + - @rocket.chat/core-services@0.2.0-rc.5 + - @rocket.chat/model-typings@0.1.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + ## 0.2.8-rc.4 ### Patch Changes diff --git a/ee/apps/authorization-service/package.json b/ee/apps/authorization-service/package.json index 9eac1c8b04fc..8aff178aeecd 100644 --- a/ee/apps/authorization-service/package.json +++ b/ee/apps/authorization-service/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/authorization-service", "private": true, - "version": "0.2.8-rc.4", + "version": "0.2.9", "description": "Rocket.Chat Authorization service", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/ee/apps/ddp-streamer/CHANGELOG.md b/ee/apps/ddp-streamer/CHANGELOG.md index 1054c42ff2de..1bafaf49ea7e 100644 --- a/ee/apps/ddp-streamer/CHANGELOG.md +++ b/ee/apps/ddp-streamer/CHANGELOG.md @@ -1,5 +1,51 @@ # @rocket.chat/ddp-streamer +## 0.1.9 + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [5cee21468e] +- Updated dependencies [2db32f0d4a] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [aaefe865a7] +- Updated dependencies [074db3b419] +- Updated dependencies [357a3a50fa] +- Updated dependencies [f556518fa1] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [9496f1eb97] +- Updated dependencies [d45365436e] +- Updated dependencies [b8f3d5014f] +- Updated dependencies [93d4912e17] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/rest-typings@6.4.0 + - @rocket.chat/model-typings@0.1.0 + - @rocket.chat/core-services@0.2.0 + - @rocket.chat/ui-contexts@2.0.0 + - @rocket.chat/models@0.0.15 + - @rocket.chat/instance-status@0.0.15 + +## 0.1.9-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/rest-typings@6.4.0-rc.5 + - @rocket.chat/core-services@0.2.0-rc.5 + - @rocket.chat/model-typings@0.1.0-rc.5 + - @rocket.chat/ui-contexts@2.0.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + - @rocket.chat/instance-status@0.0.15-rc.5 + ## 0.1.8-rc.4 ### Patch Changes diff --git a/ee/apps/ddp-streamer/package.json b/ee/apps/ddp-streamer/package.json index 893bc8f09509..aed89a2d1c45 100644 --- a/ee/apps/ddp-streamer/package.json +++ b/ee/apps/ddp-streamer/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/ddp-streamer", "private": true, - "version": "0.1.8-rc.4", + "version": "0.1.9", "description": "Rocket.Chat DDP-Streamer service", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/ee/apps/omnichannel-transcript/CHANGELOG.md b/ee/apps/omnichannel-transcript/CHANGELOG.md index eea1e8489b1b..cb28d7d7a3ee 100644 --- a/ee/apps/omnichannel-transcript/CHANGELOG.md +++ b/ee/apps/omnichannel-transcript/CHANGELOG.md @@ -1,5 +1,45 @@ # @rocket.chat/omnichannel-transcript +## 0.2.9 + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [5cee21468e] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [aaefe865a7] +- Updated dependencies [f556518fa1] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [d45365436e] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/model-typings@0.1.0 + - @rocket.chat/core-services@0.2.0 + - @rocket.chat/tools@0.1.0 + - @rocket.chat/omnichannel-services@0.0.15 + - @rocket.chat/pdf-worker@0.0.15 + - @rocket.chat/models@0.0.15 + +## 0.2.9-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/tools@0.1.0-rc.0 + - @rocket.chat/omnichannel-services@0.0.15-rc.5 + - @rocket.chat/pdf-worker@0.0.15-rc.5 + - @rocket.chat/core-services@0.2.0-rc.5 + - @rocket.chat/model-typings@0.1.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + ## 0.2.8-rc.4 ### Patch Changes diff --git a/ee/apps/omnichannel-transcript/package.json b/ee/apps/omnichannel-transcript/package.json index 851626cbe980..038055b4fb73 100644 --- a/ee/apps/omnichannel-transcript/package.json +++ b/ee/apps/omnichannel-transcript/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/omnichannel-transcript", "private": true, - "version": "0.2.8-rc.4", + "version": "0.2.9", "description": "Rocket.Chat service", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/ee/apps/presence-service/CHANGELOG.md b/ee/apps/presence-service/CHANGELOG.md index 7c2478a73718..f000298cbd18 100644 --- a/ee/apps/presence-service/CHANGELOG.md +++ b/ee/apps/presence-service/CHANGELOG.md @@ -1,5 +1,42 @@ # @rocket.chat/presence-service +## 0.2.9 + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [5cee21468e] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [aaefe865a7] +- Updated dependencies [f556518fa1] +- Updated dependencies [d9a150000d] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [d45365436e] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/model-typings@0.1.0 + - @rocket.chat/core-services@0.2.0 + - @rocket.chat/presence@0.0.15 + - @rocket.chat/models@0.0.15 + +## 0.2.9-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/presence@0.0.15-rc.5 + - @rocket.chat/core-services@0.2.0-rc.5 + - @rocket.chat/model-typings@0.1.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + ## 0.2.8-rc.4 ### Patch Changes diff --git a/ee/apps/presence-service/package.json b/ee/apps/presence-service/package.json index 9dd31e8c0ffd..87c76b4ff5c8 100644 --- a/ee/apps/presence-service/package.json +++ b/ee/apps/presence-service/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/presence-service", "private": true, - "version": "0.2.8-rc.4", + "version": "0.2.9", "description": "Rocket.Chat Presence service", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/ee/apps/queue-worker/CHANGELOG.md b/ee/apps/queue-worker/CHANGELOG.md index adb2e6c38e2e..a0abfb00c5c8 100644 --- a/ee/apps/queue-worker/CHANGELOG.md +++ b/ee/apps/queue-worker/CHANGELOG.md @@ -1,5 +1,41 @@ # @rocket.chat/queue-worker +## 0.2.9 + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [5cee21468e] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [aaefe865a7] +- Updated dependencies [f556518fa1] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [d45365436e] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/model-typings@0.1.0 + - @rocket.chat/core-services@0.2.0 + - @rocket.chat/omnichannel-services@0.0.15 + - @rocket.chat/models@0.0.15 + +## 0.2.9-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/omnichannel-services@0.0.15-rc.5 + - @rocket.chat/core-services@0.2.0-rc.5 + - @rocket.chat/model-typings@0.1.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + ## 0.2.8-rc.4 ### Patch Changes diff --git a/ee/apps/queue-worker/package.json b/ee/apps/queue-worker/package.json index 31aacc6c7c1b..156af4fdc0e6 100644 --- a/ee/apps/queue-worker/package.json +++ b/ee/apps/queue-worker/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/queue-worker", "private": true, - "version": "0.2.8-rc.4", + "version": "0.2.9", "description": "Rocket.Chat service", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/ee/apps/stream-hub-service/CHANGELOG.md b/ee/apps/stream-hub-service/CHANGELOG.md index eb569a157edc..d76068538d9c 100644 --- a/ee/apps/stream-hub-service/CHANGELOG.md +++ b/ee/apps/stream-hub-service/CHANGELOG.md @@ -1,5 +1,39 @@ # @rocket.chat/stream-hub-service +## 0.2.9 + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [5cee21468e] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [aaefe865a7] +- Updated dependencies [f556518fa1] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [d45365436e] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/model-typings@0.1.0 + - @rocket.chat/core-services@0.2.0 + - @rocket.chat/models@0.0.15 + +## 0.2.9-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/core-services@0.2.0-rc.5 + - @rocket.chat/model-typings@0.1.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + ## 0.2.8-rc.4 ### Patch Changes diff --git a/ee/apps/stream-hub-service/package.json b/ee/apps/stream-hub-service/package.json index 43753c77c78d..cc29f058e9c9 100644 --- a/ee/apps/stream-hub-service/package.json +++ b/ee/apps/stream-hub-service/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/stream-hub-service", "private": true, - "version": "0.2.8-rc.4", + "version": "0.2.9", "description": "Rocket.Chat Stream Hub service", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/ee/packages/api-client/CHANGELOG.md b/ee/packages/api-client/CHANGELOG.md index 8369eeee332f..cc600456d258 100644 --- a/ee/packages/api-client/CHANGELOG.md +++ b/ee/packages/api-client/CHANGELOG.md @@ -1,6 +1,34 @@ # @rocket.chat/api-client -## 0.1.8-rc.4 +## 0.1.9 + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [2db32f0d4a] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [357a3a50fa] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [9496f1eb97] +- Updated dependencies [d45365436e] +- Updated dependencies [93d4912e17] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/rest-typings@6.4.0 + +## 0.1.9-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/rest-typings@6.4.0-rc.5 + +## 0.1.9-rc.4 ### Patch Changes diff --git a/ee/packages/api-client/package.json b/ee/packages/api-client/package.json index 1fe0aaf539d0..4827eddb734a 100644 --- a/ee/packages/api-client/package.json +++ b/ee/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/api-client", - "version": "0.1.8-rc.4", + "version": "0.1.9", "devDependencies": { "@swc/core": "^1.3.66", "@swc/jest": "^0.2.26", diff --git a/ee/packages/ddp-client/CHANGELOG.md b/ee/packages/ddp-client/CHANGELOG.md index dd1327fb6042..f623b9a729e6 100644 --- a/ee/packages/ddp-client/CHANGELOG.md +++ b/ee/packages/ddp-client/CHANGELOG.md @@ -1,5 +1,34 @@ # @rocket.chat/ddp-client +## 0.2.0 + +### Minor Changes + +- 982ef6f459: Add new event to notify users directly about new banners + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [2db32f0d4a] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [357a3a50fa] +- Updated dependencies [1041d4d361] +- Updated dependencies [9496f1eb97] +- Updated dependencies [93d4912e17] + - @rocket.chat/rest-typings@6.4.0 + - @rocket.chat/api-client@0.1.9 + +## 0.2.0-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/rest-typings@6.4.0-rc.5 + - @rocket.chat/api-client@0.1.9-rc.5 + ## 0.2.0-rc.4 ### Patch Changes @@ -48,6 +77,13 @@ - @rocket.chat/rest-typings@6.4.0-rc.0 - @rocket.chat/api-client@0.1.5-rc.0 +## 0.1.8 + +### Patch Changes + +- @rocket.chat/rest-typings@6.3.8 +- @rocket.chat/api-client@0.1.8 + ## 0.1.7 ### Patch Changes diff --git a/ee/packages/ddp-client/package.json b/ee/packages/ddp-client/package.json index 0c64d9117894..5f62047c2429 100644 --- a/ee/packages/ddp-client/package.json +++ b/ee/packages/ddp-client/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/ddp-client", - "version": "0.2.0-rc.4", + "version": "0.2.0", "devDependencies": { "@swc/core": "^1.3.66", "@swc/jest": "^0.2.26", diff --git a/ee/packages/ddp-client/src/types/streams.ts b/ee/packages/ddp-client/src/types/streams.ts index a32dec470564..ecbefa3f7d8f 100644 --- a/ee/packages/ddp-client/src/types/streams.ts +++ b/ee/packages/ddp-client/src/types/streams.ts @@ -1,6 +1,5 @@ import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings'; -import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import type { IMessage, IRoom, @@ -24,7 +23,9 @@ import type { ILivechatAgent, IImportProgress, IBanner, + UiKit, } from '@rocket.chat/core-typings'; +import type { LicenseLimitKind } from '@rocket.chat/license'; type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; @@ -69,6 +70,7 @@ export interface StreamerEvents { { key: 'public-settings-changed'; args: ['inserted' | 'updated' | 'removed' | 'changed', ISetting] }, { key: 'deleteCustomSound'; args: [{ soundData: ICustomSound }] }, { key: 'updateCustomSound'; args: [{ soundData: ICustomSound }] }, + { key: 'license'; args: [{ preventedActions: Record }] | [] }, ]; 'notify-user': [ @@ -148,7 +150,7 @@ export interface StreamerEvents { { key: `${string}/notification`; args: [INotificationDesktop] }, { key: `${string}/voip.events`; args: [VoipEventDataSignature] }, { key: `${string}/call.hangup`; args: [{ roomId: string }] }, - { key: `${string}/uiInteraction`; args: [IUIKitInteraction] }, + { key: `${string}/uiInteraction`; args: [UiKit.ServerInteraction] }, { key: `${string}/video-conference`; args: [{ action: string; params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] } }]; @@ -235,6 +237,7 @@ export interface StreamerEvents { }, { key: 'voip.statuschanged'; args: [boolean] }, + { key: 'mac.limit'; args: [{ limitReached: boolean }] }, { key: 'omnichannel.priority-changed'; args: [{ id: string; clientAction: ClientAction; name?: string }] }, ]; diff --git a/ee/packages/license/__tests__/MockedLicenseBuilder.ts b/ee/packages/license/__tests__/MockedLicenseBuilder.ts index 316261744da5..4f2b49596be3 100644 --- a/ee/packages/license/__tests__/MockedLicenseBuilder.ts +++ b/ee/packages/license/__tests__/MockedLicenseBuilder.ts @@ -197,7 +197,6 @@ export class MockedLicenseBuilder { export const getReadyLicenseManager = async () => { const license = new LicenseImp(); await license.setWorkspaceUrl('http://localhost:3000'); - await license.setWorkspaceUrl('http://localhost:3000'); license.setLicenseLimitCounter('activeUsers', () => 0); license.setLicenseLimitCounter('guestUsers', () => 0); diff --git a/ee/packages/license/__tests__/emitter.spec.ts b/ee/packages/license/__tests__/emitter.spec.ts index 4c7c5a8255d1..ce949365e8a6 100644 --- a/ee/packages/license/__tests__/emitter.spec.ts +++ b/ee/packages/license/__tests__/emitter.spec.ts @@ -63,4 +63,297 @@ describe('Event License behaviors', () => { await expect(license.hasValidLicense()).toBe(true); await expect(fn).toBeCalledTimes(1); }); + + describe('behavior:prevent_action event', () => { + it('should emit `behavior:prevent_action` event when the limit is reached', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + + licenseManager.onBehaviorTriggered('prevent_action', fn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(1); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + + it('should emit `limitReached:activeUsers` event when the limit is reached', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + + licenseManager.onLimitReached('activeUsers', fn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(1); + + await expect(fn).toBeCalledWith(undefined); + }); + }); + + /** + * This event is used to sync multiple instances of license manager + * The sync event is triggered when the license is changed, but if the validation is running due to a previous change, no sync should be triggered, avoiding multiple/loops syncs + */ + describe('sync event', () => { + it('should emit `sync` event when the license is changed', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + + licenseManager.onChange(fn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + { + max: 20, + behavior: 'invalidate_license', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 21); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(1); + }); + + it('should not emit `sync` event when the license validation was triggered by a the sync method', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + + licenseManager.onChange(fn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + { + max: 20, + behavior: 'invalidate_license', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 21); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(1); + + fn.mockClear(); + + await expect(licenseManager.sync()).resolves.toBe(undefined); + + await expect(fn).toBeCalledTimes(0); + }); + }); + + /** + * this is only called when the prevent_action behavior is triggered for the first time + * it will not be called again until the behavior is toggled + */ + describe('Toggled behaviors', () => { + it('should emit `behaviorToggled:prevent_action` event when the limit is reached once but `behavior:prevent_action` twice', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + const toggleFn = jest.fn(); + + licenseManager.onBehaviorTriggered('prevent_action', fn); + + licenseManager.onBehaviorToggled('prevent_action', toggleFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + + await expect(fn).toBeCalledTimes(2); + await expect(toggleFn).toBeCalledTimes(1); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + + it('should emit `behaviorToggled:allow_action` event when the limit is not reached once but `behavior:allow_action` twice', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + const toggleFn = jest.fn(); + + licenseManager.onBehaviorTriggered('allow_action', fn); + + licenseManager.onBehaviorToggled('allow_action', toggleFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 9); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + + await expect(fn).toBeCalledTimes(2); + await expect(toggleFn).toBeCalledTimes(1); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + + it('should emit `behaviorToggled:prevent_action` and `behaviorToggled:allow_action` events when the shouldPreventAction function changes the result', async () => { + const licenseManager = await getReadyLicenseManager(); + const preventFn = jest.fn(); + const preventToggleFn = jest.fn(); + const allowFn = jest.fn(); + const allowToggleFn = jest.fn(); + + licenseManager.onBehaviorTriggered('prevent_action', preventFn); + licenseManager.onBehaviorToggled('prevent_action', preventToggleFn); + licenseManager.onBehaviorTriggered('allow_action', allowFn); + licenseManager.onBehaviorToggled('allow_action', allowToggleFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(preventFn).toBeCalledTimes(0); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(1); + expect(allowToggleFn).toBeCalledTimes(1); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(preventFn).toBeCalledTimes(0); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(1); + expect(allowToggleFn).toBeCalledTimes(0); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + expect(preventFn).toBeCalledTimes(1); + expect(preventToggleFn).toBeCalledTimes(1); + expect(allowFn).toBeCalledTimes(0); + expect(allowToggleFn).toBeCalledTimes(0); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + expect(preventFn).toBeCalledTimes(1); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(0); + expect(allowToggleFn).toBeCalledTimes(0); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + + preventFn.mockClear(); + preventToggleFn.mockClear(); + allowFn.mockClear(); + allowToggleFn.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(preventFn).toBeCalledTimes(0); + expect(preventToggleFn).toBeCalledTimes(0); + expect(allowFn).toBeCalledTimes(1); + expect(allowToggleFn).toBeCalledTimes(1); + }); + }); + + describe('Allow actions', () => { + it('should emit `behavior:allow_action` event when the limit is not reached', async () => { + const licenseManager = await getReadyLicenseManager(); + const fn = jest.fn(); + const preventFn = jest.fn(); + + licenseManager.onBehaviorTriggered('allow_action', fn); + licenseManager.onBehaviorTriggered('prevent_action', preventFn); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 9); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + + await expect(fn).toBeCalledTimes(1); + await expect(preventFn).toBeCalledTimes(0); + + await expect(fn).toBeCalledWith({ + reason: 'limit', + limit: 'activeUsers', + }); + }); + }); }); diff --git a/ee/packages/license/__tests__/setLicense.spec.ts b/ee/packages/license/__tests__/setLicense.spec.ts index 962f591750ad..35a7a495edc0 100644 --- a/ee/packages/license/__tests__/setLicense.spec.ts +++ b/ee/packages/license/__tests__/setLicense.spec.ts @@ -14,7 +14,7 @@ const VALID_LICENSE = describe('License set license procedures', () => { describe('Invalid formats', () => { - it('by default it should have no license', async () => { + it('should have no license by default', async () => { const license = new LicenseImp(); expect(license.hasValidLicense()).toBe(false); @@ -39,7 +39,7 @@ describe('License set license procedures', () => { await expect(license.setLicense(VALID_LICENSE)).rejects.toThrow(DuplicatedLicenseError); }); - it('should keep a valid license if a new invalid license is applied', async () => { + it('should keep a valid license if a new invalid formatted license is applied', async () => { const license = await getReadyLicenseManager(); await expect(license.setLicense(VALID_LICENSE)).resolves.toBe(true); @@ -99,5 +99,54 @@ describe('License set license procedures', () => { await expect(license.hasValidLicense()).toBe(true); await expect(license.hasModule('livechat-enterprise')).toBe(true); }); + + it('should call a validated event after set a valid license', async () => { + const license = await getReadyLicenseManager(); + const validateCallback = jest.fn(); + license.onValidateLicense(validateCallback); + await expect(license.setLicense(VALID_LICENSE)).resolves.toBe(true); + await expect(license.hasValidLicense()).toBe(true); + expect(validateCallback).toBeCalledTimes(1); + }); + + describe('License limits', () => { + describe('invalidate license', () => { + it('should trigger an invalidation event when a license with invalid limits is set after a valid one', async () => { + const invalidationCallback = jest.fn(); + + const licenseManager = await getReadyLicenseManager(); + const mocked = await new MockedLicenseBuilder(); + const oldToken = await mocked + .withLimits('activeUsers', [ + { + max: 10, + behavior: 'invalidate_license', + }, + ]) + .sign(); + + const newToken = await mocked + .withLimits('activeUsers', [ + { + max: 1, + behavior: 'invalidate_license', + }, + ]) + .sign(); + + licenseManager.onInvalidateLicense(invalidationCallback); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + + await expect(licenseManager.setLicense(oldToken)).resolves.toBe(true); + await expect(licenseManager.hasValidLicense()).toBe(true); + + await expect(licenseManager.setLicense(newToken)).resolves.toBe(true); + await expect(licenseManager.hasValidLicense()).toBe(false); + + await expect(invalidationCallback).toBeCalledTimes(1); + }); + }); + }); }); }); diff --git a/ee/packages/license/babel.config.json b/ee/packages/license/babel.config.json deleted file mode 100644 index e154c0813530..000000000000 --- a/ee/packages/license/babel.config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "presets": ["@babel/preset-typescript"], - "plugins": [ - [ - "transform-inline-environment-variables", - { - "include": ["LICENSE_PUBLIC_KEY_V3"] - } - ] - ] -} diff --git a/ee/packages/license/package.json b/ee/packages/license/package.json index 24ecdc30bc49..ec79532a9680 100644 --- a/ee/packages/license/package.json +++ b/ee/packages/license/package.json @@ -3,17 +3,11 @@ "version": "0.0.1", "private": true, "devDependencies": { - "@babel/cli": "^7.23.0", - "@babel/core": "^7.23.0", - "@babel/preset-env": "^7.22.20", - "@babel/preset-typescript": "^7.23.0", "@swc/core": "^1.3.66", "@swc/jest": "^0.2.26", - "@types/babel__core": "^7", - "@types/babel__preset-env": "^7", + "@types/bcrypt": "^5.0.0", "@types/jest": "~29.5.3", "@types/ws": "^8.5.5", - "babel-plugin-transform-inline-environment-variables": "^0.4.4", "eslint": "~8.45.0", "jest": "~29.6.1", "jest-environment-jsdom": "~29.6.1", @@ -28,7 +22,7 @@ "testunit": "jest", "build": "npm run build:types && npm run build:js", "build:types": "tsc --emitDeclarationOnly", - "build:js": "babel src --out-dir dist --extensions \".ts,.tsx\" --source-maps inline", + "build:js": "rm -rf dist && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" }, "main": "./dist/index.js", @@ -43,6 +37,6 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/jwt": "workspace:^", "@rocket.chat/logger": "workspace:^", - "@rocket.chat/server-cloud-communication": "workspace:^" + "bcrypt": "^5.0.1" } } diff --git a/ee/packages/license/src/definition/ILicenseV3.ts b/ee/packages/license/src/definition/ILicenseV3.ts index e2a8bd424bb2..d3a2d7f572a3 100644 --- a/ee/packages/license/src/definition/ILicenseV3.ts +++ b/ee/packages/license/src/definition/ILicenseV3.ts @@ -1,5 +1,3 @@ -import type { SignedSupportedVersions } from '@rocket.chat/server-cloud-communication'; - import type { ILicenseTag } from './ILicenseTag'; import type { LicenseLimit } from './LicenseLimit'; import type { LicenseModule } from './LicenseModule'; @@ -61,8 +59,6 @@ export interface ILicenseV3 { monthlyActiveContacts?: LicenseLimit[]; }; cloudMeta?: Record; - - supportedVersions?: SignedSupportedVersions; } export type LicenseLimitKind = keyof ILicenseV3['limits']; diff --git a/ee/packages/license/src/definition/LicenseBehavior.ts b/ee/packages/license/src/definition/LicenseBehavior.ts index b6d52bbfa8c5..ac2249233ab5 100644 --- a/ee/packages/license/src/definition/LicenseBehavior.ts +++ b/ee/packages/license/src/definition/LicenseBehavior.ts @@ -1,8 +1,23 @@ +import type { LicenseLimitKind } from './ILicenseV3'; import type { LicenseModule } from './LicenseModule'; -export type LicenseBehavior = 'invalidate_license' | 'start_fair_policy' | 'prevent_action' | 'prevent_installation' | 'disable_modules'; +export type LicenseBehavior = + | 'invalidate_license' + | 'start_fair_policy' + | 'prevent_action' + | 'allow_action' + | 'prevent_installation' + | 'disable_modules'; -export type BehaviorWithContext = { - behavior: LicenseBehavior; - modules?: LicenseModule[]; -}; +export type BehaviorWithContext = + | { + behavior: LicenseBehavior; + modules?: LicenseModule[]; + reason: 'limit'; + limit?: LicenseLimitKind; + } + | { + behavior: LicenseBehavior; + modules?: LicenseModule[]; + reason: 'period' | 'url'; + }; diff --git a/ee/packages/license/src/definition/LicenseInfo.ts b/ee/packages/license/src/definition/LicenseInfo.ts new file mode 100644 index 000000000000..019d1b9e1ca0 --- /dev/null +++ b/ee/packages/license/src/definition/LicenseInfo.ts @@ -0,0 +1,12 @@ +import type { ILicenseTag } from './ILicenseTag'; +import type { ILicenseV3, LicenseLimitKind } from './ILicenseV3'; +import type { LicenseModule } from './LicenseModule'; + +export type LicenseInfo = { + license?: ILicenseV3; + activeModules: LicenseModule[]; + preventedActions: Record; + limits: Record; + tags: ILicenseTag[]; + trial: boolean; +}; diff --git a/ee/packages/license/src/definition/LicenseModule.ts b/ee/packages/license/src/definition/LicenseModule.ts index 8ecebba1983b..a67a3fd54cb0 100644 --- a/ee/packages/license/src/definition/LicenseModule.ts +++ b/ee/packages/license/src/definition/LicenseModule.ts @@ -15,4 +15,5 @@ export type LicenseModule = | 'federation' | 'videoconference-enterprise' | 'message-read-receipt' - | 'outlook-calendar'; + | 'outlook-calendar' + | 'hide-watermark'; diff --git a/ee/packages/license/src/definition/LicenseValidationOptions.ts b/ee/packages/license/src/definition/LicenseValidationOptions.ts new file mode 100644 index 000000000000..9357b021878b --- /dev/null +++ b/ee/packages/license/src/definition/LicenseValidationOptions.ts @@ -0,0 +1,12 @@ +import type { LicenseLimitKind } from './ILicenseV3'; +import type { LicenseBehavior } from './LicenseBehavior'; +import type { LimitContext } from './LimitContext'; + +export type LicenseValidationOptions = { + behaviors?: LicenseBehavior[]; + limits?: LicenseLimitKind[]; + suppressLog?: boolean; + isNewLicense?: boolean; + context?: Partial<{ [K in LicenseLimitKind]: Partial> }>; + triggerSync?: boolean; +}; diff --git a/ee/packages/license/src/definition/LimitContext.ts b/ee/packages/license/src/definition/LimitContext.ts index a2c44744bd75..9dfc6d36be7f 100644 --- a/ee/packages/license/src/definition/LimitContext.ts +++ b/ee/packages/license/src/definition/LimitContext.ts @@ -2,4 +2,6 @@ import type { IUser } from '@rocket.chat/core-typings'; import type { LicenseLimitKind } from './ILicenseV3'; -export type LimitContext = T extends 'roomsPerGuest' ? { userId: IUser['_id'] } : Record; +export type LimitContext = { extraCount?: number } & (T extends 'roomsPerGuest' + ? { userId: IUser['_id'] } + : Record); diff --git a/ee/packages/license/src/definition/events.ts b/ee/packages/license/src/definition/events.ts new file mode 100644 index 000000000000..b9d211da9b7a --- /dev/null +++ b/ee/packages/license/src/definition/events.ts @@ -0,0 +1,22 @@ +import type { LicenseLimitKind } from './ILicenseV3'; +import type { BehaviorWithContext, LicenseBehavior } from './LicenseBehavior'; +import type { LicenseModule } from './LicenseModule'; + +type ModuleValidation = Record<`${'invalid' | 'valid'}:${LicenseModule}`, undefined>; +type BehaviorTriggered = Record<`behavior:${LicenseBehavior}`, { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }>; +type BehaviorTriggeredToggled = Record< + `behaviorToggled:${LicenseBehavior}`, + { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind } +>; + +type LimitReached = Record<`limitReached:${LicenseLimitKind}`, undefined>; + +export type LicenseEvents = ModuleValidation & + BehaviorTriggeredToggled & + BehaviorTriggered & + LimitReached & { + validate: undefined; + invalidate: undefined; + module: { module: LicenseModule; valid: boolean }; + sync: undefined; + }; diff --git a/ee/packages/license/src/deprecated.ts b/ee/packages/license/src/deprecated.ts index 65851a79c7eb..0a4a6b0f1bb3 100644 --- a/ee/packages/license/src/deprecated.ts +++ b/ee/packages/license/src/deprecated.ts @@ -23,8 +23,8 @@ export function getMaxActiveUsers(this: LicenseManager) { export function getAppsConfig(this: LicenseManager) { return { - maxPrivateApps: getLicenseLimit(this.getLicense(), 'privateApps') ?? -1, - maxMarketplaceApps: getLicenseLimit(this.getLicense(), 'marketplaceApps') ?? -1, + maxPrivateApps: getLicenseLimit(this.getLicense(), 'privateApps') ?? 3, + maxMarketplaceApps: getLicenseLimit(this.getLicense(), 'marketplaceApps') ?? 5, }; } diff --git a/ee/packages/license/src/events/emitter.ts b/ee/packages/license/src/events/emitter.ts index 9d4025e4bce3..51f3282a9742 100644 --- a/ee/packages/license/src/events/emitter.ts +++ b/ee/packages/license/src/events/emitter.ts @@ -1,4 +1,4 @@ -import type { LicenseLimitKind } from '../definition/ILicenseV3'; +import type { BehaviorWithContext } from '../definition/LicenseBehavior'; import type { LicenseModule } from '../definition/LicenseModule'; import type { LicenseManager } from '../license'; import { logger } from '../logger'; @@ -21,10 +21,58 @@ export function moduleRemoved(this: LicenseManager, module: LicenseModule) { } } -export function limitReached(this: LicenseManager, limitKind: LicenseLimitKind) { +export function behaviorTriggered(this: LicenseManager, options: BehaviorWithContext) { + const { behavior, reason, modules: _, ...rest } = options; + try { - this.emit(`limitReached:${limitKind}`); + this.emit(`behavior:${behavior}`, { + reason, + ...rest, + }); + } catch (error) { + logger.error({ msg: 'Error running behavior triggered event', error }); + } + + if (!['prevent_action'].includes(behavior)) { + return; + } + + if (reason !== 'limit' || !(`limit` in rest) || !rest.limit) { + return; + } + + try { + this.emit(`limitReached:${rest.limit}`); } catch (error) { logger.error({ msg: 'Error running limit reached event', error }); } } + +export function behaviorTriggeredToggled(this: LicenseManager, options: BehaviorWithContext) { + const { behavior, reason, modules: _, ...rest } = options; + + try { + this.emit(`behaviorToggled:${behavior}`, { + reason, + ...rest, + }); + } catch (error) { + logger.error({ msg: 'Error running behavior triggered event', error }); + } +} + +export function licenseValidated(this: LicenseManager) { + try { + this.emit('validate'); + } catch (error) { + logger.error({ msg: 'Error running license validated event', error }); + } +} + +export function licenseInvalidated(this: LicenseManager) { + try { + this.emit('invalidate'); + } catch (error) { + logger.error({ msg: 'Error running license invalidated event', error }); + } +} diff --git a/ee/packages/license/src/events/listeners.ts b/ee/packages/license/src/events/listeners.ts index d6e9fb016f2c..f8c291edc4be 100644 --- a/ee/packages/license/src/events/listeners.ts +++ b/ee/packages/license/src/events/listeners.ts @@ -1,8 +1,16 @@ import type { LicenseLimitKind } from '../definition/ILicenseV3'; +import type { BehaviorWithContext, LicenseBehavior } from '../definition/LicenseBehavior'; import type { LicenseModule } from '../definition/LicenseModule'; import type { LicenseManager } from '../license'; import { hasModule } from '../modules'; +/** + * Invoked when the license changes some internal state. it's called to sync the license with other instances. + */ +export function onChange(this: LicenseManager, cb: () => void) { + this.on('sync', cb); +} + export function onValidFeature(this: LicenseManager, feature: LicenseModule, cb: () => void) { this.on(`valid:${feature}`, cb); @@ -58,18 +66,34 @@ export function onToggledFeature( }; } -export function onModule(this: LicenseManager, cb: (...args: any[]) => void) { +export function onModule(this: LicenseManager, cb: (data: { module: LicenseModule; valid: boolean }) => void) { this.on('module', cb); } -export function onValidateLicense(this: LicenseManager, cb: (...args: any[]) => void) { +export function onValidateLicense(this: LicenseManager, cb: () => void) { this.on('validate', cb); } -export function onInvalidateLicense(this: LicenseManager, cb: (...args: any[]) => void) { +export function onInvalidateLicense(this: LicenseManager, cb: () => void) { this.on('invalidate', cb); } -export function onLimitReached(this: LicenseManager, limitKind: LicenseLimitKind, cb: (...args: any[]) => void) { +export function onBehaviorTriggered( + this: LicenseManager, + behavior: Exclude, + cb: (data: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }) => void, +) { + this.on(`behavior:${behavior}`, cb); +} + +export function onBehaviorToggled( + this: LicenseManager, + behavior: Exclude, + cb: (data: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }) => void, +) { + this.on(`behaviorToggled:${behavior}`, cb); +} + +export function onLimitReached(this: LicenseManager, limitKind: LicenseLimitKind, cb: () => void) { this.on(`limitReached:${limitKind}`, cb); } diff --git a/ee/packages/license/src/index.ts b/ee/packages/license/src/index.ts index 11cf3bbbe4c5..e590ce7722b2 100644 --- a/ee/packages/license/src/index.ts +++ b/ee/packages/license/src/index.ts @@ -1,12 +1,16 @@ -import type { ILicenseV3, LicenseLimitKind } from './definition/ILicenseV3'; +import type { LicenseLimitKind } from './definition/ILicenseV3'; +import type { LicenseInfo } from './definition/LicenseInfo'; import type { LimitContext } from './definition/LimitContext'; import { getAppsConfig, getMaxActiveUsers, getUnmodifiedLicenseAndModules } from './deprecated'; import { onLicense } from './events/deprecated'; import { + onBehaviorToggled, + onBehaviorTriggered, onInvalidFeature, onInvalidateLicense, onLimitReached, onModule, + onChange, onToggledFeature, onValidFeature, onValidateLicense, @@ -22,6 +26,7 @@ export * from './definition/ILicenseTag'; export * from './definition/ILicenseV2'; export * from './definition/ILicenseV3'; export * from './definition/LicenseBehavior'; +export * from './definition/LicenseInfo'; export * from './definition/LicenseLimit'; export * from './definition/LicenseModule'; export * from './definition/LicensePeriod'; @@ -44,8 +49,10 @@ interface License { onValidateLicense: typeof onValidateLicense; onInvalidateLicense: typeof onInvalidateLicense; onLimitReached: typeof onLimitReached; + onBehaviorTriggered: typeof onBehaviorTriggered; + revalidateLicense: () => Promise; - supportedVersions(): ILicenseV3['supportedVersions']; + getInfo: (info: { limits: boolean; currentValues: boolean; license: boolean }) => Promise; // Deprecated: onLicense: typeof onLicense; @@ -58,10 +65,6 @@ interface License { } export class LicenseImp extends LicenseManager implements License { - supportedVersions() { - return this.getLicense()?.supportedVersions; - } - validateFormat = validateFormat; hasModule = hasModule; @@ -76,10 +79,12 @@ export class LicenseImp extends LicenseManager implements License { getCurrentValueForLicenseLimit = getCurrentValueForLicenseLimit; - public async isLimitReached(action: T, context?: Partial>) { - return this.shouldPreventAction(action, context, 0); + public async isLimitReached(action: T, context?: Partial>): Promise { + return this.shouldPreventAction(action, 0, context); } + onChange = onChange; + onValidFeature = onValidFeature; onInvalidFeature = onInvalidFeature; @@ -94,6 +99,10 @@ export class LicenseImp extends LicenseManager implements License { onLimitReached = onLimitReached; + onBehaviorTriggered = onBehaviorTriggered; + + onBehaviorToggled = onBehaviorToggled; + // Deprecated: onLicense = onLicense; diff --git a/ee/packages/license/src/isItemAllowed.ts b/ee/packages/license/src/isItemAllowed.ts new file mode 100644 index 000000000000..16787cdf9c4d --- /dev/null +++ b/ee/packages/license/src/isItemAllowed.ts @@ -0,0 +1,12 @@ +import type { LicenseLimitKind } from './definition/ILicenseV3'; +import type { LicenseBehavior } from './definition/LicenseBehavior'; +import type { LicenseValidationOptions } from './definition/LicenseValidationOptions'; + +const isItemAllowed = (item: T, allowList?: T[]): boolean => { + return !allowList || allowList.includes(item); +}; + +export const isLimitAllowed = (item: LicenseLimitKind, options: LicenseValidationOptions): boolean => isItemAllowed(item, options.limits); + +export const isBehaviorAllowed = (item: LicenseBehavior, options: LicenseValidationOptions): boolean => + isItemAllowed(item, options.behaviors) && (options.isNewLicense || item !== 'prevent_installation'); diff --git a/ee/packages/license/src/license.spec.ts b/ee/packages/license/src/license.spec.ts index 36744585d59f..73cf13d75035 100644 --- a/ee/packages/license/src/license.spec.ts +++ b/ee/packages/license/src/license.spec.ts @@ -22,6 +22,19 @@ it('should not prevent if the counter is under the limit', async () => { await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); }); +it('should not prevent actions if there is no limit set in the license', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder(); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + licenseManager.setLicenseLimitCounter('monthlyActiveContacts', () => 5); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.shouldPreventAction('monthlyActiveContacts')).resolves.toBe(false); +}); + it('should prevent if the counter is equal or over the limit', async () => { const licenseManager = await getReadyLicenseManager(); @@ -40,3 +53,181 @@ it('should prevent if the counter is equal or over the limit', async () => { licenseManager.setLicenseLimitCounter('activeUsers', () => 11); await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); }); + +it('should not prevent an action if another limit is over the limit', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder() + .withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]) + .withLimits('monthlyActiveContacts', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 11); + licenseManager.setLicenseLimitCounter('monthlyActiveContacts', () => 2); + await expect(licenseManager.shouldPreventAction('monthlyActiveContacts')).resolves.toBe(false); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); +}); + +describe('Validate License Limits', () => { + describe('prevent_action behavior', () => { + describe('during the licensing apply', () => { + it('should not trigger the event even if the counter is over the limit', async () => { + const licenseManager = await getReadyLicenseManager(); + + const preventActionCallback = jest.fn(); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback); + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + expect(preventActionCallback).toHaveBeenCalledTimes(0); + }); + }); + }); + describe('fair usage behavior', () => { + it('should change the flag to true if the counter is equal or over the limit', async () => { + const licenseManager = await getReadyLicenseManager(); + + const fairUsageCallback = jest.fn(); + const preventActionCallback = jest.fn(); + + licenseManager.onBehaviorTriggered('start_fair_policy', fairUsageCallback); + licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + { + max: 10, + behavior: 'start_fair_policy', + }, + ]); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(fairUsageCallback).toHaveBeenCalledTimes(0); + expect(preventActionCallback).toHaveBeenCalledTimes(0); + + preventActionCallback.mockClear(); + fairUsageCallback.mockClear(); + licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + expect(fairUsageCallback).toHaveBeenCalledTimes(0); + expect(preventActionCallback).toHaveBeenCalledTimes(1); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 11); + preventActionCallback.mockClear(); + fairUsageCallback.mockClear(); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + expect(preventActionCallback).toHaveBeenCalledTimes(4); + expect(fairUsageCallback).toHaveBeenCalledTimes(4); + }); + }); + + describe('invalidate_license behavior', () => { + it('should invalidate the license if the counter is over the limit', async () => { + const licenseManager = await getReadyLicenseManager(); + + const invalidateCallback = jest.fn(); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + { + max: 10, + behavior: 'invalidate_license', + }, + ]); + + licenseManager.on('invalidate', invalidateCallback); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.hasValidLicense()).toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + await expect(licenseManager.hasValidLicense()).toBe(true); + + await licenseManager.setLicenseLimitCounter('activeUsers', () => 10); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.hasValidLicense()).toBe(true); + expect(invalidateCallback).toHaveBeenCalledTimes(0); + + await licenseManager.setLicenseLimitCounter('activeUsers', () => 11); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true); + await expect(licenseManager.hasValidLicense()).toBe(false); + expect(invalidateCallback).toHaveBeenCalledTimes(1); + }); + }); + + describe('prevent action for future limits', () => { + it('should prevent if the counter plus the extra value is equal or over the limit', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withLimits('activeUsers', [ + { + max: 10, + behavior: 'prevent_action', + }, + ]); + + const fairUsageCallback = jest.fn(); + const preventActionCallback = jest.fn(); + + licenseManager.onBehaviorTriggered('start_fair_policy', fairUsageCallback); + licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + + licenseManager.setLicenseLimitCounter('activeUsers', () => 5); + await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false); + expect(fairUsageCallback).toHaveBeenCalledTimes(0); + expect(preventActionCallback).toHaveBeenCalledTimes(0); + + for await (const extraCount of [1, 2, 3, 4, 5]) { + await expect(licenseManager.shouldPreventAction('activeUsers', extraCount)).resolves.toBe(false); + expect(fairUsageCallback).toHaveBeenCalledTimes(0); + expect(preventActionCallback).toHaveBeenCalledTimes(0); + } + + /** + * if we are testing the current count 10 should prevent the action, if we are testing the future count 10 should not prevent the action but 11 + */ + + await expect(licenseManager.shouldPreventAction('activeUsers', 6)).resolves.toBe(true); + expect(fairUsageCallback).toHaveBeenCalledTimes(0); + expect(preventActionCallback).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index 2fb25b0e3b4f..fb290d541cfb 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -1,38 +1,43 @@ import { Emitter } from '@rocket.chat/emitter'; +import { type ILicenseTag } from './definition/ILicenseTag'; import type { ILicenseV2 } from './definition/ILicenseV2'; import type { ILicenseV3, LicenseLimitKind } from './definition/ILicenseV3'; import type { BehaviorWithContext } from './definition/LicenseBehavior'; +import type { LicenseInfo } from './definition/LicenseInfo'; import type { LicenseModule } from './definition/LicenseModule'; +import type { LicenseValidationOptions } from './definition/LicenseValidationOptions'; import type { LimitContext } from './definition/LimitContext'; +import type { LicenseEvents } from './definition/events'; import { DuplicatedLicenseError } from './errors/DuplicatedLicenseError'; import { InvalidLicenseError } from './errors/InvalidLicenseError'; import { NotReadyForValidation } from './errors/NotReadyForValidation'; +import { behaviorTriggered, behaviorTriggeredToggled, licenseInvalidated, licenseValidated } from './events/emitter'; import { logger } from './logger'; -import { invalidateAll, replaceModules } from './modules'; +import { getModules, invalidateAll, replaceModules } from './modules'; import { applyPendingLicense, clearPendingLicense, hasPendingLicense, isPendingLicense, setPendingLicense } from './pendingLicense'; import { showLicense } from './showLicense'; import { replaceTags } from './tags'; import { decrypt } from './token'; import { convertToV3 } from './v2/convertToV3'; +import { filterBehaviorsResult } from './validation/filterBehaviorsResult'; import { getCurrentValueForLicenseLimit } from './validation/getCurrentValueForLicenseLimit'; import { getModulesToDisable } from './validation/getModulesToDisable'; import { isBehaviorsInResult } from './validation/isBehaviorsInResult'; import { isReadyForValidation } from './validation/isReadyForValidation'; import { runValidation } from './validation/runValidation'; import { validateFormat } from './validation/validateFormat'; +import { validateLicenseLimits } from './validation/validateLicenseLimits'; -export class LicenseManager extends Emitter< - Record<`limitReached:${LicenseLimitKind}` | `${'invalid' | 'valid'}:${LicenseModule}`, undefined> & { - validate: undefined; - invalidate: undefined; - module: { module: LicenseModule; valid: boolean }; - } -> { +const globalLimitKinds: LicenseLimitKind[] = ['activeUsers', 'guestUsers', 'privateApps', 'marketplaceApps', 'monthlyActiveContacts']; + +export class LicenseManager extends Emitter { dataCounters = new Map) => Promise>(); pendingLicense = ''; + tags = new Set(); + modules = new Set(); private workspaceUrl: string | undefined; @@ -43,10 +48,16 @@ export class LicenseManager extends Emitter< private _valid: boolean | undefined; - private _inFairPolicy: boolean | undefined; - private _lockedLicense: string | undefined; + public shouldPreventActionResults = new Map(); + + constructor() { + super(); + + this.on('validate', () => showLicense.call(this, this._license, this._valid)); + } + public get license(): ILicenseV3 | undefined { return this._license; } @@ -59,8 +70,12 @@ export class LicenseManager extends Emitter< return this._valid; } - public get inFairPolicy(): boolean { - return Boolean(this._inFairPolicy); + public get encryptedLicense(): string | undefined { + if (!this.hasValidLicense()) { + return undefined; + } + + return this._lockedLicense; } public async setWorkspaceUrl(url: string) { @@ -75,16 +90,62 @@ export class LicenseManager extends Emitter< return this.workspaceUrl; } + public async revalidateLicense(options: Omit = {}): Promise { + if (!this.hasValidLicense()) { + return; + } + + try { + await this.validateLicense({ ...options, isNewLicense: false, triggerSync: true }); + } catch (e) { + if (e instanceof InvalidLicenseError) { + this.invalidateLicense(); + this.emit('sync'); + } + } + } + + /** + * The sync method should be called when a license from a different instance is has changed, so the local instance + * needs to be updated. This method will validate the license and update the local instance if the license is valid, but will not trigger the onSync event. + */ + + public async sync(options: Omit = {}): Promise { + if (!this.hasValidLicense()) { + return; + } + + try { + await this.validateLicense({ ...options, isNewLicense: false, triggerSync: false }); + } catch (e) { + if (e instanceof InvalidLicenseError) { + this.invalidateLicense(); + } + } + } + private clearLicenseData(): void { this._license = undefined; this._unmodifiedLicense = undefined; - this._inFairPolicy = undefined; this._valid = false; this._lockedLicense = undefined; + + this.shouldPreventActionResults.clear(); clearPendingLicense.call(this); } - private async setLicenseV3(newLicense: ILicenseV3, encryptedLicense: string, originalLicense?: ILicenseV2 | ILicenseV3): Promise { + private invalidateLicense(): void { + this._valid = false; + licenseInvalidated.call(this); + invalidateAll.call(this); + } + + private async setLicenseV3( + newLicense: ILicenseV3, + encryptedLicense: string, + originalLicense?: ILicenseV2 | ILicenseV3, + isNewLicense?: boolean, + ): Promise { const hadValidLicense = this.hasValidLicense(); this.clearLicenseData(); @@ -92,26 +153,31 @@ export class LicenseManager extends Emitter< this._unmodifiedLicense = originalLicense || newLicense; this._license = newLicense; - await this.validateLicense(); - this._lockedLicense = encryptedLicense; - } finally { - if (hadValidLicense && !this.hasValidLicense()) { - this.emit('invalidate'); - invalidateAll.call(this); + + await this.validateLicense({ isNewLicense }); + } catch (e) { + if (e instanceof InvalidLicenseError) { + if (hadValidLicense) { + this.invalidateLicense(); + } } } } - private async setLicenseV2(newLicense: ILicenseV2, encryptedLicense: string): Promise { - return this.setLicenseV3(convertToV3(newLicense), encryptedLicense, newLicense); + private async setLicenseV2(newLicense: ILicenseV2, encryptedLicense: string, isNewLicense?: boolean): Promise { + return this.setLicenseV3(convertToV3(newLicense), encryptedLicense, newLicense, isNewLicense); } private isLicenseDuplicated(encryptedLicense: string): boolean { return Boolean(this._lockedLicense && this._lockedLicense === encryptedLicense); } - private async validateLicense(): Promise { + private async validateLicense( + options: LicenseValidationOptions = { + triggerSync: true, + }, + ): Promise { if (!this._license) { throw new InvalidLicenseError(); } @@ -120,18 +186,53 @@ export class LicenseManager extends Emitter< throw new NotReadyForValidation(); } - // #TODO: Only include 'prevent_installation' here if this is actually the initial installation of the license - const validationResult = await runValidation.call(this, this._license, [ - 'invalidate_license', - 'prevent_installation', - 'start_fair_policy', - 'disable_modules', - ]); + const validationResult = await runValidation.call(this, this._license, { + behaviors: ['invalidate_license', 'start_fair_policy', 'prevent_installation', 'disable_modules'], + ...options, + }); + + if (isBehaviorsInResult(validationResult, ['invalidate_license', 'prevent_installation'])) { + throw new InvalidLicenseError(); + } + + const shouldLogModules = !this._valid || options.isNewLicense; + + this._valid = true; + + if (this._license.information.tags) { + replaceTags.call(this, this._license.information.tags); + } + + const disabledModules = getModulesToDisable(validationResult); + const modulesToEnable = this._license.grantedModules.filter(({ module }) => !disabledModules.includes(module)); + + const modulesChanged = replaceModules.call( + this, + modulesToEnable.map(({ module }) => module), + ); + + if (shouldLogModules || modulesChanged) { + logger.log({ msg: 'License validated', modules: modulesToEnable }); + } + + if (!options.isNewLicense) { + this.triggerBehaviorEvents(validationResult); + } + + licenseValidated.call(this); - this.processValidationResult(validationResult); + // If something changed in the license and the sync option is enabled, trigger a sync + if ( + ((!options.isNewLicense && + filterBehaviorsResult(validationResult, ['invalidate_license', 'start_fair_policy', 'prevent_installation'])) || + modulesChanged) && + options.triggerSync + ) { + this.emit('sync'); + } } - public async setLicense(encryptedLicense: string): Promise { + public async setLicense(encryptedLicense: string, isNewLicense = true): Promise { if (!(await validateFormat(encryptedLicense))) { throw new InvalidLicenseError(); } @@ -160,10 +261,10 @@ export class LicenseManager extends Emitter< logger.debug({ msg: 'license', decrypted }); if (!encryptedLicense.startsWith('RCV3_')) { - await this.setLicenseV2(decrypted, encryptedLicense); + await this.setLicenseV2(decrypted, encryptedLicense, isNewLicense); return true; } - await this.setLicenseV3(decrypted, encryptedLicense); + await this.setLicenseV3(decrypted, encryptedLicense, decrypted, isNewLicense); return true; } catch (e) { @@ -175,29 +276,16 @@ export class LicenseManager extends Emitter< } } - private processValidationResult(result: BehaviorWithContext[]): void { - if (!this._license || isBehaviorsInResult(result, ['invalidate_license', 'prevent_installation'])) { - return; + private triggerBehaviorEvents(validationResult: BehaviorWithContext[]): void { + for (const { ...options } of validationResult) { + behaviorTriggered.call(this, { ...options }); } + } - this._valid = true; - this._inFairPolicy = isBehaviorsInResult(result, ['start_fair_policy']); - - if (this._license.information.tags) { - replaceTags(this._license.information.tags); + private triggerBehaviorEventsToggled(validationResult: BehaviorWithContext[]): void { + for (const { ...options } of validationResult) { + behaviorTriggeredToggled.call(this, { ...options }); } - - const disabledModules = getModulesToDisable(result); - const modulesToEnable = this._license.grantedModules.filter(({ module }) => !disabledModules.includes(module)); - - replaceModules.call( - this, - modulesToEnable.map(({ module }) => module), - ); - logger.log({ msg: 'License validated', modules: modulesToEnable }); - - this.emit('validate'); - showLicense.call(this, this._license, this._valid); } public hasValidLicense(): boolean { @@ -210,21 +298,152 @@ export class LicenseManager extends Emitter< } } + public syncShouldPreventActionResults(actions: Record): void { + for (const [action, shouldPreventAction] of Object.entries(actions)) { + this.shouldPreventActionResults.set(action as LicenseLimitKind, shouldPreventAction); + } + } + + public async shouldPreventActionResultsMap(): Promise<{ + [key in LicenseLimitKind]: boolean; + }> { + const keys: LicenseLimitKind[] = [ + 'activeUsers', + 'guestUsers', + 'roomsPerGuest', + 'privateApps', + 'marketplaceApps', + 'monthlyActiveContacts', + ]; + + const items = await Promise.all( + keys.map(async (limit) => { + const cached = this.shouldPreventActionResults.get(limit as LicenseLimitKind); + + if (cached !== undefined) { + return [limit as LicenseLimitKind, cached]; + } + + const fresh = this._license + ? isBehaviorsInResult( + await validateLicenseLimits.call(this, this._license, { + behaviors: ['prevent_action'], + limits: [limit], + }), + ['prevent_action'], + ) + : false; + + this.shouldPreventActionResults.set(limit as LicenseLimitKind, fresh); + + return [limit as LicenseLimitKind, fresh]; + }), + ); + + return Object.fromEntries(items); + } + public async shouldPreventAction( action: T, - context?: Partial>, - newCount = 1, + extraCount = 0, + context: Partial> = {}, + { suppressLog }: Pick = {}, ): Promise { const license = this.getLicense(); if (!license) { return false; } - const currentValue = (await getCurrentValueForLicenseLimit.call(this, action, context)) + newCount; - return Boolean( - license.limits[action] - ?.filter(({ behavior, max }) => behavior === 'prevent_action' && max >= 0) - .some(({ max }) => max < currentValue), - ); + const options: LicenseValidationOptions = { + ...(extraCount && { behaviors: ['prevent_action'] }), + isNewLicense: false, + suppressLog: !!suppressLog, + limits: [action], + context: { + [action]: { + extraCount, + ...context, + }, + }, + }; + + const validationResult = await runValidation.call(this, license, options); + + const shouldPreventAction = isBehaviorsInResult(validationResult, ['prevent_action']); + + // extra values should not call events since they are not actually reaching the limit just checking if they would + if (extraCount) { + return shouldPreventAction; + } + + if (isBehaviorsInResult(validationResult, ['invalidate_license', 'disable_modules', 'start_fair_policy'])) { + await this.revalidateLicense(); + } + + const eventsToEmit = shouldPreventAction + ? filterBehaviorsResult(validationResult, ['prevent_action']) + : [ + { + behavior: 'allow_action', + modules: [], + reason: 'limit', + limit: action, + } as BehaviorWithContext, + ]; + + if (this.shouldPreventActionResults.get(action) !== shouldPreventAction) { + this.shouldPreventActionResults.set(action, shouldPreventAction); + + this.triggerBehaviorEventsToggled(eventsToEmit); + } + + this.triggerBehaviorEvents(eventsToEmit); + + return shouldPreventAction; + } + + public async getInfo({ + limits: includeLimits, + currentValues: loadCurrentValues, + license: includeLicense, + }: { + limits: boolean; + currentValues: boolean; + license: boolean; + }): Promise { + const activeModules = getModules.call(this); + const license = this.getLicense(); + + // Get all limits present in the license and their current value + const limits = ( + (license && + includeLimits && + (await Promise.all( + globalLimitKinds + .map((limitKey) => ({ + limitKey, + max: Math.max(-1, Math.min(...Array.from(license.limits[limitKey as LicenseLimitKind] || [])?.map(({ max }) => max))), + })) + .filter(({ max }) => max >= 0 && max < Infinity) + .map(async ({ max, limitKey }) => { + return { + [limitKey as LicenseLimitKind]: { + ...(loadCurrentValues ? { value: await getCurrentValueForLicenseLimit.call(this, limitKey as LicenseLimitKind) } : {}), + max, + }, + }; + }), + ))) || + [] + ).reduce((prev, curr) => ({ ...prev, ...curr }), {}); + + return { + license: (includeLicense && license) || undefined, + activeModules, + preventedActions: await this.shouldPreventActionResultsMap(), + limits: limits as Record, + tags: license?.information.tags || [], + trial: Boolean(license?.information.trial), + }; } } diff --git a/ee/packages/license/src/modules.ts b/ee/packages/license/src/modules.ts index 7570ec525fc7..6931fb7a6a5d 100644 --- a/ee/packages/license/src/modules.ts +++ b/ee/packages/license/src/modules.ts @@ -29,7 +29,8 @@ export function hasModule(this: LicenseManager, module: LicenseModule) { return this.modules.has(module); } -export function replaceModules(this: LicenseManager, newModules: LicenseModule[]) { +export function replaceModules(this: LicenseManager, newModules: LicenseModule[]): boolean { + let anyChange = false; for (const moduleName of newModules) { if (this.modules.has(moduleName)) { continue; @@ -37,6 +38,7 @@ export function replaceModules(this: LicenseManager, newModules: LicenseModule[] this.modules.add(moduleName); moduleValidated.call(this, moduleName); + anyChange = true; } for (const moduleName of this.modules) { @@ -46,5 +48,8 @@ export function replaceModules(this: LicenseManager, newModules: LicenseModule[] moduleRemoved.call(this, moduleName); this.modules.delete(moduleName); + anyChange = true; } + + return anyChange; } diff --git a/ee/packages/license/src/pendingLicense.ts b/ee/packages/license/src/pendingLicense.ts index 2c2140044336..8dd82dcd7774 100644 --- a/ee/packages/license/src/pendingLicense.ts +++ b/ee/packages/license/src/pendingLicense.ts @@ -8,10 +8,10 @@ export function setPendingLicense(this: LicenseManager, encryptedLicense: string } } -export function applyPendingLicense(this: LicenseManager) { +export async function applyPendingLicense(this: LicenseManager) { if (this.pendingLicense) { logger.info('Applying pending license.'); - this.setLicense(this.pendingLicense); + return this.setLicense(this.pendingLicense); } } diff --git a/ee/packages/license/src/tags.ts b/ee/packages/license/src/tags.ts index ca2639678475..33434cae116d 100644 --- a/ee/packages/license/src/tags.ts +++ b/ee/packages/license/src/tags.ts @@ -1,23 +1,24 @@ import type { ILicenseTag } from './definition/ILicenseTag'; +import { type LicenseManager } from './license'; -export const tags = new Set(); - -export const addTag = (tag: ILicenseTag) => { +export function addTag(this: LicenseManager, tag: ILicenseTag) { // make sure to not add duplicated tag names - for (const addedTag of tags) { + for (const addedTag of this.tags) { if (addedTag.name.toLowerCase() === tag.name.toLowerCase()) { return; } } - tags.add(tag); -}; + this.tags.add(tag); +} -export const replaceTags = (newTags: ILicenseTag[]) => { - tags.clear(); +export function replaceTags(this: LicenseManager, newTags: ILicenseTag[]) { + this.tags.clear(); for (const tag of newTags) { - addTag(tag); + addTag.call(this, tag); } -}; +} -export const getTags = () => [...tags]; +export function getTags(this: LicenseManager) { + return [...this.tags]; +} diff --git a/ee/packages/license/src/token.ts b/ee/packages/license/src/token.ts index 80ecc29b4a3f..46daaef83974 100644 --- a/ee/packages/license/src/token.ts +++ b/ee/packages/license/src/token.ts @@ -4,10 +4,10 @@ import { verify, sign, getPairs } from '@rocket.chat/jwt'; import type { ILicenseV3 } from './definition/ILicenseV3'; -const PUBLIC_KEY_V2 = +const PUBLIC_LICENSE_KEY_V2 = 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUFxV1Nza2Q5LzZ6Ung4a3lQY2ljcwpiMzJ3Mnd4VnV3N3lCVDk2clEvOEQreU1lQ01POXdTU3BIYS85bkZ5d293RXRpZ3B0L3dyb1BOK1ZHU3didHdQCkZYQmVxRWxCbmRHRkFsODZlNStFbGlIOEt6L2hHbkNtSk5tWHB4RUsyUkUwM1g0SXhzWVg3RERCN010eC9pcXMKY2pCL091dlNCa2ppU2xlUzdibE5JVC9kQTdLNC9DSjNvaXUwMmJMNEV4Y2xDSGVwenFOTWVQM3dVWmdweE9uZgpOT3VkOElYWUs3M3pTY3VFOEUxNTdZd3B6Q0twVmFIWDdaSmY4UXVOc09PNVcvYUlqS2wzTDYyNjkrZUlPRXJHCndPTm1hSG56Zmc5RkxwSmh6Z3BPMzhhVm43NnZENUtLakJhaldza1krNGEyZ1NRbUtOZUZxYXFPb3p5RUZNMGUKY0ZXWlZWWjNMZWg0dkVNb1lWUHlJeng5Nng4ZjIveW1QbmhJdXZRdjV3TjRmeWVwYTdFWTVVQ2NwNzF6OGtmUAo0RmNVelBBMElEV3lNaWhYUi9HNlhnUVFaNEdiL3FCQmh2cnZpSkNGemZZRGNKZ0w3RmVnRllIUDNQR0wwN1FnCnZMZXZNSytpUVpQcnhyYnh5U3FkUE9rZ3VyS2pWclhUVXI0QTlUZ2lMeUlYNVVsSnEzRS9SVjdtZk9xWm5MVGEKU0NWWEhCaHVQbG5DR1pSMDFUb1RDZktoTUcxdTBDRm5MMisxNWhDOWZxT21XdjlRa2U0M3FsSjBQZ0YzVkovWAp1eC9tVHBuazlnbmJHOUpIK21mSDM5Um9GdlROaW5Zd1NNdll6dXRWT242OXNPemR3aERsYTkwbDNBQ2g0eENWCks3Sk9YK3VIa29OdTNnMmlWeGlaVU0wQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo='; -const PUBLIC_KEY_V3 = ''; +const PUBLIC_LICENSE_KEY_V3 = PUBLIC_LICENSE_KEY_V2; let TEST_KEYS: [string, string] | undefined = undefined; @@ -19,7 +19,7 @@ export async function decrypt(encrypted: string): Promise { TEST_KEYS = TEST_KEYS ?? (await getPairs()); if (!TEST_KEYS) { - throw new Error('Missing LICENSE_PUBLIC_KEY_V3'); + throw new Error('Missing PUBLIC_LICENSE_KEY_V3'); } const [spki] = TEST_KEYS; @@ -32,12 +32,12 @@ export async function decrypt(encrypted: string): Promise { // handle V3 if (encrypted.startsWith('RCV3_')) { const jwt = encrypted.substring(5); - const [payload] = await verify(jwt, PUBLIC_KEY_V3); + const [payload] = await verify(jwt, PUBLIC_LICENSE_KEY_V3); return JSON.stringify(payload); } - const decrypted = crypto.publicDecrypt(Buffer.from(PUBLIC_KEY_V2, 'base64').toString('utf-8'), Buffer.from(encrypted, 'base64')); + const decrypted = crypto.publicDecrypt(Buffer.from(PUBLIC_LICENSE_KEY_V2, 'base64').toString('utf-8'), Buffer.from(encrypted, 'base64')); return decrypted.toString('utf-8'); } @@ -49,10 +49,6 @@ export async function encrypt(license: ILicenseV3): Promise { TEST_KEYS = TEST_KEYS ?? (await getPairs()); - if (!TEST_KEYS) { - throw new Error('Missing LICENSE_PUBLIC_KEY_V3'); - } - const [, pkcs8] = TEST_KEYS; return `RCV3_${await sign(license, pkcs8)}`; diff --git a/ee/packages/license/src/v2/convertToV3.ts b/ee/packages/license/src/v2/convertToV3.ts index 7586f54c8c54..f4232c0bac4f 100644 --- a/ee/packages/license/src/v2/convertToV3.ts +++ b/ee/packages/license/src/v2/convertToV3.ts @@ -36,7 +36,7 @@ export const convertToV3 = (v2: ILicenseV2): ILicenseV3 => { serverUrls: [ { value: v2.url, - type: 'url', + type: 'regex', }, ], validPeriods: [ @@ -51,7 +51,7 @@ export const convertToV3 = (v2: ILicenseV2): ILicenseV3 => { }, grantedModules: [ ...new Set( - v2.modules + ['hide-watermark', ...v2.modules] .map((licenseModule) => (isBundle(licenseModule) ? getBundleModules(licenseModule) : [licenseModule])) .reduce((prev, curr) => [...prev, ...curr], []) .map((licenseModule) => ({ module: licenseModule as LicenseModule })), diff --git a/ee/packages/license/src/validation/filterBehaviorsResult.ts b/ee/packages/license/src/validation/filterBehaviorsResult.ts new file mode 100644 index 000000000000..e51dbac20a53 --- /dev/null +++ b/ee/packages/license/src/validation/filterBehaviorsResult.ts @@ -0,0 +1,4 @@ +import type { BehaviorWithContext, LicenseBehavior } from '../definition/LicenseBehavior'; + +export const filterBehaviorsResult = (result: BehaviorWithContext[], expectedBehaviors: LicenseBehavior[]) => + result.filter(({ behavior }) => expectedBehaviors.includes(behavior)); diff --git a/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts b/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts index 88cedc6c7bc9..8f9c6ed4034e 100644 --- a/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts +++ b/ee/packages/license/src/validation/getCurrentValueForLicenseLimit.ts @@ -1,12 +1,9 @@ -import type { IUser } from '@rocket.chat/core-typings'; - import type { LicenseLimitKind } from '../definition/ILicenseV3'; +import type { LimitContext } from '../definition/LimitContext'; import type { LicenseManager } from '../license'; import { logger } from '../logger'; import { applyPendingLicense, hasPendingLicense } from '../pendingLicense'; -type LimitContext = T extends 'roomsPerGuest' ? { userId: IUser['_id'] } : Record; - export function setLicenseLimitCounter( this: LicenseManager, limitKey: T, diff --git a/ee/packages/license/src/validation/getResultingBehavior.ts b/ee/packages/license/src/validation/getResultingBehavior.ts index 47e2d91b8b89..22ca02bfd220 100644 --- a/ee/packages/license/src/validation/getResultingBehavior.ts +++ b/ee/packages/license/src/validation/getResultingBehavior.ts @@ -1,8 +1,12 @@ +import type { LicenseLimitKind } from '../definition/ILicenseV3'; import type { BehaviorWithContext } from '../definition/LicenseBehavior'; import type { LicenseLimit } from '../definition/LicenseLimit'; import type { LicensePeriod } from '../definition/LicensePeriod'; -export const getResultingBehavior = (data: LicenseLimit | LicensePeriod | Partial): BehaviorWithContext => { +export const getResultingBehavior = ( + data: LicenseLimit | LicensePeriod | Partial>, + { reason, limit }: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }, +): BehaviorWithContext => { const behavior = 'invalidBehavior' in data ? data.invalidBehavior : data.behavior; switch (behavior) { @@ -10,11 +14,15 @@ export const getResultingBehavior = (data: LicenseLimit | LicensePeriod | Partia return { behavior, modules: ('modules' in data && data.modules) || [], + reason, + limit, }; default: return { behavior, + reason, + limit, } as BehaviorWithContext; } }; diff --git a/ee/packages/license/src/validation/runValidation.spec.ts b/ee/packages/license/src/validation/runValidation.spec.ts index 98797c86cd27..523090acd63a 100644 --- a/ee/packages/license/src/validation/runValidation.spec.ts +++ b/ee/packages/license/src/validation/runValidation.spec.ts @@ -22,16 +22,16 @@ describe('Validation behaviors', () => { }); await expect( - runValidation.call(licenseManager, await license.build(), [ - 'invalidate_license', - 'prevent_installation', - 'start_fair_policy', - 'disable_modules', - ]), + runValidation.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), ).resolves.toStrictEqual([ { behavior: 'disable_modules', + limit: undefined, modules: ['livechat-enterprise'], + reason: 'period', }, ]); }); diff --git a/ee/packages/license/src/validation/runValidation.ts b/ee/packages/license/src/validation/runValidation.ts index 9cb623b8eae0..922b4c49162e 100644 --- a/ee/packages/license/src/validation/runValidation.ts +++ b/ee/packages/license/src/validation/runValidation.ts @@ -1,5 +1,6 @@ import type { ILicenseV3 } from '../definition/ILicenseV3'; -import type { LicenseBehavior, BehaviorWithContext } from '../definition/LicenseBehavior'; +import type { BehaviorWithContext } from '../definition/LicenseBehavior'; +import type { LicenseValidationOptions } from '../definition/LicenseValidationOptions'; import type { LicenseManager } from '../license'; import { validateLicenseLimits } from './validateLicenseLimits'; import { validateLicensePeriods } from './validateLicensePeriods'; @@ -8,15 +9,11 @@ import { validateLicenseUrl } from './validateLicenseUrl'; export async function runValidation( this: LicenseManager, license: ILicenseV3, - behaviorsToValidate: LicenseBehavior[] = [], + options: LicenseValidationOptions, ): Promise { - const shouldValidateBehavior = (behavior: LicenseBehavior) => !behaviorsToValidate.length || behaviorsToValidate.includes(behavior); - return [ - ...new Set([ - ...validateLicenseUrl.call(this, license, shouldValidateBehavior), - ...validateLicensePeriods(license, shouldValidateBehavior), - ...(await validateLicenseLimits.call(this, license, shouldValidateBehavior)), - ]), + ...validateLicenseUrl.call(this, license, options), + ...validateLicensePeriods(license, options), + ...(await validateLicenseLimits.call(this, license, options)), ]; } diff --git a/ee/packages/license/src/validation/validateLicenseLimits.ts b/ee/packages/license/src/validation/validateLicenseLimits.ts index 168effe6a250..f321252ba573 100644 --- a/ee/packages/license/src/validation/validateLicenseLimits.ts +++ b/ee/packages/license/src/validation/validateLicenseLimits.ts @@ -1,5 +1,7 @@ -import type { ILicenseV3 } from '../definition/ILicenseV3'; -import type { BehaviorWithContext, LicenseBehavior } from '../definition/LicenseBehavior'; +import type { ILicenseV3, LicenseLimitKind } from '../definition/ILicenseV3'; +import type { BehaviorWithContext } from '../definition/LicenseBehavior'; +import type { LicenseValidationOptions } from '../definition/LicenseValidationOptions'; +import { isLimitAllowed, isBehaviorAllowed } from '../isItemAllowed'; import type { LicenseManager } from '../license'; import { logger } from '../logger'; import { getCurrentValueForLicenseLimit } from './getCurrentValueForLicenseLimit'; @@ -8,30 +10,49 @@ import { getResultingBehavior } from './getResultingBehavior'; export async function validateLicenseLimits( this: LicenseManager, license: ILicenseV3, - behaviorFilter: (behavior: LicenseBehavior) => boolean, + options: LicenseValidationOptions, ): Promise { const { limits } = license; - const limitKeys = Object.keys(limits) as (keyof ILicenseV3['limits'])[]; + const limitKeys = (Object.keys(limits) as LicenseLimitKind[]).filter((limit) => isLimitAllowed(limit, options)); return ( await Promise.all( limitKeys.map(async (limitKey) => { // Filter the limit list before running any query in the database so we don't end up loading some value we won't use. - const limitList = limits[limitKey]?.filter(({ behavior, max }) => max >= 0 && behaviorFilter(behavior)); + const limitList = limits[limitKey]?.filter(({ behavior, max }) => max >= 0 && isBehaviorAllowed(behavior, options)); if (!limitList?.length) { return []; } - const currentValue = await getCurrentValueForLicenseLimit.call(this, limitKey); + const extraCount = options.context?.[limitKey]?.extraCount ?? 0; + const currentValue = (await getCurrentValueForLicenseLimit.call(this, limitKey, options.context?.[limitKey])) + extraCount; + return limitList - .filter(({ max }) => max < currentValue) + .filter(({ max, behavior }) => { + switch (behavior) { + case 'invalidate_license': + case 'prevent_installation': + case 'disable_modules': + case 'start_fair_policy': + default: + return currentValue > max; + case 'prevent_action': + /** + * if we are validating the current count the limit should be equal or over the max, if we are validating the future count the limit should be over the max + */ + + return extraCount ? currentValue > max : currentValue >= max; + } + }) .map((limit) => { - logger.error({ - msg: 'Limit validation failed', - kind: limitKey, - limit, - }); - return getResultingBehavior(limit); + if (!options.suppressLog) { + logger.error({ + msg: 'Limit validation failed', + kind: limitKey, + limit, + }); + } + return getResultingBehavior(limit, { reason: 'limit', limit: limitKey }); }); }), ) diff --git a/ee/packages/license/src/validation/validateLicensePeriods.ts b/ee/packages/license/src/validation/validateLicensePeriods.ts index 5b3fae433e38..fb27f72d0a8e 100644 --- a/ee/packages/license/src/validation/validateLicensePeriods.ts +++ b/ee/packages/license/src/validation/validateLicensePeriods.ts @@ -1,6 +1,8 @@ import type { ILicenseV3 } from '../definition/ILicenseV3'; -import type { BehaviorWithContext, LicenseBehavior } from '../definition/LicenseBehavior'; +import type { BehaviorWithContext } from '../definition/LicenseBehavior'; import type { Timestamp } from '../definition/LicensePeriod'; +import type { LicenseValidationOptions } from '../definition/LicenseValidationOptions'; +import { isBehaviorAllowed } from '../isItemAllowed'; import { logger } from '../logger'; import { getResultingBehavior } from './getResultingBehavior'; @@ -18,21 +20,23 @@ export const isPeriodInvalid = (from: Timestamp | undefined, until: Timestamp | return false; }; -export const validateLicensePeriods = ( - license: ILicenseV3, - behaviorFilter: (behavior: LicenseBehavior) => boolean, -): BehaviorWithContext[] => { +export const validateLicensePeriods = (license: ILicenseV3, options: LicenseValidationOptions): BehaviorWithContext[] => { const { validation: { validPeriods }, } = license; return validPeriods - .filter(({ validFrom, validUntil, invalidBehavior }) => behaviorFilter(invalidBehavior) && isPeriodInvalid(validFrom, validUntil)) + .filter( + ({ validFrom, validUntil, invalidBehavior }) => isBehaviorAllowed(invalidBehavior, options) && isPeriodInvalid(validFrom, validUntil), + ) .map((period) => { - logger.error({ - msg: 'Period validation failed', - period, - }); - return getResultingBehavior(period); + if (!options.suppressLog) { + logger.error({ + msg: 'Period validation failed', + period, + }); + } + + return getResultingBehavior(period, { reason: 'period' }); }); }; diff --git a/ee/packages/license/src/validation/validateLicenseUrl.spec.ts b/ee/packages/license/src/validation/validateLicenseUrl.spec.ts new file mode 100644 index 000000000000..9047876f8fbc --- /dev/null +++ b/ee/packages/license/src/validation/validateLicenseUrl.spec.ts @@ -0,0 +1,130 @@ +/** + * @jest-environment node + */ + +import crypto from 'crypto'; + +import { MockedLicenseBuilder, getReadyLicenseManager } from '../../__tests__/MockedLicenseBuilder'; +import { validateLicenseUrl } from './validateLicenseUrl'; + +describe('Url Validation', () => { + describe('url method', () => { + it('should return a behavior if the license url is invalid', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withServerUrls({ + value: 'localhost:3001', + type: 'url', + }); + + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), + ).toStrictEqual([ + { + behavior: 'invalidate_license', + limit: undefined, + reason: 'url', + }, + ]); + }); + + it('should return an empty array if the license url is valid', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withServerUrls({ + value: 'localhost:3000', + type: 'url', + }); + + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), + ).toStrictEqual([]); + }); + }); + + describe('regex method', () => { + it('should return a behavior if the license does not match the regex', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withServerUrls({ + value: 'unstable.rocket.*', + type: 'regex', + }); + + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), + ).toStrictEqual([ + { + behavior: 'invalidate_license', + limit: undefined, + reason: 'url', + }, + ]); + }); + + it('should return an empty array if the license matches the regex', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withServerUrls({ + value: 'localhost:300*', + type: 'regex', + }); + + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), + ).toStrictEqual([]); + }); + }); + + describe('hash method', () => { + it('should return a behavior if the license does not match the hash', async () => { + const licenseManager = await getReadyLicenseManager(); + + const hash = crypto.createHash('sha256').update('localhost:3001').digest('hex'); + const license = await new MockedLicenseBuilder().withServerUrls({ + value: hash, + type: 'hash', + }); + + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), + ).toStrictEqual([ + { + behavior: 'invalidate_license', + limit: undefined, + reason: 'url', + }, + ]); + }); + it('should return an empty array if the license matches the hash', async () => { + const licenseManager = await getReadyLicenseManager(); + + const hash = crypto.createHash('sha256').update('localhost:3000').digest('hex'); + const license = await new MockedLicenseBuilder().withServerUrls({ + value: hash, + type: 'hash', + }); + await expect( + validateLicenseUrl.call(licenseManager, await license.build(), { + behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'], + suppressLog: true, + }), + ).toStrictEqual([]); + }); + }); +}); diff --git a/ee/packages/license/src/validation/validateLicenseUrl.ts b/ee/packages/license/src/validation/validateLicenseUrl.ts index 55cd076c4378..416b107511cb 100644 --- a/ee/packages/license/src/validation/validateLicenseUrl.ts +++ b/ee/packages/license/src/validation/validateLicenseUrl.ts @@ -1,10 +1,14 @@ +import crypto from 'crypto'; + import type { ILicenseV3 } from '../definition/ILicenseV3'; -import type { BehaviorWithContext, LicenseBehavior } from '../definition/LicenseBehavior'; +import type { BehaviorWithContext } from '../definition/LicenseBehavior'; +import type { LicenseValidationOptions } from '../definition/LicenseValidationOptions'; +import { isBehaviorAllowed } from '../isItemAllowed'; import type { LicenseManager } from '../license'; import { logger } from '../logger'; import { getResultingBehavior } from './getResultingBehavior'; -export const validateUrl = (licenseURL: string, url: string) => { +const validateRegex = (licenseURL: string, url: string) => { licenseURL = licenseURL .replace(/\./g, '\\.') // convert dots to literal .replace(/\*/g, '.*'); // convert * to .* @@ -13,12 +17,17 @@ export const validateUrl = (licenseURL: string, url: string) => { return !!regex.exec(url); }; -export function validateLicenseUrl( - this: LicenseManager, - license: ILicenseV3, - behaviorFilter: (behavior: LicenseBehavior) => boolean, -): BehaviorWithContext[] { - if (!behaviorFilter('invalidate_license')) { +const validateUrl = (licenseURL: string, url: string) => { + return licenseURL.toLowerCase() === url.toLowerCase(); +}; + +const validateHash = (licenseURL: string, url: string) => { + const value = crypto.createHash('sha256').update(url).digest('hex'); + return licenseURL === value; +}; + +export function validateLicenseUrl(this: LicenseManager, license: ILicenseV3, options: LicenseValidationOptions): BehaviorWithContext[] { + if (!isBehaviorAllowed('invalidate_license', options)) { return []; } @@ -30,18 +39,16 @@ export function validateLicenseUrl( if (!workspaceUrl) { logger.error('Unable to validate license URL without knowing the workspace URL.'); - return [getResultingBehavior({ behavior: 'invalidate_license' })]; + return [getResultingBehavior({ behavior: 'invalidate_license' }, { reason: 'url' })]; } return serverUrls .filter((url) => { switch (url.type) { case 'regex': - // #TODO - break; + return !validateRegex(url.value, workspaceUrl); case 'hash': - // #TODO - break; + return !validateHash(url.value, workspaceUrl); case 'url': return !validateUrl(url.value, workspaceUrl); } @@ -49,11 +56,13 @@ export function validateLicenseUrl( return false; }) .map((url) => { - logger.error({ - msg: 'Url validation failed', - url, - workspaceUrl, - }); - return getResultingBehavior({ behavior: 'invalidate_license' }); + if (!options.suppressLog) { + logger.error({ + msg: 'Url validation failed', + url, + workspaceUrl, + }); + } + return getResultingBehavior({ behavior: 'invalidate_license' }, { reason: 'url' }); }); } diff --git a/ee/packages/omnichannel-services/CHANGELOG.md b/ee/packages/omnichannel-services/CHANGELOG.md index 5d4f0468c000..6e4556b12ef0 100644 --- a/ee/packages/omnichannel-services/CHANGELOG.md +++ b/ee/packages/omnichannel-services/CHANGELOG.md @@ -1,5 +1,49 @@ # @rocket.chat/omnichannel-services +## 0.0.15 + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [5cee21468e] +- Updated dependencies [2db32f0d4a] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [aaefe865a7] +- Updated dependencies [357a3a50fa] +- Updated dependencies [f556518fa1] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [9496f1eb97] +- Updated dependencies [d45365436e] +- Updated dependencies [93d4912e17] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/rest-typings@6.4.0 + - @rocket.chat/model-typings@0.1.0 + - @rocket.chat/core-services@0.2.0 + - @rocket.chat/tools@0.1.0 + - @rocket.chat/pdf-worker@0.0.15 + - @rocket.chat/models@0.0.15 + +## 0.0.15-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/rest-typings@6.4.0-rc.5 + - @rocket.chat/tools@0.1.0-rc.0 + - @rocket.chat/pdf-worker@0.0.15-rc.5 + - @rocket.chat/core-services@0.2.0-rc.5 + - @rocket.chat/model-typings@0.1.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + ## 0.0.14-rc.4 ### Patch Changes diff --git a/ee/packages/omnichannel-services/package.json b/ee/packages/omnichannel-services/package.json index 17a71abc3da5..e5875c054eef 100644 --- a/ee/packages/omnichannel-services/package.json +++ b/ee/packages/omnichannel-services/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/omnichannel-services", - "version": "0.0.14-rc.4", + "version": "0.0.15", "private": true, "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", diff --git a/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts b/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts index 899d298fb445..0e135d5ed263 100644 --- a/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts +++ b/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts @@ -222,7 +222,7 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT } let file = message.files?.map((v) => ({ _id: v._id, name: v.name })).find((file) => file.name === attachment.title); if (!file) { - this.log.debug(`File ${attachment.title} not found in room ${message.rid}!`); + this.log.warn(`File ${attachment.title} not found in room ${message.rid}!`); // For some reason, when an image is uploaded from clipboard, it doesn't have a file :( // So, we'll try to get the FILE_ID from the `title_link` prop which has the format `/file-upload/FILE_ID/FILE_NAME` using a regex const fileId = attachment.title_link?.match(/\/file-upload\/(.*)\/.*/)?.[1]; @@ -236,7 +236,7 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT } if (!file) { - this.log.error(`File ${attachment.title} not found in room ${message.rid}!`); + this.log.warn(`File ${attachment.title} not found in room ${message.rid}!`); // ignore attachments without file files.push({ name: attachment.title, buffer: null }); continue; @@ -304,7 +304,8 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT const messages = await this.getMessagesFromRoom({ rid: room._id }); const visitor = - room.v && (await LivechatVisitors.findOneById(room.v._id, { projection: { _id: 1, name: 1, username: 1, visitorEmails: 1 } })); + room.v && + (await LivechatVisitors.findOneEnabledById(room.v._id, { projection: { _id: 1, name: 1, username: 1, visitorEmails: 1 } })); const agent = room.servedBy && (await Users.findOneAgentById(room.servedBy._id, { projection: { _id: 1, name: 1, username: 1, utcOffset: 1 } })); diff --git a/ee/packages/pdf-worker/CHANGELOG.md b/ee/packages/pdf-worker/CHANGELOG.md index 13dc1a43e0f5..1da194044df7 100644 --- a/ee/packages/pdf-worker/CHANGELOG.md +++ b/ee/packages/pdf-worker/CHANGELOG.md @@ -1,5 +1,26 @@ # @rocket.chat/pdf-worker +## 0.0.15 + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [d45365436e] + - @rocket.chat/core-typings@6.4.0 + +## 0.0.15-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + ## 0.0.14-rc.4 ### Patch Changes diff --git a/ee/packages/pdf-worker/package.json b/ee/packages/pdf-worker/package.json index daa5105e3fdc..f4bd7c5b44f6 100644 --- a/ee/packages/pdf-worker/package.json +++ b/ee/packages/pdf-worker/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/pdf-worker", - "version": "0.0.14-rc.4", + "version": "0.0.15", "private": true, "devDependencies": { "@storybook/addon-essentials": "~6.5.16", @@ -34,7 +34,7 @@ "dependencies": { "@react-pdf/renderer": "^3.1.12", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/fuselage-tokens": "next", + "@rocket.chat/fuselage-tokens": "^0.32.0", "@types/react": "~17.0.62", "emoji-assets": "^7.0.1", "emoji-toolkit": "^7.0.1", diff --git a/ee/packages/presence/CHANGELOG.md b/ee/packages/presence/CHANGELOG.md index befd96b837ee..4ee4d1a7df52 100644 --- a/ee/packages/presence/CHANGELOG.md +++ b/ee/packages/presence/CHANGELOG.md @@ -1,5 +1,33 @@ # @rocket.chat/presence +## 0.0.15 + +### Patch Changes + +- d9a150000d: Fixed presence broadcast being disabled on server restart +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [982ef6f459] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [d45365436e] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/core-services@0.2.0 + - @rocket.chat/models@0.0.15 + +## 0.0.15-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/core-services@0.2.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + ## 0.0.14-rc.4 ### Patch Changes diff --git a/ee/packages/presence/package.json b/ee/packages/presence/package.json index 9011dab086b6..da77719abc88 100644 --- a/ee/packages/presence/package.json +++ b/ee/packages/presence/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/presence", - "version": "0.0.14-rc.4", + "version": "0.0.15", "private": true, "devDependencies": { "@babel/core": "~7.22.9", @@ -28,6 +28,9 @@ "files": [ "/dist" ], + "volta": { + "extends": "../../../package.json" + }, "dependencies": { "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", diff --git a/ee/packages/presence/src/Presence.ts b/ee/packages/presence/src/Presence.ts index fb656fc3e158..5bd69e1f4fc8 100755 --- a/ee/packages/presence/src/Presence.ts +++ b/ee/packages/presence/src/Presence.ts @@ -19,6 +19,8 @@ export class Presence extends ServiceClass implements IPresence { private connsPerInstance = new Map(); + private peakConnections = 0; + constructor() { super(); @@ -35,6 +37,7 @@ export class Presence extends ServiceClass implements IPresence { if (diff?.hasOwnProperty('extraInformation.conns')) { this.connsPerInstance.set(id, diff['extraInformation.conns']); + this.peakConnections = Math.max(this.peakConnections, this.getTotalConnections()); this.validateAvailability(); } }); @@ -251,4 +254,16 @@ export class Presence extends ServiceClass implements IPresence { private getTotalConnections(): number { return Array.from(this.connsPerInstance.values()).reduce((acc, conns) => acc + conns, 0); } + + getPeakConnections(reset = false): number { + const peak = this.peakConnections; + if (reset) { + this.resetPeakConnections(); + } + return peak; + } + + resetPeakConnections(): void { + this.peakConnections = 0; + } } diff --git a/ee/packages/ui-theming/CHANGELOG.md b/ee/packages/ui-theming/CHANGELOG.md index 746a406ad105..134f170ce82a 100644 --- a/ee/packages/ui-theming/CHANGELOG.md +++ b/ee/packages/ui-theming/CHANGELOG.md @@ -1,5 +1,11 @@ # @rocket.chat/ui-theming +## 0.1.0 + +### Minor Changes + +- 357a3a50fa: feat: high-contrast theme + ## 0.1.0-rc.0 ### Minor Changes diff --git a/ee/packages/ui-theming/package.json b/ee/packages/ui-theming/package.json index a378fee8f896..adf0f958eee0 100644 --- a/ee/packages/ui-theming/package.json +++ b/ee/packages/ui-theming/package.json @@ -1,10 +1,10 @@ { "name": "@rocket.chat/ui-theming", - "version": "0.1.0-rc.0", + "version": "0.1.0", "private": true, "devDependencies": { "@rocket.chat/css-in-js": "next", - "@rocket.chat/fuselage": "^0.32.1", + "@rocket.chat/fuselage": "^0.36.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/icons": "^0.32.0", "@rocket.chat/ui-contexts": "workspace:~", diff --git a/ee/packages/ui-theming/src/palette.ts b/ee/packages/ui-theming/src/palette.ts index 4825beec0cff..b2d9e9955dbf 100644 --- a/ee/packages/ui-theming/src/palette.ts +++ b/ee/packages/ui-theming/src/palette.ts @@ -29,6 +29,7 @@ export const palette = [ { name: 'surface-featured', token: '', color: '#5F1477' }, { name: 'surface-featured-hover', token: '', color: '#4A105D' }, { name: 'surface-overlay', token: '', color: 'rgba(47, 52, 61, 0.5)' }, + { name: 'surface-sidebar', token: 'N400', color: '#E4E7EA' }, ], }, { @@ -44,10 +45,10 @@ export const palette = [ description: 'These should be applied according to surfaces', list: [ { name: 'font-white', token: 'white', color: '#FFFFFF' }, - { name: 'font-disabled', token: 'N100', color: '#F7F8FA' }, + { name: 'font-disabled', token: 'N500', color: '#CBCED1' }, { name: 'font-annotation', token: 'N600', color: '#9EA2A8' }, - { name: 'font-hint', token: 'N700', color: '#6C727A' }, - { name: 'font-secondary-info', token: 'N700', color: '#6C727A' }, + { name: 'font-hint', token: 'N700', color: '#6C737A' }, + { name: 'font-secondary-info', token: 'N700', color: '#6C737A' }, { name: 'font-default', token: 'N800', color: '#2F343D' }, { name: 'font-titles-labels', token: 'N900', color: '#1F2329' }, { name: 'font-info', token: 'P600', color: '#095AD2' }, @@ -76,7 +77,7 @@ export const palette = [ { name: 'status-font-on-info', token: 'P600', color: '#095AD2' }, { name: 'status-font-on-success', token: 'S800', color: '#148660' }, { name: 'status-font-on-danger', token: 'D800', color: '#9B1325' }, - { name: 'status-font-on-warning', token: 'W900', color: '#B88D00' }, + { name: 'status-font-on-warning', token: 'W900', color: '#8E6300' }, { name: 'status-font-on-warning-2', token: 'N800', color: '#2F343D' }, { name: 'status-font-on-service-1', token: 'S1-800', color: '#974809' }, { name: 'status-font-on-service-2 ', token: 'S2-600', color: '#7F1B9F' }, @@ -88,22 +89,22 @@ export const palette = [ description: 'Badge Background', list: [ { name: 'badge-background-level-0', token: '', color: '#E4E7EA' }, - { name: 'badge-background-level-1', token: 'N700', color: '#6C727A' }, - { name: 'badge-background-level-2', token: '', color: '#1D74F5' }, + { name: 'badge-background-level-1', token: 'N700', color: '#6C737A' }, + { name: 'badge-background-level-2', token: '', color: '#156FF5' }, { name: 'badge-background-level-3', token: '', color: '#F38C39' }, - { name: 'badge-background-level-4', token: '', color: '#F5455C' }, + { name: 'badge-background-level-4', token: '', color: '#EC0D2A' }, ], }, { category: 'Status Bullet', description: 'Used to show user status', list: [ - { name: 'status-bullet-online', token: '', color: '#158D65' }, + { name: 'status-bullet-online', token: '', color: '#148660' }, { name: 'status-bullet-away', token: '', color: '#AC892F' }, - { name: 'status-bullet-busy', token: '', color: '#DA1F37' }, + { name: 'status-bullet-busy', token: '', color: '#D40C26' }, { name: 'status-bullet-disabled', token: '', color: '#F38C39' }, - { name: 'status-bullet-offline', token: '', color: '#AC892F' }, - { name: 'status-bullet-loading', token: '', color: '#9ea2a8' }, + { name: 'status-bullet-offline', token: '', color: '#6C737A' }, + { name: 'status-bullet-loading', token: '', color: '#6C737A' }, ], }, { @@ -122,7 +123,7 @@ export const palette = [ list: [ { name: 'button-background-primary-default', token: 'P500', color: '#156FF5' }, { name: 'button-background-primary-hover', token: 'P600', color: '#095AD2' }, - { name: 'button-background-primary-press', token: 'P700', color: '#095AD2' }, + { name: 'button-background-primary-press', token: 'P700', color: '#10529E' }, { name: 'button-background-primary-focus', token: 'P500', color: '#156FF5' }, { name: 'button-background-primary-keyfocus', token: 'P500', color: '#156FF5' }, { name: 'button-background-primary-disabled', token: 'P200', color: '#D1EBFE' }, @@ -133,7 +134,7 @@ export const palette = [ list: [ { name: 'button-background-secondary-default', token: 'N400', color: '#E4E7EA' }, { name: 'button-background-secondary-hover', token: 'N500', color: '#CBCED1' }, - { name: 'button-background-secondary-press', token: 'N600', color: '#CBCED1' }, + { name: 'button-background-secondary-press', token: 'N600', color: '#9EA2A8' }, { name: 'button-background-secondary-focus', token: 'N400', color: '#E4E7EA' }, { name: 'button-background-secondary-keyfocus', token: 'N400', color: '#E4E7EA' }, { name: 'button-background-secondary-disabled', token: 'N300', color: '#EEEFF1' }, @@ -144,7 +145,7 @@ export const palette = [ list: [ { name: 'button-background-secondary-danger-default', token: 'N400', color: '#E4E7EA' }, { name: 'button-background-secondary-danger-hover', token: 'N500', color: '#CBCED1' }, - { name: 'button-background-secondary-danger-press', token: 'N600', color: '#CBCED1' }, + { name: 'button-background-secondary-danger-press', token: 'N600', color: '#9EA2A8' }, { name: 'button-background-secondary-danger-focus', token: 'N400', color: '#E4E7EA' }, { name: 'button-background-secondary-danger-keyfocus', token: 'N400', color: '#E4E7EA' }, { name: 'button-background-secondary-danger-disabled', token: 'N300', color: '#EEEFF1' }, @@ -164,11 +165,11 @@ export const palette = [ { description: 'Success Background', list: [ - { name: 'button-background-success-default', token: '', color: '#158D65' }, + { name: 'button-background-success-default', token: '', color: '#148660' }, { name: 'button-background-success-hover', token: 'S900', color: '#106D4F' }, { name: 'button-background-success-press', token: 'S1000', color: '#0D5940' }, - { name: 'button-background-success-focus', token: '', color: '#158D65' }, - { name: 'button-background-success-keyfocus', token: '', color: '#158D65' }, + { name: 'button-background-success-focus', token: '', color: '#148660' }, + { name: 'button-background-success-keyfocus', token: '', color: '#148660' }, { name: 'button-background-success-disabled', token: 'S200', color: '#C0F6E4' }, ], }, @@ -179,7 +180,7 @@ export const palette = [ { name: 'button-font-on-primary-disabled', token: 'white', color: '#FFFFFF' }, { name: 'button-font-on-secondary', token: 'N900', color: '#1F2329' }, { name: 'button-font-on-secondary-disabled', token: 'N600', color: '#CBCED1' }, - { name: 'button-font-on-secondary-danger', token: 'D900', color: '#BB0B21' }, + { name: 'button-font-on-secondary-danger', token: '', color: '#BB0B21' }, { name: 'button-font-on-secondary-danger-disabled', token: 'D300', @@ -187,7 +188,7 @@ export const palette = [ }, { name: 'button-font-on-danger', token: 'white', color: '#FFFFFF' }, { name: 'button-font-on-danger-disabled', token: 'white', color: '#FFFFFF' }, - { name: 'button-font-on-success', token: '', color: '#EBECEF' }, + { name: 'button-font-on-success', token: '', color: '#FFFFFF' }, { name: 'button-font-on-success-disabled', token: 'white', color: '#FFFFFF' }, ], }, diff --git a/ee/packages/ui-theming/src/paletteDark.ts b/ee/packages/ui-theming/src/paletteDark.ts index cdb60efffeac..f8db2983defc 100644 --- a/ee/packages/ui-theming/src/paletteDark.ts +++ b/ee/packages/ui-theming/src/paletteDark.ts @@ -9,7 +9,7 @@ export const palette = [ { name: 'stroke-dark', token: 'N600', color: '#9EA2A8' }, { name: 'stroke-extra-dark', token: 'N400', color: '#CBCED1' }, { name: 'stroke-extra-light-highlight', token: '', color: '#87CBFC' }, - { name: 'stroke-highlight', token: '', color: '#3976D1' }, + { name: 'stroke-highlight', token: '', color: '#6292DA' }, { name: 'stroke-extra-light-error', token: '', color: '#F49AA6' }, { name: 'stroke-error', token: '', color: '#BB3E4E' }, ], @@ -29,6 +29,7 @@ export const palette = [ { name: 'surface-featured', token: '', color: '#5F1477' }, { name: 'surface-featured-hover', token: '', color: '#4A105D' }, { name: 'surface-overlay', token: '', color: 'rgba(0, 0, 0, 0.6)' }, + { name: 'surface-sidebar', token: '', color: '#2F343D' }, ], }, { @@ -89,7 +90,7 @@ export const palette = [ list: [ { name: 'badge-background-level-0', token: '', color: '#404754' }, { name: 'badge-background-level-1', token: '', color: '#484C51' }, - { name: 'badge-background-level-2', token: '', color: '#3070CF' }, + { name: 'badge-background-level-2', token: '', color: '#2C65BA' }, { name: 'badge-background-level-3', token: '', color: '#A9642D' }, { name: 'badge-background-level-4', token: '', color: '#BB3E4E' }, ], diff --git a/ee/packages/ui-theming/src/paletteHighContrast.ts b/ee/packages/ui-theming/src/paletteHighContrast.ts index 2cb876103a1d..1ec7bcf793de 100644 --- a/ee/packages/ui-theming/src/paletteHighContrast.ts +++ b/ee/packages/ui-theming/src/paletteHighContrast.ts @@ -29,6 +29,7 @@ export const palette = [ { name: 'surface-featured', token: '', color: '#5F1477' }, { name: 'surface-featured-hover', token: '', color: '#4A105D' }, { name: 'surface-overlay', token: '', color: 'rgba(47, 52, 61, 0.5)' }, + { name: 'surface-sidebar', token: 'N400', color: '#E4E7EA' }, ], }, { diff --git a/ee/packages/ui-theming/src/sidebarPalette.ts b/ee/packages/ui-theming/src/sidebarPalette.ts index 9512af1e467c..4fb673383b3e 100644 --- a/ee/packages/ui-theming/src/sidebarPalette.ts +++ b/ee/packages/ui-theming/src/sidebarPalette.ts @@ -1,13 +1,4 @@ export const palette = [ - { - category: 'Surface', - description: 'Use as a container on top of the background', - list: [ - { name: 'surface-tint', token: '', color: '#262931' }, - { name: 'surface-hover', token: '', color: '#1B1D22' }, - { name: 'surface-selected', token: 'N900', color: '#31363F' }, - ], - }, { category: 'Font', description: 'These should be applied according to surfaces', @@ -16,57 +7,6 @@ export const palette = [ { name: 'font-default', token: '', color: '#9EA2A8' }, ], }, - { - category: 'Status Bullet', - description: 'Used to show user status', - list: [ - { name: 'status-bullet-online', token: '', color: '#1CBF89' }, - { name: 'status-bullet-away', token: '', color: '#B08C30' }, - { name: 'status-bullet-busy', token: '', color: '#C75765' }, - { name: 'status-bullet-disabled', token: '', color: '#CC7F42' }, - { name: 'status-bullet-offline', token: '', color: '#8B9098' }, - { name: 'status-bullet-loading', token: '', color: '#8B9098' }, - ], - }, - { - category: 'Badge', - description: 'Badge Background', - list: [ - { name: 'badge-background-level-0', token: '', color: '#404754' }, - { name: 'badge-background-level-1', token: '', color: '#484C51' }, - { name: 'badge-background-level-2', token: '', color: '#2C65BA' }, - { name: 'badge-background-level-3', token: '', color: '#955828' }, - { name: 'badge-background-level-4', token: '', color: '#B43C4C' }, - ], - }, - { - category: 'Stroke', - description: "Use as component's outline, stroke, dividers", - list: [ - { name: 'stroke-light', token: '', color: '#333842' }, - { name: 'stroke-medium', token: '', color: '#324677' }, - ], - }, - { - category: 'Button', - description: 'Secondary Background', - list: [ - { name: 'button-background-secondary-default', token: '', color: '#0D0F11' }, - { name: 'button-background-secondary-hover', token: '', color: '#3A404B' }, - { name: 'button-background-secondary-press', token: '', color: '#4C5362' }, - { name: 'button-background-secondary-focus', token: '', color: '#0D0F11' }, - { name: 'button-background-secondary-keyfocus', token: '', color: '#2F343D' }, - { name: 'button-background-secondary-disabled', token: '', color: '#2F343D' }, - ], - }, - { - description: 'Font', - list: [ - { name: 'button-font-on-secondary', token: '', color: '#E4E7EA' }, - { name: 'button-font-on-secondary-disabled', token: '', color: '#6C727A' }, - { name: 'button-icon-disabled-color', token: '', color: '#6C727A' }, - ], - }, ]; export const defaultSidebarPalette = { diff --git a/packages/core-services/CHANGELOG.md b/packages/core-services/CHANGELOG.md index 0f067001c3be..940c05513ec1 100644 --- a/packages/core-services/CHANGELOG.md +++ b/packages/core-services/CHANGELOG.md @@ -1,5 +1,40 @@ # @rocket.chat/core-services +## 0.2.0 + +### Minor Changes + +- 982ef6f459: Add new event to notify users directly about new banners +- 19aec23cda: New AddUser workflow for Federated Rooms + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [2db32f0d4a] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [19aec23cda] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [357a3a50fa] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [9496f1eb97] +- Updated dependencies [d45365436e] +- Updated dependencies [93d4912e17] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/rest-typings@6.4.0 + - @rocket.chat/models@0.0.15 + +## 0.2.0-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/rest-typings@6.4.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + ## 0.2.0-rc.4 ### Patch Changes @@ -57,6 +92,14 @@ - @rocket.chat/rest-typings@6.4.0-rc.0 - @rocket.chat/models@0.0.11-rc.0 +## 0.1.8 + +### Patch Changes + +- @rocket.chat/core-typings@6.3.8 +- @rocket.chat/rest-typings@6.3.8 +- @rocket.chat/models@0.0.14 + ## 0.1.7 ### Patch Changes diff --git a/packages/core-services/package.json b/packages/core-services/package.json index 4cce8aebe07b..7f6a82642d47 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/core-services", - "version": "0.2.0-rc.4", + "version": "0.2.0", "private": true, "devDependencies": { "@babel/core": "~7.22.9", @@ -30,6 +30,9 @@ "files": [ "/dist" ], + "volta": { + "extends": "../../package.json" + }, "dependencies": { "@rocket.chat/apps-engine": "1.41.0-alpha.290", "@rocket.chat/core-typings": "workspace:^", diff --git a/packages/core-services/src/Events.ts b/packages/core-services/src/Events.ts index 88ca1034b9c9..4e7bd77651c3 100644 --- a/packages/core-services/src/Events.ts +++ b/packages/core-services/src/Events.ts @@ -1,6 +1,5 @@ import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings'; -import type { IUIKitInteraction } from '@rocket.chat/apps-engine/definition/uikit'; import type { IEmailInbox, IEmoji, @@ -32,7 +31,10 @@ import type { ILivechatInquiryRecord, ILivechatAgent, IBanner, + ILivechatVisitor, + UiKit, } from '@rocket.chat/core-typings'; +import type { LicenseLimitKind } from '@rocket.chat/license'; import type { AutoUpdateRecord } from './types/IMeteor'; @@ -54,11 +56,14 @@ export type EventSignatures = { 'emoji.deleteCustom'(emoji: IEmoji): void; 'emoji.updateCustom'(emoji: IEmoji): void; 'license.module'(data: { module: string; valid: boolean }): void; + 'license.sync'(): void; + 'license.actions'(actions: Record, boolean>): void; + 'livechat-inquiry-queue-observer'(data: { action: string; inquiry: IInquiry }): void; 'message'(data: { action: string; message: IMessage }): void; 'meteor.clientVersionUpdated'(data: AutoUpdateRecord): void; 'notify.desktop'(uid: string, data: INotificationDesktop): void; - 'notify.uiInteraction'(uid: string, data: IUIKitInteraction): void; + 'notify.uiInteraction'(uid: string, data: UiKit.ServerInteraction): void; 'notify.updateInvites'(uid: string, data: { invite: Omit }): void; 'notify.ephemeralMessage'(uid: string, rid: string, message: AtLeast): void; 'notify.webdav'( @@ -242,7 +247,8 @@ export type EventSignatures = { data: | { type: 'agentStatus'; status: string } | { type: 'queueData'; data: { [k: string]: unknown } | undefined } - | { type: 'agentData'; data: ILivechatAgent | undefined | { hiddenInfo: boolean } }, + | { type: 'agentData'; data: ILivechatAgent | undefined | { hiddenInfo: boolean } } + | { type: 'visitorData'; visitor: ILivechatVisitor }, ): void; // Send all events from here @@ -267,4 +273,6 @@ export type EventSignatures = { 'command.updated'(command: string): void; 'command.removed'(command: string): void; 'actions.changed'(): void; + 'mac.limitReached'(): void; + 'mac.limitRestored'(): void; }; diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index def7622c9881..d3cc778e5a22 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -41,7 +41,7 @@ import type { } from './types/ITeamService'; import type { ITelemetryEvent, TelemetryMap, TelemetryEvents } from './types/ITelemetryEvent'; import type { ITranslationService } from './types/ITranslationService'; -import type { IUiKitCoreApp, IUiKitCoreAppService } from './types/IUiKitCoreApp'; +import type { UiKitCoreAppPayload, IUiKitCoreApp, IUiKitCoreAppService } from './types/IUiKitCoreApp'; import type { ISendFileLivechatMessageParams, ISendFileMessageParams, IUploadFileParams, IUploadService } from './types/IUploadService'; import type { IVideoConfService, VideoConferenceJoinOptions } from './types/IVideoConfService'; import type { IVoipService } from './types/IVoipService'; @@ -94,6 +94,7 @@ export { ITeamService, ITeamUpdateData, ITelemetryEvent, + UiKitCoreAppPayload, IUiKitCoreApp, IUiKitCoreAppService, IVideoConfService, diff --git a/packages/core-services/src/lib/Api.ts b/packages/core-services/src/lib/Api.ts index 66806dc54fde..f0b5e67594c2 100644 --- a/packages/core-services/src/lib/Api.ts +++ b/packages/core-services/src/lib/Api.ts @@ -46,7 +46,11 @@ export class Api implements IApiService { } async broadcast(event: T, ...args: Parameters): Promise { - return this.broker?.broadcast(event, ...args); + if (!this.broker) { + throw new Error(`No broker set to broadcast: ${event}`); + } + + return this.broker.broadcast(event, ...args); } async broadcastToServices( diff --git a/packages/core-services/src/types/IBannerService.ts b/packages/core-services/src/types/IBannerService.ts index 1035bdd59510..50b8ab08275c 100644 --- a/packages/core-services/src/types/IBannerService.ts +++ b/packages/core-services/src/types/IBannerService.ts @@ -2,7 +2,7 @@ import type { BannerPlatform, IBanner, Optional } from '@rocket.chat/core-typing export interface IBannerService { getBannersForUser(userId: string, platform: BannerPlatform, bannerId?: string): Promise; - create(banner: Optional): Promise; + create(banner: Optional): Promise; dismiss(userId: string, bannerId: string): Promise; discardDismissal(bannerId: string): Promise; getById(bannerId: string): Promise; diff --git a/packages/core-services/src/types/INPSService.ts b/packages/core-services/src/types/INPSService.ts index 4590a2910e8c..eaf54f6c6133 100644 --- a/packages/core-services/src/types/INPSService.ts +++ b/packages/core-services/src/types/INPSService.ts @@ -1,9 +1,9 @@ import type { IUser, IRole } from '@rocket.chat/core-typings'; export type NPSVotePayload = { - userId: string; + userId: string | undefined; npsId: string; - roles: IRole['_id'][]; + roles?: IRole['_id'][]; score: number; comment: string; }; diff --git a/packages/core-services/src/types/IOmnichannelService.ts b/packages/core-services/src/types/IOmnichannelService.ts index fb3cc60d9243..73006641c8cd 100644 --- a/packages/core-services/src/types/IOmnichannelService.ts +++ b/packages/core-services/src/types/IOmnichannelService.ts @@ -1,7 +1,8 @@ -import type { IOmnichannelQueue } from '@rocket.chat/core-typings'; +import type { AtLeast, IOmnichannelQueue, IOmnichannelRoom } from '@rocket.chat/core-typings'; import type { IServiceClass } from './ServiceClass'; export interface IOmnichannelService extends IServiceClass { getQueueWorker(): IOmnichannelQueue; + isWithinMACLimit(_room: AtLeast): Promise; } diff --git a/packages/core-services/src/types/IPresence.ts b/packages/core-services/src/types/IPresence.ts index 197f9b685cf8..5f7c57d67995 100644 --- a/packages/core-services/src/types/IPresence.ts +++ b/packages/core-services/src/types/IPresence.ts @@ -19,4 +19,6 @@ export interface IPresence extends IServiceClass { updateUserPresence(uid: string): Promise; toggleBroadcast(enabled: boolean): void; getConnectionCount(): { current: number; max: number }; + getPeakConnections(reset?: boolean): number; + resetPeakConnections(): void; } diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index d9eee82029af..f7be69ce2a7c 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -4,6 +4,7 @@ export interface ISubscriptionExtraData { open: boolean; ls?: Date; prid?: string; + roles?: string[]; } interface ICreateRoomOptions extends Partial> { diff --git a/packages/core-services/src/types/IUiKitCoreApp.ts b/packages/core-services/src/types/IUiKitCoreApp.ts index 92c7b7bd738e..98799918e594 100644 --- a/packages/core-services/src/types/IUiKitCoreApp.ts +++ b/packages/core-services/src/types/IUiKitCoreApp.ts @@ -1,16 +1,55 @@ +import type { IUser } from '@rocket.chat/core-typings'; + import type { IServiceClass } from './ServiceClass'; +export type UiKitCoreAppPayload = { + appId: string; + type: 'blockAction' | 'viewClosed' | 'viewSubmit'; + actionId?: string; + triggerId?: string; + container?: { + id: string; + [key: string]: unknown; + }; + message?: unknown; + payload: { + blockId?: string; + value?: unknown; + view?: { + viewId?: string; + id?: string; + state?: { [blockId: string]: { [key: string]: unknown } }; + [key: string]: unknown; + }; + isCleared?: unknown; + }; + user?: IUser; + visitor?: { + id: string; + username: string; + name?: string; + department?: string; + updatedAt?: Date; + token: string; + phone?: { phoneNumber: string }[] | null; + visitorEmails?: { address: string }[]; + livechatData?: Record; + status?: 'online' | 'away' | 'offline' | 'busy' | 'disabled'; + }; + room?: unknown; +}; + export interface IUiKitCoreApp { appId: string; - blockAction?(payload: any): Promise; - viewClosed?(payload: any): Promise; - viewSubmit?(payload: any): Promise; + blockAction?(payload: UiKitCoreAppPayload): Promise; + viewClosed?(payload: UiKitCoreAppPayload): Promise; + viewSubmit?(payload: UiKitCoreAppPayload): Promise; } export interface IUiKitCoreAppService extends IServiceClass { isRegistered(appId: string): Promise; - blockAction(payload: any): Promise; - viewClosed(payload: any): Promise; - viewSubmit(payload: any): Promise; + blockAction(payload: UiKitCoreAppPayload): Promise; + viewClosed(payload: UiKitCoreAppPayload): Promise; + viewSubmit(payload: UiKitCoreAppPayload): Promise; } diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts index d545365b452a..09e336a51623 100644 --- a/packages/core-services/src/types/IVideoConfService.ts +++ b/packages/core-services/src/types/IVideoConfService.ts @@ -1,8 +1,8 @@ -import type { IBlock } from '@rocket.chat/apps-engine/definition/uikit'; import type { IRoom, IStats, IUser, + UiKit, VideoConference, VideoConferenceCapabilities, VideoConferenceCreateData, @@ -19,7 +19,7 @@ export interface IVideoConfService { create(data: VideoConferenceCreateData, useAppUser?: boolean): Promise; start(caller: IUser['_id'], rid: string, options: { title?: string; allowRinging?: boolean }): Promise; join(uid: IUser['_id'] | undefined, callId: VideoConference['_id'], options: VideoConferenceJoinOptions): Promise; - getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise; + getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise; cancel(uid: IUser['_id'], callId: VideoConference['_id']): Promise; get(callId: VideoConference['_id']): Promise | null>; getUnfiltered(callId: VideoConference['_id']): Promise; diff --git a/packages/core-typings/.eslintrc.json b/packages/core-typings/.eslintrc.json index 56a6f6602e33..44d74e043bc4 100644 --- a/packages/core-typings/.eslintrc.json +++ b/packages/core-typings/.eslintrc.json @@ -8,5 +8,8 @@ } } ], - "ignorePatterns": ["**/dist"] + "ignorePatterns": ["**/dist"], + "rules": { + "@typescript-eslint/no-empty-interface": "off" + } } diff --git a/packages/core-typings/CHANGELOG.md b/packages/core-typings/CHANGELOG.md index be66b7a23ccb..f6c174da450f 100644 --- a/packages/core-typings/CHANGELOG.md +++ b/packages/core-typings/CHANGELOG.md @@ -1,5 +1,27 @@ # @rocket.chat/core-typings +## 6.4.0 + +### Minor Changes + +- 239a34e877: new: ring mobile users on direct conference calls +- 4186eecf05: Introduce the ability to report an user +- ebab8c4dd8: Added Reports Metrics Dashboard to Omnichannel +- 1041d4d361: Added option to select between two script engine options for the integrations + +### Patch Changes + +- 203304782f: Fixed `overrideDestinationChannelEnabled` treated as a required param in `integrations.create` and `integration.update` endpoints +- ba24f3c21f: Fixed `default` field not being returned from the `setDefault` endpoints when setting to false +- 61128364d6: Fixes a problem where the calculated time for considering the visitor abandonment was the first message from the visitor and not the visitor's reply to the agent. +- d45365436e: Use group filter when set to LDAP sync process + +## 6.4.0-rc.5 + +### Minor Changes + +- 1041d4d361: Added option to select between two script engine options for the integrations + ## 6.4.0-rc.4 ## 6.4.0-rc.3 @@ -23,6 +45,8 @@ - 61128364d6: Fixes a problem where the calculated time for considering the visitor abandonment was the first message from the visitor and not the visitor's reply to the agent. - d45365436e: Use group filter when set to LDAP sync process +## 6.3.8 + ## 6.3.7 ## 6.3.6 diff --git a/packages/core-typings/package.json b/packages/core-typings/package.json index 2d0b0d734897..60874e11c810 100644 --- a/packages/core-typings/package.json +++ b/packages/core-typings/package.json @@ -1,6 +1,7 @@ { + "$schema": "https://json.schemastore.org/package", "name": "@rocket.chat/core-typings", - "version": "6.4.0-rc.4", + "version": "6.4.0", "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", "eslint": "~8.45.0", diff --git a/packages/core-typings/src/IBanner.ts b/packages/core-typings/src/IBanner.ts index 29867cdfb6c8..275c3353aa1f 100644 --- a/packages/core-typings/src/IBanner.ts +++ b/packages/core-typings/src/IBanner.ts @@ -1,6 +1,6 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; import type { IUser } from './IUser'; -import type { UiKitBannerPayload } from './UIKit'; +import type * as UiKit from './uikit'; export enum BannerPlatform { Web = 'web', @@ -13,7 +13,7 @@ export interface IBanner extends IRocketChatRecord { roles?: string[]; // only show the banner to this roles createdBy: Pick; createdAt: Date; - view: UiKitBannerPayload; + view: UiKit.BannerView; active?: boolean; inactivedAt?: Date; snapshot?: string; diff --git a/packages/core-typings/src/ILivechatVisitor.ts b/packages/core-typings/src/ILivechatVisitor.ts index d22ea36aa7c6..21819cc23f24 100644 --- a/packages/core-typings/src/ILivechatVisitor.ts +++ b/packages/core-typings/src/ILivechatVisitor.ts @@ -7,7 +7,7 @@ export interface IVisitorPhone { export interface IVisitorLastChat { _id: string; - ts: string; + ts: Date; } export interface ILivechatVisitorConnectionData { @@ -47,6 +47,8 @@ export interface ILivechatVisitor extends IRocketChatRecord { contactManager?: { username: string; }; + activity?: string[]; + disabled?: boolean; } export interface ILivechatVisitorDTO { diff --git a/packages/core-typings/src/INps.ts b/packages/core-typings/src/INps.ts index e89796d9d9a4..12b3a1a15d89 100644 --- a/packages/core-typings/src/INps.ts +++ b/packages/core-typings/src/INps.ts @@ -27,7 +27,7 @@ export interface INpsVote extends IRocketChatRecord { npsId: INps['_id']; ts: Date; identifier: string; // voter identifier - roles: IUser['roles']; // voter roles + roles?: IUser['roles']; // voter roles score: number; comment: string; status: INpsVoteStatus; diff --git a/packages/core-typings/src/IOembed.ts b/packages/core-typings/src/IOembed.ts index c540cb893817..0b781aa07fc8 100644 --- a/packages/core-typings/src/IOembed.ts +++ b/packages/core-typings/src/IOembed.ts @@ -1,9 +1,5 @@ -import type Url from 'url'; - import type { MessageAttachment } from './IMessage'; -export type ParsedUrl = Pick; - export type OEmbedMeta = { [key: string]: string; } & { @@ -12,8 +8,7 @@ export type OEmbedMeta = { }; export type OEmbedUrlContent = { - urlObj: Url.UrlWithParsedQuery; - parsedUrl: ParsedUrl; + urlObj: URL; headers: { [k: string]: string }; body: string; statusCode: number; @@ -27,7 +22,6 @@ export type OEmbedProvider = { export type OEmbedUrlContentResult = { headers: { [key: string]: string }; body: string; - parsedUrl: Pick; statusCode: number; attachments?: MessageAttachment[]; }; @@ -38,7 +32,6 @@ export type OEmbedUrlWithMetadata = { url: string; meta: OEmbedMeta; headers: { [k: string]: string }; - parsedUrl: Pick; content: OEmbedUrlContent; }; diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 875dea70781e..523450e9594d 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -151,7 +151,11 @@ export enum OmnichannelSourceType { export interface IOmnichannelGenericRoom extends Omit { t: 'l' | 'v'; - v: Pick & { lastMessageTs?: Date; phone?: string }; + v: Pick & { + lastMessageTs?: Date; + phone?: string; + activity?: string[]; + }; email?: { // Data used when the room is created from an email, via email Integration. inbox: string; diff --git a/packages/core-typings/src/ISetting.ts b/packages/core-typings/src/ISetting.ts index 0766d782980c..7b3aaa5cf2a4 100644 --- a/packages/core-typings/src/ISetting.ts +++ b/packages/core-typings/src/ISetting.ts @@ -72,7 +72,7 @@ export interface ISettingBase { hidden?: boolean; modules?: Array; invalidValue?: SettingValue; - valueSource?: string; + valueSource?: 'packageValue' | 'processEnvValue'; secret?: boolean; i18nDescription?: string; autocomplete?: boolean; diff --git a/packages/core-typings/src/IStats.ts b/packages/core-typings/src/IStats.ts index 2ea8115a727c..0df389f2dd86 100644 --- a/packages/core-typings/src/IStats.ts +++ b/packages/core-typings/src/IStats.ts @@ -3,11 +3,22 @@ import type { CpuInfo } from 'os'; import type { DeviceSessionAggregationResult, OSSessionAggregationResult, UserSessionAggregationResult } from './ISession'; import type { ISettingStatisticsObject } from './ISetting'; import type { ITeamStats } from './ITeam'; +import type { MACStats } from './omnichannel'; export interface IStats { _id: string; - wizard: Record; + wizard: { + organizationType?: string; + industry?: string; + size?: string; + country?: string; + language?: string; + serverType?: string; + registerServer?: boolean; + }; uniqueId: string; + deploymentFingerprintHash: string; + deploymentFingerprintVerified: boolean; installedAt?: string; version?: string; tag?: string; @@ -85,6 +96,10 @@ export interface IStats { mongoStorageEngine: string; pushQueue: number; omnichannelSources: { [key: string]: number | string }[]; + omnichannelContactsBySource: MACStats; + uniqueContactsOfLastMonth: MACStats; + uniqueContactsOfLastWeek: MACStats; + uniqueContactsOfYesterday: MACStats; departments: number; archivedDepartments: number; routingAlgorithm: string; @@ -203,10 +218,14 @@ export interface IStats { totalCustomRoles: number; totalWebRTCCalls: number; uncaughtExceptionsCount: number; + push: number; + dailyPeakConnections: number; + maxMonthlyPeakConnections: number; matrixFederation: { enabled: boolean; }; webRTCEnabled: boolean; webRTCEnabledForOmnichannel: boolean; omnichannelWebRTCCalls: number; + statsToken?: string; } diff --git a/packages/core-typings/src/Serialized.ts b/packages/core-typings/src/Serialized.ts index c84077610ee8..94f79cb64d06 100644 --- a/packages/core-typings/src/Serialized.ts +++ b/packages/core-typings/src/Serialized.ts @@ -1,9 +1,26 @@ -export type Serialized = T extends Date - ? Exclude | string - : T extends boolean | number | string | null | undefined +/* eslint-disable @typescript-eslint/ban-types */ + +type SerializablePrimitive = boolean | number | string | null; + +type UnserializablePrimitive = Function | bigint | symbol | undefined; + +type CustomSerializable = { + toJSON(key: string): T; +}; + +/** + * The type of a value that was serialized via `JSON.stringify` and then deserialized via `JSON.parse`. + */ +export type Serialized = T extends CustomSerializable + ? Serialized + : T extends [any, ...any] // is T a tuple? + ? { [K in keyof T]: T extends UnserializablePrimitive ? null : Serialized } + : T extends any[] + ? Serialized[] + : T extends object + ? { [K in keyof T]: Serialized } + : T extends SerializablePrimitive ? T - : T extends {} - ? { - [K in keyof T]: Serialized; - } + : T extends UnserializablePrimitive + ? undefined : null; diff --git a/packages/core-typings/src/UIKit.ts b/packages/core-typings/src/UIKit.ts deleted file mode 100644 index 19cf46f82b92..000000000000 --- a/packages/core-typings/src/UIKit.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { UIKitInteractionType as UIKitInteractionTypeApi } from '@rocket.chat/apps-engine/definition/uikit'; -import type { - IDividerBlock, - ISectionBlock, - IActionsBlock, - IContextBlock, - IInputBlock, -} from '@rocket.chat/apps-engine/definition/uikit/blocks/Blocks'; - -enum UIKitInteractionTypeExtended { - BANNER_OPEN = 'banner.open', - BANNER_UPDATE = 'banner.update', - BANNER_CLOSE = 'banner.close', -} - -export type UIKitInteractionType = UIKitInteractionTypeApi | UIKitInteractionTypeExtended; - -export const UIKitInteractionTypes = { - ...UIKitInteractionTypeApi, - ...UIKitInteractionTypeExtended, -}; - -export type UiKitPayload = { - viewId: string; - appId: string; - blocks: (IDividerBlock | ISectionBlock | IActionsBlock | IContextBlock | IInputBlock)[]; -}; - -export type UiKitBannerPayload = UiKitPayload & { - inline?: boolean; - variant?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; - icon?: string; - title?: string; -}; - -export type UIKitUserInteraction = { - type: UIKitInteractionType; -} & UiKitPayload; - -export type UiKitBannerProps = { - payload: UiKitBannerPayload; -}; - -export type UIKitUserInteractionResult = UIKitUserInteractionResultError | UIKitUserInteraction; - -type UIKitUserInteractionResultError = UIKitUserInteraction & { - type: UIKitInteractionTypeApi.ERRORS; - errors?: Array<{ [key: string]: string }>; -}; - -export const isErrorType = (result: UIKitUserInteractionResult): result is UIKitUserInteractionResultError => - result.type === UIKitInteractionTypeApi.ERRORS; - -export type UIKitActionEvent = { - blockId: string; - value?: unknown; - appId: string; - actionId: string; - viewId: string; -}; diff --git a/packages/core-typings/src/cloud/Announcement.ts b/packages/core-typings/src/cloud/Announcement.ts new file mode 100644 index 000000000000..7c9541efe75a --- /dev/null +++ b/packages/core-typings/src/cloud/Announcement.ts @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import type { IRocketChatRecord } from '../IRocketChatRecord'; +import type * as UiKit from '../uikit'; + +type TargetPlatform = 'web' | 'mobile'; + +type Dictionary = { + [lng: string]: { + [key: string]: string; + }; +}; + +type Creator = 'cloud' | 'system'; + +export interface Announcement extends IRocketChatRecord { + selector?: { + roles?: string[]; + }; + platform: TargetPlatform[]; + expireAt: Date; + startAt: Date; + createdBy: Creator; + createdAt: Date; + dictionary?: Dictionary; + view: UiKit.View; + surface: 'banner' | 'modal'; +} diff --git a/packages/core-typings/src/cloud/NpsSurveyAnnouncement.ts b/packages/core-typings/src/cloud/NpsSurveyAnnouncement.ts new file mode 100644 index 000000000000..fff1db8f1b99 --- /dev/null +++ b/packages/core-typings/src/cloud/NpsSurveyAnnouncement.ts @@ -0,0 +1,7 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +export interface NpsSurveyAnnouncement { + id: string; + startAt: Date; + expireAt: Date; +} diff --git a/packages/core-typings/src/cloud/WorkspaceLicensePayload.ts b/packages/core-typings/src/cloud/WorkspaceLicensePayload.ts new file mode 100644 index 000000000000..7e81e1b47599 --- /dev/null +++ b/packages/core-typings/src/cloud/WorkspaceLicensePayload.ts @@ -0,0 +1,10 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +export interface WorkspaceLicensePayload { + version: number; + address: string; + license: string; + updatedAt: Date; + modules: string; + expireAt: Date; +} diff --git a/packages/core-typings/src/cloud/WorkspaceSyncPayload.ts b/packages/core-typings/src/cloud/WorkspaceSyncPayload.ts new file mode 100644 index 000000000000..fb95cfa4553c --- /dev/null +++ b/packages/core-typings/src/cloud/WorkspaceSyncPayload.ts @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import type { IBanner } from '../IBanner'; +import type { Announcement } from './Announcement'; +import type { NpsSurveyAnnouncement } from './NpsSurveyAnnouncement'; + +export interface WorkspaceSyncPayload { + workspaceId: string; + publicKey?: string; + trial?: { + trialing: boolean; + trialID: string; + endDate: Date; + marketing: { + utmContent: string; + utmMedium: string; + utmSource: string; + utmCampaign: string; + }; + DowngradesToPlan: { + id: string; + }; + trialRequested: boolean; + }; + /** @deprecated */ + nps?: NpsSurveyAnnouncement; + /** @deprecated */ + banners?: IBanner[]; +} + +export interface WorkspaceSyncRequestPayload { + uniqueId: string; + workspaceId: string; + seats: number; + MAC: number; // Need to align on the property + address: string; + siteName: string; + deploymentMethod: string; + deploymentPlatform: string; + version: string; + licenseVersion: number; + connectionDisable: boolean; +} + +export interface WorkspaceSyncResponse { + workspaceId: string; + publicKey: string; + license: unknown; +} + +export interface WorkspaceCommsRequestPayload { + npsEnabled: boolean; + deploymentMethod: string; + deploymentPlatform: string; + version: string; +} +export interface WorkspaceCommsResponsePayload { + nps?: NpsSurveyAnnouncement | null; // Potentially consolidate into announcements + announcements?: { + create: Announcement[]; + delete: Announcement['_id'][]; + }; +} diff --git a/packages/core-typings/src/cloud/index.ts b/packages/core-typings/src/cloud/index.ts new file mode 100644 index 000000000000..da0565a215ed --- /dev/null +++ b/packages/core-typings/src/cloud/index.ts @@ -0,0 +1,10 @@ +export { Announcement } from './Announcement'; +export { NpsSurveyAnnouncement } from './NpsSurveyAnnouncement'; +export { WorkspaceLicensePayload } from './WorkspaceLicensePayload'; +export { + WorkspaceSyncPayload, + WorkspaceSyncRequestPayload, + WorkspaceSyncResponse, + WorkspaceCommsRequestPayload, + WorkspaceCommsResponsePayload, +} from './WorkspaceSyncPayload'; diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 459e5680900b..6411390f0fe9 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -4,7 +4,6 @@ export * from './FeaturedApps'; export * from './AppRequests'; export * from './MarketplaceRest'; export * from './IRoom'; -export * from './UIKit'; export * from './IMessage'; export * from './federation'; export * from './Serialized'; @@ -134,3 +133,7 @@ export * from './ICustomOAuthConfig'; export * from './IModerationReport'; export * from './CustomFieldMetadata'; + +export * as Cloud from './cloud'; + +export * as UiKit from './uikit'; diff --git a/packages/core-typings/src/migrations/IControl.ts b/packages/core-typings/src/migrations/IControl.ts index 9ff993703550..3f89ce730f1a 100644 --- a/packages/core-typings/src/migrations/IControl.ts +++ b/packages/core-typings/src/migrations/IControl.ts @@ -2,6 +2,7 @@ export type IControl = { _id: string; version: number; locked: boolean; + hash?: string; buildAt?: string | Date; lockedAt?: string | Date; }; diff --git a/packages/core-typings/src/omnichannel/index.ts b/packages/core-typings/src/omnichannel/index.ts index 703cf3b4ca77..c6235175dafc 100644 --- a/packages/core-typings/src/omnichannel/index.ts +++ b/packages/core-typings/src/omnichannel/index.ts @@ -2,3 +2,4 @@ export * from './sms'; export * from './routing'; export * from './queue'; export * from './reports'; +export * from './mac'; diff --git a/packages/core-typings/src/omnichannel/mac.ts b/packages/core-typings/src/omnichannel/mac.ts new file mode 100644 index 000000000000..8591edbb0287 --- /dev/null +++ b/packages/core-typings/src/omnichannel/mac.ts @@ -0,0 +1,5 @@ +export type MACStats = { + contactsCount: number; + conversationsCount: number; + sources: { source: string; contactsCount: number; conversationsCount: number }[]; +}; diff --git a/packages/core-typings/src/omnichannel/queue.ts b/packages/core-typings/src/omnichannel/queue.ts index 46036622713f..1ae697c88a23 100644 --- a/packages/core-typings/src/omnichannel/queue.ts +++ b/packages/core-typings/src/omnichannel/queue.ts @@ -2,4 +2,5 @@ export interface IOmnichannelQueue { start(): Promise; shouldStart(): void; stop(): Promise; + isRunning(): boolean; } diff --git a/packages/core-typings/src/omnichannel/routing.ts b/packages/core-typings/src/omnichannel/routing.ts index eed6dd6f1a19..43ca0c08f5d2 100644 --- a/packages/core-typings/src/omnichannel/routing.ts +++ b/packages/core-typings/src/omnichannel/routing.ts @@ -24,7 +24,7 @@ export interface IRoutingMethod { } export type TransferData = { - userId: string; + userId?: string; departmentId?: string; department?: Pick; transferredBy: { @@ -36,7 +36,7 @@ export type TransferData = { name?: string; }; clientAction?: boolean; - scope: 'agent' | 'department' | 'queue' | 'autoTransferUnansweredChatsToAgent' | 'autoTransferUnansweredChatsToQueue'; + scope?: 'agent' | 'department' | 'queue' | 'autoTransferUnansweredChatsToAgent' | 'autoTransferUnansweredChatsToQueue'; comment?: string; }; diff --git a/packages/core-typings/src/uikit/BannerView.ts b/packages/core-typings/src/uikit/BannerView.ts new file mode 100644 index 000000000000..f6914f75a6af --- /dev/null +++ b/packages/core-typings/src/uikit/BannerView.ts @@ -0,0 +1,16 @@ +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { BannerSurfaceLayout } from '@rocket.chat/ui-kit'; + +import type { View } from './View'; + +/** + * A view that is displayed as a banner. + */ +export type BannerView = View & { + viewId: string; + inline?: boolean; + variant?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; + icon?: IconName; + title?: string; // TODO: change to plain_text block in the future + blocks: BannerSurfaceLayout; +}; diff --git a/packages/core-typings/src/uikit/ContextualBarView.ts b/packages/core-typings/src/uikit/ContextualBarView.ts new file mode 100644 index 000000000000..ab480be19b77 --- /dev/null +++ b/packages/core-typings/src/uikit/ContextualBarView.ts @@ -0,0 +1,14 @@ +import type { ButtonElement, ContextualBarSurfaceLayout, TextObject } from '@rocket.chat/ui-kit'; + +import type { View } from './View'; + +/** + * A view that is displayed as a contextual bar. + */ +export type ContextualBarView = View & { + id: string; + title: TextObject; + close?: ButtonElement; + submit?: ButtonElement; + blocks: ContextualBarSurfaceLayout; +}; diff --git a/packages/core-typings/src/uikit/ModalView.ts b/packages/core-typings/src/uikit/ModalView.ts new file mode 100644 index 000000000000..2e2fc12befe8 --- /dev/null +++ b/packages/core-typings/src/uikit/ModalView.ts @@ -0,0 +1,15 @@ +import type { ButtonElement, ModalSurfaceLayout, TextObject } from '@rocket.chat/ui-kit'; + +import type { View } from './View'; + +/** + * A view that is displayed as a modal dialog. + */ +export type ModalView = View & { + id: string; + showIcon?: boolean; + title: TextObject; + close?: ButtonElement; + submit?: ButtonElement; + blocks: ModalSurfaceLayout; +}; diff --git a/packages/core-typings/src/uikit/ServerInteraction.ts b/packages/core-typings/src/uikit/ServerInteraction.ts new file mode 100644 index 000000000000..a5b8aabca26e --- /dev/null +++ b/packages/core-typings/src/uikit/ServerInteraction.ts @@ -0,0 +1,84 @@ +import type { BannerView } from './BannerView'; +import type { ContextualBarView } from './ContextualBarView'; +import type { ModalView } from './ModalView'; + +type OpenModalServerInteraction = { + type: 'modal.open'; + triggerId: string; + appId: string; + view: ModalView; +}; + +type UpdateModalServerInteraction = { + type: 'modal.update'; + triggerId: string; + appId: string; + view: ModalView; +}; + +type CloseModalServerInteraction = { + type: 'modal.close'; + triggerId: string; + appId: string; +}; + +type OpenBannerServerInteraction = { + type: 'banner.open'; + triggerId: string; + appId: string; +} & BannerView; + +type UpdateBannerServerInteraction = { + type: 'banner.update'; + triggerId: string; + appId: string; + view: BannerView; +}; + +type CloseBannerServerInteraction = { + type: 'banner.close'; + triggerId: string; + appId: string; + viewId: BannerView['viewId']; +}; + +type OpenContextualBarServerInteraction = { + type: 'contextual_bar.open'; + triggerId: string; + appId: string; + view: ContextualBarView; +}; + +type UpdateContextualBarServerInteraction = { + type: 'contextual_bar.update'; + triggerId: string; + appId: string; + view: ContextualBarView; +}; + +type CloseContextualBarServerInteraction = { + type: 'contextual_bar.close'; + triggerId: string; + appId: string; + view: ContextualBarView; +}; + +type ReportErrorsServerInteraction = { + type: 'errors'; + triggerId: string; + appId: string; + viewId: ModalView['id'] | BannerView['viewId'] | ContextualBarView['id']; + errors: { [field: string]: string }[]; +}; + +export type ServerInteraction = + | OpenModalServerInteraction + | UpdateModalServerInteraction + | CloseModalServerInteraction + | OpenBannerServerInteraction + | UpdateBannerServerInteraction + | CloseBannerServerInteraction + | OpenContextualBarServerInteraction + | UpdateContextualBarServerInteraction + | CloseContextualBarServerInteraction + | ReportErrorsServerInteraction; diff --git a/packages/core-typings/src/uikit/UserInteraction.ts b/packages/core-typings/src/uikit/UserInteraction.ts new file mode 100644 index 000000000000..3b65acb839f8 --- /dev/null +++ b/packages/core-typings/src/uikit/UserInteraction.ts @@ -0,0 +1,122 @@ +import type { IMessage } from '../IMessage'; +import type { IRoom } from '../IRoom'; +import type { View } from './View'; + +export type MessageBlockActionUserInteraction = { + type: 'blockAction'; + actionId: string; + payload: { + blockId: string; + value: unknown; + }; + container: { + type: 'message'; + id: IMessage['_id']; + }; + mid: IMessage['_id']; + tmid?: IMessage['_id']; + rid: IRoom['_id']; + triggerId: string; +}; + +export type ViewBlockActionUserInteraction = { + type: 'blockAction'; + actionId: string; + payload: { + blockId: string; + value: unknown; + }; + container: { + type: 'view'; + id: string; + }; + triggerId: string; +}; + +export type ViewClosedUserInteraction = { + type: 'viewClosed'; + payload: { + viewId: string; + view: View & { + id: string; + state: { [blockId: string]: { [key: string]: unknown } }; + }; + isCleared?: boolean; + }; + triggerId: string; +}; + +export type ViewSubmitUserInteraction = { + type: 'viewSubmit'; + actionId?: undefined; + payload: { + view: View & { + id: string; + state: { [blockId: string]: { [key: string]: unknown } }; + }; + }; + triggerId: string; + viewId: string; +}; + +export type MessageBoxActionButtonUserInteraction = { + type: 'actionButton'; + actionId: string; + payload: { + context: 'messageBoxAction'; + message: string; + }; + mid?: undefined; + tmid?: IMessage['_id']; + rid: IRoom['_id']; + triggerId: string; +}; + +export type UserDropdownActionButtonUserInteraction = { + type: 'actionButton'; + actionId: string; + payload: { + context: 'userDropdownAction'; + message?: undefined; + }; + mid?: undefined; + tmid?: undefined; + rid?: undefined; + triggerId: string; +}; + +export type MesssageActionButtonUserInteraction = { + type: 'actionButton'; + actionId: string; + payload: { + context: 'messageAction'; + message?: undefined; + }; + mid: IMessage['_id']; + tmid?: IMessage['_id']; + rid: IRoom['_id']; + triggerId: string; +}; + +export type RoomActionButtonUserInteraction = { + type: 'actionButton'; + actionId: string; + payload: { + context: 'roomAction'; + message?: undefined; + }; + mid?: undefined; + tmid?: undefined; + rid: IRoom['_id']; + triggerId: string; +}; + +export type UserInteraction = + | MessageBlockActionUserInteraction + | ViewBlockActionUserInteraction + | ViewClosedUserInteraction + | ViewSubmitUserInteraction + | MessageBoxActionButtonUserInteraction + | UserDropdownActionButtonUserInteraction + | MesssageActionButtonUserInteraction + | RoomActionButtonUserInteraction; diff --git a/packages/core-typings/src/uikit/View.ts b/packages/core-typings/src/uikit/View.ts new file mode 100644 index 000000000000..fe3b3a366635 --- /dev/null +++ b/packages/core-typings/src/uikit/View.ts @@ -0,0 +1,9 @@ +import type { LayoutBlock } from '@rocket.chat/ui-kit'; + +/** + * An instance of a UiKit surface and its metadata. + */ +export type View = { + appId: string; + blocks: LayoutBlock[]; +}; diff --git a/packages/core-typings/src/uikit/index.ts b/packages/core-typings/src/uikit/index.ts new file mode 100644 index 000000000000..61ab79621d1a --- /dev/null +++ b/packages/core-typings/src/uikit/index.ts @@ -0,0 +1,17 @@ +export * from '@rocket.chat/ui-kit'; +export type { + UserInteraction, + MessageBlockActionUserInteraction, + ViewBlockActionUserInteraction, + ViewClosedUserInteraction, + ViewSubmitUserInteraction, + MessageBoxActionButtonUserInteraction, + UserDropdownActionButtonUserInteraction, + MesssageActionButtonUserInteraction, + RoomActionButtonUserInteraction, +} from './UserInteraction'; +export type { View } from './View'; +export type { BannerView } from './BannerView'; +export type { ContextualBarView } from './ContextualBarView'; +export type { ModalView } from './ModalView'; +export type { ServerInteraction } from './ServerInteraction'; diff --git a/packages/core-typings/src/utils.ts b/packages/core-typings/src/utils.ts index f3f0db9da1c2..e739257c070b 100644 --- a/packages/core-typings/src/utils.ts +++ b/packages/core-typings/src/utils.ts @@ -32,3 +32,5 @@ export type DeepWritable = T extends (...args: any) => any : { -readonly [P in keyof T]: DeepWritable; }; + +export type DistributiveOmit = T extends any ? Omit : never; diff --git a/packages/cron/CHANGELOG.md b/packages/cron/CHANGELOG.md index 32872b06ffbe..b0cf975ee4d8 100644 --- a/packages/cron/CHANGELOG.md +++ b/packages/cron/CHANGELOG.md @@ -1,5 +1,33 @@ # @rocket.chat/cron +## 0.0.11 + +### Patch Changes + +- 61a106fbf2: Increase cron job check delay to 1 min from 5s. + + This reduces MongoDB requests introduced on 6.3. + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [d45365436e] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/models@0.0.15 + - @rocket.chat/random@1.2.1 + +## 0.0.11-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/models@0.0.15-rc.5 + ## 0.0.10-rc.4 ### Patch Changes @@ -51,6 +79,13 @@ ### Patch Changes +- @rocket.chat/core-typings@6.3.8 +- @rocket.chat/models@0.0.14 + +## 0.0.9 + +### Patch Changes + - deffcb187c: Increase cron job check delay to 1 min from 5s. This reduces MongoDB requests introduced on 6.3. diff --git a/packages/cron/package.json b/packages/cron/package.json index c7db1e371cc1..37e751fe03d5 100644 --- a/packages/cron/package.json +++ b/packages/cron/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/cron", - "version": "0.0.10-rc.4", + "version": "0.0.11", "private": true, "devDependencies": { "@types/jest": "~29.5.3", diff --git a/packages/eslint-config/CHANGELOG.md b/packages/eslint-config/CHANGELOG.md index 59cacad3f786..62704f6c7714 100644 --- a/packages/eslint-config/CHANGELOG.md +++ b/packages/eslint-config/CHANGELOG.md @@ -1,5 +1,11 @@ # @rocket.chat/eslint-config +## 0.6.0 + +### Minor Changes + +- 0f56aacc4d: Unpublished changes in ESLint config + ## 0.6.0-rc.0 ### Minor Changes diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 1ef1e593f7d5..9b0adef2d80e 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/eslint-config", - "version": "0.6.0-rc.0", + "version": "0.6.0", "description": "Rocket.Chat's JS/TS ESLint config", "dependencies": { "@babel/core": "^7.20.7", diff --git a/packages/fuselage-ui-kit/CHANGELOG.md b/packages/fuselage-ui-kit/CHANGELOG.md index bffaa90dcb07..dd8a0fce9789 100644 --- a/packages/fuselage-ui-kit/CHANGELOG.md +++ b/packages/fuselage-ui-kit/CHANGELOG.md @@ -1,5 +1,32 @@ # Change Log +## 2.0.0 + +### Minor Changes + +- 1246a21648: feat: Add missing variants to UIKit button +- f9a748526d: feat: Adding new UIKit components: Callout, Checkbox, Radio Button, Time Picker, Toast Bar, Toggle Switch, Tab Navigation + +### Patch Changes + +- dc1d8ce92e: feat(fuselage-ui-kit): Introduce `TabsNavigationBlock` +- dce4a829fa: Handle invalid context on `VideoConferenceBlock` component +- Updated dependencies [074db3b419] +- Updated dependencies [b8f3d5014f] +- Updated dependencies [0f56aacc4d] + - @rocket.chat/ui-contexts@2.0.0 + - @rocket.chat/eslint-config@0.6.0 + - @rocket.chat/gazzodown@2.0.0 + - @rocket.chat/ui-video-conf@2.0.0 + +## 2.0.0-rc.5 + +### Patch Changes + +- @rocket.chat/gazzodown@2.0.0-rc.5 +- @rocket.chat/ui-contexts@2.0.0-rc.5 +- @rocket.chat/ui-video-conf@2.0.0-rc.5 + ## 2.0.0-rc.4 ### Patch Changes @@ -51,6 +78,14 @@ - @rocket.chat/gazzodown@2.0.0-rc.0 - @rocket.chat/ui-video-conf@2.0.0-rc.0 +## 1.0.8 + +### Patch Changes + +- @rocket.chat/gazzodown@1.0.8 +- @rocket.chat/ui-contexts@1.0.8 +- @rocket.chat/ui-video-conf@1.0.8 + ## 1.0.7 ### Patch Changes diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json index a376275d3b90..273ae96862d9 100644 --- a/packages/fuselage-ui-kit/package.json +++ b/packages/fuselage-ui-kit/package.json @@ -1,7 +1,7 @@ { "name": "@rocket.chat/fuselage-ui-kit", "private": true, - "version": "2.0.0-rc.4", + "version": "2.0.0", "description": "UiKit elements for Rocket.Chat Apps built under Fuselage design system", "homepage": "https://rocketchat.github.io/Rocket.Chat.Fuselage/", "author": { @@ -39,16 +39,16 @@ }, "peerDependencies": { "@rocket.chat/apps-engine": "*", - "@rocket.chat/eslint-config": "0.6.0-rc.0", + "@rocket.chat/eslint-config": "0.6.0", "@rocket.chat/fuselage": "*", "@rocket.chat/fuselage-hooks": "*", "@rocket.chat/fuselage-polyfills": "*", "@rocket.chat/icons": "*", "@rocket.chat/prettier-config": "*", "@rocket.chat/styled": "*", - "@rocket.chat/ui-contexts": "2.0.0-rc.4", + "@rocket.chat/ui-contexts": "2.0.0", "@rocket.chat/ui-kit": "*", - "@rocket.chat/ui-video-conf": "2.0.0-rc.4", + "@rocket.chat/ui-video-conf": "2.0.0", "@tanstack/react-query": "*", "react": "*", "react-dom": "*" @@ -56,7 +56,7 @@ "devDependencies": { "@rocket.chat/apps-engine": "1.41.0-alpha.290", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.32.1", + "@rocket.chat/fuselage": "^0.36.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/fuselage-polyfills": "next", "@rocket.chat/icons": "^0.32.0", diff --git a/packages/fuselage-ui-kit/src/blocks/InputBlock.tsx b/packages/fuselage-ui-kit/src/blocks/InputBlock.tsx index bbeaf3c338c6..979e04e808c7 100644 --- a/packages/fuselage-ui-kit/src/blocks/InputBlock.tsx +++ b/packages/fuselage-ui-kit/src/blocks/InputBlock.tsx @@ -1,4 +1,10 @@ -import { Field } from '@rocket.chat/fuselage'; +import { + Field, + FieldLabel, + FieldRow, + FieldError, + FieldHint, +} from '@rocket.chat/fuselage'; import * as UiKit from '@rocket.chat/ui-kit'; import type { ReactElement } from 'react'; import { memo, useMemo } from 'react'; @@ -28,19 +34,19 @@ const InputBlock = ({ return ( {block.label && ( - + {surfaceRenderer.renderTextObject( block.label, 0, UiKit.BlockContext.NONE )} - + )} - + {surfaceRenderer.renderInputBlockElement(inputElement, 0)} - - {error && {error}} - {block.hint && {block.hint}} + + {error && {error}} + {block.hint && {block.hint}} ); }; diff --git a/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts b/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts index 2c8aa02e0fa6..9e5ca1a04e5f 100644 --- a/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts +++ b/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts @@ -1,10 +1,15 @@ -import type { InputElementDispatchAction } from '@rocket.chat/ui-kit'; +import type { + ActionableElement, + InputElementDispatchAction, +} from '@rocket.chat/ui-kit'; import { createContext } from 'react'; +type ActionId = ActionableElement['actionId']; + type ActionParams = { blockId: string; appId: string; - actionId: string; + actionId: ActionId; value: unknown; viewId?: string; dispatchActionConfig?: InputElementDispatchAction[]; @@ -21,7 +26,7 @@ type UiKitContextValue = { ) => Promise | void; appId: string; errors?: Record; - values: Record; + values: Record; viewId?: string; rid?: string; }; diff --git a/packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx b/packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx index da66e4f299fb..0ff2631d7a3a 100644 --- a/packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx +++ b/packages/fuselage-ui-kit/src/elements/MarkdownTextElement.tsx @@ -2,15 +2,16 @@ import { Markup } from '@rocket.chat/gazzodown'; import { parse } from '@rocket.chat/message-parser'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { TextObject } from '@rocket.chat/ui-kit'; +import { useContext } from 'react'; -import { useUiKitContext } from '../hooks/useUiKitContext'; +import { UiKitContext } from '../contexts/UiKitContext'; const MarkdownTextElement = ({ textObject }: { textObject: TextObject }) => { const t = useTranslation() as ( key: string, args: { [key: string]: string | number } ) => string; - const { appId } = useUiKitContext(); + const { appId } = useContext(UiKitContext); const { i18n } = textObject; diff --git a/packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx b/packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx index bdb59e523dee..4e692caa0993 100644 --- a/packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx +++ b/packages/fuselage-ui-kit/src/elements/PlainTextElement.tsx @@ -1,14 +1,15 @@ import { useTranslation } from '@rocket.chat/ui-contexts'; import type { TextObject } from '@rocket.chat/ui-kit'; +import { useContext } from 'react'; -import { useUiKitContext } from '../hooks/useUiKitContext'; +import { UiKitContext } from '../contexts/UiKitContext'; const PlainTextElement = ({ textObject }: { textObject: TextObject }) => { const t = useTranslation() as ( key: string, args: { [key: string]: string | number } ) => string; - const { appId } = useUiKitContext(); + const { appId } = useContext(UiKitContext); const { i18n } = textObject; diff --git a/packages/fuselage-ui-kit/src/extractInitialStateFromLayout.ts b/packages/fuselage-ui-kit/src/extractInitialStateFromLayout.ts new file mode 100644 index 000000000000..10b6790d976a --- /dev/null +++ b/packages/fuselage-ui-kit/src/extractInitialStateFromLayout.ts @@ -0,0 +1,90 @@ +import type * as UiKit from '@rocket.chat/ui-kit'; + +type Value = { value: unknown; blockId?: string }; + +type LayoutBlockWithElement = Extract< + UiKit.LayoutBlock, + { element: UiKit.BlockElement | UiKit.TextObject } +>; +type LayoutBlockWithElements = Extract< + UiKit.LayoutBlock, + { elements: readonly (UiKit.BlockElement | UiKit.TextObject)[] } +>; + +const hasElement = ( + block: UiKit.LayoutBlock +): block is LayoutBlockWithElement => 'element' in block; + +const hasElements = ( + block: UiKit.LayoutBlock +): block is LayoutBlockWithElements => + 'elements' in block && Array.isArray(block.elements); + +const isActionableElement = ( + element: UiKit.BlockElement | UiKit.TextObject +): element is UiKit.ActionableElement => + 'actionId' in element && typeof element.actionId === 'string'; + +const hasInitialValue = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialValue: number | string } => + 'initialValue' in element; + +const hasInitialTime = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialTime: string } => + 'initialTime' in element; + +const hasInitialDate = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialDate: string } => + 'initialDate' in element; + +const hasInitialOption = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialOption: UiKit.Option } => + 'initialOption' in element; + +const hasInitialOptions = ( + element: UiKit.ActionableElement +): element is UiKit.ActionableElement & { initialOptions: UiKit.Option[] } => + 'initialOptions' in element; + +const getInitialValue = (element: UiKit.ActionableElement) => + (hasInitialValue(element) && element.initialValue) || + (hasInitialTime(element) && element.initialTime) || + (hasInitialDate(element) && element.initialDate) || + (hasInitialOption(element) && element.initialOption.value) || + (hasInitialOptions(element) && + element.initialOptions.map((option) => option.value)) || + undefined; + +const reduceInitialValuesFromLayoutBlock = ( + state: { [actionId: string]: Value }, + block: UiKit.LayoutBlock +) => { + if (hasElement(block)) { + if (isActionableElement(block.element)) { + state[block.element.actionId] = { + value: getInitialValue(block.element), + blockId: block.blockId, + }; + } + } + + if (hasElements(block)) { + for (const element of block.elements) { + if (isActionableElement(element)) { + state[element.actionId] = { + value: getInitialValue(element), + blockId: block.blockId, + }; + } + } + } + + return state; +}; + +export const extractInitialStateFromLayout = (blocks: UiKit.LayoutBlock[]) => + blocks.reduce(reduceInitialValuesFromLayoutBlock, {}); diff --git a/packages/fuselage-ui-kit/src/hooks/useUiKitContext.ts b/packages/fuselage-ui-kit/src/hooks/useUiKitContext.ts deleted file mode 100644 index 1924b96d507e..000000000000 --- a/packages/fuselage-ui-kit/src/hooks/useUiKitContext.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useContext } from 'react'; - -import { UiKitContext } from '../contexts/UiKitContext'; - -export const useUiKitContext = () => useContext(UiKitContext); diff --git a/packages/fuselage-ui-kit/src/hooks/useUiKitState.ts b/packages/fuselage-ui-kit/src/hooks/useUiKitState.ts index 5cbae5db2b5d..56fc553b1996 100644 --- a/packages/fuselage-ui-kit/src/hooks/useUiKitState.ts +++ b/packages/fuselage-ui-kit/src/hooks/useUiKitState.ts @@ -3,16 +3,6 @@ import * as UiKit from '@rocket.chat/ui-kit'; import { useContext, useMemo, useState } from 'react'; import { UiKitContext } from '../contexts/UiKitContext'; -import { useUiKitStateValue } from './useUiKitStateValue'; - -type UiKitState< - TElement extends UiKit.ActionableElement = UiKit.ActionableElement -> = { - loading: boolean; - setLoading: (loading: boolean) => void; - error?: string; - value: UiKit.ActionOf; -}; const hasInitialValue = ( element: TElement @@ -37,10 +27,48 @@ const hasInitialOptions = ( ): element is TElement & { initialOptions: UiKit.Option[] } => 'initialOptions' in element; -export const useUiKitState: ( +const getInitialValue = ( + element: TElement +) => + (hasInitialValue(element) && element.initialValue) || + (hasInitialTime(element) && element.initialTime) || + (hasInitialDate(element) && element.initialDate) || + (hasInitialOption(element) && element.initialOption.value) || + (hasInitialOptions(element) && + element.initialOptions.map((option) => option.value)) || + undefined; + +const getElementValueFromState = ( + actionId: string, + values: Record< + string, + | { + value: unknown; + } + | undefined + >, + initialValue: string | number | string[] | undefined +) => { + return ( + (values && + (values[actionId]?.value as string | number | string[] | undefined)) ?? + initialValue + ); +}; + +type UiKitState< + TElement extends UiKit.ActionableElement = UiKit.ActionableElement +> = { + loading: boolean; + setLoading: (loading: boolean) => void; + error?: string; + value: UiKit.ActionOf; +}; + +export const useUiKitState = ( element: TElement, context: UiKit.BlockContext -) => [ +): [ state: UiKitState, action: ( pseudoEvent?: @@ -48,8 +76,8 @@ export const useUiKitState: ( | { target: EventTarget } | { target: { value: UiKit.ActionOf } } ) => void -] = (rest, context) => { - const { blockId, actionId, appId, dispatchActionConfig } = rest; +] => { + const { blockId, actionId, appId, dispatchActionConfig } = element; const { action, appId: appIdFromContext, @@ -57,16 +85,13 @@ export const useUiKitState: ( state, } = useContext(UiKitContext); - const initialValue = - (hasInitialValue(rest) && rest.initialValue) || - (hasInitialTime(rest) && rest.initialTime) || - (hasInitialDate(rest) && rest.initialDate) || - (hasInitialOption(rest) && rest.initialOption.value) || - (hasInitialOptions(rest) && - rest.initialOptions.map((option) => option.value)) || - undefined; + const initialValue = getInitialValue(element); + + const { values, errors } = useContext(UiKitContext); + + const _value = getElementValueFromState(actionId, values, initialValue); + const error = errors?.[actionId]; - const { value: _value, error } = useUiKitStateValue(actionId, initialValue); const [value, setValue] = useSafely(useState(_value)); const [loading, setLoading] = useSafely(useState(false)); @@ -147,9 +172,9 @@ export const useUiKitState: ( ); if ( - rest.type === 'plain_text_input' && - Array.isArray(rest?.dispatchActionConfig) && - rest.dispatchActionConfig.includes('on_character_entered') + element.type === 'plain_text_input' && + Array.isArray(element?.dispatchActionConfig) && + element.dispatchActionConfig.includes('on_character_entered') ) { return [result, noLoadStateActionFunction]; } @@ -159,8 +184,8 @@ export const useUiKitState: ( [UiKit.BlockContext.SECTION, UiKit.BlockContext.ACTION].includes( context )) || - (Array.isArray(rest?.dispatchActionConfig) && - rest.dispatchActionConfig.includes('on_item_selected')) + (Array.isArray(element?.dispatchActionConfig) && + element.dispatchActionConfig.includes('on_item_selected')) ) { return [result, actionFunction]; } diff --git a/packages/fuselage-ui-kit/src/hooks/useUiKitStateValue.ts b/packages/fuselage-ui-kit/src/hooks/useUiKitStateValue.ts deleted file mode 100644 index 8d7e81aa69c5..000000000000 --- a/packages/fuselage-ui-kit/src/hooks/useUiKitStateValue.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useUiKitContext } from './useUiKitContext'; - -export const useUiKitStateValue = < - T extends string | string[] | number | undefined ->( - actionId: string, - initialValue: T -): { - value: T; - error: string | undefined; -} => { - const { values, errors } = useUiKitContext(); - - return { - value: (values && (values[actionId]?.value as T)) ?? initialValue, - error: errors?.[actionId], - }; -}; diff --git a/packages/fuselage-ui-kit/src/index.ts b/packages/fuselage-ui-kit/src/index.ts index 95a713de071a..9db1f2097835 100644 --- a/packages/fuselage-ui-kit/src/index.ts +++ b/packages/fuselage-ui-kit/src/index.ts @@ -2,3 +2,4 @@ export * from './hooks/useUiKitState'; export * from './contexts/UiKitContext'; export * from './surfaces'; export { UiKitComponent } from './utils/UiKitComponent'; +export { extractInitialStateFromLayout } from './extractInitialStateFromLayout'; diff --git a/packages/gazzodown/CHANGELOG.md b/packages/gazzodown/CHANGELOG.md index d0178b78e124..336e44b65402 100644 --- a/packages/gazzodown/CHANGELOG.md +++ b/packages/gazzodown/CHANGELOG.md @@ -1,5 +1,33 @@ # @rocket.chat/gazzodown +## 2.0.0 + +### Patch Changes + +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [074db3b419] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [d45365436e] +- Updated dependencies [b8f3d5014f] +- Updated dependencies [ee3815fce4] + - @rocket.chat/core-typings@6.4.0 + - @rocket.chat/ui-client@2.0.0 + - @rocket.chat/ui-contexts@2.0.0 + +## 2.0.0-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + - @rocket.chat/ui-contexts@2.0.0-rc.5 + - @rocket.chat/ui-client@2.0.0-rc.5 + ## 2.0.0-rc.4 ### Patch Changes @@ -50,6 +78,14 @@ - @rocket.chat/ui-client@2.0.0-rc.0 - @rocket.chat/ui-contexts@2.0.0-rc.0 +## 1.0.8 + +### Patch Changes + +- @rocket.chat/core-typings@6.3.8 +- @rocket.chat/ui-contexts@1.0.8 +- @rocket.chat/ui-client@1.0.8 + ## 1.0.7 ### Patch Changes diff --git a/packages/gazzodown/package.json b/packages/gazzodown/package.json index 1b57545091f0..d525c696c685 100644 --- a/packages/gazzodown/package.json +++ b/packages/gazzodown/package.json @@ -1,13 +1,13 @@ { "name": "@rocket.chat/gazzodown", - "version": "2.0.0-rc.4", + "version": "2.0.0", "private": true, "devDependencies": { "@babel/core": "~7.22.9", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/css-in-js": "next", - "@rocket.chat/fuselage": "^0.32.1", - "@rocket.chat/fuselage-tokens": "next", + "@rocket.chat/fuselage": "^0.36.0", + "@rocket.chat/fuselage-tokens": "^0.32.0", "@rocket.chat/message-parser": "next", "@rocket.chat/styled": "next", "@rocket.chat/ui-client": "workspace:^", @@ -65,14 +65,14 @@ "/dist" ], "peerDependencies": { - "@rocket.chat/core-typings": "6.4.0-rc.4", + "@rocket.chat/core-typings": "6.4.0", "@rocket.chat/css-in-js": "*", "@rocket.chat/fuselage": "*", "@rocket.chat/fuselage-tokens": "*", "@rocket.chat/message-parser": "*", "@rocket.chat/styled": "*", - "@rocket.chat/ui-client": "2.0.0-rc.4", - "@rocket.chat/ui-contexts": "2.0.0-rc.4", + "@rocket.chat/ui-client": "2.0.0", + "@rocket.chat/ui-contexts": "2.0.0", "katex": "*", "react": "*" }, diff --git a/packages/gazzodown/src/mentions/UserMentionElement.tsx b/packages/gazzodown/src/mentions/UserMentionElement.tsx index 308363309aa2..a25106918af8 100644 --- a/packages/gazzodown/src/mentions/UserMentionElement.tsx +++ b/packages/gazzodown/src/mentions/UserMentionElement.tsx @@ -13,14 +13,11 @@ const handleUserMention = (mention: string | undefined, withSymbol: boolean | un const UserMentionElement = ({ mention }: UserMentionElementProps): ReactElement => { const t = useTranslation(); - const { resolveUserMention, onUserMentionClick, isMobile, ownUserId, useRealName, showMentionSymbol } = - useContext(MarkupInteractionContext); + const { resolveUserMention, onUserMentionClick, ownUserId, useRealName, showMentionSymbol } = useContext(MarkupInteractionContext); const resolved = useMemo(() => resolveUserMention?.(mention), [mention, resolveUserMention]); const handleClick = useMemo(() => (resolved ? onUserMentionClick?.(resolved) : undefined), [resolved, onUserMentionClick]); - const showRealName = useRealName && !isMobile; - if (mention === 'all') { return ( @@ -49,7 +46,7 @@ const UserMentionElement = ({ mention }: UserMentionElementProps): ReactElement onClick={handleClick} data-uid={resolved._id} > - {handleUserMention((showRealName ? resolved.name : resolved.username) ?? mention, showMentionSymbol)} + {handleUserMention((useRealName ? resolved.name : resolved.username) ?? mention, showMentionSymbol)} ); }; diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md index 88d629fae65c..7eecb2d845e9 100644 --- a/packages/i18n/CHANGELOG.md +++ b/packages/i18n/CHANGELOG.md @@ -1,5 +1,11 @@ # @rocket.chat/i18n +## 0.0.2 + +### Patch Changes + +- b8f3d5014f: Fixed the login page language switcher, now the component has a new look, is reactive and the language selection becomes concrete upon login in. Also changed the default language of the login page to be the browser language. + ## 0.0.2-rc.0 ### Patch Changes diff --git a/packages/i18n/package.json b/packages/i18n/package.json index da2dae7600ac..08c924493580 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/i18n", - "version": "0.0.2-rc.0", + "version": "0.0.2", "private": true, "devDependencies": { "@babel/core": "~7.22.10", diff --git a/packages/instance-status/CHANGELOG.md b/packages/instance-status/CHANGELOG.md index 3571dc6806ce..3c454da0e972 100644 --- a/packages/instance-status/CHANGELOG.md +++ b/packages/instance-status/CHANGELOG.md @@ -1,5 +1,17 @@ # @rocket.chat/instance-status +## 0.0.15 + +### Patch Changes + +- @rocket.chat/models@0.0.15 + +## 0.0.15-rc.5 + +### Patch Changes + +- @rocket.chat/models@0.0.15-rc.5 + ## 0.0.14-rc.4 ### Patch Changes diff --git a/packages/instance-status/package.json b/packages/instance-status/package.json index ed9c13ba5231..4b6d8a882650 100644 --- a/packages/instance-status/package.json +++ b/packages/instance-status/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/instance-status", - "version": "0.0.14-rc.4", + "version": "0.0.15", "private": true, "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", diff --git a/packages/livechat/CHANGELOG.md b/packages/livechat/CHANGELOG.md index 922a2d7f442b..7d537677ad04 100644 --- a/packages/livechat/CHANGELOG.md +++ b/packages/livechat/CHANGELOG.md @@ -1,5 +1,25 @@ # @rocket.chat/livechat Change Log +## 1.14.0 + +### Minor Changes + +- 04fe492555: Added new Omnichannel's trigger condition "After starting a chat". + +### Patch Changes + +- 62a13ed97d: chore: (Livechat) Replace all `dangerouslySetInnerHTML` with `gazzodown` +- 817141dcab: fix: Issue caused by spaces in the `config.url` setting +- b8f3d5014f: Fixed the login page language switcher, now the component has a new look, is reactive and the language selection becomes concrete upon login in. Also changed the default language of the login page to be the browser language. + - @rocket.chat/gazzodown@2.0.0 + - @rocket.chat/random@1.2.1 + +## 1.14.0-rc.5 + +### Patch Changes + +- @rocket.chat/gazzodown@2.0.0-rc.5 + ## 1.14.0-rc.4 ### Patch Changes diff --git a/packages/livechat/package.json b/packages/livechat/package.json index deba850e4abd..bb92a0716669 100644 --- a/packages/livechat/package.json +++ b/packages/livechat/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/livechat", - "version": "1.14.0-rc.4", + "version": "1.14.0", "files": [ "/build" ], @@ -30,8 +30,8 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/ddp-client": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage-tokens": "next", - "@rocket.chat/logo": "^0.31.27", + "@rocket.chat/fuselage-tokens": "^0.32.0", + "@rocket.chat/logo": "^0.31.28", "@storybook/addon-essentials": "~6.5.16", "@storybook/addon-postcss": "~2.0.0", "@storybook/preact": "~6.5.16", diff --git a/packages/livechat/src/components/App/App.tsx b/packages/livechat/src/components/App/App.tsx index 0ca3f1b6b366..cfaa52b94999 100644 --- a/packages/livechat/src/components/App/App.tsx +++ b/packages/livechat/src/components/App/App.tsx @@ -75,7 +75,26 @@ type AppState = { poppedOut: boolean; }; -// eslint-disable-next-line react/prefer-stateless-function +export type ScreenPropsType = { + notificationsEnabled: boolean; + minimized: boolean; + expanded: boolean; + windowed: boolean; + sound: unknown; + alerts: unknown; + modal: unknown; + nameDefault: string; + emailDefault: string; + departmentDefault: string; + onEnableNotifications: () => unknown; + onDisableNotifications: () => unknown; + onMinimize: () => unknown; + onRestore: () => unknown; + onOpenWindow: () => unknown; + onDismissAlert: () => unknown; + dismissNotification: () => void; +}; + export class App extends Component { state = { initialized: false, diff --git a/packages/livechat/src/components/Header/index.js b/packages/livechat/src/components/Header/index.tsx similarity index 67% rename from packages/livechat/src/components/Header/index.js rename to packages/livechat/src/components/Header/index.tsx index 669b46571b0e..9764c3c2ce37 100644 --- a/packages/livechat/src/components/Header/index.js +++ b/packages/livechat/src/components/Header/index.tsx @@ -1,12 +1,41 @@ +import type { ComponentChildren, Ref } from 'preact'; import { toChildArray } from 'preact'; +import type { JSXInternal } from 'preact/src/jsx'; import { createClassName } from '../../helpers/createClassName'; import styles from './styles.scss'; -export const Header = ({ children, theme: { color: backgroundColor, fontColor: color } = {}, className, post, large, style, ...props }) => ( +type HeaderProps = { + children?: ComponentChildren; + theme?: { + color?: string; + fontColor?: string; + }; + className?: string; + post?: ComponentChildren; + large?: boolean; + style?: JSXInternal.CSSProperties; + ref?: Ref; + onClick?: JSXInternal.DOMAttributes['onClick']; +}; + +type HeaderComponentProps = { + children?: ComponentChildren; + className?: string; +}; + +export const Header = ({ + children, + theme: { color: backgroundColor, fontColor: color } = {}, + className, + post, + large, + style, + ...props +}: HeaderProps) => (
{children} @@ -14,25 +43,25 @@ export const Header = ({ children, theme: { color: backgroundColor, fontColor: c
); -export const Picture = ({ children, className = undefined, ...props }) => ( +export const Picture = ({ children, className = undefined, ...props }: HeaderComponentProps) => (
{children}
); -export const Content = ({ children, className = undefined, ...props }) => ( +export const Content = ({ children, className = undefined, ...props }: HeaderComponentProps) => (
{children}
); -export const Title = ({ children, className = undefined, ...props }) => ( +export const Title = ({ children, className = undefined, ...props }: HeaderComponentProps) => (
{children}
); -export const SubTitle = ({ children, className = undefined, ...props }) => ( +export const SubTitle = ({ children, className = undefined, ...props }: HeaderComponentProps) => (
(
); -export const Actions = ({ children, className = undefined, ...props }) => ( +export const Actions = ({ children, className = undefined, ...props }: HeaderComponentProps) => ( ); -export const Action = ({ children, className = undefined, ...props }) => ( +export const Action = ({ children, className = undefined, ...props }: HeaderComponentProps & { onClick?: () => void }) => ( ); -export const Post = ({ children, className = undefined, ...props }) => ( +export const Post = ({ children, className = undefined, ...props }: HeaderComponentProps) => (
{children}
); -export const CustomField = ({ children, className = undefined, ...props }) => ( +export const CustomField = ({ children, className = undefined, ...props }: HeaderComponentProps) => (
{children}
diff --git a/packages/livechat/src/components/Modal/styles.scss b/packages/livechat/src/components/Modal/styles.scss index 6c3a7fd38957..359a953bf124 100644 --- a/packages/livechat/src/components/Modal/styles.scss +++ b/packages/livechat/src/components/Modal/styles.scss @@ -59,6 +59,12 @@ $modal-background-color: $bg-color-white; line-height: 1.5; } +@media (prefers-reduced-motion) { + .modal--animated { + animation: none; + } +} + @keyframes fadeInUp { 0% { transform: translate3d(-50%, 100%, 0); diff --git a/packages/livechat/src/components/Screen/Header.js b/packages/livechat/src/components/Screen/Header.tsx similarity index 54% rename from packages/livechat/src/components/Screen/Header.js rename to packages/livechat/src/components/Screen/Header.tsx index 9f9e41810c7b..671a5b343b21 100644 --- a/packages/livechat/src/components/Screen/Header.js +++ b/packages/livechat/src/components/Screen/Header.tsx @@ -1,6 +1,8 @@ -import { Component } from 'preact'; -import { withTranslation } from 'react-i18next'; +import type { ComponentChildren } from 'preact'; +import { useRef } from 'preact/hooks'; +import { useTranslation, withTranslation } from 'react-i18next'; +import type { Agent } from '../../definitions/agents'; import MinimizeIcon from '../../icons/arrowDown.svg'; import RestoreIcon from '../../icons/arrowUp.svg'; import NotificationsEnabledIcon from '../../icons/bell.svg'; @@ -11,70 +13,84 @@ import { Avatar } from '../Avatar'; import Header from '../Header'; import Tooltip from '../Tooltip'; -class ScreenHeader extends Component { - largeHeader = () => { - const { agent } = this.props; - return !!(agent && agent.email && agent.phone); +type screenHeaderProps = { + alerts: { id: string; children: ComponentChildren; [key: string]: unknown }[]; + agent: Agent; + notificationsEnabled: boolean; + minimized: boolean; + expanded: boolean; + windowed: boolean; + onDismissAlert?: (id?: string) => void; + onEnableNotifications: () => unknown; + onDisableNotifications: () => unknown; + onMinimize: () => unknown; + onRestore: () => unknown; + onOpenWindow: () => unknown; + queueInfo: { + spot: number; }; + title: string; +}; - headerTitle = (t) => { - const { agent, queueInfo, title } = this.props; - if (agent && agent.name) { +const ScreenHeader = ({ + alerts, + agent, + notificationsEnabled, + minimized, + expanded, + windowed, + onDismissAlert, + onEnableNotifications, + onDisableNotifications, + onMinimize, + onRestore, + onOpenWindow, + queueInfo, + title, +}: screenHeaderProps) => { + const { t } = useTranslation(); + const headerRef = useRef(null); + + const largeHeader = () => { + return !!(agent?.email && agent.phone); + }; + + const headerTitle = () => { + if (agent?.name) { return agent.name; } - if (queueInfo && queueInfo.spot && queueInfo.spot > 0) { + if (queueInfo?.spot && queueInfo.spot > 0) { return t('waiting_queue'); } return title; }; - render = ({ - alerts, - agent, - notificationsEnabled, - minimized, - expanded, - windowed, - onDismissAlert, - onEnableNotifications, - onDisableNotifications, - onMinimize, - onRestore, - onOpenWindow, - t, - }) => ( + return (
- {alerts && - alerts.map((alert) => ( - - {alert.children} - - ))} + {alerts?.map((alert) => ( + + {alert.children} + + ))} } - large={this.largeHeader()} + large={largeHeader()} > - {agent && agent.avatar && ( + {agent?.avatar && ( - + )} - {this.headerTitle(t)} - {agent && agent.email && {agent.email}} - {agent && agent.phone && {agent.phone}} + {headerTitle()} + {agent?.email && {agent.email}} + {agent?.phone && {agent.phone}} @@ -108,6 +124,6 @@ class ScreenHeader extends Component {
); -} +}; export default withTranslation()(ScreenHeader); diff --git a/packages/livechat/src/components/Tooltip/index.js b/packages/livechat/src/components/Tooltip/index.js index 2f5368d8729c..3b2d36f3609a 100644 --- a/packages/livechat/src/components/Tooltip/index.js +++ b/packages/livechat/src/components/Tooltip/index.js @@ -98,7 +98,7 @@ export class TooltipContainer extends Component { } } -export const TooltipTrigger = ({ children, content, placement }) => ( +export const TooltipTrigger = ({ children, content, placement = '' }) => ( {({ showTooltip, hideTooltip }) => toChildArray(children).map((child, index) => diff --git a/packages/livechat/src/definitions/agents.d.ts b/packages/livechat/src/definitions/agents.d.ts new file mode 100644 index 000000000000..da1b81242574 --- /dev/null +++ b/packages/livechat/src/definitions/agents.d.ts @@ -0,0 +1,13 @@ +// TODO: Fully type agents in livechat +export type Agent = { + name?: string; + status?: string; + email?: string; + phone?: string; + username: string; + avatar?: { + description: string; + src: string; + }; + [key: string]: unknown; +}; diff --git a/packages/livechat/src/lib/hooks.js b/packages/livechat/src/lib/hooks.js index b988579acfa8..795358312173 100644 --- a/packages/livechat/src/lib/hooks.js +++ b/packages/livechat/src/lib/hooks.js @@ -137,15 +137,11 @@ const api = { }, async setGuestToken(token) { - const { - token: localToken, - iframe, - iframe: { guest }, - } = store.state; + const { token: localToken } = store.state; if (token === localToken) { return; } - store.setState({ token, iframe: { ...iframe, guest: { ...guest, token } } }); + createOrUpdateGuest({ token }); await loadConfig(); }, diff --git a/packages/livechat/src/lib/random.ts b/packages/livechat/src/lib/random.ts index 705fdcacc7da..068c84c1c459 100644 --- a/packages/livechat/src/lib/random.ts +++ b/packages/livechat/src/lib/random.ts @@ -4,6 +4,6 @@ export const chooseElement = Random.choice; export const createRandomString = Random._randomString; -export const createRandomId = Random.id; +export const createRandomId = () => Random.id(); export const createToken = () => Random.hexString(64); diff --git a/packages/livechat/src/lib/triggers.js b/packages/livechat/src/lib/triggers.js index 4cf01090587c..efa9e9bdc65d 100644 --- a/packages/livechat/src/lib/triggers.js +++ b/packages/livechat/src/lib/triggers.js @@ -34,7 +34,7 @@ const getAgent = (triggerAction) => { let agent; try { - agent = await Livechat.nextAgent(department); + agent = await Livechat.nextAgent({ department }); } catch (error) { return reject(error); } diff --git a/packages/mock-providers/CHANGELOG.md b/packages/mock-providers/CHANGELOG.md index 448ae0b742a4..915bcabc5395 100644 --- a/packages/mock-providers/CHANGELOG.md +++ b/packages/mock-providers/CHANGELOG.md @@ -1,5 +1,13 @@ # @rocket.chat/mock-providers +## 0.0.2 + +### Patch Changes + +- b8f3d5014f: Fixed the login page language switcher, now the component has a new look, is reactive and the language selection becomes concrete upon login in. Also changed the default language of the login page to be the browser language. +- Updated dependencies [b8f3d5014f] + - @rocket.chat/i18n@0.0.2 + ## 0.0.2-rc.0 ### Patch Changes diff --git a/packages/mock-providers/package.json b/packages/mock-providers/package.json index d5bc013d9a18..c2aeb2665350 100644 --- a/packages/mock-providers/package.json +++ b/packages/mock-providers/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/mock-providers", - "version": "0.0.2-rc.0", + "version": "0.0.2", "private": true, "dependencies": { "@rocket.chat/i18n": "workspace:~", diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 9e114be87d15..ae3eeea4cf1d 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -430,16 +430,13 @@ export class MockedAppRootBuilder { */} Promise.reject(new Error('not implemented')), generateTriggerId: () => '', - getUserInteractionPayloadByViewId: () => undefined, - handlePayloadUserInteraction: () => undefined, + emitInteraction: () => Promise.reject(new Error('not implemented')), + getInteractionPayloadByViewId: () => undefined, + handleServerInteraction: () => undefined, off: () => undefined, on: () => undefined, - triggerActionButtonAction: () => Promise.reject(new Error('not implemented')), - triggerBlockAction: () => Promise.reject(new Error('not implemented')), - triggerCancel: () => Promise.reject(new Error('not implemented')), - triggerSubmitView: () => Promise.reject(new Error('not implemented')), + disposeView: () => undefined, }} > {/* diff --git a/packages/model-typings/CHANGELOG.md b/packages/model-typings/CHANGELOG.md index f80a0d805977..8d03eaafa208 100644 --- a/packages/model-typings/CHANGELOG.md +++ b/packages/model-typings/CHANGELOG.md @@ -1,5 +1,39 @@ # @rocket.chat/model-typings +## 0.1.0 + +### Minor Changes + +- 4186eecf05: Introduce the ability to report an user +- ead7c7bef2: Fixed read receipts not getting deleted after corresponding message is deleted + +### Patch Changes + +- 8a59855fcf: When setting a room as read-only, do not allow previously unmuted users to send messages. +- 5cee21468e: Fix spotlight search does not find rooms with special or non-latin characters +- aaefe865a7: fix: agent role being removed upon user deactivation +- f556518fa1: Change SAU aggregation to consider only sessions from few days ago instead of the whole past. + + This is particularly important for large workspaces in case the cron job did not run for some time, in that case the amount of sessions would accumulate and the aggregation would take a long time to run. + +- 61128364d6: Fixes a problem where the calculated time for considering the visitor abandonment was the first message from the visitor and not the visitor's reply to the agent. +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [d45365436e] + - @rocket.chat/core-typings@6.4.0 + +## 0.1.0-rc.5 + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + ## 0.1.0-rc.4 ### Patch Changes @@ -50,6 +84,12 @@ - Updated dependencies [d45365436e] - @rocket.chat/core-typings@6.4.0-rc.0 +## 0.0.14 + +### Patch Changes + +- @rocket.chat/core-typings@6.3.8 + ## 0.0.13 ### Patch Changes diff --git a/packages/model-typings/package.json b/packages/model-typings/package.json index b187c15b99c7..5174bf22f97a 100644 --- a/packages/model-typings/package.json +++ b/packages/model-typings/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/model-typings", - "version": "0.1.0-rc.4", + "version": "0.1.0", "private": true, "devDependencies": { "@types/jest": "~29.5.3", diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 23e77ff1de29..a1874b144347 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -79,3 +79,4 @@ export * from './models/IAuditLogModel'; export * from './models/ICronHistoryModel'; export * from './models/IMigrationsModel'; export * from './models/IModerationReportsModel'; +export * from './models/ICloudAnnouncementsModel'; diff --git a/packages/model-typings/src/models/IBannersModel.ts b/packages/model-typings/src/models/IBannersModel.ts index 62f33ef5d3b2..4fe496bb954c 100644 --- a/packages/model-typings/src/models/IBannersModel.ts +++ b/packages/model-typings/src/models/IBannersModel.ts @@ -1,10 +1,10 @@ -import type { BannerPlatform, IBanner } from '@rocket.chat/core-typings'; +import type { BannerPlatform, IBanner, Optional } from '@rocket.chat/core-typings'; import type { Document, FindCursor, FindOptions, UpdateResult, InsertOneResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; export interface IBannersModel extends IBaseModel { - create(doc: IBanner): Promise>; + create(doc: Optional): Promise>; findActiveByRoleOrId(roles: string[], platform: BannerPlatform, bannerId?: string, options?: FindOptions): FindCursor; diff --git a/packages/model-typings/src/models/ICloudAnnouncementsModel.ts b/packages/model-typings/src/models/ICloudAnnouncementsModel.ts new file mode 100644 index 000000000000..672ff8c316a0 --- /dev/null +++ b/packages/model-typings/src/models/ICloudAnnouncementsModel.ts @@ -0,0 +1,6 @@ +import type { Cloud } from '@rocket.chat/core-typings'; + +import type { IBaseModel } from './IBaseModel'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ICloudAnnouncementsModel extends IBaseModel {} diff --git a/packages/model-typings/src/models/ILivechatDepartmentModel.ts b/packages/model-typings/src/models/ILivechatDepartmentModel.ts index a074d5c31126..75fe0f54b2eb 100644 --- a/packages/model-typings/src/models/ILivechatDepartmentModel.ts +++ b/packages/model-typings/src/models/ILivechatDepartmentModel.ts @@ -71,4 +71,6 @@ export interface ILivechatDepartmentModel extends IBaseModel; countArchived(): Promise; findEnabledInIds(departmentsIds: string[], options?: FindOptions): FindCursor; + archiveDepartment(_id: string): Promise; + unarchiveDepartment(_id: string): Promise; } diff --git a/packages/model-typings/src/models/ILivechatRoomsModel.ts b/packages/model-typings/src/models/ILivechatRoomsModel.ts index 68b72be33ba8..20100cbb4f61 100644 --- a/packages/model-typings/src/models/ILivechatRoomsModel.ts +++ b/packages/model-typings/src/models/ILivechatRoomsModel.ts @@ -1,4 +1,11 @@ -import type { IMessage, IOmnichannelRoom, IOmnichannelRoomClosingInfo, ISetting, ILivechatVisitor } from '@rocket.chat/core-typings'; +import type { + IMessage, + IOmnichannelRoom, + IOmnichannelRoomClosingInfo, + ISetting, + ILivechatVisitor, + MACStats, +} from '@rocket.chat/core-typings'; import type { FindCursor, UpdateResult, AggregationCursor, Document, FindOptions, DeleteResult, Filter } from 'mongodb'; import type { FindPaginated } from '..'; @@ -234,4 +241,7 @@ export interface ILivechatRoomsModel extends IBaseModel { setVisitorInactivityInSecondsById(roomId: string, visitorInactivity: any): Promise; changeVisitorByRoomId(roomId: string, visitor: { _id: string; username: string; token: string }): Promise; unarchiveOneById(roomId: string): Promise; + markVisitorActiveForPeriod(rid: string, period: string): Promise; + getMACStatisticsForPeriod(period: string): Promise; + getMACStatisticsBetweenDates(start: Date, end: Date): Promise; } diff --git a/packages/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index 370db511dadf..5c598c6a6a97 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -48,4 +48,16 @@ export interface ILivechatVisitorsModel extends IBaseModel { updateById(_id: string, update: UpdateFilter): Promise; saveGuestEmailPhoneById(_id: string, emails: string[], phones: string[]): Promise; + + isVisitorActiveOnPeriod(visitorId: string, period: string): Promise; + + markVisitorActiveForPeriod(visitorId: string, period: string): Promise; + + findOneEnabledById(_id: string, options?: FindOptions): Promise; + + disableById(_id: string): Promise; + + findEnabled(query: Filter, options?: FindOptions): FindCursor; + + countVisitorsOnPeriod(period: string): Promise; } diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index de0cf9d10f96..66ffe9232749 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -130,6 +130,8 @@ export interface IRoomsModel extends IBaseModel { findByE2E(options?: FindOptions): FindCursor; + findE2ERoomById(roomId: IRoom['_id'], options?: FindOptions): Promise; + findRoomsInsideTeams(autoJoin?: boolean): FindCursor; findOneDirectRoomContainingAllUserIDs(uid: IDirectMessageRoom['uids'], options?: FindOptions): Promise; diff --git a/packages/model-typings/src/models/ISettingsModel.ts b/packages/model-typings/src/models/ISettingsModel.ts index 9dc2005867fa..d382d4853a4b 100644 --- a/packages/model-typings/src/models/ISettingsModel.ts +++ b/packages/model-typings/src/models/ISettingsModel.ts @@ -17,6 +17,11 @@ export interface ISettingsModel extends IBaseModel { value: (ISetting['value'] extends undefined ? never : ISetting['value']) | null, ): Promise; + resetValueById( + _id: string, + value?: (ISetting['value'] extends undefined ? never : ISetting['value']) | null, + ): Promise; + incrementValueById(_id: ISetting['_id'], value?: number): Promise; updateOptionsById( diff --git a/packages/model-typings/src/models/IStatisticsModel.ts b/packages/model-typings/src/models/IStatisticsModel.ts index ac84a49525b3..fe4534eaee0f 100644 --- a/packages/model-typings/src/models/IStatisticsModel.ts +++ b/packages/model-typings/src/models/IStatisticsModel.ts @@ -4,4 +4,5 @@ import type { IBaseModel } from './IBaseModel'; export interface IStatisticsModel extends IBaseModel { findLast(): Promise; + findMonthlyPeakConnections(): Promise | null>; } diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index aebda87c78cb..56119982afb3 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -1,5 +1,15 @@ -import type { ISubscription, IRole, IUser, IRoom, RoomType, SpotlightUser } from '@rocket.chat/core-typings'; -import type { FindOptions, FindCursor, UpdateResult, DeleteResult, Document, AggregateOptions, Filter, InsertOneResult } from 'mongodb'; +import type { ISubscription, IRole, IUser, IRoom, RoomType, SpotlightUser, AtLeast } from '@rocket.chat/core-typings'; +import type { + FindOptions, + FindCursor, + UpdateResult, + DeleteResult, + Document, + AggregateOptions, + Filter, + InsertOneResult, + InsertManyResult, +} from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -121,6 +131,7 @@ export interface ISubscriptionsModel extends IBaseModel { findByUserId(userId: string, options?: FindOptions): FindCursor; cachedFindByUserId(userId: string, options?: FindOptions): FindCursor; updateAutoTranslateById(_id: string, autoTranslate: boolean): Promise; + disableAutoTranslateByRoomId(roomId: IRoom['_id']): Promise; findAlwaysNotifyDesktopUsersByRoomId(roomId: string): FindCursor; findOneByRoomNameAndUserId(roomName: string, userId: string): Promise; @@ -216,6 +227,10 @@ export interface ISubscriptionsModel extends IBaseModel { ): Promise; removeByUserId(userId: string): Promise; createWithRoomAndUser(room: IRoom, user: IUser, extraData?: Record): Promise>; + createWithRoomAndManyUsers( + room: IRoom, + users: { user: AtLeast; extraData: Record }[], + ): Promise>; removeByRoomIdsAndUserId(rids: string[], userId: string): Promise; removeByRoomIdAndUserId(roomId: string, userId: string): Promise; diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index 1ee2a432c3df..f14f5bc90d0d 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -239,6 +239,7 @@ export interface IUsersModel extends IBaseModel { removeAllRoomsByUserId(userId: string): Promise; removeRoomByUserId(userId: string, rid: string): Promise; addRoomByUserId(userId: string, rid: string): Promise; + addRoomByUserIds(uids: string[], rid: string): Promise; removeRoomByRoomIds(rids: string[]): Promise; getLoginTokensByUserId(userId: string): FindCursor; addPersonalAccessTokenToUser(data: { userId: string; loginTokenObject: IPersonalAccessToken }): Promise; @@ -317,7 +318,7 @@ export interface IUsersModel extends IBaseModel { findByUsernameNameOrEmailAddress(nameOrUsernameOrEmail: string, options?: FindOptions): FindCursor; findCrowdUsers(options?: FindOptions): FindCursor; getLastLogin(options?: FindOptions): Promise; - findUsersByUsernames(usernames: string[], options?: FindOptions): FindCursor; + findUsersByUsernames(usernames: string[], options?: FindOptions): FindCursor; findUsersByIds(userIds: string[], options?: FindOptions): FindCursor; findUsersWithUsernameByIds(userIds: string[], options?: FindOptions): FindCursor; findUsersWithUsernameByIdsNotOffline(userIds: string[], options?: FindOptions): FindCursor; diff --git a/packages/models/CHANGELOG.md b/packages/models/CHANGELOG.md index 31d2440aaa7f..ac261acbc4d7 100644 --- a/packages/models/CHANGELOG.md +++ b/packages/models/CHANGELOG.md @@ -1,5 +1,24 @@ # @rocket.chat/models +## 0.0.15 + +### Patch Changes + +- Updated dependencies [4186eecf05] +- Updated dependencies [8a59855fcf] +- Updated dependencies [5cee21468e] +- Updated dependencies [aaefe865a7] +- Updated dependencies [f556518fa1] +- Updated dependencies [ead7c7bef2] +- Updated dependencies [61128364d6] + - @rocket.chat/model-typings@0.1.0 + +## 0.0.15-rc.5 + +### Patch Changes + +- @rocket.chat/model-typings@0.1.0-rc.5 + ## 0.0.14-rc.4 ### Patch Changes diff --git a/packages/models/package.json b/packages/models/package.json index 4dbaa73f037b..1644a8362c77 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/models", - "version": "0.0.14-rc.4", + "version": "0.0.15", "private": true, "devDependencies": { "@types/jest": "~29.5.3", diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index e1cf91f1b0ee..1e83fe72b93e 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -78,6 +78,7 @@ import type { ICronHistoryModel, IMigrationsModel, IModerationReportsModel, + ICloudAnnouncementsModel, } from '@rocket.chat/model-typings'; import { proxify } from './proxify'; @@ -170,3 +171,4 @@ export const AuditLog = proxify('IAuditLogModel'); export const CronHistory = proxify('ICronHistoryModel'); export const Migrations = proxify('IMigrationsModel'); export const ModerationReports = proxify('IModerationReportsModel'); +export const CloudAnnouncements = proxify('ICloudAnnouncementsModel'); diff --git a/packages/random/src/NodeRandomGenerator.ts b/packages/random/src/NodeRandomGenerator.ts index b9d556c6ac07..8c9f239413ca 100644 --- a/packages/random/src/NodeRandomGenerator.ts +++ b/packages/random/src/NodeRandomGenerator.ts @@ -38,6 +38,16 @@ export class NodeRandomGenerator extends RandomGenerator { return result.substring(0, digits); } + /** + * @name Random.between Returns a random integer between min and max, inclusive. + * @param min Minimum value (inclusive) + * @param max Maximum value (inclusive) + * @returns A random integer between min and max, inclusive. + */ + between(min: number, max: number) { + return Math.floor(this.fraction() * (max - min + 1)) + min; + } + protected safelyCreateWithSeeds(...seeds: readonly unknown[]) { return new AleaRandomGenerator({ seeds }); } diff --git a/packages/release-action/CHANGELOG.md b/packages/release-action/CHANGELOG.md index 23416df8c835..3435ec55ffa5 100644 --- a/packages/release-action/CHANGELOG.md +++ b/packages/release-action/CHANGELOG.md @@ -1,5 +1,16 @@ # @rocket.chat/release-action +## 2.2.0 + +### Minor Changes + +- f93648a5df: Add back "Engine Versions" to the release notes + +### Patch Changes + +- Updated dependencies [0f56aacc4d] + - @rocket.chat/eslint-config@0.6.0 + ## 2.2.0-rc.0 ### Minor Changes diff --git a/packages/release-action/package.json b/packages/release-action/package.json index c227df3f2d0f..0a1e3ded8927 100644 --- a/packages/release-action/package.json +++ b/packages/release-action/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/release-action", - "version": "2.2.0-rc.0", + "version": "2.2.0", "private": true, "scripts": { "build": "tsc", diff --git a/packages/rest-typings/CHANGELOG.md b/packages/rest-typings/CHANGELOG.md index 506ed5c3580f..da3d48464546 100644 --- a/packages/rest-typings/CHANGELOG.md +++ b/packages/rest-typings/CHANGELOG.md @@ -1,5 +1,43 @@ # @rocket.chat/rest-typings +## 6.4.0 + +### Minor Changes + +- 239a34e877: new: ring mobile users on direct conference calls +- 4186eecf05: Introduce the ability to report an user +- 2db32f0d4a: Add option to select what URL previews should be generated for each message. +- 19aec23cda: New AddUser workflow for Federated Rooms +- ebab8c4dd8: Added Reports Metrics Dashboard to Omnichannel +- 357a3a50fa: feat: high-contrast theme +- 1041d4d361: Added option to select between two script engine options for the integrations +- 93d4912e17: fix: missing params on updateOwnBasicInfo endpoint + +### Patch Changes + +- 203304782f: Fixed `overrideDestinationChannelEnabled` treated as a required param in `integrations.create` and `integration.update` endpoints +- 9496f1eb97: Deprecate `livechat:getOverviewData` and `livechat:getAgentOverviewData` methods and create API endpoints `livechat/analytics/overview` and `livechat/analytics/agent-overview` to fetch analytics data +- Updated dependencies [239a34e877] +- Updated dependencies [203304782f] +- Updated dependencies [4186eecf05] +- Updated dependencies [ba24f3c21f] +- Updated dependencies [ebab8c4dd8] +- Updated dependencies [1041d4d361] +- Updated dependencies [61128364d6] +- Updated dependencies [d45365436e] + - @rocket.chat/core-typings@6.4.0 + +## 6.4.0-rc.5 + +### Minor Changes + +- 1041d4d361: Added option to select between two script engine options for the integrations + +### Patch Changes + +- Updated dependencies [1041d4d361] + - @rocket.chat/core-typings@6.4.0-rc.5 + ## 6.4.0-rc.4 ### Patch Changes @@ -49,6 +87,12 @@ - Updated dependencies [d45365436e] - @rocket.chat/core-typings@6.4.0-rc.0 +## 6.3.8 + +### Patch Changes + +- @rocket.chat/core-typings@6.3.8 + ## 6.3.7 ### Patch Changes diff --git a/packages/rest-typings/package.json b/packages/rest-typings/package.json index 9da7694d28b9..d5996987acf1 100644 --- a/packages/rest-typings/package.json +++ b/packages/rest-typings/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/rest-typings", - "version": "6.4.0-rc.4", + "version": "6.4.0", "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", "@types/jest": "~29.5.3", diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index 06ce6d98169e..31427afb3fee 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -12,6 +12,7 @@ import type { AppRequestFilter, AppRequestsStats, PaginatedAppRequests, + UiKit, } from '@rocket.chat/core-typings'; export type AppsEndpoints = { @@ -258,15 +259,6 @@ export type AppsEndpoints = { }; '/apps/ui.interaction/:id': { - POST: (params: { - type: string; - actionId: string; - rid: string; - mid: string; - viewId: string; - container: string; - triggerId: string; - payload: any; - }) => any; + POST: (params: UiKit.UserInteraction) => any; }; }; diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 066e3248dc33..3b8197ce20bf 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -228,6 +228,7 @@ export * from './v1/invites'; export * from './v1/dm'; export * from './v1/dm/DmHistoryProps'; export * from './v1/integrations'; +export * from './v1/licenses'; export * from './v1/omnichannel'; export * from './v1/oauthapps'; export * from './v1/oauthapps/UpdateOAuthAppParamsPOST'; diff --git a/packages/rest-typings/src/v1/autotranslate/AutotranslateSaveSettingsParamsPOST.ts b/packages/rest-typings/src/v1/autotranslate/AutotranslateSaveSettingsParamsPOST.ts index 3690d0672ce2..914739d000a0 100644 --- a/packages/rest-typings/src/v1/autotranslate/AutotranslateSaveSettingsParamsPOST.ts +++ b/packages/rest-typings/src/v1/autotranslate/AutotranslateSaveSettingsParamsPOST.ts @@ -21,7 +21,7 @@ const AutotranslateSaveSettingsParamsPostSchema = { enum: ['autoTranslate', 'autoTranslateLanguage'], }, value: { - type: ['boolean', 'string'], + anyOf: [{ type: 'boolean' }, { type: 'string' }], }, defaultLanguage: { type: 'string', diff --git a/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts b/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts index c8bb88cfc1c6..e25dfb0ce2fb 100644 --- a/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts +++ b/packages/rest-typings/src/v1/channels/ChannelsCreateProps.ts @@ -12,6 +12,7 @@ export type ChannelsCreateProps = { encrypted?: boolean; teamId?: string; }; + excludeSelf?: boolean; }; const channelsCreatePropsSchema = { diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index b8def106c78a..c29e420a47f3 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -697,8 +697,8 @@ const ChatGetDeletedMessagesSchema = { export const isChatGetDeletedMessagesProps = ajv.compile(ChatGetDeletedMessagesSchema); type ChatPostMessage = - | { roomId: string; text?: string; alias?: string; emoji?: string; avatar?: string; attachments?: MessageAttachment[] } - | { channel: string; text?: string; alias?: string; emoji?: string; avatar?: string; attachments?: MessageAttachment[] }; + | { roomId: string | string[]; text?: string; alias?: string; emoji?: string; avatar?: string; attachments?: MessageAttachment[] } + | { channel: string | string[]; text?: string; alias?: string; emoji?: string; avatar?: string; attachments?: MessageAttachment[] }; const ChatPostMessageSchema = { oneOf: [ @@ -706,7 +706,15 @@ const ChatPostMessageSchema = { type: 'object', properties: { roomId: { - type: 'string', + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { + type: 'string', + }, + }, + ], }, text: { type: 'string', @@ -739,7 +747,15 @@ const ChatPostMessageSchema = { type: 'object', properties: { channel: { - type: 'string', + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { + type: 'string', + }, + }, + ], }, text: { type: 'string', diff --git a/packages/rest-typings/src/v1/federation/FederationVerifyMatrixIdProps.ts b/packages/rest-typings/src/v1/federation/FederationVerifyMatrixIdProps.ts index a63d37da07ba..a6009fe20d85 100644 --- a/packages/rest-typings/src/v1/federation/FederationVerifyMatrixIdProps.ts +++ b/packages/rest-typings/src/v1/federation/FederationVerifyMatrixIdProps.ts @@ -11,7 +11,7 @@ const FederationVerifyMatrixIdPropsSchema = { properties: { matrixIds: { type: 'array', - items: [{ type: 'string' }], + items: { type: 'string' }, uniqueItems: true, }, }, diff --git a/packages/rest-typings/src/v1/groups/GroupsCreateProps.ts b/packages/rest-typings/src/v1/groups/GroupsCreateProps.ts index c34a720bd4b7..7c3781d787c4 100644 --- a/packages/rest-typings/src/v1/groups/GroupsCreateProps.ts +++ b/packages/rest-typings/src/v1/groups/GroupsCreateProps.ts @@ -14,6 +14,7 @@ export type GroupsCreateProps = { encrypted: boolean; teamId?: string; }; + excludeSelf?: boolean; }; const GroupsCreatePropsSchema = { diff --git a/packages/rest-typings/src/v1/licenses.ts b/packages/rest-typings/src/v1/licenses.ts index 96c67e2654bb..4eb1ac196840 100644 --- a/packages/rest-typings/src/v1/licenses.ts +++ b/packages/rest-typings/src/v1/licenses.ts @@ -1,4 +1,4 @@ -import type { ILicenseV2, ILicenseV3 } from '@rocket.chat/license'; +import type { ILicenseV2, ILicenseV3, LicenseInfo } from '@rocket.chat/license'; import Ajv from 'ajv'; const ajv = new Ajv({ @@ -22,10 +22,32 @@ const licensesAddPropsSchema = { export const isLicensesAddProps = ajv.compile(licensesAddPropsSchema); +type licensesInfoProps = { + loadValues?: boolean; +}; + +const licensesInfoPropsSchema = { + type: 'object', + properties: { + loadValues: { + type: 'boolean', + }, + }, + required: [], + additionalProperties: false, +}; + +export const isLicensesInfoProps = ajv.compile(licensesInfoPropsSchema); + export type LicensesEndpoints = { '/v1/licenses.get': { GET: () => { licenses: Array }; }; + '/v1/licenses.info': { + GET: (params: licensesInfoProps) => { + license: LicenseInfo; + }; + }; '/v1/licenses.add': { POST: (params: licensesAddProps) => void; }; diff --git a/packages/rest-typings/src/v1/misc.ts b/packages/rest-typings/src/v1/misc.ts index 4af37334e287..804b72a763de 100644 --- a/packages/rest-typings/src/v1/misc.ts +++ b/packages/rest-typings/src/v1/misc.ts @@ -164,6 +164,22 @@ const MethodCallAnonSchema = { export const isMethodCallAnonProps = ajv.compile(MethodCallAnonSchema); +type Fingerprint = { setDeploymentAs: 'new-workspace' | 'updated-configuration' }; + +const FingerprintSchema = { + type: 'object', + properties: { + setDeploymentAs: { + type: 'string', + enum: ['new-workspace', 'updated-configuration'], + }, + }, + required: ['setDeploymentAs'], + additionalProperties: false, +}; + +export const isFingerprintProps = ajv.compile(FingerprintSchema); + type PwGetPolicyReset = { token: string }; const PwGetPolicyResetSchema = { @@ -229,6 +245,12 @@ export type MiscEndpoints = { }; }; + '/v1/fingerprint': { + POST: (params: Fingerprint) => { + success: boolean; + }; + }; + '/v1/smtp.check': { GET: () => { isSMTPConfigured: boolean; diff --git a/packages/rest-typings/src/v1/moderation/ReportHistoryProps.ts b/packages/rest-typings/src/v1/moderation/ReportHistoryProps.ts index 69b1d85f22a5..48b859a7899b 100644 --- a/packages/rest-typings/src/v1/moderation/ReportHistoryProps.ts +++ b/packages/rest-typings/src/v1/moderation/ReportHistoryProps.ts @@ -10,6 +10,7 @@ type ReportHistoryProps = { export type ReportHistoryPropsGET = PaginatedRequest; const reportHistoryPropsSchema = { + type: 'object', properties: { latest: { type: 'string', diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index 60f6ed7ace08..fcdbf17bb94d 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -749,7 +749,13 @@ const LivechatDepartmentsByUnitIdSchema = { export const isLivechatDepartmentsByUnitIdProps = ajv.compile(LivechatDepartmentsByUnitIdSchema); -type LivechatUsersManagerGETProps = PaginatedRequest<{ text?: string; fields?: string }>; +type LivechatUsersManagerGETProps = PaginatedRequest<{ + text?: string; + fields?: string; + onlyAvailable?: boolean; + excludeId?: string; + showIdleAgents?: boolean; +}>; const LivechatUsersManagerGETSchema = { type: 'object', @@ -758,6 +764,18 @@ const LivechatUsersManagerGETSchema = { type: 'string', nullable: true, }, + onlyAvailable: { + type: 'boolean', + nullable: true, + }, + excludeId: { + type: 'string', + nullable: true, + }, + showIdleAgents: { + type: 'boolean', + nullable: true, + }, count: { type: 'number', nullable: true, @@ -2550,6 +2568,10 @@ const GETLivechatRoomsParamsSchema = { type: 'string', nullable: true, }, + query: { + type: 'string', + nullable: true, + }, fields: { type: 'string', nullable: true, @@ -2582,12 +2604,16 @@ const GETLivechatRoomsParamsSchema = { nullable: true, }, open: { - type: ['string', 'boolean'], - nullable: true, + anyOf: [ + { type: 'string', nullable: true }, + { type: 'boolean', nullable: true }, + ], }, onhold: { - type: ['string', 'boolean'], - nullable: true, + anyOf: [ + { type: 'string', nullable: true }, + { type: 'boolean', nullable: true }, + ], }, tags: { type: 'array', @@ -3112,7 +3138,7 @@ const POSTLivechatAppearanceParamsSchema = { type: 'string', }, value: { - type: ['string', 'boolean', 'number'], + anyOf: [{ type: 'string' }, { type: 'boolean' }, { type: 'number' }], }, }, required: ['_id', 'value'], @@ -3251,7 +3277,7 @@ export type OmnichannelEndpoints = { }>; }; '/v1/livechat/tags/:tagId': { - GET: () => ILivechatTag | null; + GET: () => ILivechatTag; }; '/v1/livechat/department': { GET: (params?: LivechatDepartmentProps) => PaginatedResult<{ @@ -3378,7 +3404,9 @@ export type OmnichannelEndpoints = { }; '/v1/livechat/users/agent': { - GET: (params: PaginatedRequest<{ text?: string }>) => PaginatedResult<{ + GET: ( + params: PaginatedRequest<{ text?: string; onlyAvailable?: boolean; excludeId?: string; showIdleAgents?: boolean }>, + ) => PaginatedResult<{ users: (ILivechatAgent & { departments: string[] })[]; }>; POST: (params: LivechatUsersManagerPOSTProps) => { success: boolean }; @@ -3588,6 +3616,7 @@ export type OmnichannelEndpoints = { }; '/v1/livechat/triggers/:_id': { GET: () => { trigger: ILivechatTrigger | null }; + DELETE: () => void; }; '/v1/livechat/rooms': { GET: (params: GETLivechatRoomsParams) => PaginatedResult<{ rooms: IOmnichannelRoom[] }>; diff --git a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts index bb32dc27fb04..1c89fdc04d5d 100644 --- a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts +++ b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts @@ -49,6 +49,7 @@ export type UsersSetPreferencesParamsPOST = { idleTimeLimit?: number; omnichannelTranscriptEmail?: boolean; omnichannelTranscriptPDF?: boolean; + omnichannelHideConversationAfterClosing?: boolean; enableMobileRinging?: boolean; mentionsWithSymbol?: boolean; }; @@ -242,6 +243,10 @@ const UsersSetPreferencesParamsPostSchema = { type: 'boolean', nullable: true, }, + omnichannelHideConversationAfterClosing: { + type: 'boolean', + nullable: true, + }, enableMobileRinging: { type: 'boolean', nullable: true, diff --git a/packages/server-cloud-communication/package.json b/packages/server-cloud-communication/package.json index 9b091bbc464f..52a3ff801dac 100644 --- a/packages/server-cloud-communication/package.json +++ b/packages/server-cloud-communication/package.json @@ -3,12 +3,16 @@ "version": "0.0.1", "private": true, "devDependencies": { + "@rocket.chat/license": "workspace:^", "@types/jest": "~29.5.3", "eslint": "~8.45.0", "jest": "~29.6.1", "ts-jest": "~29.0.5", "typescript": "~5.1.6" }, + "volta": { + "extends": "../../package.json" + }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", diff --git a/packages/server-cloud-communication/src/definitions/index.ts b/packages/server-cloud-communication/src/definitions/index.ts index d554aa538059..4ac455ccefd1 100644 --- a/packages/server-cloud-communication/src/definitions/index.ts +++ b/packages/server-cloud-communication/src/definitions/index.ts @@ -17,6 +17,8 @@ type Message = { type Version = { version: string; expiration: Date; + security: boolean; + infoUrl: string; messages?: Message[]; }; diff --git a/packages/server-cloud-communication/src/index.ts b/packages/server-cloud-communication/src/index.ts index a18306b926eb..382400b0c72c 100644 --- a/packages/server-cloud-communication/src/index.ts +++ b/packages/server-cloud-communication/src/index.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + import type { SupportedVersions, SignedSupportedVersions } from './definitions'; export { SupportedVersions, SignedSupportedVersions }; diff --git a/packages/server-fetch/src/parsers.ts b/packages/server-fetch/src/parsers.ts index 598ecbbd0e8e..ad0a44e96cfb 100644 --- a/packages/server-fetch/src/parsers.ts +++ b/packages/server-fetch/src/parsers.ts @@ -1,32 +1,20 @@ import type { ExtendedFetchOptions, FetchOptions, OriginalFetchOptions } from './types'; -function isPostOrPutOrDeleteWithBody(options?: ExtendedFetchOptions): boolean { - // No method === 'get' - if (!options?.method) { - return false; - } - const { method, body } = options; - const lowerMethod = method?.toLowerCase(); - return ['post', 'put', 'delete'].includes(lowerMethod) && body != null; -} - const jsonParser = (options: ExtendedFetchOptions) => { if (!options) { return {}; } - if (isPostOrPutOrDeleteWithBody(options)) { - try { - if (options && typeof options.body === 'object' && !Buffer.isBuffer(options.body)) { - options.body = JSON.stringify(options.body); - options.headers = { - 'Content-Type': 'application/json', - ...options.headers, - }; - } - } catch (e) { - // Body is not JSON, do nothing + try { + if (typeof options.body === 'object' && !Buffer.isBuffer(options.body)) { + options.body = JSON.stringify(options.body); + options.headers = { + ...options.headers, + 'Content-Type': 'application/json', // force content type to be json + }; } + } catch (e) { + // Body is not JSON, do nothing } return options as FetchOptions; diff --git a/packages/tools/CHANGELOG.md b/packages/tools/CHANGELOG.md new file mode 100644 index 000000000000..b5d9e6f419d4 --- /dev/null +++ b/packages/tools/CHANGELOG.md @@ -0,0 +1,13 @@ +# @rocket.chat/tools + +## 0.1.0 + +### Minor Changes + +- 1041d4d361: Added option to select between two script engine options for the integrations + +## 0.1.0-rc.0 + +### Minor Changes + +- 1041d4d361: Added option to select between two script engine options for the integrations diff --git a/packages/tools/package.json b/packages/tools/package.json index d9ab70550ce4..ed5e7bdd44bf 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/tools", - "version": "0.0.1", + "version": "0.1.0", "private": true, "devDependencies": { "@types/jest": "~29.5.3", diff --git a/packages/ui-client/CHANGELOG.md b/packages/ui-client/CHANGELOG.md index 5a987f493cf1..77ab6ff504ca 100644 --- a/packages/ui-client/CHANGELOG.md +++ b/packages/ui-client/CHANGELOG.md @@ -1,5 +1,25 @@ # @rocket.chat/ui-client +## 2.0.0 + +### Minor Changes + +- ebab8c4dd8: Added Reports Metrics Dashboard to Omnichannel +- ee3815fce4: feat: add ChangePassword field to Account/Security + +### Patch Changes + +- b8f3d5014f: Fixed the login page language switcher, now the component has a new look, is reactive and the language selection becomes concrete upon login in. Also changed the default language of the login page to be the browser language. +- Updated dependencies [074db3b419] +- Updated dependencies [b8f3d5014f] + - @rocket.chat/ui-contexts@2.0.0 + +## 2.0.0-rc.5 + +### Patch Changes + +- @rocket.chat/ui-contexts@2.0.0-rc.5 + ## 2.0.0-rc.4 ### Patch Changes @@ -38,6 +58,12 @@ - Updated dependencies [b8f3d5014f] - @rocket.chat/ui-contexts@2.0.0-rc.0 +## 1.0.8 + +### Patch Changes + +- @rocket.chat/ui-contexts@1.0.8 + ## 1.0.7 ### Patch Changes diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json index ce183b859e06..80e318c216ab 100644 --- a/packages/ui-client/package.json +++ b/packages/ui-client/package.json @@ -1,11 +1,11 @@ { "name": "@rocket.chat/ui-client", - "version": "2.0.0-rc.4", + "version": "2.0.0", "private": true, "devDependencies": { "@babel/core": "~7.22.9", "@rocket.chat/css-in-js": "next", - "@rocket.chat/fuselage": "^0.32.1", + "@rocket.chat/fuselage": "^0.36.0", "@rocket.chat/fuselage-hooks": "^0.32.1", "@rocket.chat/icons": "^0.32.0", "@rocket.chat/mock-providers": "workspace:^", @@ -61,7 +61,7 @@ "@rocket.chat/fuselage": "*", "@rocket.chat/fuselage-hooks": "*", "@rocket.chat/icons": "*", - "@rocket.chat/ui-contexts": "2.0.0-rc.4", + "@rocket.chat/ui-contexts": "2.0.0", "react": "~17.0.2" }, "volta": { diff --git a/packages/ui-client/src/components/CustomFieldsForm.tsx b/packages/ui-client/src/components/CustomFieldsForm.tsx index 9423456ebe8e..0d1ba2ca5b1b 100644 --- a/packages/ui-client/src/components/CustomFieldsForm.tsx +++ b/packages/ui-client/src/components/CustomFieldsForm.tsx @@ -1,6 +1,6 @@ import type { CustomFieldMetadata } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; -import { Field, Select, TextInput } from '@rocket.chat/fuselage'; +import { Field, FieldLabel, FieldRow, FieldError, Select, TextInput } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; @@ -73,10 +73,10 @@ const CustomField = ({ rules={{ minLength: props.minLength, maxLength: props.maxLength, validate: { required: validateRequired } }} render={({ field }) => ( - + {label || t(name as TranslationKey)} - - + + ({ options={selectOptions as SelectOption[]} flexGrow={1} /> - - + + {errorMessage} - + )} /> diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx index 1420a62346d6..6c5e12be8622 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustom.tsx @@ -57,7 +57,6 @@ type DropDownProps = { selectedOptionsTitle: TranslationKey; selectedOptions: OptionProp[]; setSelectedOptions: Dispatch>; - customSetSelected: Dispatch>; searchBarText?: TranslationKey; }; @@ -67,7 +66,6 @@ export const MultiSelectCustom = ({ selectedOptionsTitle, selectedOptions, setSelectedOptions, - customSetSelected, searchBarText, }: DropDownProps): ReactElement => { const reference = useRef(null); @@ -90,26 +88,15 @@ export const MultiSelectCustom = ({ const onSelect = (item: OptionProp, e?: FormEvent): void => { e?.stopPropagation(); - item.checked = !item.checked; if (item.checked === true) { - // the user has enabled this option -> add it to the selected options setSelectedOptions([...new Set([...selectedOptions, item])]); - customSetSelected((prevItems) => { - const newItems = prevItems; - const toggledItem = newItems.find(({ id }) => id === item.id); - - if (toggledItem) { - toggledItem.checked = !toggledItem.checked; - } - - return [...prevItems]; - }); - } else { - // the user has disabled this option -> remove this from the selected options list - setSelectedOptions(selectedOptions.filter((option: OptionProp) => option.id !== item.id)); + return; } + + // the user has disabled this option -> remove this from the selected options list + setSelectedOptions(selectedOptions.filter((option: OptionProp) => option.id !== item.id)); }; const count = dropdownOptions.filter((option) => option.checked).length; diff --git a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx index 7e6bfdb9fee1..d8f8d60d8096 100644 --- a/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx +++ b/packages/ui-client/src/components/MultiSelectCustom/MultiSelectCustomList.tsx @@ -2,7 +2,7 @@ import { Box, CheckBox, Icon, Option, SearchInput, Tile } from '@rocket.chat/fus import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { FormEvent } from 'react'; -import { Fragment, useCallback, useEffect, useState } from 'react'; +import { Fragment, useCallback, useState } from 'react'; import type { OptionProp } from './MultiSelectCustom'; import { useFilteredOptions } from './useFilteredOptions'; @@ -19,13 +19,10 @@ const MultiSelectCustomList = ({ const t = useTranslation(); const [text, setText] = useState(''); - const handleChange = useCallback((event) => setText(event.currentTarget.value), []); - - const [optionSearch, setOptionSearch] = useState(''); - useEffect(() => setOptionSearch(text), [setOptionSearch, text]); + const handleChange = useCallback((event) => setText(event.currentTarget.value), []); - const filteredOptions = useFilteredOptions(optionSearch, options); + const filteredOptions = useFilteredOptions(text, options); return ( @@ -48,11 +45,11 @@ const MultiSelectCustomList = ({ {t(option.text as TranslationKey)} ) : ( -