diff --git a/.changeset/angry-llamas-retire.md b/.changeset/angry-llamas-retire.md new file mode 100644 index 000000000000..d017be6b5999 --- /dev/null +++ b/.changeset/angry-llamas-retire.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/eslint-config': patch +--- + +Include missing ESLint configuration for React diff --git a/.changeset/attachment-collapse.md b/.changeset/attachment-collapse.md new file mode 100644 index 000000000000..b4302b83fe76 --- /dev/null +++ b/.changeset/attachment-collapse.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed Attachments not respecting collapse property when using incoming webhook diff --git a/.changeset/beige-deers-laugh.md b/.changeset/beige-deers-laugh.md new file mode 100644 index 000000000000..fe7faf5c6f9b --- /dev/null +++ b/.changeset/beige-deers-laugh.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix user being logged out after using 2FA diff --git a/.changeset/big-teachers-change.md b/.changeset/big-teachers-change.md new file mode 100644 index 000000000000..ec8980779031 --- /dev/null +++ b/.changeset/big-teachers-change.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/ui-contexts": minor +--- + +Add the possibility to hide some elements through postMessage events. diff --git a/.changeset/blue-tomatoes-retire.md b/.changeset/blue-tomatoes-retire.md new file mode 100644 index 000000000000..2e8ac9419e6a --- /dev/null +++ b/.changeset/blue-tomatoes-retire.md @@ -0,0 +1,8 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/ui-client": minor +--- + +Room header keyboard navigability + +![Kapture 2024-01-22 at 11 33 14](https://github.com/RocketChat/Rocket.Chat/assets/27704687/f116c1e6-4ec7-4175-a01b-fa98eade2416) diff --git a/.changeset/calm-carrots-judge.md b/.changeset/calm-carrots-judge.md new file mode 100644 index 000000000000..3bfced31b306 --- /dev/null +++ b/.changeset/calm-carrots-judge.md @@ -0,0 +1,12 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-services": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/gazzodown": patch +"@rocket.chat/livechat": patch +"@rocket.chat/rest-typings": patch +"@rocket.chat/pdf-worker": patch +"rocketchat-services": patch +--- + +feat: Implemented InlineCode handling in Bold, Italic and Strike diff --git a/.changeset/clean-melons-return.md b/.changeset/clean-melons-return.md new file mode 100644 index 000000000000..3b521860efbc --- /dev/null +++ b/.changeset/clean-melons-return.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed image dropping from another browser window creates two upload dialogs in some OS and browsers diff --git a/.changeset/clever-parrots-count.md b/.changeset/clever-parrots-count.md new file mode 100644 index 000000000000..cfc37f51719b --- /dev/null +++ b/.changeset/clever-parrots-count.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed a bug where some sessions were being saved without a sessionId diff --git a/.changeset/curly-years-smile.md b/.changeset/curly-years-smile.md new file mode 100644 index 000000000000..78ce09097844 --- /dev/null +++ b/.changeset/curly-years-smile.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed an issue with the composer losing its edit state and highlighted after resizing the window. diff --git a/.changeset/cyan-balloons-unite.md b/.changeset/cyan-balloons-unite.md new file mode 100644 index 000000000000..608b6d970f4f --- /dev/null +++ b/.changeset/cyan-balloons-unite.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/ddp-client": patch +--- + +chore: Add readme to ddp-client package diff --git a/.changeset/cyan-penguins-listen.md b/.changeset/cyan-penguins-listen.md new file mode 100644 index 000000000000..96ca04b25e7c --- /dev/null +++ b/.changeset/cyan-penguins-listen.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue where texts are not being displayed in the correct direction on messages diff --git a/.changeset/empty-tables-deliver.md b/.changeset/empty-tables-deliver.md new file mode 100644 index 000000000000..2edf518cdf2c --- /dev/null +++ b/.changeset/empty-tables-deliver.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed UI crashing for users reading a room when it's deleted. diff --git a/.changeset/five-dragons-joke.md b/.changeset/five-dragons-joke.md new file mode 100644 index 000000000000..09a636ec3213 --- /dev/null +++ b/.changeset/five-dragons-joke.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Freezes the permission table's first column allowing the user to visualize the permission name when scrolling horizontally diff --git a/.changeset/flat-windows-juggle.md b/.changeset/flat-windows-juggle.md new file mode 100644 index 000000000000..4a0410b7fab1 --- /dev/null +++ b/.changeset/flat-windows-juggle.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/web-ui-registration": patch +--- + +Fixed login email verification flow when a user tries to join with username diff --git a/.changeset/forty-dragons-juggle.md b/.changeset/forty-dragons-juggle.md new file mode 100644 index 000000000000..8abdfc63a7eb --- /dev/null +++ b/.changeset/forty-dragons-juggle.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed an issue not allowing users to remove the password to join the room on room edit diff --git a/.changeset/four-eels-compete.md b/.changeset/four-eels-compete.md new file mode 100644 index 000000000000..65b2d0c495ff --- /dev/null +++ b/.changeset/four-eels-compete.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/ui-composer': minor +'@rocket.chat/meteor': minor +--- + +Composer keyboard navigability + +![Kapture 2024-01-22 at 11 33 14](https://github.com/RocketChat/Rocket.Chat/assets/27704687/f116c1e6-4ec7-4175-a01b-fa98eade2416) diff --git a/.changeset/fresh-maps-rhyme.md b/.changeset/fresh-maps-rhyme.md new file mode 100644 index 000000000000..2a272a3f81a2 --- /dev/null +++ b/.changeset/fresh-maps-rhyme.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fix: multiple indexes creation error during 304 migration diff --git a/.changeset/friendly-cycles-grab.md b/.changeset/friendly-cycles-grab.md new file mode 100644 index 000000000000..39c5493c48d1 --- /dev/null +++ b/.changeset/friendly-cycles-grab.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed values discrepancy with downloaded report from Active users at Engagement Dashboard + + diff --git a/.changeset/funny-buses-own.md b/.changeset/funny-buses-own.md new file mode 100644 index 000000000000..faa0159807cf --- /dev/null +++ b/.changeset/funny-buses-own.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +feat: add `ImageGallery` zoom controls diff --git a/.changeset/green-timers-cross.md b/.changeset/green-timers-cross.md new file mode 100644 index 000000000000..7af076cc0303 --- /dev/null +++ b/.changeset/green-timers-cross.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +notification emails should now show emojis properly diff --git a/.changeset/green-tools-exercise.md b/.changeset/green-tools-exercise.md new file mode 100644 index 000000000000..30e61efb7e16 --- /dev/null +++ b/.changeset/green-tools-exercise.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/livechat": patch +--- + +Fixed an issue with translations that caused text containing special characters to be shown escaped on UI diff --git a/.changeset/grumpy-eagles-roll.md b/.changeset/grumpy-eagles-roll.md new file mode 100644 index 000000000000..37e0c7af1688 --- /dev/null +++ b/.changeset/grumpy-eagles-roll.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed Mail dryrun sending email to all users diff --git a/.changeset/grumpy-trainers-hope.md b/.changeset/grumpy-trainers-hope.md new file mode 100644 index 000000000000..842ae8306e1d --- /dev/null +++ b/.changeset/grumpy-trainers-hope.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Apply plural translations at a few places. diff --git a/.changeset/honest-gorillas-reply.md b/.changeset/honest-gorillas-reply.md new file mode 100644 index 000000000000..1da5f80bdb00 --- /dev/null +++ b/.changeset/honest-gorillas-reply.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Show marketplace apps installed as private in the right place (private tab) diff --git a/.changeset/itchy-zoos-appear.md b/.changeset/itchy-zoos-appear.md new file mode 100644 index 000000000000..6d9ab31eb7c8 --- /dev/null +++ b/.changeset/itchy-zoos-appear.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": minor +--- + +Fixes an issue where avatars are not being disabled based on preference on quote attachments diff --git a/.changeset/kind-dragons-flash.md b/.changeset/kind-dragons-flash.md new file mode 100644 index 000000000000..a7619ccf5d95 --- /dev/null +++ b/.changeset/kind-dragons-flash.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +feat: add a11y doc links diff --git a/.changeset/kind-melons-try.md b/.changeset/kind-melons-try.md new file mode 100644 index 000000000000..e778b6f01582 --- /dev/null +++ b/.changeset/kind-melons-try.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Added `push.test` POST endpoint for sending test push notification to user (requires `test-push-notifications` permission) diff --git a/.changeset/little-planes-wonder.md b/.changeset/little-planes-wonder.md new file mode 100644 index 000000000000..13c90d0efcdc --- /dev/null +++ b/.changeset/little-planes-wonder.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/core-services': patch +'@rocket.chat/ddp-streamer': patch +'@rocket.chat/meteor': patch +--- + +Fixed an issue that caused login buttons to not be reactively removed from the login page when the related authentication service was disabled by an admin. diff --git a/.changeset/lucky-bikes-enjoy.md b/.changeset/lucky-bikes-enjoy.md new file mode 100644 index 000000000000..b616afebba83 --- /dev/null +++ b/.changeset/lucky-bikes-enjoy.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/rest-typings": patch +--- + +Added `chat.getURLPreview` endpoint to enable users to retrieve previews for URL (ready to be provided in message send/update) diff --git a/.changeset/many-dolphins-deny.md b/.changeset/many-dolphins-deny.md new file mode 100644 index 000000000000..5c69fa825cb7 --- /dev/null +++ b/.changeset/many-dolphins-deny.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed SHIFT+ESCAPE inconsistency for clearing unread messages across browsers. diff --git a/.changeset/message-composer-hint.md b/.changeset/message-composer-hint.md new file mode 100644 index 000000000000..1df080174924 --- /dev/null +++ b/.changeset/message-composer-hint.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/ui-composer": minor +--- + +New feature to support cancel message editing message and hints for shortcuts. diff --git a/.changeset/mighty-shirts-sell.md b/.changeset/mighty-shirts-sell.md new file mode 100644 index 000000000000..7ff8c1fca455 --- /dev/null +++ b/.changeset/mighty-shirts-sell.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed an issue where the webclient didn't properly clear the message caches from memory when a room is deleted. When this happened to basic DMs and the user started a new DM with the same target user, the client would show the old messages in the room history even though they no longer existed in the server. diff --git a/.changeset/nice-points-notice.md b/.changeset/nice-points-notice.md new file mode 100644 index 000000000000..de69416bf43c --- /dev/null +++ b/.changeset/nice-points-notice.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixed an issue when editing a channel's type or name sometimes showing "Room not found" error. diff --git a/.changeset/rare-eels-fetch.md b/.changeset/rare-eels-fetch.md new file mode 100644 index 000000000000..295fce932656 --- /dev/null +++ b/.changeset/rare-eels-fetch.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +**Added ‘Reported Users’ Tab to Moderation Console:** Enhances user monitoring by displaying reported users. diff --git a/.changeset/rude-spoons-pump.md b/.changeset/rude-spoons-pump.md new file mode 100644 index 000000000000..3743d1d4b897 --- /dev/null +++ b/.changeset/rude-spoons-pump.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed Engagement Dashboard timezone selector freezing UI diff --git a/.changeset/selfish-rice-wave.md b/.changeset/selfish-rice-wave.md new file mode 100644 index 000000000000..3d6f1935dce2 --- /dev/null +++ b/.changeset/selfish-rice-wave.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +--- + +Fixed issue with OEmbed cache not being cleared daily diff --git a/.changeset/serious-cows-compete.md b/.changeset/serious-cows-compete.md new file mode 100644 index 000000000000..be419a9bd9cd --- /dev/null +++ b/.changeset/serious-cows-compete.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed a bug on the rooms page's "Favorite" setting, which previously failed to designate selected rooms as favorites by default. diff --git a/.changeset/seven-pugs-argue.md b/.changeset/seven-pugs-argue.md new file mode 100644 index 000000000000..f4a549124fc7 --- /dev/null +++ b/.changeset/seven-pugs-argue.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed using real names on messages reactions diff --git a/.changeset/sixty-bananas-sit.md b/.changeset/sixty-bananas-sit.md new file mode 100644 index 000000000000..8b39668be9ab --- /dev/null +++ b/.changeset/sixty-bananas-sit.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/model-typings": minor +--- + +Added feature to sync the user's language preference with the autotranslate setting. diff --git a/.changeset/small-beers-call.md b/.changeset/small-beers-call.md new file mode 100644 index 000000000000..0e0f7414b8fc --- /dev/null +++ b/.changeset/small-beers-call.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Looking at the user's permission before rendering the 'Start Call' button on the UserInfo panel, so if the user does not have the permissions, the button does not show diff --git a/.changeset/smooth-kangaroos-mate.md b/.changeset/smooth-kangaroos-mate.md new file mode 100644 index 000000000000..81b2156192a3 --- /dev/null +++ b/.changeset/smooth-kangaroos-mate.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: quote image gallery diff --git a/.changeset/spotty-tips-sell.md b/.changeset/spotty-tips-sell.md new file mode 100644 index 000000000000..8505e004a3c2 --- /dev/null +++ b/.changeset/spotty-tips-sell.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix multi-instance data formats being lost diff --git a/.changeset/stale-monkeys-yell.md b/.changeset/stale-monkeys-yell.md new file mode 100644 index 000000000000..7f80c152b888 --- /dev/null +++ b/.changeset/stale-monkeys-yell.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed an issue where room access and creation were hindered due to join codes not being fetched correctly in the API. diff --git a/.changeset/strong-countries-dance.md b/.changeset/strong-countries-dance.md new file mode 100644 index 000000000000..96dd8d70b258 --- /dev/null +++ b/.changeset/strong-countries-dance.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': minor +--- + +feat: `Bubble` on new messages indicators +image \ No newline at end of file diff --git a/.changeset/swift-beans-reflect.md b/.changeset/swift-beans-reflect.md new file mode 100644 index 000000000000..f8e8a487493d --- /dev/null +++ b/.changeset/swift-beans-reflect.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": minor +--- + +Added a modal to confirm the intention to pin a message, preventing users from doing it by mistake diff --git a/.changeset/tender-lizards-shop.md b/.changeset/tender-lizards-shop.md new file mode 100644 index 000000000000..d0155ba05342 --- /dev/null +++ b/.changeset/tender-lizards-shop.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Added missing labels to "Users by time of the day" card at Engagement Dashboard page diff --git a/.changeset/tiny-onions-remember.md b/.changeset/tiny-onions-remember.md new file mode 100644 index 000000000000..1592c42cb094 --- /dev/null +++ b/.changeset/tiny-onions-remember.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/model-typings": patch +--- + +Fixed an issue caused by the `Fallback Forward Department` feature. Feature could be configured by admins in a way that mimis a loop, causing a chat to be forwarded "infinitely" between those departments. System will now prevent Self & 1-level deep circular references from being saved, and a new setting is added to control the maximum number of hops that the system will do between fallback departments before considering a transfer failure. diff --git a/.changeset/tough-candles-argue.md b/.changeset/tough-candles-argue.md new file mode 100644 index 000000000000..40f11419a990 --- /dev/null +++ b/.changeset/tough-candles-argue.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fix an issue that breaks the avatar if you hit the button adding an invalid link diff --git a/.changeset/wet-crabs-brush.md b/.changeset/wet-crabs-brush.md new file mode 100644 index 000000000000..375d59addc07 --- /dev/null +++ b/.changeset/wet-crabs-brush.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed LDAP "Group filter" malfunction, which prevented LDAP users from logging in. diff --git a/.changeset/wicked-pumpkins-walk.md b/.changeset/wicked-pumpkins-walk.md new file mode 100644 index 000000000000..49e5b2e19626 --- /dev/null +++ b/.changeset/wicked-pumpkins-walk.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/agenda': minor +--- + +Remove `@ts-ignore` directives and unused code from Agenda code diff --git a/.changeset/witty-dogs-share.md b/.changeset/witty-dogs-share.md new file mode 100644 index 000000000000..d8917e0b7125 --- /dev/null +++ b/.changeset/witty-dogs-share.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed issue with user presence displayed as offline on SAML login diff --git a/.changeset/witty-pumas-pretend.md b/.changeset/witty-pumas-pretend.md new file mode 100644 index 000000000000..53e1b45a15ea --- /dev/null +++ b/.changeset/witty-pumas-pretend.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed Atlassian Crowd integration with Rocket.Chat not working diff --git a/.changeset/yellow-brooms-brake.md b/.changeset/yellow-brooms-brake.md new file mode 100644 index 000000000000..7bad9e102e84 --- /dev/null +++ b/.changeset/yellow-brooms-brake.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +fix: change the push sound sent when the push is from video conference diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index eb7b228022c3..3f75695b4877 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -160,7 +160,7 @@ jobs: path: | ~/.cache/ms-playwright # This is the version of Playwright that we are using, if you are willing to upgrade, you should update this. - key: playwright-1.37.1 + key: playwright-1.40.1 - name: Install Playwright if: inputs.type == 'ui' && steps.cache-playwright.outputs.cache-hit != 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdcea4c42133..a8f8d29610bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -724,21 +724,6 @@ jobs: # Makes build fail if the release isn't there curl --fail https://releases.rocket.chat/$RC_VERSION/info - - name: RedHat Registry - if: github.event_name == 'release' - env: - REDHAT_REGISTRY_PID: ${{ secrets.REDHAT_REGISTRY_PID }} - REDHAT_REGISTRY_KEY: ${{ secrets.REDHAT_REGISTRY_KEY }} - run: | - GIT_TAG="${GITHUB_REF#*tags/}" - - curl -X POST \ - https://connect.redhat.com/api/v2/projects/$REDHAT_REGISTRY_PID/build \ - -H "Authorization: Bearer $REDHAT_REGISTRY_KEY" \ - -H 'Cache-Control: no-cache' \ - -H 'Content-Type: application/json' \ - -d '{"tag":"'$GIT_TAG'"}' - trigger-dependent-workflows: runs-on: ubuntu-latest if: github.event_name == 'release' diff --git a/.github/workflows/pr-update-description.yml b/.github/workflows/pr-update-description.yml new file mode 100644 index 000000000000..71b4ffeda801 --- /dev/null +++ b/.github/workflows/pr-update-description.yml @@ -0,0 +1,39 @@ +name: 'Release PR Description' + +on: + pull_request: + branches: + - master + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + update-pr: + runs-on: ubuntu-latest + if: startsWith(github.head_ref, 'release-') + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.CI_PAT }} + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + node-version: 14.21.3 + cache-modules: true + install: true + + - uses: dtinth/setup-github-actions-caching-for-turbo@v1 + + - name: Build packages + run: yarn build + + - name: Update PR description + uses: ./packages/release-action + with: + action: update-pr-description + env: + GITHUB_TOKEN: ${{ secrets.CI_PAT }} + diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 000000000000..e24ff7d2ebf1 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,26 @@ +tasks: + - init: | + nvm install $(jq -r .engines.node package.json) && + curl https://install.meteor.com/ | sh && + export PATH="$PATH:$HOME/.meteor" && + yarn && + export ROOT_URL=$(gp url 3000) + command: yarn build && yarn dev + +ports: + - port: 3000 + visibility: public + onOpen: open-preview + +github: + prebuilds: + master: true + pullRequests: true + pullRequestsFromForks: true + addCheck: true + addComment: true + addBadge: true + +vscode: + extensions: + - esbenp.prettier-vscode \ No newline at end of file diff --git a/.kodiak.toml b/.kodiak.toml index 884f356a1712..7f89eed8f169 100644 --- a/.kodiak.toml +++ b/.kodiak.toml @@ -5,9 +5,8 @@ version = 1 method = "squash" automerge_label = ["stat: ready to merge", "automerge"] block_on_neutral_required_check_runs = true -blocking_labels = ["stat: needs QA", "Invalid PR Title"] +blocking_labels = ["stat: needs QA", "Invalid PR Title", "do not merge"] prioritize_ready_to_merge = true -merge.do_not_merge=true [merge.message] title = "pull_request_title" diff --git a/README.md b/README.md index a63baba65dd0..64dec811e1ca 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,14 @@ yarn dsv # run only meteor (front and back) with pre-built packages After initialized, you can access the server at http://localhost:3000 +# Gitpod Setup + +1. Click the button below to open this project in Gitpod. + +2. This will open a fully configured workspace in your browser with all the necessary dependencies already installed. + +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/RocketChat/Rocket.Chat) + **Starting Rocket.Chat in microservices mode:** ```bash diff --git a/apps/meteor/.docker/Dockerfile.rhel b/apps/meteor/.docker/Dockerfile.rhel deleted file mode 100644 index dc90b0f88133..000000000000 --- a/apps/meteor/.docker/Dockerfile.rhel +++ /dev/null @@ -1,44 +0,0 @@ -FROM registry.access.redhat.com/ubi8/nodejs-12 - -ENV RC_VERSION 6.6.0-develop - -MAINTAINER buildmaster@rocket.chat - -LABEL name="Rocket.Chat" \ - vendor="Rocket.Chat" \ - version="${RC_VERSION}" \ - release="1" \ - url="https://rocket.chat" \ - summary="The Ultimate Open Source Web Chat Platform" \ - description="The Ultimate Open Source Web Chat Platform" \ - run="docker run -d --name ${NAME} ${IMAGE}" - -USER root -RUN dnf install -y python38 && rm -rf /var/cache /var/log/dnf* /var/log/yum.* -USER default - -RUN set -x \ - && gpg --keyserver keys.openpgp.org --recv-keys 0E163286C20D07B9787EBE9FD7F9D0414FD08104 \ - && curl -SLf "https://releases.rocket.chat/${RC_VERSION}/download" -o rocket.chat.tgz \ - && curl -SLf "https://releases.rocket.chat/${RC_VERSION}/asc" -o rocket.chat.tgz.asc \ - && gpg --verify rocket.chat.tgz.asc \ - && tar -zxf rocket.chat.tgz -C /opt/app-root/src/ \ - && cd /opt/app-root/src/bundle/programs/server \ - && npm install - -COPY licenses /licenses - -VOLUME /opt/app-root/src/uploads - -WORKDIR /opt/app-root/src/bundle - -ENV DEPLOY_METHOD=docker-redhat \ - NODE_ENV=production \ - MONGO_URL=mongodb://mongo:27017/rocketchat \ - HOME=/tmp \ - PORT=3000 \ - ROOT_URL=http://localhost:3000 - -EXPOSE 3000 - -CMD ["node", "main.js"] diff --git a/apps/meteor/.meteor/packages b/apps/meteor/.meteor/packages index e99aa0bdfb67..fd33bef3892f 100644 --- a/apps/meteor/.meteor/packages +++ b/apps/meteor/.meteor/packages @@ -15,34 +15,34 @@ rocketchat:streamer rocketchat:version rocketchat:user-presence -accounts-base@2.2.8 +accounts-base@2.2.9 accounts-facebook@1.3.3 accounts-github@1.5.0 accounts-google@1.4.0 accounts-meteor-developer@1.5.0 -accounts-oauth@1.4.2 -accounts-password@2.3.4 +accounts-oauth@1.4.3 +accounts-password@2.4.0 accounts-twitter@1.5.0 pauli:accounts-linkedin google-oauth@1.4.4 -oauth@2.2.0 +oauth@2.2.1 oauth2@1.3.2 check@1.3.2 -ddp-rate-limiter@1.2.0 +ddp-rate-limiter@1.2.1 rate-limit@1.1.1 email@2.2.5 http@2.0.0 meteor-base@1.5.1 ddp-common@1.4.0 -webapp@1.13.5 +webapp@1.13.6 -mongo@1.16.7 +mongo@1.16.8 reload@1.3.1 -service-configuration@1.3.1 +service-configuration@1.3.2 session@1.2.1 shell-server@0.5.0 @@ -58,15 +58,15 @@ routepolicy@1.1.1 webapp-hashing@1.1.1 facts-base@1.0.1 -tracker@1.3.2 +tracker@1.3.3 reactive-dict@1.3.1 reactive-var@1.0.12 -babel-compiler@7.10.4 +babel-compiler@7.10.5 standard-minifier-css@1.9.2 dynamic-import@0.7.3 -ecmascript@0.16.7 -typescript@4.9.4 +ecmascript@0.16.8 +typescript@4.9.5 autoupdate@1.8.0 diff --git a/apps/meteor/.meteor/release b/apps/meteor/.meteor/release index 6641d0478a10..c500c39d6da2 100644 --- a/apps/meteor/.meteor/release +++ b/apps/meteor/.meteor/release @@ -1 +1 @@ -METEOR@2.13.3 +METEOR@2.14 diff --git a/apps/meteor/.meteor/versions b/apps/meteor/.meteor/versions index 6dab889e3e38..8c135becc110 100644 --- a/apps/meteor/.meteor/versions +++ b/apps/meteor/.meteor/versions @@ -1,18 +1,18 @@ -accounts-base@2.2.8 +accounts-base@2.2.9 accounts-facebook@1.3.3 accounts-github@1.5.0 accounts-google@1.4.0 accounts-meteor-developer@1.5.0 -accounts-oauth@1.4.2 -accounts-password@2.3.4 +accounts-oauth@1.4.3 +accounts-password@2.4.0 accounts-twitter@1.5.0 allow-deny@1.1.1 autoupdate@1.8.0 -babel-compiler@7.10.4 +babel-compiler@7.10.5 babel-runtime@1.5.1 base64@1.0.12 binary-heap@1.0.11 -boilerplate-generator@1.7.1 +boilerplate-generator@1.7.2 caching-compiler@1.2.2 callback-hook@1.5.1 check@1.3.2 @@ -21,21 +21,21 @@ coffeescript-compiler@2.4.1 ddp@1.4.1 ddp-client@2.6.1 ddp-common@1.4.0 -ddp-rate-limiter@1.2.0 -ddp-server@2.6.2 +ddp-rate-limiter@1.2.1 +ddp-server@2.7.0 diff-sequence@1.1.2 dispatch:run-as-user@1.1.1 dynamic-import@0.7.3 -ecmascript@0.16.7 +ecmascript@0.16.8 ecmascript-runtime@0.8.1 ecmascript-runtime-client@0.12.1 ecmascript-runtime-server@0.11.0 ejson@1.1.3 email@2.2.5 es5-shim@4.8.0 -facebook-oauth@1.11.2 +facebook-oauth@1.11.3 facts-base@1.0.1 -fetch@0.1.3 +fetch@0.1.4 geojson-utils@1.0.11 github-oauth@1.4.1 google-oauth@1.4.4 @@ -45,22 +45,22 @@ id-map@1.1.1 inter-process-messaging@0.1.1 kadira:flow-router@2.12.1 localstorage@1.2.0 -logging@1.3.2 -meteor@1.11.3 +logging@1.3.3 +meteor@1.11.4 meteor-base@1.5.1 meteor-developer-oauth@1.3.2 meteorhacks:inject-initial@1.0.5 minifier-css@1.6.4 minimongo@1.9.3 -modern-browsers@0.1.9 -modules@0.19.0 +modern-browsers@0.1.10 +modules@0.20.0 modules-runtime@0.13.1 -mongo@1.16.7 +mongo@1.16.8 mongo-decimal@0.1.3 mongo-dev-server@1.1.0 mongo-id@1.0.8 -npm-mongo@4.16.0 -oauth@2.2.0 +npm-mongo@4.17.2 +oauth@2.2.1 oauth1@1.5.1 oauth2@1.3.2 ordered-dict@1.1.0 @@ -70,7 +70,7 @@ pauli:linkedin-oauth@6.0.0 promise@0.12.2 random@1.2.1 rate-limit@1.1.1 -react-fast-refresh@0.2.7 +react-fast-refresh@0.2.8 reactive-dict@1.3.1 reactive-var@1.0.12 reload@1.3.1 @@ -84,19 +84,19 @@ rocketchat:streamer@1.1.0 rocketchat:user-presence@2.6.3 rocketchat:version@1.0.0 routepolicy@1.1.1 -service-configuration@1.3.1 +service-configuration@1.3.2 session@1.2.1 sha@1.0.9 shell-server@0.5.0 -socket-stream-client@0.5.1 +socket-stream-client@0.5.2 standard-minifier-css@1.9.2 -tracker@1.3.2 +tracker@1.3.3 twitter-oauth@1.3.3 -typescript@4.9.4 +typescript@4.9.5 underscore@1.0.13 url@1.3.2 -webapp@1.13.5 +webapp@1.13.6 webapp-hashing@1.1.1 zodern:caching-minifier@0.5.0 -zodern:standard-minifier-js@5.1.2 -zodern:types@1.0.9 +zodern:standard-minifier-js@5.3.1 +zodern:types@1.0.11 diff --git a/apps/meteor/.scripts/check-i18n.js b/apps/meteor/.scripts/check-i18n.js deleted file mode 100644 index a56980f2406a..000000000000 --- a/apps/meteor/.scripts/check-i18n.js +++ /dev/null @@ -1,109 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const fg = require('fast-glob'); - -const regexVar = /__[a-zA-Z_]+__/g; - -const validateKeys = (json, usedKeys) => - usedKeys - .filter(({ key }) => typeof json[key] !== 'undefined') - .reduce((prev, cur) => { - const { key, replaces } = cur; - - const miss = replaces.filter((replace) => json[key] && json[key].indexOf(replace) === -1); - - if (miss.length > 0) { - prev.push({ key, miss }); - } - - return prev; - }, []); - -const removeMissingKeys = (i18nFiles, usedKeys) => { - i18nFiles.forEach((file) => { - const json = JSON.parse(fs.readFileSync(file, 'utf8')); - if (Object.keys(json).length === 0) { - return; - } - - validateKeys(json, usedKeys).forEach(({ key }) => { - json[key] = null; - }); - - fs.writeFileSync(file, JSON.stringify(json, null, 2)); - }); -}; - -const checkUniqueKeys = (content, json, filename) => { - const matchKeys = content.matchAll(/^\s+"([^"]+)"/gm); - - const allKeys = [...matchKeys]; - - if (allKeys.length !== Object.keys(json).length) { - throw new Error(`Duplicated keys found on file ${filename}`); - } -}; - -const validate = (i18nFiles, usedKeys) => { - const totalErrors = i18nFiles.reduce((errors, file) => { - const content = fs.readFileSync(file, 'utf8'); - const json = JSON.parse(content); - - checkUniqueKeys(content, json, file); - - // console.log('json, usedKeys2', json, usedKeys); - - const result = validateKeys(json, usedKeys); - - if (result.length === 0) { - return errors; - } - - console.log('\n## File', file, `(${result.length} errors)`); - - result.forEach(({ key, miss }) => { - console.log('\n- Key:', key, '\n Missing variables:', miss.join(', ')); - }); - - return errors + result.length; - }, 0); - - if (totalErrors > 0) { - throw new Error(`\n${totalErrors} errors found`); - } -}; - -const checkFiles = async (sourcePath, sourceFile, fix = false) => { - const content = fs.readFileSync(path.join(sourcePath, sourceFile), 'utf8'); - const sourceContent = JSON.parse(content); - - checkUniqueKeys(content, sourceContent, sourceFile); - - const usedKeys = Object.entries(sourceContent).map(([key, value]) => { - const replaces = value.match(regexVar); - return { - key, - replaces, - }; - }); - - const keysWithInterpolation = usedKeys.filter(({ replaces }) => !!replaces); - - const i18nFiles = await fg([`${sourcePath}/**/*.i18n.json`]); - - if (fix) { - return removeMissingKeys(i18nFiles, keysWithInterpolation); - } - - validate(i18nFiles, keysWithInterpolation); -}; - -(async () => { - try { - await checkFiles('./packages/rocketchat-i18n/i18n', 'en.i18n.json', process.argv[2] === '--fix'); - } catch (e) { - console.error(e); - process.exit(1); - } -})(); diff --git a/apps/meteor/.scripts/translation-check.ts b/apps/meteor/.scripts/translation-check.ts new file mode 100644 index 000000000000..11782c8db649 --- /dev/null +++ b/apps/meteor/.scripts/translation-check.ts @@ -0,0 +1,249 @@ +import type { PathLike } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { inspect } from 'node:util'; + +import fg from 'fast-glob'; +import i18next from 'i18next'; +import supportsColor from 'supports-color'; + +const hasDuplicatedKeys = (content: string, json: Record) => { + const matchKeys = content.matchAll(/^\s+"([^"]+)"/gm); + + const allKeys = [...matchKeys]; + + return allKeys.length !== Object.keys(json).length; +}; + +const parseFile = async (path: PathLike) => { + const content = await readFile(path, 'utf-8'); + let json: Record; + try { + json = JSON.parse(content); + } catch (e) { + if (e instanceof SyntaxError) { + const matches = /^Unexpected token .* in JSON at position (\d+)$/.exec(e.message); + + if (matches) { + const [, positionStr] = matches; + const position = parseInt(positionStr, 10); + const line = content.slice(0, position).split('\n').length; + const column = position - content.slice(0, position).lastIndexOf('\n'); + throw new SyntaxError(`Invalid JSON on file ${path}:${line}:${column}`); + } + } + throw new SyntaxError(`Invalid JSON on file ${path}: ${e.message}`); + } + + if (hasDuplicatedKeys(content, json)) { + throw new SyntaxError(`Duplicated keys found on file ${path}`); + } + + return json; +}; + +const insertTranslation = (json: Record, refKey: string, [key, value]: [key: string, value: string]) => { + const entries = Object.entries(json); + + const refIndex = entries.findIndex(([entryKey]) => entryKey === refKey); + + if (refIndex === -1) { + throw new Error(`Reference key ${refKey} not found`); + } + + const movingEntries = entries.slice(refIndex + 1); + + for (const [key] of movingEntries) { + delete json[key]; + } + + json[key] = value; + + for (const [key, value] of movingEntries) { + json[key] = value; + } +}; + +const persistFile = async (path: PathLike, json: Record) => { + const content = JSON.stringify(json, null, 2); + + await writeFile(path, content, 'utf-8'); +}; + +const oldPlaceholderFormat = /__([a-zA-Z_]+)__/g; + +const checkPlaceholdersFormat = async ({ json, path, fix = false }: { json: Record; path: PathLike; fix?: boolean }) => { + const outdatedKeys = Object.entries(json) + .map(([key, value]) => ({ + key, + value, + placeholders: value.match(oldPlaceholderFormat), + })) + .filter((outdatedKey): outdatedKey is { key: string; value: string; placeholders: RegExpMatchArray } => !!outdatedKey.placeholders); + + if (outdatedKeys.length > 0) { + const message = `Outdated placeholder format on file ${path}: ${inspect(outdatedKeys, { colors: !!supportsColor.stdout })}`; + + if (fix) { + console.warn(message); + + for (const { key, value } of outdatedKeys) { + const newValue = value.replace(oldPlaceholderFormat, (_, name) => `{{${name}}}`); + + json[key] = newValue; + } + + await persistFile(path, json); + + return; + } + + throw new Error(message); + } +}; + +export const extractSingularKeys = (json: Record, lng: string) => { + if (!i18next.isInitialized) { + i18next.init({ initImmediate: false }); + } + + const pluralSuffixes = i18next.services.pluralResolver.getSuffixes(lng) as string[]; + + const singularKeys = new Set( + Object.keys(json).map((key) => { + for (const pluralSuffix of pluralSuffixes) { + if (key.endsWith(pluralSuffix)) { + return key.slice(0, -pluralSuffix.length); + } + } + + return key; + }), + ); + + return [singularKeys, pluralSuffixes] as const; +}; + +const checkMissingPlurals = async ({ + json, + path, + lng, + fix = false, +}: { + json: Record; + path: PathLike; + lng: string; + fix?: boolean; +}) => { + const [singularKeys, pluralSuffixes] = extractSingularKeys(json, lng); + + const missingPluralKeys: { singularKey: string; existing: string[]; missing: string[] }[] = []; + + for (const singularKey of singularKeys) { + if (singularKey in json) { + continue; + } + + const pluralKeys = pluralSuffixes.map((suffix) => `${singularKey}${suffix}`); + + const existing = pluralKeys.filter((key) => key in json); + const missing = pluralKeys.filter((key) => !(key in json)); + + if (missing.length > 0) { + missingPluralKeys.push({ singularKey, existing, missing }); + } + } + + if (missingPluralKeys.length > 0) { + const message = `Missing plural keys on file ${path}: ${inspect(missingPluralKeys, { colors: !!supportsColor.stdout })}`; + + if (fix) { + console.warn(message); + + for (const { existing, missing } of missingPluralKeys) { + for (const missingKey of missing) { + const refKey = existing.slice(-1)[0]; + const value = json[refKey]; + insertTranslation(json, refKey, [missingKey, value]); + } + } + + await persistFile(path, json); + + return; + } + + throw new Error(message); + } +}; + +const checkExceedingKeys = async ({ + json, + path, + lng, + sourceJson, + sourceLng, + fix = false, +}: { + json: Record; + path: PathLike; + lng: string; + sourceJson: Record; + sourceLng: string; + fix?: boolean; +}) => { + const [singularKeys] = extractSingularKeys(json, lng); + const [sourceSingularKeys] = extractSingularKeys(sourceJson, sourceLng); + + const exceedingKeys = [...singularKeys].filter((key) => !sourceSingularKeys.has(key)); + + if (exceedingKeys.length > 0) { + const message = `Exceeding keys on file ${path}: ${inspect(exceedingKeys, { colors: !!supportsColor.stdout })}`; + + if (fix) { + for (const key of exceedingKeys) { + delete json[key]; + } + + await persistFile(path, json); + + return; + } + + throw new Error(message); + } +}; + +const checkFiles = async (sourceDirPath: string, sourceLng: string, fix = false) => { + const sourcePath = join(sourceDirPath, `${sourceLng}.i18n.json`); + const sourceJson = await parseFile(sourcePath); + + await checkPlaceholdersFormat({ json: sourceJson, path: sourcePath, fix }); + await checkMissingPlurals({ json: sourceJson, path: sourcePath, lng: sourceLng, fix }); + + const i18nFiles = await fg([join(sourceDirPath, `**/*.i18n.json`), `!${sourcePath}`]); + + const languageFileRegex = /\/([^\/]*?).i18n.json$/; + const translations = await Promise.all( + i18nFiles.map(async (path) => { + const lng = languageFileRegex.exec(path)?.[1]; + if (!lng) { + throw new Error(`Invalid language file path ${path}`); + } + + return { path, json: await parseFile(path), lng }; + }), + ); + + for await (const { path, json, lng } of translations) { + await checkPlaceholdersFormat({ json, path, fix }); + await checkExceedingKeys({ json, path, lng, sourceJson, sourceLng, fix }); + await checkMissingPlurals({ json, path, lng, fix }); + } +}; + +const fix = process.argv[2] === '--fix'; +checkFiles('./packages/rocketchat-i18n/i18n', 'en', fix).catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/meteor/.scripts/translationDiff.js b/apps/meteor/.scripts/translation-diff.ts similarity index 54% rename from apps/meteor/.scripts/translationDiff.js rename to apps/meteor/.scripts/translation-diff.ts index 7c83e33c76ee..0ee7a1c72b9d 100644 --- a/apps/meteor/.scripts/translationDiff.js +++ b/apps/meteor/.scripts/translation-diff.ts @@ -1,18 +1,14 @@ -#!/usr/bin/env node +#!/usr/bin/env ts-node -const fs = require('fs'); -const path = require('path'); -const util = require('util'); - -// Convert fs.readFile into Promise version of same -const readFile = util.promisify(fs.readFile); +import { readFile } from 'fs/promises'; +import path from 'path'; const translationDir = path.resolve(__dirname, '../packages/rocketchat-i18n/i18n/'); -async function translationDiff(source, target) { +async function translationDiff(source: string, target: string) { console.debug('loading translations from', translationDir); - function diffKeys(a, b) { + function diffKeys(a: Record, b: Record) { const diff = {}; Object.keys(a).forEach((key) => { if (!b[key]) { @@ -29,10 +25,9 @@ async function translationDiff(source, target) { return diffKeys(sourceTranslations, targetTranslations); } -console.log('Note: You can set the source and target language of the comparison with env-variables SOURCE/TARGET_LANGUAGE'); -const sourceLang = process.env.SOURCE_LANGUAGE || 'en'; -const targetLang = process.env.TARGET_LANGUAGE || 'de'; +const sourceLang = process.argv[2] || 'en'; +const targetLang = process.argv[3] || 'de'; translationDiff(sourceLang, targetLang).then((diff) => { console.log('Diff between', sourceLang, 'and', targetLang); - console.log(JSON.stringify(diff, '', 2)); + console.log(JSON.stringify(diff, undefined, 2)); }); diff --git a/apps/meteor/.scripts/fix-i18n.js b/apps/meteor/.scripts/translation-fix-order.ts similarity index 84% rename from apps/meteor/.scripts/fix-i18n.js rename to apps/meteor/.scripts/translation-fix-order.ts index f0002c8ca4eb..14eba2e73682 100644 --- a/apps/meteor/.scripts/fix-i18n.js +++ b/apps/meteor/.scripts/translation-fix-order.ts @@ -6,11 +6,11 @@ * - remove all keys not present in source i18n file */ -const fs = require('fs'); +import fs from 'fs'; -const fg = require('fast-glob'); +import fg from 'fast-glob'; -const fixFiles = (path, source, newlineAtEnd = false) => { +const fixFiles = (path: string, source: string, newlineAtEnd = false) => { const sourceFile = JSON.parse(fs.readFileSync(`${path}${source}`, 'utf8')); const sourceKeys = Object.keys(sourceFile); diff --git a/apps/meteor/CHANGELOG.md b/apps/meteor/CHANGELOG.md index 125ba1541bf2..7cd9d4f4449a 100644 --- a/apps/meteor/CHANGELOG.md +++ b/apps/meteor/CHANGELOG.md @@ -1,5 +1,63 @@ # @rocket.chat/meteor +## 6.5.3 + +### Patch Changes + +- b1e72a84d9: Fix user being logged out after using 2FA +- de2658e874: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- 2a04cc850b: fix: multiple indexes creation error during 304 migration + - @rocket.chat/core-typings@6.5.3 + - @rocket.chat/rest-typings@6.5.3 + - @rocket.chat/api-client@0.1.21 + - @rocket.chat/license@0.1.3 + - @rocket.chat/omnichannel-services@0.1.3 + - @rocket.chat/pdf-worker@0.0.27 + - @rocket.chat/presence@0.1.3 + - @rocket.chat/core-services@0.3.3 + - @rocket.chat/cron@0.0.23 + - @rocket.chat/gazzodown@3.0.3 + - @rocket.chat/model-typings@0.2.3 + - @rocket.chat/ui-contexts@3.0.3 + - @rocket.chat/server-cloud-communication@0.0.1 + - @rocket.chat/fuselage-ui-kit@3.0.3 + - @rocket.chat/models@0.0.27 + - @rocket.chat/ui-theming@0.1.1 + - @rocket.chat/ui-client@3.0.3 + - @rocket.chat/ui-video-conf@3.0.3 + - @rocket.chat/web-ui-registration@3.0.3 + - @rocket.chat/instance-status@0.0.27 + +## 6.5.2 + +### Patch Changes + +- a075950e23: Bump @rocket.chat/meteor version. +- Bump @rocket.chat/meteor version. +- 84c4b0709e: Fixed conversations in queue being limited to 50 items +- 886d92009e: Fix wrong value used for Workspace Registration + - @rocket.chat/core-typings@6.5.2 + - @rocket.chat/rest-typings@6.5.2 + - @rocket.chat/api-client@0.1.20 + - @rocket.chat/license@0.1.2 + - @rocket.chat/omnichannel-services@0.1.2 + - @rocket.chat/pdf-worker@0.0.26 + - @rocket.chat/presence@0.1.2 + - @rocket.chat/core-services@0.3.2 + - @rocket.chat/cron@0.0.22 + - @rocket.chat/gazzodown@3.0.2 + - @rocket.chat/model-typings@0.2.2 + - @rocket.chat/ui-contexts@3.0.2 + - @rocket.chat/server-cloud-communication@0.0.1 + - @rocket.chat/fuselage-ui-kit@3.0.2 + - @rocket.chat/models@0.0.26 + - @rocket.chat/ui-theming@0.1.1 + - @rocket.chat/ui-client@3.0.2 + - @rocket.chat/ui-video-conf@3.0.2 + - @rocket.chat/web-ui-registration@3.0.2 + - @rocket.chat/instance-status@0.0.26 + ## 6.5.1 ### Patch Changes diff --git a/apps/meteor/app/2fa/client/TOTPCrowd.js b/apps/meteor/app/2fa/client/TOTPCrowd.js deleted file mode 100644 index 6b4e55a85211..000000000000 --- a/apps/meteor/app/2fa/client/TOTPCrowd.js +++ /dev/null @@ -1,38 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -import '../../crowd/client/index'; -import { overrideLoginMethod } from '../../../client/lib/2fa/overrideLoginMethod'; -import { reportError } from '../../../client/lib/2fa/utils'; - -Meteor.loginWithCrowdAndTOTP = function (username, password, code, callback) { - const loginRequest = { - crowd: true, - username, - crowdPassword: password, - }; - - Accounts.callLoginMethod({ - methodArguments: [ - { - totp: { - login: loginRequest, - code, - }, - }, - ], - userCallback(error) { - if (error) { - reportError(error, callback); - } else { - callback && callback(); - } - }, - }); -}; - -const { loginWithCrowd } = Meteor; - -Meteor.loginWithCrowd = function (username, password, callback) { - overrideLoginMethod(loginWithCrowd, [username, password], callback, Meteor.loginWithCrowdAndTOTP); -}; diff --git a/apps/meteor/app/2fa/client/TOTPGoogle.js b/apps/meteor/app/2fa/client/TOTPGoogle.js deleted file mode 100644 index bb1e509a46d7..000000000000 --- a/apps/meteor/app/2fa/client/TOTPGoogle.js +++ /dev/null @@ -1,39 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Google } from 'meteor/google-oauth'; -import { Meteor } from 'meteor/meteor'; - -import { overrideLoginMethod } from '../../../client/lib/2fa/overrideLoginMethod'; - -const loginWithGoogleAndTOTP = function (options, code, callback) { - // support a callback without options - if (!callback && typeof options === 'function') { - callback = options; - options = null; - } - - if (Meteor.isCordova && Google.signIn) { - // After 20 April 2017, Google OAuth login will no longer work from - // a WebView, so Cordova apps must use Google Sign-In instead. - // https://github.com/meteor/meteor/issues/8253 - Google.signIn(options, callback); - return; - } // Use Google's domain-specific login page if we want to restrict creation to - // a particular email domain. (Don't use it if restrictCreationByEmailDomain - // is a function.) Note that all this does is change Google's UI --- - // accounts-base/accounts_server.js still checks server-side that the server - // has the proper email address after the OAuth conversation. - - if (typeof Accounts._options.restrictCreationByEmailDomain === 'string') { - options = Object.assign({}, options || {}); - options.loginUrlParameters = Object.assign({}, options.loginUrlParameters || {}); - options.loginUrlParameters.hd = Accounts._options.restrictCreationByEmailDomain; - } - - const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback, code); - Google.requestCredential(options, credentialRequestCompleteCallback); -}; - -const { loginWithGoogle } = Meteor; -Meteor.loginWithGoogle = function (options, cb) { - overrideLoginMethod(loginWithGoogle, [options], cb, loginWithGoogleAndTOTP); -}; diff --git a/apps/meteor/app/2fa/client/TOTPLDAP.js b/apps/meteor/app/2fa/client/TOTPLDAP.js deleted file mode 100644 index f3b833d04a72..000000000000 --- a/apps/meteor/app/2fa/client/TOTPLDAP.js +++ /dev/null @@ -1,54 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -import '../../../client/startup/ldap'; -import { overrideLoginMethod } from '../../../client/lib/2fa/overrideLoginMethod'; -import { reportError } from '../../../client/lib/2fa/utils'; - -Meteor.loginWithLDAPAndTOTP = function (...args) { - // Pull username and password - const username = args.shift(); - const ldapPass = args.shift(); - - // Check if last argument is a function. if it is, pop it off and set callback to it - const callback = typeof args[args.length - 1] === 'function' ? args.pop() : null; - // The last argument before the callback is the totp code - const code = args.pop(); - - // if args still holds options item, grab it - const ldapOptions = args.length > 0 ? args.shift() : {}; - - // Set up loginRequest object - const loginRequest = { - ldap: true, - username, - ldapPass, - ldapOptions, - }; - - Accounts.callLoginMethod({ - methodArguments: [ - { - totp: { - login: loginRequest, - code, - }, - }, - ], - userCallback(error) { - if (error) { - reportError(error, callback); - } else { - callback && callback(); - } - }, - }); -}; - -const { loginWithLDAP } = Meteor; - -Meteor.loginWithLDAP = function (...args) { - const callback = typeof args[args.length - 1] === 'function' ? args.pop() : null; - - overrideLoginMethod(loginWithLDAP, args, callback, Meteor.loginWithLDAPAndTOTP, args[0]); -}; diff --git a/apps/meteor/app/2fa/client/TOTPOAuth.js b/apps/meteor/app/2fa/client/TOTPOAuth.js deleted file mode 100644 index 47c5e70998b6..000000000000 --- a/apps/meteor/app/2fa/client/TOTPOAuth.js +++ /dev/null @@ -1,142 +0,0 @@ -import { capitalize } from '@rocket.chat/string-helpers'; -import { Accounts } from 'meteor/accounts-base'; -import { Facebook } from 'meteor/facebook-oauth'; -import { Github } from 'meteor/github-oauth'; -import { Meteor } from 'meteor/meteor'; -import { MeteorDeveloperAccounts } from 'meteor/meteor-developer-oauth'; -import { OAuth } from 'meteor/oauth'; -import { Linkedin } from 'meteor/pauli:linkedin-oauth'; -import { Twitter } from 'meteor/twitter-oauth'; - -import { overrideLoginMethod } from '../../../client/lib/2fa/overrideLoginMethod'; -import { process2faReturn } from '../../../client/lib/2fa/process2faReturn'; -import { convertError } from '../../../client/lib/2fa/utils'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; - -let lastCredentialToken = null; -let lastCredentialSecret = null; - -Accounts.oauth.tryLoginAfterPopupClosed = function (credentialToken, callback, totpCode, credentialSecret = null) { - credentialSecret = credentialSecret || OAuth._retrieveCredentialSecret(credentialToken) || null; - const methodArgument = { - oauth: { - credentialToken, - credentialSecret, - }, - }; - - lastCredentialToken = credentialToken; - lastCredentialSecret = credentialSecret; - - if (totpCode && typeof totpCode === 'string') { - methodArgument.totp = { - code: totpCode, - }; - } - - Accounts.callLoginMethod({ - methodArguments: [methodArgument], - userCallback: - callback && - function (err) { - callback(convertError(err)); - }, - }); -}; - -Accounts.oauth.credentialRequestCompleteHandler = function (callback, totpCode) { - return function (credentialTokenOrError) { - if (credentialTokenOrError && credentialTokenOrError instanceof Error) { - callback && callback(credentialTokenOrError); - } else { - Accounts.oauth.tryLoginAfterPopupClosed(credentialTokenOrError, callback, totpCode); - } - }; -}; - -const createOAuthTotpLoginMethod = (credentialProvider) => (options, code, callback) => { - // support a callback without options - if (!callback && typeof options === 'function') { - callback = options; - options = null; - } - - if (lastCredentialToken && lastCredentialSecret) { - Accounts.oauth.tryLoginAfterPopupClosed(lastCredentialToken, callback, code, lastCredentialSecret); - } else { - const provider = (credentialProvider && credentialProvider()) || this; - const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback, code); - provider.requestCredential(options, credentialRequestCompleteCallback); - } - - lastCredentialToken = null; - lastCredentialSecret = null; -}; - -const loginWithOAuthTokenAndTOTP = createOAuthTotpLoginMethod(); - -const loginWithFacebookAndTOTP = createOAuthTotpLoginMethod(() => Facebook); -const { loginWithFacebook } = Meteor; -Meteor.loginWithFacebook = function (options, cb) { - overrideLoginMethod(loginWithFacebook, [options], cb, loginWithFacebookAndTOTP); -}; - -const loginWithGithubAndTOTP = createOAuthTotpLoginMethod(() => Github); -const { loginWithGithub } = Meteor; -Meteor.loginWithGithub = function (options, cb) { - overrideLoginMethod(loginWithGithub, [options], cb, loginWithGithubAndTOTP); -}; - -const loginWithMeteorDeveloperAccountAndTOTP = createOAuthTotpLoginMethod(() => MeteorDeveloperAccounts); -const { loginWithMeteorDeveloperAccount } = Meteor; -Meteor.loginWithMeteorDeveloperAccount = function (options, cb) { - overrideLoginMethod(loginWithMeteorDeveloperAccount, [options], cb, loginWithMeteorDeveloperAccountAndTOTP); -}; - -const loginWithTwitterAndTOTP = createOAuthTotpLoginMethod(() => Twitter); -const { loginWithTwitter } = Meteor; -Meteor.loginWithTwitter = function (options, cb) { - overrideLoginMethod(loginWithTwitter, [options], cb, loginWithTwitterAndTOTP); -}; - -const loginWithLinkedinAndTOTP = createOAuthTotpLoginMethod(() => Linkedin); -const { loginWithLinkedin } = Meteor; -Meteor.loginWithLinkedin = function (options, cb) { - overrideLoginMethod(loginWithLinkedin, [options], cb, loginWithLinkedinAndTOTP); -}; - -Accounts.onPageLoadLogin(async (loginAttempt) => { - if (loginAttempt?.error?.error !== 'totp-required') { - return; - } - - const { methodArguments } = loginAttempt; - if (!methodArguments?.length) { - return; - } - - const oAuthArgs = methodArguments.find((arg) => arg.oauth); - const { credentialToken, credentialSecret } = oAuthArgs.oauth; - const cb = loginAttempt.userCallback; - - await process2faReturn({ - error: loginAttempt.error, - originalCallback: cb, - onCode: (code) => { - Accounts.oauth.tryLoginAfterPopupClosed(credentialToken, cb, code, credentialSecret); - }, - }); -}); - -const oldConfigureLogin = CustomOAuth.prototype.configureLogin; -CustomOAuth.prototype.configureLogin = function (...args) { - const loginWithService = `loginWith${capitalize(String(this.name || ''))}`; - - oldConfigureLogin.apply(this, args); - - const oldMethod = Meteor[loginWithService]; - - Meteor[loginWithService] = function (options, cb) { - overrideLoginMethod(oldMethod, [options], cb, loginWithOAuthTokenAndTOTP); - }; -}; diff --git a/apps/meteor/app/2fa/client/TOTPPassword.js b/apps/meteor/app/2fa/client/TOTPPassword.js deleted file mode 100644 index 20269744fa77..000000000000 --- a/apps/meteor/app/2fa/client/TOTPPassword.js +++ /dev/null @@ -1,71 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -import { process2faReturn } from '../../../client/lib/2fa/process2faReturn'; -import { isTotpInvalidError, isTotpMaxAttemptsError, reportError } from '../../../client/lib/2fa/utils'; -import { dispatchToastMessage } from '../../../client/lib/toast'; -import { t } from '../../utils/lib/i18n'; - -Meteor.loginWithPasswordAndTOTP = function (selector, password, code, callback) { - if (typeof selector === 'string') { - if (selector.indexOf('@') === -1) { - selector = { username: selector }; - } else { - selector = { email: selector }; - } - } - - Accounts.callLoginMethod({ - methodArguments: [ - { - totp: { - login: { - user: selector, - password: Accounts._hashPassword(password), - }, - code, - }, - }, - ], - userCallback(error) { - if (error) { - reportError(error, callback); - } else { - callback && callback(); - } - }, - }); -}; - -const { loginWithPassword } = Meteor; - -Meteor.loginWithPassword = function (email, password, cb) { - loginWithPassword(email, password, async (error) => { - await process2faReturn({ - error, - originalCallback: cb, - emailOrUsername: email, - onCode: (code) => { - Meteor.loginWithPasswordAndTOTP(email, password, code, (error) => { - if (isTotpMaxAttemptsError(error)) { - dispatchToastMessage({ - type: 'error', - message: t('totp-max-attempts'), - }); - cb(); - return; - } - if (isTotpInvalidError(error)) { - dispatchToastMessage({ - type: 'error', - message: t('Invalid_two_factor_code'), - }); - cb(); - return; - } - cb(error); - }); - }, - }); - }); -}; diff --git a/apps/meteor/app/2fa/client/TOTPSaml.js b/apps/meteor/app/2fa/client/TOTPSaml.js deleted file mode 100644 index 7d9ec34541df..000000000000 --- a/apps/meteor/app/2fa/client/TOTPSaml.js +++ /dev/null @@ -1,35 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -import '../../meteor-accounts-saml/client/saml_client'; -import { overrideLoginMethod } from '../../../client/lib/2fa/overrideLoginMethod'; -import { reportError } from '../../../client/lib/2fa/utils'; - -Meteor.loginWithSamlTokenAndTOTP = function (credentialToken, code, callback) { - Accounts.callLoginMethod({ - methodArguments: [ - { - totp: { - login: { - saml: true, - credentialToken, - }, - code, - }, - }, - ], - userCallback(error) { - if (error) { - reportError(error, callback); - } else { - callback && callback(); - } - }, - }); -}; - -const { loginWithSamlToken } = Meteor; - -Meteor.loginWithSamlToken = function (options, callback) { - overrideLoginMethod(loginWithSamlToken, [options], callback, Meteor.loginWithSamlTokenAndTOTP); -}; diff --git a/apps/meteor/app/2fa/client/index.ts b/apps/meteor/app/2fa/client/index.ts deleted file mode 100644 index 1e8f20eb784c..000000000000 --- a/apps/meteor/app/2fa/client/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import './TOTPPassword'; -import './TOTPOAuth'; -import './TOTPGoogle'; -import './TOTPSaml'; -import './TOTPLDAP'; -import './TOTPCrowd'; -import './overrideMeteorCall'; diff --git a/apps/meteor/app/2fa/client/overrideMeteorCall.ts b/apps/meteor/app/2fa/client/overrideMeteorCall.ts deleted file mode 100644 index e373c8a421be..000000000000 --- a/apps/meteor/app/2fa/client/overrideMeteorCall.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { process2faReturn, process2faAsyncReturn } from '../../../client/lib/2fa/process2faReturn'; -import { isTotpInvalidError } from '../../../client/lib/2fa/utils'; -import { t } from '../../utils/lib/i18n'; - -const { call, callAsync } = Meteor; - -type Callback = { - (error: unknown): void; - (error: unknown, result: unknown): void; -}; - -const callWithTotp = - (methodName: string, args: unknown[], callback: Callback) => - (twoFactorCode: string, twoFactorMethod: string): unknown => - call(methodName, ...args, { twoFactorCode, twoFactorMethod }, (error: unknown, result: unknown): void => { - if (isTotpInvalidError(error)) { - callback(new Error(twoFactorMethod === 'password' ? t('Invalid_password') : t('Invalid_two_factor_code'))); - return; - } - - callback(error, result); - }); - -const callWithoutTotp = (methodName: string, args: unknown[], callback: Callback) => (): unknown => - call(methodName, ...args, async (error: unknown, result: unknown): Promise => { - await process2faReturn({ - error, - result, - onCode: callWithTotp(methodName, args, callback), - originalCallback: callback, - emailOrUsername: undefined, - }); - }); - -Meteor.call = function (methodName: string, ...args: unknown[]): unknown { - const callback = args.length > 0 && typeof args[args.length - 1] === 'function' ? (args.pop() as Callback) : (): void => undefined; - - return callWithoutTotp(methodName, args, callback)(); -}; - -Meteor.callAsync = async function _callAsyncWithTotp(methodName: string, ...args: unknown[]): Promise { - try { - return await callAsync(methodName, ...args); - } catch (error: unknown) { - return process2faAsyncReturn({ - error, - onCode: (twoFactorCode, twoFactorMethod) => Meteor.callAsync(methodName, ...args, { twoFactorCode, twoFactorMethod }), - emailOrUsername: undefined, - }); - } -}; diff --git a/apps/meteor/app/2fa/server/definitions/MeteorUser.d.ts b/apps/meteor/app/2fa/server/definitions/MeteorUser.d.ts new file mode 100644 index 000000000000..5c7d97a21230 --- /dev/null +++ b/apps/meteor/app/2fa/server/definitions/MeteorUser.d.ts @@ -0,0 +1,12 @@ +declare module 'meteor/meteor' { + namespace Meteor { + interface UserServices { + totp?: { + enabled: boolean; + hashedBackup: string[]; + secret: string; + tempSecret?: string; + }; + } + } +} diff --git a/apps/meteor/app/2fa/server/methods/disable.ts b/apps/meteor/app/2fa/server/methods/disable.ts index 8768f86c0424..0927b3f854ac 100644 --- a/apps/meteor/app/2fa/server/methods/disable.ts +++ b/apps/meteor/app/2fa/server/methods/disable.ts @@ -26,6 +26,10 @@ Meteor.methods({ }); } + if (!user.services?.totp) { + return false; + } + const verified = await TOTP.verify({ secret: user.services.totp.secret, token: code, diff --git a/apps/meteor/app/2fa/server/methods/validateTempToken.ts b/apps/meteor/app/2fa/server/methods/validateTempToken.ts index 5931d0a8e80d..e1804930a48c 100644 --- a/apps/meteor/app/2fa/server/methods/validateTempToken.ts +++ b/apps/meteor/app/2fa/server/methods/validateTempToken.ts @@ -33,12 +33,24 @@ Meteor.methods({ secret: user.services.totp.tempSecret, token: userToken, }); + if (!verified) { + throw new Meteor.Error('invalid-totp'); + } + + const { codes, hashedCodes } = TOTP.generateCodes(); - if (verified) { - const { codes, hashedCodes } = TOTP.generateCodes(); + await Users.enable2FAAndSetSecretAndCodesByUserId(userId, user.services.totp.tempSecret, hashedCodes); - await Users.enable2FAAndSetSecretAndCodesByUserId(userId, user.services.totp.tempSecret, hashedCodes); - return { codes }; + // Once the TOTP is validated we logout all other clients + const { 'x-auth-token': xAuthToken } = this.connection?.httpHeaders ?? {}; + if (xAuthToken) { + const hashedToken = Accounts._hashLoginToken(xAuthToken); + + if (!(await Users.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { + throw new Meteor.Error('error-logging-out-other-clients', 'Error logging out other clients'); + } } + + return { codes }; }, }); diff --git a/apps/meteor/app/api/server/api.ts b/apps/meteor/app/api/server/api.ts index d4e8377a4dd7..08e1ef17e348 100644 --- a/apps/meteor/app/api/server/api.ts +++ b/apps/meteor/app/api/server/api.ts @@ -108,6 +108,22 @@ const getRequestIP = (req: Request): string | null => { return forwardedFor[forwardedFor.length - httpForwardedCount]; }; +const generateConnection = ( + ipAddress: string, + httpHeaders: Record, +): { + id: string; + close: () => void; + clientAddress: string; + httpHeaders: Record; +} => ({ + id: Random.id(), + // eslint-disable-next-line @typescript-eslint/no-empty-function + close() {}, + httpHeaders, + clientAddress: ipAddress, +}); + let prometheusAPIUserAgent = false; export class APIClass extends Restivus { @@ -322,7 +338,7 @@ export class APIClass extends Restivus { } rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.increment(objectForRateLimitMatch); - const attemptResult = rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.check(objectForRateLimitMatch); + const attemptResult = await rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.check(objectForRateLimitMatch); const timeToResetAttempsInSeconds = Math.ceil(attemptResult.timeToReset / 1000); response.setHeader('X-RateLimit-Limit', rateLimiterDictionary[objectForRateLimitMatch.route].options.numRequestsAllowed); response.setHeader('X-RateLimit-Remaining', attemptResult.numInvocationsLeft); @@ -569,14 +585,7 @@ export class APIClass extends Restivus { let result; - const connection = { - id: Random.id(), - // eslint-disable-next-line @typescript-eslint/no-empty-function - close() {}, - token: this.token, - httpHeaders: this.request.headers, - clientAddress: this.requestIp, - }; + const connection = { ...generateConnection(this.requestIp, this.request.headers), token: this.token }; try { if (options.deprecationVersion) { @@ -761,12 +770,7 @@ export class APIClass extends Restivus { const args = loginCompatibility(this.bodyParams, request); const invocation = new DDPCommon.MethodInvocation({ - connection: { - // eslint-disable-next-line @typescript-eslint/no-empty-function - close() {}, - httpHeaders: this.request.headers, - clientAddress: getRequestIP(request) || '', - }, + connection: generateConnection(getRequestIP(request) || '', this.request.headers), }); let auth; diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 75fc98e69c4d..98fc278594ae 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -1,7 +1,7 @@ import { Message } from '@rocket.chat/core-services'; import type { IMessage } from '@rocket.chat/core-typings'; import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models'; -import { isChatReportMessageProps } from '@rocket.chat/rest-typings'; +import { isChatReportMessageProps, isChatGetURLPreviewProps } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -15,6 +15,7 @@ import { deleteMessageValidatingPermission } from '../../../lib/server/functions import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage'; +import { OEmbed } from '../../../oembed/server/server'; import { executeSetReaction } from '../../../reactions/server/setReaction'; import { settings } from '../../../settings/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; @@ -822,3 +823,22 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'chat.getURLPreview', + { authRequired: true, validateParams: isChatGetURLPreviewProps }, + { + async get() { + const { roomId, url } = this.queryParams; + + if (!(await canAccessRoomIdAsync(roomId, this.userId))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + const { urlPreview } = await OEmbed.parseUrl(url); + urlPreview.ignoreParse = true; + + return API.v1.success({ urlPreview }); + }, + }, +); diff --git a/apps/meteor/app/api/server/v1/ldap.ts b/apps/meteor/app/api/server/v1/ldap.ts index f0d3a52b504f..9a057c9b0afc 100644 --- a/apps/meteor/app/api/server/v1/ldap.ts +++ b/apps/meteor/app/api/server/v1/ldap.ts @@ -31,7 +31,7 @@ API.v1.addRoute( } return API.v1.success({ - message: 'Connection_success' as const, + message: 'LDAP_Connection_successful' as const, }); }, }, diff --git a/apps/meteor/app/api/server/v1/moderation.ts b/apps/meteor/app/api/server/v1/moderation.ts index fe31487bdc47..d04259b123e2 100644 --- a/apps/meteor/app/api/server/v1/moderation.ts +++ b/apps/meteor/app/api/server/v1/moderation.ts @@ -1,10 +1,10 @@ -import type { IModerationReport, IUser } from '@rocket.chat/core-typings'; +import type { IModerationReport, IUser, IUserEmail } from '@rocket.chat/core-typings'; import { ModerationReports, Users } from '@rocket.chat/models'; import { isReportHistoryProps, isArchiveReportProps, isReportInfoParams, - isReportMessageHistoryParams, + isGetUserReportsParams, isModerationReportUserPost, isModerationDeleteMsgHistoryParams, isReportsByMsgIdParams, @@ -51,7 +51,7 @@ API.v1.addRoute( }); } - const total = await ModerationReports.countMessageReportsInRange(latest, oldest, escapedSelector); + const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector, true); return API.v1.success({ reports, @@ -63,11 +63,60 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'moderation.userReports', + { + authRequired: true, + validateParams: isReportHistoryProps, + permissionsRequired: ['view-moderation-console'], + }, + { + async get() { + const { latest: _latest, oldest: _oldest, selector = '' } = this.queryParams; + + const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); + + const { sort } = await this.parseJsonQuery(); + + const latest = _latest ? new Date(_latest) : new Date(); + + const oldest = _oldest ? new Date(_oldest) : new Date(0); + + const escapedSelector = escapeRegExp(selector); + + const reports = await ModerationReports.findUserReports(latest, oldest, escapedSelector, { + offset, + count, + sort, + }).toArray(); + + if (reports.length === 0) { + return API.v1.success({ + reports, + count: 0, + offset, + total: 0, + }); + } + + const total = await ModerationReports.getTotalUniqueReportedUsers(latest, oldest, escapedSelector); + + const result = { + reports, + count: reports.length, + offset, + total, + }; + return API.v1.success(result); + }, + }, +); + API.v1.addRoute( 'moderation.user.reportedMessages', { authRequired: true, - validateParams: isReportMessageHistoryParams, + validateParams: isGetUserReportsParams, permissionsRequired: ['view-moderation-console'], }, { @@ -113,6 +162,64 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'moderation.user.reportsByUserId', + { + authRequired: true, + validateParams: isGetUserReportsParams, + permissionsRequired: ['view-moderation-console'], + }, + { + async get() { + const { userId, selector = '' } = this.queryParams; + const { sort } = await this.parseJsonQuery(); + const { count = 50, offset = 0 } = await getPaginationItems(this.queryParams); + + const user = await Users.findOneById(userId, { + projection: { + _id: 1, + username: 1, + name: 1, + avatarETag: 1, + active: 1, + roles: 1, + emails: 1, + createdAt: 1, + }, + }); + + const escapedSelector = escapeRegExp(selector); + const { cursor, totalCount } = ModerationReports.findUserReportsByReportedUserId(userId, escapedSelector, { + offset, + count, + sort, + }); + + const [reports, total] = await Promise.all([cursor.toArray(), totalCount]); + + const emailSet = new Map(); + + reports.forEach((report) => { + const email = report.reportedUser?.emails?.[0]; + if (email) { + emailSet.set(email.address, email); + } + }); + if (user) { + user.emails = Array.from(emailSet.values()); + } + + return API.v1.success({ + user, + reports, + count: reports.length, + total, + offset, + }); + }, + }, +); + API.v1.addRoute( 'moderation.user.deleteReportedMessages', { @@ -196,6 +303,33 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'moderation.dismissUserReports', + { + authRequired: true, + validateParams: isArchiveReportProps, + permissionsRequired: ['manage-moderation-actions'], + }, + { + async post() { + const { userId, reason, action: actionParam } = this.bodyParams; + + if (!userId) { + return API.v1.failure('error-user-id-param-not-provided'); + } + + const sanitizedReason: string = reason ?? 'No reason provided'; + const action: string = actionParam ?? 'None'; + + const { userId: moderatorId } = this; + + await ModerationReports.hideUserReportsByUserId(userId, moderatorId, sanitizedReason, action); + + return API.v1.success(); + }, + }, +); + API.v1.addRoute( 'moderation.reports', { diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts index 5910d47e4c76..a2c29f85db40 100644 --- a/apps/meteor/app/api/server/v1/push.ts +++ b/apps/meteor/app/api/server/v1/push.ts @@ -3,6 +3,7 @@ import { Random } from '@rocket.chat/random'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { executePushTest } from '../../../../server/lib/pushConfig'; import { canAccessRoomAsync } from '../../../authorization/server/functions/canAccessRoom'; import PushNotification from '../../../push-notifications/server/lib/PushNotification'; import { settings } from '../../../settings/server'; @@ -126,3 +127,27 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'push.test', + { + authRequired: true, + rateLimiterOptions: { + numRequestsAllowed: 1, + intervalTimeInMS: 1000, + }, + permissionsRequired: ['test-push-notifications'], + }, + { + async post() { + if (settings.get('Push_enable') !== true) { + throw new Meteor.Error('error-push-disabled', 'Push is disabled', { + method: 'push_test', + }); + } + + const tokensCount = await executePushTest(this.userId, this.user.username); + return API.v1.success({ tokensCount }); + }, + }, +); diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index cbaff50729ff..011988f5ba2e 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -1,14 +1,14 @@ -import type { ISetting, ISettingColor } from '@rocket.chat/core-typings'; +import type { + FacebookOAuthConfiguration, + ISetting, + ISettingColor, + TwitterOAuthConfiguration, + OAuthConfiguration, +} from '@rocket.chat/core-typings'; import { isSettingAction, isSettingColor } from '@rocket.chat/core-typings'; -import { Settings } from '@rocket.chat/models'; -import { - isOauthCustomConfiguration, - isSettingsUpdatePropDefault, - isSettingsUpdatePropsActions, - isSettingsUpdatePropsColor, -} from '@rocket.chat/rest-typings'; +import { LoginServiceConfiguration as LoginServiceConfigurationModel, Settings } from '@rocket.chat/models'; +import { isSettingsUpdatePropDefault, isSettingsUpdatePropsActions, isSettingsUpdatePropsColor } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import type { FindOptions } from 'mongodb'; import _ from 'underscore'; @@ -71,22 +71,25 @@ API.v1.addRoute( { authRequired: false }, { async get() { - const oAuthServicesEnabled = await ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetchAsync(); + const oAuthServicesEnabled = await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(); return API.v1.success({ services: oAuthServicesEnabled.map((service) => { - if (!isOauthCustomConfiguration(service)) { + if (!service) { return service; } - if (service.custom || (service.service && ['saml', 'cas', 'wordpress'].includes(service.service))) { + if ((service as OAuthConfiguration).custom || (service.service && ['saml', 'cas', 'wordpress'].includes(service.service))) { return { ...service }; } return { _id: service._id, name: service.service, - clientId: service.appId || service.clientId || service.consumerKey, + clientId: + (service as FacebookOAuthConfiguration).appId || + (service as OAuthConfiguration).clientId || + (service as TwitterOAuthConfiguration).consumerKey, buttonLabelText: service.buttonLabelText || '', buttonColor: service.buttonColor || '', buttonLabelColor: service.buttonLabelColor || '', @@ -215,7 +218,7 @@ API.v1.addRoute( { async get() { return API.v1.success({ - configurations: await ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetchAsync(), + configurations: await LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(), }); }, }, diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index b23d41255c3b..10ea2f0b5ac2 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -1,4 +1,4 @@ -import { Team, api } from '@rocket.chat/core-services'; +import { MeteorError, Team, api } from '@rocket.chat/core-services'; import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings'; import { Users, Subscriptions } from '@rocket.chat/models'; import { @@ -792,8 +792,23 @@ API.v1.addRoute( { authRequired: true }, { async post() { + const hasUnverifiedEmail = this.user.emails?.some((email) => !email.verified); + if (hasUnverifiedEmail) { + throw new MeteorError('error-invalid-user', 'You need to verify your emails before setting up 2FA'); + } + await Users.enableEmail2FAByUserId(this.userId); + // When 2FA is enable we logout all other clients + const xAuthToken = this.request.headers['x-auth-token'] as string; + if (xAuthToken) { + const hashedToken = Accounts._hashLoginToken(xAuthToken); + + if (!(await Users.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { + throw new MeteorError('error-logging-out-other-clients', 'Error logging out other clients'); + } + } + return API.v1.success(); }, }, diff --git a/apps/meteor/app/apple/client/index.ts b/apps/meteor/app/apple/client/index.ts index 3e4d15a67fe1..2c59dbe5b3d4 100644 --- a/apps/meteor/app/apple/client/index.ts +++ b/apps/meteor/app/apple/client/index.ts @@ -1,4 +1,4 @@ -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { config } from '../lib/config'; new CustomOAuth('apple', config); diff --git a/apps/meteor/app/apple/server/appleOauthRegisterService.ts b/apps/meteor/app/apple/server/appleOauthRegisterService.ts index e19564542e15..b9558fa701f7 100644 --- a/apps/meteor/app/apple/server/appleOauthRegisterService.ts +++ b/apps/meteor/app/apple/server/appleOauthRegisterService.ts @@ -70,7 +70,7 @@ settings.watchMultiple( secret, enabled: settings.get('Accounts_OAuth_Apple'), loginStyle: 'popup', - clientId, + clientId: clientId as string, buttonColor: '#000', buttonLabelColor: '#FFF', }, diff --git a/apps/meteor/app/autotranslate/server/autotranslate.ts b/apps/meteor/app/autotranslate/server/autotranslate.ts index 6cac8028e1f2..7a9eb8780a2d 100644 --- a/apps/meteor/app/autotranslate/server/autotranslate.ts +++ b/apps/meteor/app/autotranslate/server/autotranslate.ts @@ -1,4 +1,3 @@ -import { api } from '@rocket.chat/core-services'; import type { IMessage, IRoom, @@ -16,7 +15,7 @@ import _ from 'underscore'; import { callbacks } from '../../../lib/callbacks'; import { isTruthy } from '../../../lib/isTruthy'; -import { broadcastMessageSentEvent } from '../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../server/modules/watchers/lib/messages'; import { Markdown } from '../../markdown/server'; import { settings } from '../../settings/server'; @@ -333,9 +332,8 @@ export abstract class AutoTranslate { } private notifyTranslatedMessage(messageId: string): void { - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: messageId, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } diff --git a/apps/meteor/app/cas/client/cas_client.ts b/apps/meteor/app/cas/client/cas_client.ts deleted file mode 100644 index ea4b3047f6bf..000000000000 --- a/apps/meteor/app/cas/client/cas_client.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Random } from '@rocket.chat/random'; -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -import { settings } from '../../settings/client'; - -const openCenteredPopup = (url: string, width: number, height: number) => { - const screenX = typeof window.screenX !== 'undefined' ? window.screenX : window.screenLeft; - const screenY = typeof window.screenY !== 'undefined' ? window.screenY : window.screenTop; - const outerWidth = typeof window.outerWidth !== 'undefined' ? window.outerWidth : document.body.clientWidth; - const outerHeight = typeof window.outerHeight !== 'undefined' ? window.outerHeight : document.body.clientHeight - 22; - // XXX what is the 22? - - // Use `outerWidth - width` and `outerHeight - height` for help in - // positioning the popup centered relative to the current window - const left = screenX + (outerWidth - width) / 2; - const top = screenY + (outerHeight - height) / 2; - const features = `width=${width},height=${height},left=${left},top=${top},scrollbars=yes`; - - const newwindow = window.open(url, 'Login', features); - newwindow?.focus(); - - return newwindow; -}; - -(Meteor as any).loginWithCas = (_?: unknown, callback?: () => void) => { - const credentialToken = Random.id(); - const loginUrl = settings.get('CAS_login_url'); - const popupWidth = settings.get('CAS_popup_width') || 800; - const popupHeight = settings.get('CAS_popup_height') || 600; - - if (!loginUrl) { - return; - } - - const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; - // check if the provided CAS URL already has some parameters - const delim = loginUrl.split('?').length > 1 ? '&' : '?'; - const popupUrl = `${loginUrl}${delim}service=${appUrl}/_cas/${credentialToken}`; - - const popup = openCenteredPopup(popupUrl, popupWidth, popupHeight); - - const checkPopupOpen = setInterval(() => { - let popupClosed; - try { - // Fix for #328 - added a second test criteria (popup.closed === undefined) - // to humour this Android quirk: - // http://code.google.com/p/android/issues/detail?id=21061 - popupClosed = popup?.closed || popup?.closed === undefined; - } catch (e) { - // For some unknown reason, IE9 (and others?) sometimes (when - // the popup closes too quickly?) throws "SCRIPT16386: No such - // interface supported" when trying to read 'popup.closed'. Try - // again in 100ms. - return; - } - - if (popupClosed) { - clearInterval(checkPopupOpen); - - // check auth on server. - Accounts.callLoginMethod({ - methodArguments: [{ cas: { credentialToken } }], - userCallback: callback, - }); - } - }, 100); -}; diff --git a/apps/meteor/app/cas/client/index.ts b/apps/meteor/app/cas/client/index.ts deleted file mode 100644 index 75213558d6d8..000000000000 --- a/apps/meteor/app/cas/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './cas_client'; diff --git a/apps/meteor/app/cas/server/cas_rocketchat.js b/apps/meteor/app/cas/server/cas_rocketchat.js deleted file mode 100644 index f0b62b6ccb8a..000000000000 --- a/apps/meteor/app/cas/server/cas_rocketchat.js +++ /dev/null @@ -1,43 +0,0 @@ -import { Logger } from '@rocket.chat/logger'; -import { ServiceConfiguration } from 'meteor/service-configuration'; - -import { settings } from '../../settings/server'; - -export const logger = new Logger('CAS'); - -let timer; - -async function updateServices(/* record*/) { - if (typeof timer !== 'undefined') { - clearTimeout(timer); - } - - timer = setTimeout(async () => { - const data = { - // These will pe passed to 'node-cas' as options - enabled: settings.get('CAS_enabled'), - base_url: settings.get('CAS_base_url'), - login_url: settings.get('CAS_login_url'), - // Rocketchat Visuals - buttonLabelText: settings.get('CAS_button_label_text'), - buttonLabelColor: settings.get('CAS_button_label_color'), - buttonColor: settings.get('CAS_button_color'), - width: settings.get('CAS_popup_width'), - height: settings.get('CAS_popup_height'), - autoclose: settings.get('CAS_autoclose'), - }; - - // Either register or deregister the CAS login service based upon its configuration - if (data.enabled) { - logger.info('Enabling CAS login service'); - await ServiceConfiguration.configurations.upsertAsync({ service: 'cas' }, { $set: data }); - } else { - logger.info('Disabling CAS login service'); - await ServiceConfiguration.configurations.removeAsync({ service: 'cas' }); - } - }, 2000); -} - -settings.watchByRegex(/^CAS_.+/, async (key, value) => { - await updateServices(value); -}); diff --git a/apps/meteor/app/cas/server/cas_server.js b/apps/meteor/app/cas/server/cas_server.js deleted file mode 100644 index 60880c77d4f4..000000000000 --- a/apps/meteor/app/cas/server/cas_server.js +++ /dev/null @@ -1,272 +0,0 @@ -import url from 'url'; - -import { validate } from '@rocket.chat/cas-validate'; -import { CredentialTokens, Rooms, Users } from '@rocket.chat/models'; -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; -import { RoutePolicy } from 'meteor/routepolicy'; -import { WebApp } from 'meteor/webapp'; -import _ from 'underscore'; - -import { createRoom } from '../../lib/server/functions/createRoom'; -import { _setRealName } from '../../lib/server/functions/setRealName'; -import { settings } from '../../settings/server'; -import { logger } from './cas_rocketchat'; - -RoutePolicy.declare('/_cas/', 'network'); - -const closePopup = function (res) { - res.writeHead(200, { 'Content-Type': 'text/html' }); - const content = ''; - res.end(content, 'utf-8'); -}; - -const casTicket = function (req, token, callback) { - // get configuration - if (!settings.get('CAS_enabled')) { - logger.error('Got ticket validation request, but CAS is not enabled'); - callback(); - } - - // get ticket and validate. - const parsedUrl = url.parse(req.url, true); - const ticketId = parsedUrl.query.ticket; - const baseUrl = settings.get('CAS_base_url'); - const cas_version = parseFloat(settings.get('CAS_version')); - const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; - logger.debug(`Using CAS_base_url: ${baseUrl}`); - - validate( - { - base_url: baseUrl, - version: cas_version, - service: `${appUrl}/_cas/${token}`, - }, - ticketId, - async (err, status, username, details) => { - if (err) { - logger.error(`error when trying to validate: ${err.message}`); - } else if (status) { - logger.info(`Validated user: ${username}`); - const user_info = { username }; - - // CAS 2.0 attributes handling - if (details && details.attributes) { - _.extend(user_info, { attributes: details.attributes }); - } - await CredentialTokens.create(token, user_info); - } else { - logger.error(`Unable to validate ticket: ${ticketId}`); - } - // logger.debug("Received response: " + JSON.stringify(details, null , 4)); - - callback(); - }, - ); -}; - -const middleware = function (req, res, next) { - // Make sure to catch any exceptions because otherwise we'd crash - // the runner - try { - const barePath = req.url.substring(0, req.url.indexOf('?')); - const splitPath = barePath.split('/'); - - // Any non-cas request will continue down the default - // middlewares. - if (splitPath[1] !== '_cas') { - next(); - return; - } - - // get auth token - const credentialToken = splitPath[2]; - if (!credentialToken) { - closePopup(res); - return; - } - - // validate ticket - casTicket(req, credentialToken, () => { - closePopup(res); - }); - } catch (err) { - logger.error({ msg: 'Unexpected error', err }); - closePopup(res); - } -}; - -// Listen to incoming OAuth http requests -WebApp.connectHandlers.use((req, res, next) => { - middleware(req, res, next); -}); - -/* - * Register a server-side login handle. - * It is call after Accounts.callLoginMethod() is call from client. - * - */ -Accounts.registerLoginHandler('cas', async (options) => { - if (!options.cas) { - return undefined; - } - - // TODO: Sync wrapper due to the chain conversion to async models - const credentials = await CredentialTokens.findOneNotExpiredById(options.cas.credentialToken); - if (credentials === undefined) { - throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching login attempt found'); - } - - const result = credentials.userInfo; - const syncUserDataFieldMap = settings.get('CAS_Sync_User_Data_FieldMap').trim(); - const cas_version = parseFloat(settings.get('CAS_version')); - const sync_enabled = settings.get('CAS_Sync_User_Data_Enabled'); - const trustUsername = settings.get('CAS_trust_username'); - const verified = settings.get('Accounts_Verify_Email_For_External_Accounts'); - const userCreationEnabled = settings.get('CAS_Creation_User_Enabled'); - - // We have these - const ext_attrs = { - username: result.username, - }; - - // We need these - const int_attrs = { - email: undefined, - name: undefined, - username: undefined, - rooms: undefined, - }; - - // Import response attributes - if (cas_version >= 2.0) { - // Clean & import external attributes - _.each(result.attributes, (value, ext_name) => { - if (value) { - ext_attrs[ext_name] = value[0]; - } - }); - } - - // Source internal attributes - if (syncUserDataFieldMap) { - // Our mapping table: key(int_attr) -> value(ext_attr) - // Spoken: Source this internal attribute from these external attributes - const attr_map = JSON.parse(syncUserDataFieldMap); - - _.each(attr_map, (source, int_name) => { - // Source is our String to interpolate - if (source && typeof source.valueOf() === 'string') { - let replacedValue = source; - _.each(ext_attrs, (value, ext_name) => { - replacedValue = replacedValue.replace(`%${ext_name}%`, ext_attrs[ext_name]); - }); - - if (source !== replacedValue) { - int_attrs[int_name] = replacedValue; - logger.debug(`Sourced internal attribute: ${int_name} = ${replacedValue}`); - } else { - logger.debug(`Sourced internal attribute: ${int_name} skipped.`); - } - } - }); - } - - // Search existing user by its external service id - logger.debug(`Looking up user by id: ${result.username}`); - // First, look for a user that has logged in from CAS with this username before - let user = await Users.findOne({ 'services.cas.external_id': result.username }); - if (!user) { - // If that user was not found, check if there's any Rocket.Chat user with that username - // With this, CAS login will continue to work if the user is renamed on both sides and also if the user is renamed only on Rocket.Chat. - // It'll also allow non-CAS users to switch to CAS based login - if (trustUsername) { - const username = new RegExp(`^${result.username}$`, 'i'); - user = await Users.findOne({ username }); - if (user) { - // Update the user's external_id to reflect this new username. - await Users.updateOne({ _id: user._id }, { $set: { 'services.cas.external_id': result.username } }); - } - } - } - - if (user) { - logger.debug(`Using existing user for '${result.username}' with id: ${user._id}`); - if (sync_enabled) { - logger.debug('Syncing user attributes'); - // Update name - if (int_attrs.name) { - await _setRealName(user._id, int_attrs.name); - } - - // Update email - if (int_attrs.email) { - await Users.updateOne({ _id: user._id }, { $set: { emails: [{ address: int_attrs.email, verified }] } }); - } - } - } else if (userCreationEnabled) { - // Define new user - const newUser = { - username: result.username, - active: true, - globalRoles: ['user'], - emails: [], - services: { - cas: { - external_id: result.username, - version: cas_version, - attrs: int_attrs, - }, - }, - }; - - // Add username - if (int_attrs.username) { - _.extend(newUser, { - username: int_attrs.username, - }); - } - - // Add User.name - if (int_attrs.name) { - _.extend(newUser, { - name: int_attrs.name, - }); - } - - // Add email - if (int_attrs.email) { - _.extend(newUser, { - emails: [{ address: int_attrs.email, verified }], - }); - } - - // Create the user - logger.debug(`User "${result.username}" does not exist yet, creating it`); - const userId = Accounts.insertUserDoc({}, newUser); - - // Fetch and use it - user = await Users.findOneById(userId); - logger.debug(`Created new user for '${result.username}' with id: ${user._id}`); - // logger.debug(JSON.stringify(user, undefined, 4)); - - logger.debug(`Joining user to attribute channels: ${int_attrs.rooms}`); - if (int_attrs.rooms) { - const roomNames = int_attrs.rooms.split(','); - for await (const roomName of roomNames) { - if (roomName) { - let room = await Rooms.findOneByNameAndType(roomName, 'c'); - if (!room) { - room = await createRoom('c', roomName, user); - } - } - } - } - } else { - // Should fail as no user exist and can't be created - logger.debug(`User "${result.username}" does not exist yet, will fail as no user creation is enabled`); - throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching user account found'); - } - - return { userId: user._id }; -}); diff --git a/apps/meteor/app/cas/server/index.ts b/apps/meteor/app/cas/server/index.ts deleted file mode 100644 index 0ad22d77b198..000000000000 --- a/apps/meteor/app/cas/server/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import './cas_rocketchat'; -import './cas_server'; diff --git a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts index cc1af6fb1767..07db80c5cca9 100644 --- a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts +++ b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts @@ -65,7 +65,7 @@ export async function buildWorkspaceRegistrationData('Language'); const organizationType = settings.get('Organization_Type'); const industry = settings.get('Industry'); - const orgSize = settings.get('Organization_Size'); + const orgSize = settings.get('Size'); const workspaceType = settings.get('Server_Type'); const seats = await Users.getActiveLocalUserCount(); diff --git a/apps/meteor/app/crowd/client/index.ts b/apps/meteor/app/crowd/client/index.ts deleted file mode 100644 index fecf898e1ae4..000000000000 --- a/apps/meteor/app/crowd/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './loginHelper'; diff --git a/apps/meteor/app/crowd/client/loginHelper.js b/apps/meteor/app/crowd/client/loginHelper.js deleted file mode 100644 index a2bb14023b3a..000000000000 --- a/apps/meteor/app/crowd/client/loginHelper.js +++ /dev/null @@ -1,26 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -Meteor.loginWithCrowd = function (...args) { - // Pull username and password - const username = args.shift(); - const password = args.shift(); - const callback = args.shift(); - - const loginRequest = { - crowd: true, - username, - crowdPassword: password, - }; - Accounts.callLoginMethod({ - methodArguments: [loginRequest], - userCallback(error) { - if (callback) { - if (error) { - return callback(error); - } - return callback(); - } - }, - }); -}; diff --git a/apps/meteor/app/crowd/server/crowd.ts b/apps/meteor/app/crowd/server/crowd.ts index b6b94f33e566..e43c65c70f91 100644 --- a/apps/meteor/app/crowd/server/crowd.ts +++ b/apps/meteor/app/crowd/server/crowd.ts @@ -68,23 +68,46 @@ export class CROWD { this.crowdClient = new AtlassianCrowd(this.options); } - async checkConnection() { - await this.crowdClient.ping(); + async checkConnection(): Promise { + return new Promise((resolve, reject) => + this.crowdClient.ping((err: any) => { + if (err) { + reject(err); + } + resolve(); + }), + ); } - async fetchCrowdUser(crowdUsername: string) { - const userResponse = await this.crowdClient.user.find(crowdUsername); + async fetchCrowdUser(crowdUsername: string): Promise> { + return new Promise((resolve, reject) => + this.crowdClient.user.find(crowdUsername, (err: any, userResponse: Record) => { + if (err) { + reject(err); + } + resolve({ + displayname: userResponse['display-name'], + username: userResponse.name, + email: userResponse.email, + active: userResponse.active, + crowd_username: crowdUsername, + }); + }), + ); + } - return { - displayname: userResponse['display-name'], - username: userResponse.name, - email: userResponse.email, - active: userResponse.active, - crowd_username: crowdUsername, - }; + async searchForCrowdUserByMail(email?: string): Promise | undefined> { + return new Promise((resolve) => + this.crowdClient.search('user', `email=" ${email} "`, (err: any, response: Record) => { + if (err) { + resolve(undefined); + } + resolve(response); + }), + ); } - async authenticate(username: string, password: string) { + async authenticate(username: string, password: string): Promise | undefined> { if (!username || !password) { logger.error('No username or password'); return; @@ -134,24 +157,30 @@ export class CROWD { logger.debug('New user. User is not synced yet.'); } logger.debug('Going to crowd:', crowdUsername); - const auth = await this.crowdClient.user.authenticate(crowdUsername, password); - - if (!auth) { - return; - } - const crowdUser: Record = await this.fetchCrowdUser(crowdUsername); - - if (user && settings.get('CROWD_Allow_Custom_Username') === true) { - crowdUser.username = user.username; - } + return new Promise((resolve, reject) => + this.crowdClient.user.authenticate(crowdUsername, password, async (err: any, res: Record) => { + if (err) { + reject(err); + } + const user = res; + try { + const crowdUser: Record = await this.fetchCrowdUser(crowdUsername); + if (user && settings.get('CROWD_Allow_Custom_Username') === true) { + crowdUser.username = user.name; + } - if (user) { - crowdUser._id = user._id; - } - crowdUser.password = password; + if (user) { + crowdUser._id = user._id; + } + crowdUser.password = password; - return crowdUser; + resolve(crowdUser); + } catch (err) { + reject(err); + } + }), + ); } async syncDataToUser(crowdUser: Record, id: string) { @@ -219,7 +248,7 @@ export class CROWD { const email = user.emails?.[0].address; logger.info('Attempting to find for user by email', email); - const response = this.crowdClient.searchSync('user', `email=" ${email} "`); + const response = await this.searchForCrowdUserByMail(email); if (!response || response.users.length === 0) { logger.warn('Could not find user in CROWD with username or email:', crowdUsername, email); if (settings.get('CROWD_Remove_Orphaned_Users') === true) { @@ -257,8 +286,15 @@ export class CROWD { } async updateUserCollection(crowdUser: Record) { + const username = crowdUser.crowd_username || crowdUser.username; + const mail = crowdUser.email; + + // If id is not provided, user is linked by crowd_username or email address const userQuery = { - _id: crowdUser._id, + ...(crowdUser._id && { _id: crowdUser._id }), + ...(!crowdUser._id && { + $or: [{ crowd_username: username }, { 'emails.address': mail }], + }), }; // find our existing user if they exist @@ -321,16 +357,17 @@ Accounts.registerLoginHandler('crowd', async function (this: typeof Accounts, lo } if (!user) { - logger.debug(`User ${loginRequest.username} is not allowd to access Rocket.Chat`); + logger.debug(`User ${loginRequest.username} is not allowed to access Rocket.Chat`); return new Meteor.Error('not-authorized', 'User is not authorized by crowd'); } const result = await crowd.updateUserCollection(user); return result; - } catch (err) { + } catch (err: any) { logger.debug({ err }); logger.error('Crowd user not authenticated due to an error'); + throw new Meteor.Error('user-not-found', err.message); } }); diff --git a/apps/meteor/app/crowd/server/methods.ts b/apps/meteor/app/crowd/server/methods.ts index 758ebd1fcb3c..a621e3c8d027 100644 --- a/apps/meteor/app/crowd/server/methods.ts +++ b/apps/meteor/app/crowd/server/methods.ts @@ -38,7 +38,7 @@ Meteor.methods({ await crowd.checkConnection(); return { - message: 'Connection_success' as const, + message: 'Crowd_Connection_successful' as const, params: [], }; } catch (err) { diff --git a/apps/meteor/app/custom-oauth/client/custom_oauth_client.js b/apps/meteor/app/custom-oauth/client/CustomOAuth.ts similarity index 63% rename from apps/meteor/app/custom-oauth/client/custom_oauth_client.js rename to apps/meteor/app/custom-oauth/client/CustomOAuth.ts index c516f115aede..1d57d1969d93 100644 --- a/apps/meteor/app/custom-oauth/client/custom_oauth_client.js +++ b/apps/meteor/app/custom-oauth/client/CustomOAuth.ts @@ -1,11 +1,15 @@ +import type { OAuthConfiguration, OauthConfig } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import { capitalize } from '@rocket.chat/string-helpers'; import { Accounts } from 'meteor/accounts-base'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { OAuth } from 'meteor/oauth'; -import { ServiceConfiguration } from 'meteor/service-configuration'; +import type { IOAuthProvider } from '../../../client/definitions/IOAuthProvider'; +import { overrideLoginMethod, type LoginCallback } from '../../../client/lib/2fa/overrideLoginMethod'; +import { loginServices } from '../../../client/lib/loginServices'; +import { createOAuthTotpLoginMethod } from '../../../client/meteorOverrides/login/oauth'; import { isURL } from '../../../lib/utils/isURL'; // Request custom OAuth credentials for the user @@ -14,8 +18,16 @@ import { isURL } from '../../../lib/utils/isURL'; // completion. Takes one argument, credentialToken on success, or Error on // error. -export class CustomOAuth { - constructor(name, options) { +export class CustomOAuth implements IOAuthProvider { + public serverURL: string; + + public authorizePath: string; + + public scope: string; + + public responseType: string; + + constructor(public readonly name: string, options: OauthConfig) { this.name = name; if (!Match.test(this.name, String)) { throw new Meteor.Error('CustomOAuth: Name is required and must be String'); @@ -28,7 +40,7 @@ export class CustomOAuth { this.configureLogin(); } - configure(options) { + configure(options: OauthConfig) { if (!Match.test(options, Object)) { throw new Meteor.Error('CustomOAuth: Options is required and must be Object'); } @@ -56,37 +68,34 @@ export class CustomOAuth { } configureLogin() { - const loginWithService = `loginWith${capitalize(String(this.name || ''))}`; + const loginWithService = `loginWith${capitalize(String(this.name || ''))}` as const; - Meteor[loginWithService] = async (options, callback) => { - // support a callback without options - if (!callback && typeof options === 'function') { - callback = options; - options = null; - } + const loginWithOAuthTokenAndTOTP = createOAuthTotpLoginMethod(this); + const loginWithOAuthToken = async (options?: Meteor.LoginWithExternalServiceOptions, callback?: LoginCallback) => { const credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); await this.requestCredential(options, credentialRequestCompleteCallback); }; - } - async requestCredential(options, credentialRequestCompleteCallback) { - // support both (options, callback) and (callback). - if (!credentialRequestCompleteCallback && typeof options === 'function') { - credentialRequestCompleteCallback = options; - options = {}; - } + (Meteor as any)[loginWithService] = (options: Meteor.LoginWithExternalServiceOptions, callback: LoginCallback) => { + overrideLoginMethod(loginWithOAuthToken, [options], callback, loginWithOAuthTokenAndTOTP); + }; + } - const config = await ServiceConfiguration.configurations.findOneAsync({ service: this.name }); + async requestCredential( + options: Meteor.LoginWithExternalServiceOptions = {}, + credentialRequestCompleteCallback: (credentialTokenOrError?: string | Error) => void, + ) { + const config = await loginServices.loadLoginService(this.name); if (!config) { if (credentialRequestCompleteCallback) { - credentialRequestCompleteCallback(new ServiceConfiguration.ConfigError()); + credentialRequestCompleteCallback(new Accounts.ConfigError()); } return; } const credentialToken = Random.secret(); - const loginStyle = OAuth._loginStyle(this.name, config, options); + const loginStyle = OAuth._loginStyle(this.name, config); const separator = this.authorizePath.indexOf('?') !== -1 ? '&' : '?'; diff --git a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js index bb939febaef8..6b225069734d 100644 --- a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js +++ b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js @@ -106,7 +106,7 @@ export class CustomOAuth { async getAccessToken(query) { const config = await ServiceConfiguration.configurations.findOneAsync({ service: this.name }); if (!config) { - throw new ServiceConfiguration.ConfigError(); + throw new Accounts.ConfigError(); } let response = undefined; diff --git a/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts b/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts index e366216ed7f9..0f42f495e962 100644 --- a/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts +++ b/apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts @@ -1,9 +1,8 @@ -import { api } from '@rocket.chat/core-services'; import type { IRoom } from '@rocket.chat/core-typings'; import { Messages, Rooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; -import { broadcastMessageSentEvent } from '../../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { deleteRoom } from '../../../lib/server/functions/deleteRoom'; const updateAndNotifyParentRoomWithParentMessage = async (room: IRoom): Promise => { @@ -11,10 +10,9 @@ const updateAndNotifyParentRoomWithParentMessage = async (room: IRoom): Promise< if (!parentMessage) { return; } - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: parentMessage._id, data: parentMessage, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); }; diff --git a/apps/meteor/app/dolphin/client/lib.ts b/apps/meteor/app/dolphin/client/lib.ts index c04ee1b7859d..31a767dd5556 100644 --- a/apps/meteor/app/dolphin/client/lib.ts +++ b/apps/meteor/app/dolphin/client/lib.ts @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; const config = { diff --git a/apps/meteor/app/drupal/client/lib.ts b/apps/meteor/app/drupal/client/lib.ts index 9edbb560a450..f477a326d706 100644 --- a/apps/meteor/app/drupal/client/lib.ts +++ b/apps/meteor/app/drupal/client/lib.ts @@ -2,7 +2,7 @@ import type { OauthConfig } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; // Drupal Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/drupal diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.room.js b/apps/meteor/app/e2e/client/rocketchat.e2e.room.js index f64b243e0d88..bd0863d691a9 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.room.js +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.room.js @@ -7,7 +7,6 @@ import { RoomManager } from '../../../client/lib/RoomManager'; import { roomCoordinator } from '../../../client/lib/rooms/roomCoordinator'; import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig'; import { ChatRoom, Subscriptions, Messages } from '../../models/client'; -import { Notifications } from '../../notifications/client'; import { sdk } from '../../utils/client/lib/SDKClient'; import { E2ERoomState } from './E2ERoomState'; import { @@ -240,7 +239,7 @@ export class E2ERoom extends Emitter { this.setState(E2ERoomState.WAITING_KEYS); this.log('Requesting room key'); - Notifications.notifyUsersOfRoom(this.roomId, 'e2ekeyRequest', this.roomId, room.e2eKeyId); + sdk.publish('notify-room-users', [`${this.roomId}/e2ekeyRequest`, this.roomId, room.e2eKeyId]); } catch (error) { // this.error = error; this.setState(E2ERoomState.ERROR); diff --git a/apps/meteor/app/emoji-custom/client/index.ts b/apps/meteor/app/emoji-custom/client/index.ts index d8a1b75e275d..780a12a3898f 100644 --- a/apps/meteor/app/emoji-custom/client/index.ts +++ b/apps/meteor/app/emoji-custom/client/index.ts @@ -1,3 +1 @@ import './lib/emojiCustom'; -import './notifications/deleteEmojiCustom'; -import './notifications/updateEmojiCustom'; diff --git a/apps/meteor/app/emoji-custom/client/notifications/deleteEmojiCustom.ts b/apps/meteor/app/emoji-custom/client/notifications/deleteEmojiCustom.ts deleted file mode 100644 index 0ee00976956e..000000000000 --- a/apps/meteor/app/emoji-custom/client/notifications/deleteEmojiCustom.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Notifications } from '../../../notifications/client'; -import { deleteEmojiCustom } from '../lib/emojiCustom'; - -Meteor.startup(() => Notifications.onLogged('deleteEmojiCustom', (data) => deleteEmojiCustom(data.emojiData))); diff --git a/apps/meteor/app/emoji-custom/client/notifications/updateEmojiCustom.ts b/apps/meteor/app/emoji-custom/client/notifications/updateEmojiCustom.ts deleted file mode 100644 index 326edef0fa6c..000000000000 --- a/apps/meteor/app/emoji-custom/client/notifications/updateEmojiCustom.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Notifications } from '../../../notifications/client'; -import { updateEmojiCustom } from '../lib/emojiCustom'; - -Meteor.startup(() => Notifications.onLogged('updateEmojiCustom', (data) => updateEmojiCustom(data.emojiData))); diff --git a/apps/meteor/app/federation/server/endpoints/dispatch.js b/apps/meteor/app/federation/server/endpoints/dispatch.js index e54441a7aa9d..4b3e148bbdad 100644 --- a/apps/meteor/app/federation/server/endpoints/dispatch.js +++ b/apps/meteor/app/federation/server/endpoints/dispatch.js @@ -3,7 +3,7 @@ import { eventTypes } from '@rocket.chat/core-typings'; import { FederationServers, FederationRoomEvents, Rooms, Messages, Subscriptions, Users, ReadReceipts } from '@rocket.chat/models'; import EJSON from 'ejson'; -import { broadcastMessageSentEvent } from '../../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { API } from '../../../api/server'; import { FileUpload } from '../../../file-upload/server'; import { deleteRoom } from '../../../lib/server/functions/deleteRoom'; @@ -284,10 +284,9 @@ const eventHandlers = { } } if (messageForNotification) { - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: messageForNotification._id, data: messageForNotification, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } } @@ -316,14 +315,13 @@ const eventHandlers = { } else { // Update the message await Messages.updateOne({ _id: persistedMessage._id }, { $set: { msg: message.msg, federation: message.federation } }); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: persistedMessage._id, data: { ...persistedMessage, msg: message.msg, federation: message.federation, }, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } } @@ -387,7 +385,7 @@ const eventHandlers = { // Update the property await Messages.updateOne({ _id: messageId }, { $set: { [`reactions.${reaction}`]: reactionObj } }); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: persistedMessage._id, data: { ...persistedMessage, @@ -396,7 +394,6 @@ const eventHandlers = { [reaction]: reactionObj, }, }, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } @@ -446,7 +443,7 @@ const eventHandlers = { // Otherwise, update the property await Messages.updateOne({ _id: messageId }, { $set: { [`reactions.${reaction}`]: reactionObj } }); } - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: persistedMessage._id, data: { ...persistedMessage, @@ -455,7 +452,6 @@ const eventHandlers = { [reaction]: reactionObj, }, }, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } diff --git a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts index d922b5adab78..255bd4ee7c79 100644 --- a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts +++ b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts @@ -1,4 +1,4 @@ -import type { MessageAttachment, FileAttachmentProps, IUser, IUpload, AtLeast } from '@rocket.chat/core-typings'; +import type { MessageAttachment, FileAttachmentProps, IUser, IUpload, AtLeast, FilesAndAttachments } from '@rocket.chat/core-typings'; import { Rooms, Uploads, Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Match, check } from 'meteor/check'; @@ -25,7 +25,7 @@ export const parseFileIntoMessageAttachments = async ( file: Partial, roomId: string, user: IUser, -): Promise> => { +): Promise => { validateFileRequiredFields(file); await Uploads.updateFileComplete(file._id, user._id, omit(file, '_id')); @@ -37,8 +37,10 @@ export const parseFileIntoMessageAttachments = async ( const files = [ { _id: file._id, - name: file.name, - type: file.type, + name: file.name || '', + type: file.type || 'file', + size: file.size || 0, + format: file.identify?.format || '', }, ]; @@ -73,8 +75,10 @@ export const parseFileIntoMessageAttachments = async ( }; files.push({ _id: thumbnail._id, - name: file.name, - type: thumbnail.type, + name: file.name || '', + type: thumbnail.type || 'file', + size: thumbnail.size || 0, + format: thumbnail.identify?.format || '', }); } } catch (e) { diff --git a/apps/meteor/app/github-enterprise/client/lib.ts b/apps/meteor/app/github-enterprise/client/lib.ts index ec03985df0cf..97b9e6867799 100644 --- a/apps/meteor/app/github-enterprise/client/lib.ts +++ b/apps/meteor/app/github-enterprise/client/lib.ts @@ -2,7 +2,7 @@ import type { OauthConfig } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; // GitHub Enterprise Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/github_enterprise diff --git a/apps/meteor/app/gitlab/client/lib.ts b/apps/meteor/app/gitlab/client/lib.ts index a1b2ded0cc1a..518478f91227 100644 --- a/apps/meteor/app/gitlab/client/lib.ts +++ b/apps/meteor/app/gitlab/client/lib.ts @@ -2,7 +2,7 @@ import type { OauthConfig } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; const config: OauthConfig = { diff --git a/apps/meteor/app/integrations/server/lib/triggerHandler.js b/apps/meteor/app/integrations/server/lib/triggerHandler.js index b5050b8c4716..07f7a3d903a2 100644 --- a/apps/meteor/app/integrations/server/lib/triggerHandler.js +++ b/apps/meteor/app/integrations/server/lib/triggerHandler.js @@ -503,6 +503,7 @@ class RocketChatIntegrationHandler { { method: opts.method, headers: opts.headers, + ...(opts.timeout && { timeout: opts.timeout }), ...(opts.data && { body: opts.data }), }, settings.get('Allow_Invalid_SelfSigned_Certs'), diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index d76fd4b34507..b8383875444f 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -48,9 +48,17 @@ export async function createDirectRoom( subscriptionExtra?: ISubscriptionExtraData; }, ): Promise { - if (members.length > (settings.get('DirectMesssage_maxUsers') || 1)) { - throw new Error('error-direct-message-max-user-exceeded'); + const maxUsers = settings.get('DirectMesssage_maxUsers') || 1; + if (members.length > maxUsers) { + throw new Meteor.Error( + 'error-direct-message-max-user-exceeded', + `You cannot add more than ${maxUsers} users, including yourself to a direct message`, + { + method: 'createDirectRoom', + }, + ); } + await callbacks.run('beforeCreateDirectRoom', members); const membersUsernames: string[] = members diff --git a/apps/meteor/app/lib/server/functions/deleteMessage.ts b/apps/meteor/app/lib/server/functions/deleteMessage.ts index 37ae72254418..cd4456b24514 100644 --- a/apps/meteor/app/lib/server/functions/deleteMessage.ts +++ b/apps/meteor/app/lib/server/functions/deleteMessage.ts @@ -5,7 +5,7 @@ import { Meteor } from 'meteor/meteor'; import { Apps } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; -import { broadcastMessageSentEvent } from '../../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { canDeleteMessageAsync } from '../../../authorization/server/functions/canDeleteMessage'; import { FileUpload } from '../../../file-upload/server'; import { settings } from '../../../settings/server'; @@ -90,9 +90,8 @@ export async function deleteMessage(message: IMessage, user: IUser): Promise api.broadcast('message.sent', message), }); } diff --git a/apps/meteor/app/lib/server/functions/deleteUser.ts b/apps/meteor/app/lib/server/functions/deleteUser.ts index 7a01dab2fcd1..24ef854d48f3 100644 --- a/apps/meteor/app/lib/server/functions/deleteUser.ts +++ b/apps/meteor/app/lib/server/functions/deleteUser.ts @@ -25,6 +25,13 @@ import { relinquishRoomOwnerships } from './relinquishRoomOwnerships'; import { updateGroupDMsName } from './updateGroupDMsName'; export async function deleteUser(userId: string, confirmRelinquish = false, deletedBy?: IUser['_id']): Promise { + if (userId === 'rocket.cat') { + throw new Meteor.Error('error-action-not-allowed', 'Deleting the rocket.cat user is not allowed', { + method: 'deleteUser', + action: 'Delete_user', + }); + } + const user = await Users.findOneById(userId, { projection: { username: 1, avatarOrigin: 1, roles: 1, federated: 1 }, }); diff --git a/apps/meteor/app/lib/server/functions/notifications/desktop.ts b/apps/meteor/app/lib/server/functions/notifications/desktop.ts index a20ed6ba1677..7d8d0f5f4db8 100644 --- a/apps/meteor/app/lib/server/functions/notifications/desktop.ts +++ b/apps/meteor/app/lib/server/functions/notifications/desktop.ts @@ -1,5 +1,5 @@ import { api } from '@rocket.chat/core-services'; -import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings'; import { roomCoordinator } from '../../../../../server/lib/rooms/roomCoordinator'; import { metrics } from '../../../../metrics/server'; @@ -24,10 +24,10 @@ export async function notifyDesktopUser({ notificationMessage, }: { userId: string; - user: IUser; - message: IMessage; + user: AtLeast; + message: IMessage | Pick; room: IRoom; - duration: number; + duration?: number; notificationMessage: string; }): Promise { const { title, text, name } = await roomCoordinator @@ -39,14 +39,22 @@ export async function notifyDesktopUser({ text, duration, payload: { - _id: message._id, - rid: message.rid, - tmid: message.tmid, + _id: '', + rid: '', + tmid: '', + ...('_id' in message && { + // TODO: omnichannel is not sending _id, rid, tmid + _id: message._id, + rid: message.rid, + tmid: message.tmid, + }), sender: message.u, type: room.t, message: { - msg: message.msg, - t: message.t, + msg: 'msg' in message ? message.msg : '', + ...('t' in message && { + t: message.t, + }), }, name, }, diff --git a/apps/meteor/app/lib/server/functions/notifications/index.ts b/apps/meteor/app/lib/server/functions/notifications/index.ts index 11e4418c4510..54b18f502ae7 100644 --- a/apps/meteor/app/lib/server/functions/notifications/index.ts +++ b/apps/meteor/app/lib/server/functions/notifications/index.ts @@ -11,7 +11,11 @@ import { settings } from '../../../../settings/server'; * * @param {object} message the message to be parsed */ -export async function parseMessageTextPerUser(messageText: string, message: IMessage, receiver: IUser): Promise { +export async function parseMessageTextPerUser( + messageText: string, + message: Pick, + receiver: Pick, +): Promise { const lng = receiver.language || settings.get('Language') || 'en'; const firstAttachment = message.attachments?.[0]; @@ -36,9 +40,6 @@ export async function parseMessageTextPerUser(messageText: string, message: IMes * @returns {string} */ export function replaceMentionedUsernamesWithFullNames(message: string, mentions: NonNullable): string { - if (!mentions?.length) { - return message; - } mentions.forEach((mention) => { if (mention.name) { message = message.replace(new RegExp(escapeRegExp(`@${mention.username}`), 'g'), mention.name); @@ -55,7 +56,7 @@ export function replaceMentionedUsernamesWithFullNames(message: string, mentions * * @returns {boolean} */ -export function messageContainsHighlight(message: IMessage, highlights: string[]): boolean { +export function messageContainsHighlight(message: Pick, highlights: string[] | undefined): boolean { if (!highlights || highlights.length === 0) { return false; } diff --git a/apps/meteor/app/lib/server/functions/sendMessage.ts b/apps/meteor/app/lib/server/functions/sendMessage.ts index 4145cc4cf627..486f7c360f99 100644 --- a/apps/meteor/app/lib/server/functions/sendMessage.ts +++ b/apps/meteor/app/lib/server/functions/sendMessage.ts @@ -1,4 +1,4 @@ -import { Message, api } from '@rocket.chat/core-services'; +import { Message } from '@rocket.chat/core-services'; import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; @@ -7,7 +7,7 @@ import { Apps } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; import { isRelativeURL } from '../../../../lib/utils/isRelativeURL'; import { isURL } from '../../../../lib/utils/isURL'; -import { broadcastMessageSentEvent } from '../../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { FileUpload } from '../../../file-upload/server'; import notifications from '../../../notifications/server/lib/Notifications'; @@ -285,9 +285,8 @@ export const sendMessage = async function (user: any, message: any, room: any, u // Execute all callbacks await callbacks.run('afterSaveMessage', message, room); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: message._id, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); return message; }; diff --git a/apps/meteor/app/lib/server/functions/updateMessage.ts b/apps/meteor/app/lib/server/functions/updateMessage.ts index 8abb8b4b0a02..5cfe29ef41ae 100644 --- a/apps/meteor/app/lib/server/functions/updateMessage.ts +++ b/apps/meteor/app/lib/server/functions/updateMessage.ts @@ -1,11 +1,11 @@ -import { Message, api } from '@rocket.chat/core-services'; +import { Message } from '@rocket.chat/core-services'; import type { IEditedMessage, IMessage, IUser, AtLeast } from '@rocket.chat/core-typings'; import { Messages, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { Apps } from '../../../../ee/server/apps'; import { callbacks } from '../../../../lib/callbacks'; -import { broadcastMessageSentEvent } from '../../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { settings } from '../../../settings/server'; import { parseUrlsInMessage } from './parseUrlsInMessage'; @@ -85,10 +85,9 @@ export const updateMessage = async function ( const msg = await Messages.findOneById(_id); if (msg) { await callbacks.run('afterSaveMessage', msg, room, user._id); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: msg._id, data: msg, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } }); diff --git a/apps/meteor/app/lib/server/lib/RateLimiter.js b/apps/meteor/app/lib/server/lib/RateLimiter.js index b8c069ed290e..126b236426da 100644 --- a/apps/meteor/app/lib/server/lib/RateLimiter.js +++ b/apps/meteor/app/lib/server/lib/RateLimiter.js @@ -7,13 +7,60 @@ export const RateLimiterClass = new (class { if (process.env.TEST_MODE === 'true') { return fn; } - const rateLimiter = new RateLimiter(); + const rateLimiter = new (class extends RateLimiter { + async check(input) { + const reply = { + allowed: true, + timeToReset: 0, + numInvocationsLeft: Infinity, + }; + + const matchedRules = this._findAllMatchingRules(input); + + for await (const rule of matchedRules) { + const ruleResult = await rule.apply(input); + let numInvocations = rule.counters[ruleResult.key]; + + if (ruleResult.timeToNextReset < 0) { + // Reset all the counters since the rule has reset + await rule.resetCounter(); + ruleResult.timeSinceLastReset = new Date().getTime() - rule._lastResetTime; + ruleResult.timeToNextReset = rule.options.intervalTime; + numInvocations = 0; + } + + if (numInvocations > rule.options.numRequestsAllowed) { + // Only update timeToReset if the new time would be longer than the + // previously set time. This is to ensure that if this input triggers + // multiple rules, we return the longest period of time until they can + // successfully make another call + if (reply.timeToReset < ruleResult.timeToNextReset) { + reply.timeToReset = ruleResult.timeToNextReset; + } + reply.allowed = false; + reply.numInvocationsLeft = 0; + reply.ruleId = rule.id; + await rule._executeCallback(reply, input); + } else { + // If this is an allowed attempt and we haven't failed on any of the + // other rules that match, update the reply field. + if (rule.options.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && reply.allowed) { + reply.timeToReset = ruleResult.timeToNextReset; + reply.numInvocationsLeft = rule.options.numRequestsAllowed - numInvocations; + } + reply.ruleId = rule.id; + await rule._executeCallback(reply, input); + } + } + return reply; + } + })(); Object.entries(matchers).forEach(([key, matcher]) => { - matchers[key] = (...args) => Promise.await(matcher(...args)); + matchers[key] = matcher; }); rateLimiter.addRule(matchers, numRequests, timeInterval); - return function (...args) { + return async function (...args) { const match = {}; Object.keys(matchers).forEach((key) => { @@ -21,7 +68,7 @@ export const RateLimiterClass = new (class { }); rateLimiter.increment(match); - const rateLimitResult = rateLimiter.check(match); + const rateLimitResult = await rateLimiter.check(match); if (rateLimitResult.allowed) { return fn.apply(null, args); } diff --git a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.js b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts similarity index 72% rename from apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.js rename to apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts index 395ddfe6d460..ff3261a4bcf7 100644 --- a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.js +++ b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts @@ -1,5 +1,16 @@ +import { + type IMessage, + type ISubscription, + type IUser, + type IRoom, + type NotificationItem, + isEditedMessage, + type AtLeast, +} from '@rocket.chat/core-typings'; import { Subscriptions, Users } from '@rocket.chat/models'; +import emojione from 'emojione'; import moment from 'moment'; +import type { Filter, RootFilterOperators } from 'mongodb'; import { callbacks } from '../../../../lib/callbacks'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; @@ -12,7 +23,16 @@ import { getEmailData, shouldNotifyEmail } from '../functions/notifications/emai import { getPushData, shouldNotifyMobile } from '../functions/notifications/mobile'; import { getMentions } from './notifyUsersOnMessage'; -let TroubleshootDisableNotifications; +type SubscriptionAggregation = { + receiver: [Pick | null]; +} & Pick< + ISubscription, + 'desktopNotifications' | 'emailNotifications' | 'mobilePushNotifications' | 'muteGroupMentions' | 'name' | 'rid' | 'userHighlights' | 'u' +>; + +type WithRequiredProperty = Type & { + [Property in Key]-?: Type[Property]; +}; export const sendNotification = async ({ subscription, @@ -25,8 +45,20 @@ export const sendNotification = async ({ room, mentionIds, disableAllMessageNotifications, +}: { + subscription: SubscriptionAggregation; + sender: Pick; + + hasReplyToThread: boolean; + hasMentionToAll: boolean; + hasMentionToHere: boolean; + message: AtLeast; + notificationMessage: string; + room: IRoom; + mentionIds: string[]; + disableAllMessageNotifications: boolean; }) => { - if (TroubleshootDisableNotifications === true) { + if (settings.get('Troubleshoot_Disable_Notifications') === true) { return; } @@ -60,6 +92,10 @@ export const sendNotification = async ({ const [receiver] = subscription.receiver; + if (!receiver) { + throw new Error('receiver not found'); + } + const roomType = room.t; // If the user doesn't have permission to view direct messages, don't send notification of direct messages. if (roomType === 'd' && !(await hasPermissionAsync(subscription.u._id, 'view-d-room'))) { @@ -79,9 +115,9 @@ export const sendNotification = async ({ if ( shouldNotifyDesktop({ disableAllMessageNotifications, - status: receiver.status, - statusConnection: receiver.statusConnection, - desktopNotifications, + status: receiver.status ?? 'offline', + statusConnection: receiver.statusConnection ?? 'offline', + desktopNotifications: desktopNotifications ?? 'default', hasMentionToAll, hasMentionToHere, isHighlighted, @@ -100,7 +136,7 @@ export const sendNotification = async ({ }); } - const queueItems = []; + const queueItems: NotificationItem[] = []; if ( shouldNotifyMobile({ @@ -145,12 +181,26 @@ export const sendNotification = async ({ isThread, }) ) { + const messageWithUnicode = message.msg ? emojione.shortnameToUnicode(message.msg) : message.msg; + const firstAttachment = message.attachments?.length && message.attachments.shift(); + + if (firstAttachment) { + firstAttachment.description = + typeof firstAttachment.description === 'string' ? emojione.shortnameToUnicode(firstAttachment.description) : undefined; + firstAttachment.text = typeof firstAttachment.text === 'string' ? emojione.shortnameToUnicode(firstAttachment.text) : undefined; + } + + const attachments = firstAttachment ? [firstAttachment, ...(message.attachments ?? [])].filter(Boolean) : []; for await (const email of receiver.emails) { if (email.verified) { queueItems.push({ type: 'email', data: await getEmailData({ - message, + message: { + ...message, + msg: messageWithUnicode, + ...(attachments.length > 0 ? { attachments } : {}), + }, receiver, sender, subscription, @@ -166,7 +216,7 @@ export const sendNotification = async ({ } if (queueItems.length) { - Notification.scheduleItem({ + void Notification.scheduleItem({ user: receiver, uid: subscription.u._id, rid: room._id, @@ -194,13 +244,13 @@ const project = { 'receiver.username': 1, 'receiver.settings.preferences.enableMobileRinging': 1, }, -}; +} as const; const filter = { $match: { 'receiver.active': true, }, -}; +} as const; const lookup = { $lookup: { @@ -209,10 +259,10 @@ const lookup = { foreignField: '_id', as: 'receiver', }, -}; +} as const; -export async function sendMessageNotifications(message, room, usersInThread = []) { - if (TroubleshootDisableNotifications === true) { +export async function sendMessageNotifications(message: IMessage, room: IRoom, usersInThread: string[] = []) { + if (settings.get('Troubleshoot_Disable_Notifications') === true) { return; } @@ -238,25 +288,25 @@ export async function sendMessageNotifications(message, room, usersInThread = [] let notificationMessage = await callbacks.run('beforeSendMessageNotifications', message.msg); if (mentionIds.length > 0 && settings.get('UI_Use_Real_Name')) { - notificationMessage = replaceMentionedUsernamesWithFullNames(message.msg, message.mentions); + notificationMessage = replaceMentionedUsernamesWithFullNames(message.msg, message.mentions ?? []); } // Don't fetch all users if room exceeds max members - const maxMembersForNotification = settings.get('Notifications_Max_Room_Members'); + const maxMembersForNotification = settings.get('Notifications_Max_Room_Members'); const roomMembersCount = await Users.countRoomMembers(room._id); const disableAllMessageNotifications = roomMembersCount > maxMembersForNotification && maxMembersForNotification !== 0; - const query = { + const query: WithRequiredProperty, '$or'> = { rid: room._id, ignored: { $ne: sender._id }, disableNotifications: { $ne: true }, $or: [{ 'userHighlights.0': { $exists: 1 } }, ...(usersInThread.length > 0 ? [{ 'u._id': { $in: usersInThread } }] : [])], - }; + } as const; - ['audio', 'desktop', 'mobile', 'email'].forEach((kind) => { + (['desktop', 'mobile', 'email'] as const).forEach((kind) => { const notificationField = `${kind === 'mobile' ? 'mobilePush' : kind}Notifications`; - const filter = { [notificationField]: 'all' }; + const filter: Filter = { [notificationField]: 'all' }; if (disableAllMessageNotifications) { filter[`${kind}PrefOrigin`] = { $ne: 'user' }; @@ -295,7 +345,7 @@ export async function sendMessageNotifications(message, room, usersInThread = [] // the find below is crucial. All subscription records returned will receive at least one kind of notification. // the query is defined by the server's default values and Notifications_Max_Room_Members setting. - const subscriptions = await Subscriptions.col.aggregate([{ $match: query }, lookup, filter, project]).toArray(); + const subscriptions = await Subscriptions.col.aggregate([{ $match: query }, lookup, filter, project]).toArray(); subscriptions.forEach( (subscription) => @@ -309,7 +359,7 @@ export async function sendMessageNotifications(message, room, usersInThread = [] room, mentionIds, disableAllMessageNotifications, - hasReplyToThread: usersInThread && usersInThread.includes(subscription.u._id), + hasReplyToThread: usersInThread?.includes(subscription.u._id), }), ); @@ -323,8 +373,8 @@ export async function sendMessageNotifications(message, room, usersInThread = [] }; } -export async function sendAllNotifications(message, room) { - if (TroubleshootDisableNotifications === true) { +export async function sendAllNotifications(message: IMessage, room: IRoom) { + if (settings.get('Troubleshoot_Disable_Notifications') === true) { return message; } @@ -333,11 +383,11 @@ export async function sendAllNotifications(message, room) { return message; } // skips this callback if the message was edited - if (message.editedAt) { + if (isEditedMessage(message)) { return message; } - if (message.ts && Math.abs(moment(message.ts).diff()) > 60000) { + if (message.ts && Math.abs(moment(message.ts).diff(new Date())) > 60000) { return message; } @@ -351,11 +401,6 @@ export async function sendAllNotifications(message, room) { } settings.watch('Troubleshoot_Disable_Notifications', (value) => { - if (TroubleshootDisableNotifications === value) { - return; - } - TroubleshootDisableNotifications = value; - if (value) { return callbacks.remove('afterSaveMessage', 'sendNotificationsOnMessage'); } diff --git a/apps/meteor/app/lib/server/methods/addOAuthService.ts b/apps/meteor/app/lib/server/methods/addOAuthService.ts index abf1b7035af1..05b0e5a7e4e6 100644 --- a/apps/meteor/app/lib/server/methods/addOAuthService.ts +++ b/apps/meteor/app/lib/server/methods/addOAuthService.ts @@ -2,8 +2,8 @@ import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { addOAuthService } from '../../../../server/lib/oauth/addOAuthService'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { addOAuthService } from '../functions/addOAuthService'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/apps/meteor/app/lib/server/methods/refreshOAuthService.ts b/apps/meteor/app/lib/server/methods/refreshOAuthService.ts index 9faa67f239a1..e5b1c377a33e 100644 --- a/apps/meteor/app/lib/server/methods/refreshOAuthService.ts +++ b/apps/meteor/app/lib/server/methods/refreshOAuthService.ts @@ -1,8 +1,7 @@ -import { Settings } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; +import { refreshLoginServices } from '../../../../server/lib/refreshLoginServices'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; declare module '@rocket.chat/ui-contexts' { @@ -29,8 +28,6 @@ Meteor.methods({ }); } - await ServiceConfiguration.configurations.removeAsync({}); - - await Settings.update({ _id: /^(Accounts_OAuth_|SAML_|CAS_).+/ }, { $set: { _updatedAt: new Date() } }, { multi: true }); + await refreshLoginServices(); }, }); diff --git a/apps/meteor/app/lib/server/oauth/oauth.js b/apps/meteor/app/lib/server/oauth/oauth.js index 2618a6e7a569..27342416bedb 100644 --- a/apps/meteor/app/lib/server/oauth/oauth.js +++ b/apps/meteor/app/lib/server/oauth/oauth.js @@ -36,7 +36,7 @@ Accounts.registerLoginHandler(async (options) => { // Make sure we're configured if (!(await ServiceConfiguration.configurations.findOneAsync({ service: options.serviceName }))) { - throw new ServiceConfiguration.ConfigError(); + throw new Accounts.ConfigError(); } if (!_.contains(Accounts.oauth.serviceNames(), service.serviceName)) { diff --git a/apps/meteor/app/lib/server/startup/index.ts b/apps/meteor/app/lib/server/startup/index.ts index d4e5183ad7f5..deadb8a44c06 100644 --- a/apps/meteor/app/lib/server/startup/index.ts +++ b/apps/meteor/app/lib/server/startup/index.ts @@ -1,4 +1,3 @@ -import './oAuthServicesUpdate'; import './rateLimiter'; import './robots'; import './settingsOnLoadCdnPrefix'; diff --git a/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js b/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js deleted file mode 100644 index b01ef2f9fb0c..000000000000 --- a/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js +++ /dev/null @@ -1,206 +0,0 @@ -import { Logger } from '@rocket.chat/logger'; -import { ServiceConfiguration } from 'meteor/service-configuration'; -import _ from 'underscore'; - -import { CustomOAuth } from '../../../custom-oauth/server/custom_oauth_server'; -import { settings } from '../../../settings/server'; -import { addOAuthService } from '../functions/addOAuthService'; - -const logger = new Logger('rocketchat:lib'); - -async function _OAuthServicesUpdate() { - const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); - const filteredServices = services.filter(([, value]) => typeof value === 'boolean'); - for await (const [key, value] of filteredServices) { - logger.debug({ oauth_updated: key }); - let serviceName = key.replace('Accounts_OAuth_', ''); - if (serviceName === 'Meteor') { - serviceName = 'meteor-developer'; - } - if (/Accounts_OAuth_Custom-/.test(key)) { - serviceName = key.replace('Accounts_OAuth_Custom-', ''); - } - - if (value === true) { - const data = { - clientId: settings.get(`${key}_id`), - secret: settings.get(`${key}_secret`), - }; - - if (/Accounts_OAuth_Custom-/.test(key)) { - data.custom = true; - data.clientId = settings.get(`${key}-id`); - data.secret = settings.get(`${key}-secret`); - data.serverURL = settings.get(`${key}-url`); - data.tokenPath = settings.get(`${key}-token_path`); - data.identityPath = settings.get(`${key}-identity_path`); - data.authorizePath = settings.get(`${key}-authorize_path`); - data.scope = settings.get(`${key}-scope`); - data.accessTokenParam = settings.get(`${key}-access_token_param`); - data.buttonLabelText = settings.get(`${key}-button_label_text`); - data.buttonLabelColor = settings.get(`${key}-button_label_color`); - data.loginStyle = settings.get(`${key}-login_style`); - data.buttonColor = settings.get(`${key}-button_color`); - data.tokenSentVia = settings.get(`${key}-token_sent_via`); - data.identityTokenSentVia = settings.get(`${key}-identity_token_sent_via`); - data.keyField = settings.get(`${key}-key_field`); - data.usernameField = settings.get(`${key}-username_field`); - data.emailField = settings.get(`${key}-email_field`); - data.nameField = settings.get(`${key}-name_field`); - data.avatarField = settings.get(`${key}-avatar_field`); - data.rolesClaim = settings.get(`${key}-roles_claim`); - data.groupsClaim = settings.get(`${key}-groups_claim`); - data.channelsMap = settings.get(`${key}-groups_channel_map`); - data.channelsAdmin = settings.get(`${key}-channels_admin`); - data.mergeUsers = settings.get(`${key}-merge_users`); - data.mergeUsersDistinctServices = settings.get(`${key}-merge_users_distinct_services`); - data.mapChannels = settings.get(`${key}-map_channels`); - data.mergeRoles = settings.get(`${key}-merge_roles`); - data.rolesToSync = settings.get(`${key}-roles_to_sync`); - data.showButton = settings.get(`${key}-show_button`); - - new CustomOAuth(serviceName.toLowerCase(), { - serverURL: data.serverURL, - tokenPath: data.tokenPath, - identityPath: data.identityPath, - authorizePath: data.authorizePath, - scope: data.scope, - loginStyle: data.loginStyle, - tokenSentVia: data.tokenSentVia, - identityTokenSentVia: data.identityTokenSentVia, - keyField: data.keyField, - usernameField: data.usernameField, - emailField: data.emailField, - nameField: data.nameField, - avatarField: data.avatarField, - rolesClaim: data.rolesClaim, - groupsClaim: data.groupsClaim, - mapChannels: data.mapChannels, - channelsMap: data.channelsMap, - channelsAdmin: data.channelsAdmin, - mergeUsers: data.mergeUsers, - mergeUsersDistinctServices: data.mergeUsersDistinctServices, - mergeRoles: data.mergeRoles, - rolesToSync: data.rolesToSync, - accessTokenParam: data.accessTokenParam, - showButton: data.showButton, - }); - } - if (serviceName === 'Facebook') { - data.appId = data.clientId; - delete data.clientId; - } - if (serviceName === 'Twitter') { - data.consumerKey = data.clientId; - delete data.clientId; - } - - if (serviceName === 'Linkedin') { - data.clientConfig = { - requestPermissions: ['openid', 'email', 'profile'], - }; - } - - if (serviceName === 'Nextcloud') { - data.buttonLabelText = settings.get('Accounts_OAuth_Nextcloud_button_label_text'); - data.buttonLabelColor = settings.get('Accounts_OAuth_Nextcloud_button_label_color'); - data.buttonColor = settings.get('Accounts_OAuth_Nextcloud_button_color'); - } - - // If there's no data other than the service name, then put the service name in the data object so the operation won't fail - const keys = Object.keys(data).filter((key) => data[key] !== undefined); - if (!keys.length) { - data.service = serviceName.toLowerCase(); - } - - await ServiceConfiguration.configurations.upsertAsync( - { - service: serviceName.toLowerCase(), - }, - { - $set: data, - }, - ); - } else { - await ServiceConfiguration.configurations.removeAsync({ - service: serviceName.toLowerCase(), - }); - } - } -} - -const OAuthServicesUpdate = _.debounce(_OAuthServicesUpdate, 2000); - -async function OAuthServicesRemove(_id) { - const serviceName = _id.replace('Accounts_OAuth_Custom-', ''); - return ServiceConfiguration.configurations.removeAsync({ - service: serviceName.toLowerCase(), - }); -} - -settings.watchByRegex(/^Accounts_OAuth_.+/, () => { - return OAuthServicesUpdate(); // eslint-disable-line new-cap -}); - -settings.watchByRegex(/^Accounts_OAuth_Custom-[a-z0-9_]+/, (key, value) => { - if (!value) { - return OAuthServicesRemove(key); // eslint-disable-line new-cap - } -}); - -async function customOAuthServicesInit() { - // Add settings for custom OAuth providers to the settings so they get - // automatically added when they are defined in ENV variables - for await (const key of Object.keys(process.env)) { - if (/Accounts_OAuth_Custom_[a-zA-Z0-9_-]+$/.test(key)) { - // Most all shells actually prohibit the usage of - in environment variables - // So this will allow replacing - with _ and translate it back to the setting name - let name = key.replace('Accounts_OAuth_Custom_', ''); - - if (name.indexOf('_') > -1) { - name = name.replace(name.substr(name.indexOf('_')), ''); - } - - const serviceKey = `Accounts_OAuth_Custom_${name}`; - - if (key === serviceKey) { - const values = { - enabled: process.env[`${serviceKey}`] === 'true', - clientId: process.env[`${serviceKey}_id`], - clientSecret: process.env[`${serviceKey}_secret`], - serverURL: process.env[`${serviceKey}_url`], - tokenPath: process.env[`${serviceKey}_token_path`], - identityPath: process.env[`${serviceKey}_identity_path`], - authorizePath: process.env[`${serviceKey}_authorize_path`], - scope: process.env[`${serviceKey}_scope`], - accessTokenParam: process.env[`${serviceKey}_access_token_param`], - buttonLabelText: process.env[`${serviceKey}_button_label_text`], - buttonLabelColor: process.env[`${serviceKey}_button_label_color`], - loginStyle: process.env[`${serviceKey}_login_style`], - buttonColor: process.env[`${serviceKey}_button_color`], - tokenSentVia: process.env[`${serviceKey}_token_sent_via`], - identityTokenSentVia: process.env[`${serviceKey}_identity_token_sent_via`], - keyField: process.env[`${serviceKey}_key_field`], - usernameField: process.env[`${serviceKey}_username_field`], - nameField: process.env[`${serviceKey}_name_field`], - emailField: process.env[`${serviceKey}_email_field`], - rolesClaim: process.env[`${serviceKey}_roles_claim`], - groupsClaim: process.env[`${serviceKey}_groups_claim`], - channelsMap: process.env[`${serviceKey}_groups_channel_map`], - channelsAdmin: process.env[`${serviceKey}_channels_admin`], - mergeUsers: process.env[`${serviceKey}_merge_users`] === 'true', - mergeUsersDistinctServices: process.env[`${serviceKey}_merge_users_distinct_services`] === 'true', - mapChannels: process.env[`${serviceKey}_map_channels`], - mergeRoles: process.env[`${serviceKey}_merge_roles`] === 'true', - rolesToSync: process.env[`${serviceKey}_roles_to_sync`], - showButton: process.env[`${serviceKey}_show_button`] === 'true', - avatarField: process.env[`${serviceKey}_avatar_field`], - }; - - await addOAuthService(name, values); - } - } - } -} - -await customOAuthServicesInit(); diff --git a/apps/meteor/app/lib/server/startup/rateLimiter.js b/apps/meteor/app/lib/server/startup/rateLimiter.js index a1ddfe87886c..5a312f4520d4 100644 --- a/apps/meteor/app/lib/server/startup/rateLimiter.js +++ b/apps/meteor/app/lib/server/startup/rateLimiter.js @@ -123,7 +123,7 @@ const checkNameForStream = (name) => name && !names.has(name) && name.startsWith const ruleIds = {}; -const callback = (msg, name) => (reply, input) => { +const callback = (msg, name) => async (reply, input) => { if (reply.allowed === false) { rateLimiterLog({ msg, reply, input }); metrics.ddpRateLimitExceeded.inc({ @@ -136,7 +136,7 @@ const callback = (msg, name) => (reply, input) => { }); // sleep before sending the error to slow down next requests if (slowDownRate > 0 && reply.numInvocationsExceeded) { - Promise.await(sleep(slowDownRate * reply.numInvocationsExceeded)); + await sleep(slowDownRate * reply.numInvocationsExceeded); } // } else { // console.log('DDP RATE LIMIT:', message); diff --git a/apps/meteor/app/livechat/imports/server/rest/appearance.ts b/apps/meteor/app/livechat/imports/server/rest/appearance.ts index 0fa365be1871..5a0a884d97b2 100644 --- a/apps/meteor/app/livechat/imports/server/rest/appearance.ts +++ b/apps/meteor/app/livechat/imports/server/rest/appearance.ts @@ -1,6 +1,7 @@ import { Settings } from '@rocket.chat/models'; import { isPOSTLivechatAppearanceParams } from '@rocket.chat/rest-typings'; +import { isTruthy } from '../../../../../lib/isTruthy'; import { API } from '../../../../api/server'; import { findAppearance } from '../../../server/api/lib/appearance'; @@ -52,8 +53,35 @@ API.v1.addRoute( throw new Error('invalid-setting'); } + const dbSettings = await Settings.findByIds(validSettingList, { projection: { _id: 1, value: 1, type: 1 } }) + .map((dbSetting) => { + const setting = settings.find(({ _id }) => _id === dbSetting._id); + if (!setting || dbSetting.value === setting.value) { + return; + } + + switch (dbSetting?.type) { + case 'boolean': + return { + _id: dbSetting._id, + value: setting.value === 'true' || setting.value === true, + }; + case 'int': + return { + _id: dbSetting._id, + value: coerceInt(setting.value), + }; + default: + return { + _id: dbSetting._id, + value: setting?.value, + }; + } + }) + .toArray(); + await Promise.all( - settings.map((setting) => { + dbSettings.filter(isTruthy).map((setting) => { return Settings.updateValueById(setting._id, setting.value); }), ); @@ -62,3 +90,20 @@ API.v1.addRoute( }, }, ); + +function coerceInt(value: string | number | boolean): number { + if (typeof value === 'number') { + return value; + } + + if (typeof value === 'boolean') { + return 0; + } + + const parsedValue = parseInt(value, 10); + if (Number.isNaN(parsedValue)) { + return 0; + } + + return parsedValue; +} diff --git a/apps/meteor/app/livechat/imports/server/rest/departments.ts b/apps/meteor/app/livechat/imports/server/rest/departments.ts index b8788aae2eed..816c298f0a05 100644 --- a/apps/meteor/app/livechat/imports/server/rest/departments.ts +++ b/apps/meteor/app/livechat/imports/server/rest/departments.ts @@ -117,23 +117,17 @@ API.v1.addRoute( const { _id } = this.urlParams; const { department, agents } = this.bodyParams; - let success; - if (permissionToSave) { - success = await LivechatEnterprise.saveDepartment(_id, department); + if (!permissionToSave) { + throw new Error('error-not-allowed'); } - if (success && agents && permissionToAddAgents) { - success = await LivechatTs.saveDepartmentAgents(_id, { upsert: agents }); - } + const agentParam = permissionToAddAgents && agents ? { upsert: agents } : {}; + await LivechatEnterprise.saveDepartment(_id, department, agentParam); - if (success) { - return API.v1.success({ - department: await LivechatDepartment.findOneById(_id), - agents: await LivechatDepartmentAgents.findByDepartmentId(_id).toArray(), - }); - } - - return API.v1.failure(); + return API.v1.success({ + department: await LivechatDepartment.findOneById(_id), + agents: await LivechatDepartmentAgents.findByDepartmentId(_id).toArray(), + }); }, async delete() { check(this.urlParams, { diff --git a/apps/meteor/app/livechat/imports/server/rest/inquiries.ts b/apps/meteor/app/livechat/imports/server/rest/inquiries.ts index d3a9eec7494d..8118f353b167 100644 --- a/apps/meteor/app/livechat/imports/server/rest/inquiries.ts +++ b/apps/meteor/app/livechat/imports/server/rest/inquiries.ts @@ -23,7 +23,7 @@ API.v1.addRoute( const { department } = this.queryParams; const ourQuery: { status: string; department?: string } = { status: 'queued' }; if (department) { - const departmentFromDB = await LivechatDepartment.findOneByIdOrName(department); + const departmentFromDB = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } }); if (departmentFromDB) { ourQuery.department = departmentFromDB._id; } diff --git a/apps/meteor/app/livechat/lib/messageTypes.ts b/apps/meteor/app/livechat/lib/messageTypes.ts index ace0d4e65bfa..8a1931a300dc 100644 --- a/apps/meteor/app/livechat/lib/messageTypes.ts +++ b/apps/meteor/app/livechat/lib/messageTypes.ts @@ -108,19 +108,25 @@ MessageTypes.registerType({ MessageTypes.registerType({ id: 'livechat_webrtc_video_call', - render(message) { + message: 'room_changed_type', + data(message) { if (message.msg === 'ended' && message.webRtcCallEndTs && message.ts) { - return t('WebRTC_call_ended_message', { - callDuration: formatDistance(new Date(message.webRtcCallEndTs), new Date(message.ts)), - endTime: moment(message.webRtcCallEndTs).format('h:mm A'), - }); + return { + message: t('WebRTC_call_ended_message', { + callDuration: formatDistance(new Date(message.webRtcCallEndTs), new Date(message.ts)), + endTime: moment(message.webRtcCallEndTs).format('h:mm A'), + }), + }; } if (message.msg === 'declined' && message.webRtcCallEndTs) { - return t('WebRTC_call_declined_message'); + return { + message: t('WebRTC_call_declined_message'), + }; } - return escapeHTML(message.msg); + return { + message: escapeHTML(message.msg), + }; }, - message: 'room_changed_type', }); MessageTypes.registerType({ diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 23d7fe2c507a..f610b9a9d3de 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -416,6 +416,10 @@ API.v1.addRoute( throw new Error('error-invalid-room'); } + if (!room.open) { + throw new Error('room-closed'); + } + if (!(await Omnichannel.isWithinMACLimit(room))) { throw new Error('error-mac-limit-reached'); } diff --git a/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts b/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts index 55de5bbf6315..a5f11caaab63 100644 --- a/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts +++ b/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts @@ -1,4 +1,4 @@ -import type { ILivechatAgentStatus, ILivechatBusinessHour, ILivechatDepartment } from '@rocket.chat/core-typings'; +import type { AtLeast, ILivechatAgentStatus, ILivechatBusinessHour, ILivechatDepartment } from '@rocket.chat/core-typings'; import type { ILivechatBusinessHoursModel, IUsersModel } from '@rocket.chat/model-typings'; import { LivechatBusinessHours, Users } from '@rocket.chat/models'; import moment from 'moment-timezone'; @@ -14,8 +14,8 @@ export interface IBusinessHourBehavior { onAddAgentToDepartment(options?: { departmentId: string; agentsId: string[] }): Promise; onRemoveAgentFromDepartment(options?: Record): Promise; onRemoveDepartment(options: { department: ILivechatDepartment; agentsIds: string[] }): Promise; - onDepartmentDisabled(department?: ILivechatDepartment): Promise; - onDepartmentArchived(department: Pick): Promise; + onDepartmentDisabled(department?: AtLeast): Promise; + onDepartmentArchived(department: Pick): Promise; onStartBusinessHours(): Promise; afterSaveBusinessHours(businessHourData: ILivechatBusinessHour): Promise; allowAgentChangeServiceStatus(agentId: string): Promise; diff --git a/apps/meteor/app/livechat/server/lib/Departments.ts b/apps/meteor/app/livechat/server/lib/Departments.ts index f17015e52e79..ed55a856e0b8 100644 --- a/apps/meteor/app/livechat/server/lib/Departments.ts +++ b/apps/meteor/app/livechat/server/lib/Departments.ts @@ -1,4 +1,4 @@ -import type { ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { LivechatDepartment, LivechatDepartmentAgents, LivechatRooms } from '@rocket.chat/models'; @@ -10,7 +10,9 @@ class DepartmentHelperClass { async removeDepartment(departmentId: string) { this.logger.debug(`Removing department: ${departmentId}`); - const department = await LivechatDepartment.findOneById(departmentId); + const department = await LivechatDepartment.findOneById>(departmentId, { + projection: { _id: 1, businessHourId: 1 }, + }); if (!department) { throw new Error('error-department-not-found'); } @@ -44,9 +46,7 @@ class DepartmentHelperClass { } }); - setImmediate(() => { - void callbacks.run('livechat.afterRemoveDepartment', { department, agentsIds }); - }); + await callbacks.run('livechat.afterRemoveDepartment', { department, agentsIds }); return ret; } diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index c1acc87018e8..771f50724c38 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -372,7 +372,6 @@ export const dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, age // fake a subscription in order to make use of the function defined above subscription: { rid, - t: 'l', u: { _id, }, @@ -386,13 +385,14 @@ export const dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, age username, }, ], + name: '', }, sender: v, hasMentionToAll: true, // consider all agents to be in the room hasReplyToThread: false, disableAllMessageNotifications: false, hasMentionToHere: false, - message: Object.assign({}, { u: v }), + message: { _id: '', u: v, msg: '' }, // we should use server's language for this type of messages instead of user's notificationMessage: i18n.t('User_started_a_new_conversation', { username: notificationUserName }, language), room: Object.assign(room, { name: i18n.t('New_chat_in_queue', {}, language) }), @@ -561,16 +561,20 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi const { servedBy, chatQueued } = roomTaken; if (!chatQueued && oldServedBy && servedBy && oldServedBy._id === servedBy._id) { const department = departmentId - ? await LivechatDepartment.findOneById>(departmentId, { - projection: { fallbackForwardDepartment: 1 }, + ? await LivechatDepartment.findOneById>(departmentId, { + projection: { fallbackForwardDepartment: 1, name: 1 }, }) : null; if (!department?.fallbackForwardDepartment?.length) { logger.debug(`Cannot forward room ${room._id}. Chat assigned to agent ${servedBy._id} (Previous was ${oldServedBy._id})`); throw new Error('error-no-agents-online-in-department'); } + + if (!transferData.originalDepartmentName) { + transferData.originalDepartmentName = department.name; + } // if a chat has a fallback department, attempt to redirect chat to there [EE] - const transferSuccess = !!(await callbacks.run('livechat:onTransferFailure', room, { guest, transferData })); + const transferSuccess = !!(await callbacks.run('livechat:onTransferFailure', room, { guest, transferData, department })); // On CE theres no callback so it will return the room if (typeof transferSuccess !== 'boolean' || !transferSuccess) { logger.debug(`Cannot forward room ${room._id}. Unable to delegate inquiry`); @@ -580,6 +584,23 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi return true; } + // Send just 1 message to the room to inform the user that the chat was transferred + if (transferData.usingFallbackDep) { + const { _id, username } = transferData.transferredBy; + await Message.saveSystemMessage( + 'livechat_transfer_history_fallback', + room._id, + '', + { _id, username }, + { + transferData: { + ...transferData, + prevDepartment: transferData.originalDepartmentName, + }, + }, + ); + } + await LivechatTyped.saveTransferHistory(room, transferData); if (oldServedBy) { // if chat is queued then we don't ignore the new servedBy agent bcs at this @@ -648,13 +669,24 @@ export const updateDepartmentAgents = async ( departmentEnabled: boolean, ) => { check(departmentId, String); - check( - agents, - Match.ObjectIncluding({ - upsert: Match.Maybe(Array), - remove: Match.Maybe(Array), - }), - ); + check(agents, { + upsert: Match.Maybe([ + Match.ObjectIncluding({ + agentId: String, + username: Match.Maybe(String), + count: Match.Maybe(Match.Integer), + order: Match.Maybe(Match.Integer), + }), + ]), + remove: Match.Maybe([ + Match.ObjectIncluding({ + agentId: String, + username: Match.Maybe(String), + count: Match.Maybe(Match.Integer), + order: Match.Maybe(Match.Integer), + }), + ]), + }); const { upsert = [], remove = [] } = agents; const agentsRemoved = []; diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index ea508b047882..79d225626ea1 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -290,11 +290,17 @@ class LivechatClass { this.logger.debug(`Updating DB for room ${room._id} with close data`); - await Promise.all([ - LivechatRooms.closeRoomById(rid, closeData), - LivechatInquiry.removeByRoomId(rid), - Subscriptions.removeByRoomId(rid), - ]); + const removedInquiry = await LivechatInquiry.removeByRoomId(rid); + if (removedInquiry && removedInquiry.deletedCount !== 1) { + throw new Error('Error removing inquiry'); + } + + const updatedRoom = await LivechatRooms.closeRoomById(rid, closeData); + if (!updatedRoom || updatedRoom.modifiedCount !== 1) { + throw new Error('Error closing room'); + } + + await Subscriptions.removeByRoomId(rid); this.logger.debug(`DB updated for room ${room._id}`); @@ -469,7 +475,7 @@ class LivechatClass { }, }; - const dep = await LivechatDepartment.findOneById(department); + const dep = await LivechatDepartment.findOneById>(department, { projection: { _id: 1 } }); if (!dep) { throw new Meteor.Error('invalid-department', 'Provided department does not exists'); } @@ -981,7 +987,9 @@ class LivechatClass { } async archiveDepartment(_id: string) { - const department = await LivechatDepartment.findOneById(_id, { projection: { _id: 1 } }); + const department = await LivechatDepartment.findOneById>(_id, { + projection: { _id: 1, businessHourId: 1 }, + }); if (!department) { throw new Error('department-not-found'); @@ -1047,7 +1055,7 @@ class LivechatClass { } if (transferData.departmentId) { - const department = await LivechatDepartment.findOneById(transferData.departmentId, { + const department = await LivechatDepartment.findOneById>(transferData.departmentId, { projection: { name: 1 }, }); if (!department) { diff --git a/apps/meteor/app/livechat/server/livechat.ts b/apps/meteor/app/livechat/server/livechat.ts index 4a3847fecd9d..795deaba3452 100644 --- a/apps/meteor/app/livechat/server/livechat.ts +++ b/apps/meteor/app/livechat/server/livechat.ts @@ -1,5 +1,7 @@ import url from 'url'; +import jsdom from 'jsdom'; +import mem from 'mem'; import { WebApp } from 'meteor/webapp'; import { settings } from '../../settings/server'; @@ -7,6 +9,37 @@ import { addServerUrlToIndex } from '../lib/Assets'; const indexHtmlWithServerURL = addServerUrlToIndex((await Assets.getTextAsync('livechat/index.html')) || ''); +function parseExtraAttributes(widgetData: string): string { + const liveChatAdditionalScripts = settings.get('Livechat_AdditionalWidgetScripts'); + const additionalClass = settings.get('Livechat_WidgetLayoutClasses'); + + if (liveChatAdditionalScripts == null || additionalClass == null) { + return widgetData; + } + + const domParser = new jsdom.JSDOM(widgetData); + const doc = domParser.window.document; + const head = doc.querySelector('head'); + const body = doc.querySelector('body'); + + liveChatAdditionalScripts.split(',').forEach((script) => { + const scriptElement = doc.createElement('script'); + scriptElement.src = script; + body?.appendChild(scriptElement); + }); + + additionalClass.split(',').forEach((css) => { + const linkElement = doc.createElement('link'); + linkElement.rel = 'stylesheet'; + linkElement.href = css; + head?.appendChild(linkElement); + }); + + return doc.documentElement.innerHTML; +} + +const memoizedParseExtraAttributes = mem(parseExtraAttributes, { maxAge: process.env.TEST_MODE === 'true' ? 1 : 60000 }); + WebApp.connectHandlers.use('/livechat', (req, res, next) => { if (!req.url) { return next(); @@ -18,24 +51,21 @@ WebApp.connectHandlers.use('/livechat', (req, res, next) => { } res.setHeader('content-type', 'text/html; charset=utf-8'); - const domainWhiteListSetting = settings.get('Livechat_AllowedDomainsList'); let domainWhiteList = []; if (req.headers.referer && domainWhiteListSetting.trim()) { domainWhiteList = domainWhiteListSetting.split(',').map((domain) => domain.trim()); - const referer = url.parse(req.headers.referer); if (referer.host && !domainWhiteList.includes(referer.host)) { res.setHeader('Content-Security-Policy', "frame-ancestors 'none'"); return next(); } - res.setHeader('Content-Security-Policy', `frame-ancestors ${referer.protocol}//${referer.host}`); } else { // TODO need to remove inline scripts from this route to be able to enable CSP here as well res.removeHeader('Content-Security-Policy'); } - res.write(indexHtmlWithServerURL); + res.write(memoizedParseExtraAttributes(indexHtmlWithServerURL)); res.end(); }); diff --git a/apps/meteor/app/livechat/server/methods/sendFileLivechatMessage.ts b/apps/meteor/app/livechat/server/methods/sendFileLivechatMessage.ts index 7d64763cd634..15577abd76e3 100644 --- a/apps/meteor/app/livechat/server/methods/sendFileLivechatMessage.ts +++ b/apps/meteor/app/livechat/server/methods/sendFileLivechatMessage.ts @@ -1,5 +1,5 @@ import type { - MessageAttachment, + FileAttachmentProps, ImageAttachmentProps, AudioAttachmentProps, VideoAttachmentProps, @@ -56,7 +56,7 @@ export const sendFileLivechatMessage = async ({ roomId, visitorToken, file, msgD const fileUrl = file.name && FileUpload.getPath(`${file._id}/${encodeURI(file.name)}`); - const attachment: MessageAttachment = { + const attachment: Partial = { title: file.name, type: 'file', description: file.description, diff --git a/apps/meteor/app/livechat/server/startup.ts b/apps/meteor/app/livechat/server/startup.ts index 3ea87f3d568f..547deb6044ce 100644 --- a/apps/meteor/app/livechat/server/startup.ts +++ b/apps/meteor/app/livechat/server/startup.ts @@ -65,7 +65,7 @@ Meteor.startup(async () => { await createDefaultBusinessHourIfNotExists(); settings.watch('Livechat_enable_business_hours', async (value) => { - logger.info(`Changing business hour type to ${value}`); + logger.debug(`Starting business hour manager ${value}`); if (value) { await businessHourManager.startManager(); return; diff --git a/apps/meteor/app/mail-messages/server/functions/sendMail.ts b/apps/meteor/app/mail-messages/server/functions/sendMail.ts index ce57fc06b1c8..50435bc24812 100644 --- a/apps/meteor/app/mail-messages/server/functions/sendMail.ts +++ b/apps/meteor/app/mail-messages/server/functions/sendMail.ts @@ -36,33 +36,38 @@ export const sendMail = async function ({ userQuery = { $and: [userQuery, EJSON.parse(query)] }; } - const users = await Users.find(userQuery).toArray(); - if (dryrun) { - for await (const u of users) { - const user: Partial & Pick = u; - const email = `${user.name} <${user.emails?.[0].address}>`; - const html = placeholders.replace(body, { - unsubscribe: Meteor.absoluteUrl( - generatePath('mailer/unsubscribe/:_id/:createdAt', { - _id: user._id, - createdAt: user.createdAt?.getTime().toString() || '', - }), - ), - name: user.name, - email, - }); + const user = await Users.findOneByEmailAddress(from); - SystemLogger.debug(`Sending email to ${email}`); - await Mailer.send({ - to: email, - from, - subject, - html, + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + function: 'Mailer.sendMail', }); } + + const email = `${user.name} <${user.emails?.[0].address}>`; + const html = placeholders.replace(body, { + unsubscribe: Meteor.absoluteUrl( + generatePath('mailer/unsubscribe/:_id/:createdAt', { + _id: user._id, + createdAt: user.createdAt?.getTime().toString() || '', + }), + ), + name: user.name, + email, + }); + + SystemLogger.debug(`Sending email to ${email}`); + return Mailer.send({ + to: email, + from, + subject, + html, + }); } + const users = await Users.find(userQuery).toArray(); + for await (const u of users) { const user: Partial & Pick = u; if (user?.emails && Array.isArray(user.emails) && user.emails.length) { diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index ff38c7e8d4bc..1ed0a172028b 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -1,4 +1,4 @@ -import { Message, api } from '@rocket.chat/core-services'; +import { Message } from '@rocket.chat/core-services'; import { isQuoteAttachment, isRegisterUser } from '@rocket.chat/core-typings'; import type { IMessage, MessageAttachment, MessageQuoteAttachment } from '@rocket.chat/core-typings'; import { Messages, Rooms, Subscriptions, Users, ReadReceipts } from '@rocket.chat/models'; @@ -8,7 +8,7 @@ import { Meteor } from 'meteor/meteor'; import { Apps, AppEvents } from '../../../ee/server/apps/orchestrator'; import { isTruthy } from '../../../lib/isTruthy'; -import { broadcastMessageSentEvent } from '../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../server/modules/watchers/lib/messages'; import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization/server'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { isTheLastMessage } from '../../lib/server/functions/isTheLastMessage'; @@ -222,9 +222,8 @@ Meteor.methods({ if (settings.get('Message_Read_Receipt_Store_Users')) { await ReadReceipts.setPinnedByMessageId(originalMessage._id, originalMessage.pinned); } - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: message._id, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); return true; diff --git a/apps/meteor/app/message-star/server/starMessage.ts b/apps/meteor/app/message-star/server/starMessage.ts index aaa5657c5b35..8f025d920057 100644 --- a/apps/meteor/app/message-star/server/starMessage.ts +++ b/apps/meteor/app/message-star/server/starMessage.ts @@ -1,11 +1,10 @@ -import { api } from '@rocket.chat/core-services'; import type { IMessage } from '@rocket.chat/core-typings'; import { Messages, Subscriptions, Rooms } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { Apps, AppEvents } from '../../../ee/server/apps/orchestrator'; -import { broadcastMessageSentEvent } from '../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../server/modules/watchers/lib/messages'; import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization/server'; import { isTheLastMessage } from '../../lib/server/functions/isTheLastMessage'; import { settings } from '../../settings/server'; @@ -62,9 +61,8 @@ Meteor.methods({ await Messages.updateUserStarById(message._id, uid, message.starred); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: message._id, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); return true; diff --git a/apps/meteor/app/meteor-accounts-saml/client/index.ts b/apps/meteor/app/meteor-accounts-saml/client/index.ts deleted file mode 100644 index 5ca4ae3d5c18..000000000000 --- a/apps/meteor/app/meteor-accounts-saml/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './saml_client'; diff --git a/apps/meteor/app/meteor-accounts-saml/client/saml_client.js b/apps/meteor/app/meteor-accounts-saml/client/saml_client.js deleted file mode 100644 index f1f14be530dd..000000000000 --- a/apps/meteor/app/meteor-accounts-saml/client/saml_client.js +++ /dev/null @@ -1,86 +0,0 @@ -import { Random } from '@rocket.chat/random'; -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; - -import { sdk } from '../../utils/client/lib/SDKClient'; - -if (!Accounts.saml) { - Accounts.saml = {}; -} - -// Override the standard logout behaviour. -// -// If we find a samlProvider, and we are using single -// logout we will initiate logout from rocketchat via saml. -// If not using single logout, we just do the standard logout. -// This can be overridden by a configured logout behaviour. -// -// TODO: This may need some work as it is not clear if we are really -// logging out of the idp when doing the standard logout. - -const MeteorLogout = Meteor.logout; -const logoutBehaviour = { - TERMINATE_SAML: 'SAML', - ONLY_RC: 'Local', -}; - -Meteor.logout = async function (...args) { - const samlService = await ServiceConfiguration.configurations.findOneAsync({ service: 'saml' }); - if (samlService) { - const provider = samlService.clientConfig && samlService.clientConfig.provider; - if (provider) { - if (samlService.logoutBehaviour == null || samlService.logoutBehaviour === logoutBehaviour.TERMINATE_SAML) { - if (samlService.idpSLORedirectURL) { - console.info('SAML session terminated via SLO'); - return Meteor.logoutWithSaml({ provider }); - } - } - - if (samlService.logoutBehaviour === logoutBehaviour.ONLY_RC) { - console.info('SAML session not terminated, only the Rocket.Chat session is going to be killed'); - } - } - } - return MeteorLogout.apply(Meteor, args); -}; - -Meteor.loginWithSaml = function (options /* , callback*/) { - options = options || {}; - const credentialToken = `id-${Random.id()}`; - options.credentialToken = credentialToken; - - window.location.href = `_saml/authorize/${options.provider}/${options.credentialToken}`; -}; - -Meteor.logoutWithSaml = function (options /* , callback*/) { - // Accounts.saml.idpInitiatedSLO(options, callback); - sdk - .call('samlLogout', options.provider) - .then((result) => { - if (!result) { - MeteorLogout.apply(Meteor); - return; - } - - // Remove the userId from the client to prevent calls to the server while the logout is processed. - // If the logout fails, the userId will be reloaded on the resume call - Meteor._localStorage.removeItem(Accounts.USER_ID_KEY); - - // A nasty bounce: 'result' has the SAML LogoutRequest but we need a proper 302 to redirected from the server. - window.location.replace(Meteor.absoluteUrl(`_saml/sloRedirect/${options.provider}/?redirect=${encodeURIComponent(result)}`)); - }) - .catch(() => MeteorLogout.apply(Meteor)); -}; - -Meteor.loginWithSamlToken = function (token, userCallback) { - Accounts.callLoginMethod({ - methodArguments: [ - { - saml: true, - credentialToken: token, - }, - ], - userCallback, - }); -}; diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts index 31bb8e37cfac..2086e934271e 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts @@ -1,5 +1,6 @@ +import type { SAMLConfiguration } from '@rocket.chat/core-typings'; +import { LoginServiceConfiguration } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings, settingsRegistry } from '../../../settings/server'; @@ -17,13 +18,13 @@ import { defaultMetadataCertificateTemplate, } from './constants'; -const getSamlConfigs = function (service: string): Record { - const configs = { +const getSamlConfigs = function (service: string): SAMLConfiguration { + const configs: SAMLConfiguration = { buttonLabelText: settings.get(`${service}_button_label_text`), buttonLabelColor: settings.get(`${service}_button_label_color`), buttonColor: settings.get(`${service}_button_color`), clientConfig: { - provider: settings.get(`${service}_provider`), + provider: settings.get(`${service}_provider`), }, entryPoint: settings.get(`${service}_entry_point`), idpSLORedirectURL: settings.get(`${service}_idp_slo_redirect_url`), @@ -115,19 +116,10 @@ export const loadSamlServiceProviders = async function (): Promise { if (value === true) { const samlConfigs = getSamlConfigs(key); SAMLUtils.log(key); - await ServiceConfiguration.configurations.upsertAsync( - { - service: serviceName.toLowerCase(), - }, - { - $set: samlConfigs, - }, - ); + await LoginServiceConfiguration.createOrUpdateService(serviceName, samlConfigs); return configureSamlService(samlConfigs); } - await ServiceConfiguration.configurations.removeAsync({ - service: serviceName.toLowerCase(), - }); + await LoginServiceConfiguration.removeService(serviceName); return false; }), ) diff --git a/apps/meteor/app/meteor-accounts-saml/server/methods/samlLogout.ts b/apps/meteor/app/meteor-accounts-saml/server/methods/samlLogout.ts index 2b960059f164..956426082d40 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/methods/samlLogout.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/methods/samlLogout.ts @@ -28,7 +28,7 @@ function getSamlServiceProviderOptions(provider: string): IServiceProviderOption declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - samlLogout(provider: string): Promise; + samlLogout(provider: string): string | undefined; } } diff --git a/apps/meteor/app/meteor-accounts-saml/server/startup.ts b/apps/meteor/app/meteor-accounts-saml/server/startup.ts index 7a2bf16d3244..556ab7df13e7 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/startup.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/startup.ts @@ -1,4 +1,5 @@ import { Logger } from '@rocket.chat/logger'; +import debounce from 'lodash.debounce'; import { Meteor } from 'meteor/meteor'; import { settings } from '../../settings/server'; @@ -10,5 +11,6 @@ SAMLUtils.setLoggerInstance(logger); Meteor.startup(async () => { await addSettings('Default'); - settings.watchByRegex(/^SAML_.+/, loadSamlServiceProviders); }); + +settings.watchByRegex(/^SAML_.+/, debounce(loadSamlServiceProviders, 2000)); diff --git a/apps/meteor/app/models/client/index.ts b/apps/meteor/app/models/client/index.ts index b4c540318a9b..354baa2b71fd 100644 --- a/apps/meteor/app/models/client/index.ts +++ b/apps/meteor/app/models/client/index.ts @@ -14,20 +14,6 @@ import { RoomRoles } from './models/RoomRoles'; import { UserAndRoom } from './models/UserAndRoom'; import { UserRoles } from './models/UserRoles'; import { Users } from './models/Users'; -import { WebdavAccounts } from './models/WebdavAccounts'; - -// overwrite Meteor.users collection so records on it don't get erased whenever the client reconnects to websocket -const meteorUserOverwrite = () => { - const uid = Meteor.userId(); - - if (!uid) { - return null; - } - - return (Users.findOne({ _id: uid }) ?? null) as Meteor.User | null; -}; -Meteor.users = Users as typeof Meteor.users; -Meteor.user = meteorUserOverwrite; export { Base, @@ -43,7 +29,6 @@ export { ChatPermissions, CustomSounds, EmojiCustom, - WebdavAccounts, /** @deprecated */ Users, /** @deprecated */ diff --git a/apps/meteor/app/models/client/models/ChatPermissions.ts b/apps/meteor/app/models/client/models/ChatPermissions.ts index 8f1c7b18c060..e836f58ebb2e 100644 --- a/apps/meteor/app/models/client/models/ChatPermissions.ts +++ b/apps/meteor/app/models/client/models/ChatPermissions.ts @@ -4,7 +4,7 @@ import { CachedCollection } from '../../../ui-cached-collection/client'; export const AuthzCachedCollection = new CachedCollection({ name: 'permissions', - eventType: 'onLogged', + eventType: 'notify-logged', }); export const ChatPermissions = AuthzCachedCollection.collection; diff --git a/apps/meteor/app/models/client/models/WebdavAccounts.ts b/apps/meteor/app/models/client/models/WebdavAccounts.ts deleted file mode 100644 index fbc1092cd86e..000000000000 --- a/apps/meteor/app/models/client/models/WebdavAccounts.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Mongo } from 'meteor/mongo'; - -/** @deprecated */ -export const WebdavAccounts = new Mongo.Collection<{ - _id: string; - name: string; - username: string; - serverURL: string; -}>(null); diff --git a/apps/meteor/app/nextcloud/client/lib.ts b/apps/meteor/app/nextcloud/client/lib.ts index 12a54217691c..fb7f5391bc3a 100644 --- a/apps/meteor/app/nextcloud/client/lib.ts +++ b/apps/meteor/app/nextcloud/client/lib.ts @@ -3,7 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import _ from 'underscore'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; const config: OauthConfig = { diff --git a/apps/meteor/app/notifications/client/index.ts b/apps/meteor/app/notifications/client/index.ts deleted file mode 100644 index 1cb2af14e70e..000000000000 --- a/apps/meteor/app/notifications/client/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Notifications from './lib/Notifications'; - -export { Notifications }; diff --git a/apps/meteor/app/notifications/client/lib/Notifications.ts b/apps/meteor/app/notifications/client/lib/Notifications.ts deleted file mode 100644 index 8d77b9e3a0cb..000000000000 --- a/apps/meteor/app/notifications/client/lib/Notifications.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { StreamKeys, StreamerCallback } from '@rocket.chat/ddp-client/src/types/streams'; -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -import './Presence'; -import { sdk } from '../../../utils/client/lib/SDKClient'; - -type ExtractSecondString = E extends `${string}/${infer X}` ? X : never; - -class Notifications { - private logged: boolean; - - private loginCb: any[]; - - private debug: boolean; - - constructor() { - this.logged = Meteor.userId() !== null; - this.loginCb = []; - Tracker.autorun(() => { - if (Meteor.userId() !== null && this.logged === false) { - this.loginCb.forEach((cb) => cb()); - } - this.logged = Meteor.userId() !== null; - }); - this.debug = false; - } - - onLogged>(eventName: E, callback: StreamerCallback<'notify-logged', E>) { - return this.onLogin(() => sdk.stream('notify-logged', [eventName], callback)); - } - - onAll>(eventName: E, callback: StreamerCallback<'notify-all', E>) { - return sdk.stream('notify-all', [eventName], callback); - } - - onRoom>>( - room: string, - eventName: E, - callback: StreamerCallback<'notify-room', `${string}/${E}`>, - ) { - return sdk.stream('notify-room', [`${room}/${eventName}`], callback); - } - - onUser>>( - eventName: E, - callback: StreamerCallback<'notify-user', `${string}/${E}`>, - ) { - return sdk.stream('notify-user', [`${Meteor.userId()}/${eventName}`], callback); - } - - onVisitor>>( - visitor: string, - eventName: E, - callback: StreamerCallback<'notify-user', `${string}/${E}`>, - ) { - return sdk.stream('notify-user', [`${visitor}/${eventName}`], callback); - } - - unUser>>(eventName: E) { - return sdk.stop('notify-user', `${Meteor.userId()}/${eventName}`); - } - - unRoom>>(room: string, eventName: E) { - return sdk.stop('notify-room', `${room}/${eventName}`); - } - - onLogin(cb: () => void) { - this.loginCb.push(cb); - if (this.logged) { - return cb(); - } - } - - notifyRoom>>(room: string, eventName: E, ...args: any[]) { - args.unshift(`${room}/${eventName}`); - return sdk.publish('notify-room', args); - } - - notifyVisitor>>(visitor: string, eventName: E, ...args: any[]) { - args.unshift(`${visitor}/${eventName}`); - return sdk.publish('notify-user', args); - } - - notifyUser>>(uid: string, eventName: E, ...args: any[]) { - args.unshift(`${uid}/${eventName}`); - return sdk.publish('notify-user', args); - } - - notifyUsersOfRoom>>(room: string, eventName: E, ...args: any[]) { - if (this.debug === true) { - console.log('RocketChat.Notifications: notifyUsersOfRoomExceptSender', [room, eventName, ...args]); - } - args.unshift(`${room}/${eventName}`); - return sdk.publish('notify-room-users', args); - } -} - -/** @deprecated it should be used `sdk`instead both perform the same */ -const ns = new Notifications(); - -export default ns; diff --git a/apps/meteor/app/notifications/client/lib/Presence.ts b/apps/meteor/app/notifications/client/lib/Presence.ts index 69c255aaf1f9..3cfc98c440c2 100644 --- a/apps/meteor/app/notifications/client/lib/Presence.ts +++ b/apps/meteor/app/notifications/client/lib/Presence.ts @@ -1,17 +1,13 @@ -import type { StreamerEvents } from '@rocket.chat/ui-contexts'; +import type { UserStatus } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import { Presence, STATUS_MAP } from '../../../../client/lib/presence'; +import { Presence } from '../../../../client/lib/presence'; // TODO implement API on Streamer to be able to listen to all streamed data // this is a hacky way to listen to all streamed data from user-presence Streamer new Meteor.Streamer('user-presence'); -(Meteor as any).StreamerCentral.on('stream-user-presence', (uid: string, ...args: StreamerEvents['user-presence'][number]['args']) => { - if (!Array.isArray(args)) { - throw new Error('Presence event must be an array'); - } - const [[username, status, statusText]] = args; - Presence.notify({ _id: uid, username, status: STATUS_MAP[status ?? 0], statusText }); +Meteor.StreamerCentral.on('stream-user-presence', (uid: string, username: string, statusChanged?: UserStatus, statusText?: string) => { + Presence.notify({ _id: uid, username, status: statusChanged, statusText }); }); diff --git a/apps/meteor/app/oembed/server/server.ts b/apps/meteor/app/oembed/server/server.ts index 79de0402043f..1e758b1371e8 100644 --- a/apps/meteor/app/oembed/server/server.ts +++ b/apps/meteor/app/oembed/server/server.ts @@ -1,5 +1,12 @@ -import type { OEmbedUrlContentResult, OEmbedUrlWithMetadata, IMessage, MessageAttachment, OEmbedMeta } from '@rocket.chat/core-typings'; -import { isOEmbedUrlContentResult, isOEmbedUrlWithMetadata } from '@rocket.chat/core-typings'; +import type { + OEmbedUrlContentResult, + OEmbedUrlWithMetadata, + IMessage, + MessageAttachment, + OEmbedMeta, + MessageUrl, +} from '@rocket.chat/core-typings'; +import { isOEmbedUrlWithMetadata } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { Messages, OEmbedCache } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -128,6 +135,41 @@ const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise { + const parsedUrlObject: MessageUrl = { url, meta: {} }; + let foundMeta = false; + if (!isURL(url)) { + return { urlPreview: parsedUrlObject, foundMeta }; + } + + const data = await getUrlMetaWithCache(url); + if (!data) { + return { urlPreview: parsedUrlObject, foundMeta }; + } + + if (isOEmbedUrlWithMetadata(data) && data.meta) { + parsedUrlObject.meta = getRelevantMetaTags(data.meta) || {}; + if (parsedUrlObject.meta?.oembedHtml) { + parsedUrlObject.meta.oembedHtml = insertMaxWidthInOembedHtml(parsedUrlObject.meta.oembedHtml) || ''; + } + } + + foundMeta = true; + return { + urlPreview: { + ...parsedUrlObject, + ...((parsedUrlObject.headers || data.headers) && { + headers: { + ...parsedUrlObject.headers, + ...(data.headers?.contentLength && { contentLength: data.headers.contentLength }), + ...(data.headers?.contentType && { contentType: data.headers.contentType }), + }, + }), + }, + foundMeta, + }; +}; + const getUrlMeta = async function ( url: string, withFragment?: boolean, @@ -151,10 +193,6 @@ const getUrlMeta = async function ( return; } - if (content.attachments) { - return content; - } - log.debug('Parsing metadata for URL', url); const metas: { [k: string]: string } = {}; @@ -273,37 +311,10 @@ const rocketUrlParser = async function (message: IMessage): Promise { continue; } - if (!isURL(item.url)) { - continue; - } - - 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 (data.headers?.contentLength) { - item.headers = { ...item.headers, contentLength: data.headers.contentLength }; - } - - if (data.headers?.contentType) { - item.headers = { ...item.headers, contentType: data.headers.contentType }; - } + const { urlPreview, foundMeta } = await parseUrl(item.url); - changed = true; + Object.assign(item, foundMeta ? urlPreview : {}); + changed = changed || foundMeta; } if (attachments.length) { @@ -321,10 +332,12 @@ const OEmbed: { getUrlMeta: (url: string, withFragment?: boolean) => Promise; getUrlMetaWithCache: (url: string, withFragment?: boolean) => Promise; rocketUrlParser: (message: IMessage) => Promise; + parseUrl: (url: string) => Promise<{ urlPreview: MessageUrl; foundMeta: boolean }>; } = { rocketUrlParser, getUrlMetaWithCache, getUrlMeta, + parseUrl, }; settings.watch('API_Embed', (value) => { diff --git a/apps/meteor/app/push/server/push.ts b/apps/meteor/app/push/server/push.ts index a80631d335bf..2594625155fa 100644 --- a/apps/meteor/app/push/server/push.ts +++ b/apps/meteor/app/push/server/push.ts @@ -283,11 +283,11 @@ class PushClass { logger.debug('GUIDE: The "AppsTokens" is empty - No clients have registered on the server yet...'); } } else if (!countApn.length) { - if ((await AppsTokens.col.countDocuments({ 'token.apn': { $exists: true } })) === 0) { + if ((await AppsTokens.countApnTokens()) === 0) { logger.debug('GUIDE: The "AppsTokens" - No APN clients have registered on the server yet...'); } } else if (!countGcm.length) { - if ((await AppsTokens.col.countDocuments({ 'token.gcm': { $exists: true } })) === 0) { + if ((await AppsTokens.countGcmTokens()) === 0) { logger.debug('GUIDE: The "AppsTokens" - No GCM clients have registered on the server yet...'); } } diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index fab1100fc615..27fe4d36a053 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -8,7 +8,7 @@ import _ from 'underscore'; import { AppEvents, Apps } from '../../../ee/server/apps/orchestrator'; import { callbacks } from '../../../lib/callbacks'; import { i18n } from '../../../server/lib/i18n'; -import { broadcastMessageSentEvent } from '../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../server/modules/watchers/lib/messages'; import { canAccessRoomAsync } from '../../authorization/server'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { emoji } from '../../emoji/server'; @@ -108,9 +108,8 @@ async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction await Apps.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: message._id, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } diff --git a/apps/meteor/app/statistics/server/lib/SAUMonitor.ts b/apps/meteor/app/statistics/server/lib/SAUMonitor.ts index b3aa68337106..0db63a929491 100644 --- a/apps/meteor/app/statistics/server/lib/SAUMonitor.ts +++ b/apps/meteor/app/statistics/server/lib/SAUMonitor.ts @@ -146,7 +146,7 @@ export class SAUMonitorClass { const searchTerm = this._getSearchTerm(data); - await Sessions.insertOne({ ...data, searchTerm, createdAt: new Date() }); + await Sessions.createOrUpdate({ ...data, searchTerm }); } private async _finishSessionsFromDate(yesterday: Date, today: Date): Promise { diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index c0ef16b5025b..8887f4ce36f2 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -555,7 +555,7 @@ export const statistics = { statistics.totalLinkInvitationUses = await Invites.countUses(); statistics.totalEmailInvitation = settings.get('Invitation_Email_Count'); statistics.totalE2ERooms = await Rooms.findByE2E({ readPreference }).count(); - statistics.logoChange = Object.keys(settings.get('Assets_logo')).includes('url'); + statistics.logoChange = Object.keys(settings.get('Assets_logo') || {}).includes('url'); statistics.showHomeButton = settings.get('Layout_Show_Home_Button'); statistics.totalEncryptedMessages = await Messages.countByType('e2e', { readPreference }); statistics.totalManuallyAddedUsers = settings.get('Manual_Entry_User_Count'); @@ -592,7 +592,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); + try { + statistics.dailyPeakConnections = await Presence.getPeakConnections(true); + } catch { + statistics.dailyPeakConnections = 0; + } const peak = await Statistics.findMonthlyPeakConnections(); statistics.maxMonthlyPeakConnections = Math.max(statistics.dailyPeakConnections, peak?.dailyPeakConnections || 0); diff --git a/apps/meteor/app/theme/client/imports/general/base.css b/apps/meteor/app/theme/client/imports/general/base.css index d1f4b8d11fb6..9aa9b6c5ea9f 100644 --- a/apps/meteor/app/theme/client/imports/general/base.css +++ b/apps/meteor/app/theme/client/imports/general/base.css @@ -34,7 +34,7 @@ body { } :focus { - outline: 0 !important; + outline: 0; outline-style: none; outline-color: transparent; } @@ -50,10 +50,6 @@ body { } } -a { - cursor: pointer; -} - button { padding: 0; diff --git a/apps/meteor/app/theme/client/imports/general/base_old.css b/apps/meteor/app/theme/client/imports/general/base_old.css index 7f1ede6067fc..cead4a2cb584 100644 --- a/apps/meteor/app/theme/client/imports/general/base_old.css +++ b/apps/meteor/app/theme/client/imports/general/base_old.css @@ -86,8 +86,6 @@ font-size: smaller; & a { - text-decoration: underline; - font-weight: bold !important; } } @@ -605,7 +603,7 @@ overflow: hidden; flex-direction: column; - margin: 5px 10px 0; + margin: 8px 10px 0; transition: transform 0.4s ease, visibility 0.3s ease, opacity 0.3s ease; transform: translateY(-10px); diff --git a/apps/meteor/app/theme/client/imports/general/reset.css b/apps/meteor/app/theme/client/imports/general/reset.css index ad5cbd9cddf2..425d9ff98319 100644 --- a/apps/meteor/app/theme/client/imports/general/reset.css +++ b/apps/meteor/app/theme/client/imports/general/reset.css @@ -137,7 +137,3 @@ table { border-collapse: collapse; } - -a { - color: var(--rcx-color-font-info, #095ad2); -} diff --git a/apps/meteor/app/threads/server/hooks/aftersavemessage.ts b/apps/meteor/app/threads/server/hooks/aftersavemessage.ts index 6fa780e12f8d..4b0f94aa8b52 100644 --- a/apps/meteor/app/threads/server/hooks/aftersavemessage.ts +++ b/apps/meteor/app/threads/server/hooks/aftersavemessage.ts @@ -1,11 +1,10 @@ -import { api } from '@rocket.chat/core-services'; import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../lib/callbacks'; -import { broadcastMessageSentEvent } from '../../../../server/modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages'; import { updateThreadUsersSubscriptions, getMentions } from '../../../lib/server/lib/notifyUsersOnMessage'; import { sendMessageNotifications } from '../../../lib/server/lib/sendNotificationsOnMessage'; import { settings } from '../../../settings/server'; @@ -63,9 +62,8 @@ export async function processThreads(message: IMessage, room: IRoom) { await notifyUsersOnReply(message, replies, room); await metaData(message, parentMessage, replies); await notification(message, room, replies); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: message.tmid, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); return message; diff --git a/apps/meteor/app/tokenpass/client/lib.ts b/apps/meteor/app/tokenpass/client/lib.ts index c8c1daf1cd60..e0b40a9b6de9 100644 --- a/apps/meteor/app/tokenpass/client/lib.ts +++ b/apps/meteor/app/tokenpass/client/lib.ts @@ -2,7 +2,7 @@ import type { OauthConfig } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; const config: OauthConfig = { diff --git a/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts b/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts index 9c970ccf697b..77190992612a 100644 --- a/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts +++ b/apps/meteor/app/ui-cached-collection/client/models/CachedCollection.ts @@ -1,4 +1,5 @@ import { Emitter } from '@rocket.chat/emitter'; +import type { StreamNames } from '@rocket.chat/ui-contexts'; import localforage from 'localforage'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; @@ -10,11 +11,10 @@ import { baseURI } from '../../../../client/lib/baseURI'; import { getConfig } from '../../../../client/lib/utils/getConfig'; import { isTruthy } from '../../../../lib/isTruthy'; import { withDebouncing } from '../../../../lib/utils/highOrderFunctions'; -import Notifications from '../../../notifications/client/lib/Notifications'; import { sdk } from '../../../utils/client/lib/SDKClient'; import { CachedCollectionManager } from './CachedCollectionManager'; -export type EventType = Extract; +export type EventType = 'notify-logged' | 'notify-all' | 'notify-user'; type Name = 'rooms' | 'subscriptions' | 'permissions' | 'public-settings' | 'private-settings'; @@ -48,7 +48,7 @@ export class CachedCollection extends Emitter< public name: Name; - public eventType: EventType; + public eventType: StreamNames; public version = 18; @@ -60,7 +60,7 @@ export class CachedCollection extends Emitter< public timer: ReturnType; - constructor({ name, eventType = 'onUser', userRelated = true }: { name: Name; eventType?: EventType; userRelated?: boolean }) { + constructor({ name, eventType = 'notify-user', userRelated = true }: { name: Name; eventType?: StreamNames; userRelated?: boolean }) { super(); this.collection = new Mongo.Collection(null) as MinimongoCollection; @@ -85,7 +85,10 @@ export class CachedCollection extends Emitter< }); } - protected get eventName(): `${Name}-changed` { + protected get eventName(): `${Name}-changed` | `${string}/${Name}-changed` { + if (this.eventType === 'notify-user') { + return `${Meteor.userId()}/${this.name}-changed`; + } return `${this.name}-changed`; } @@ -232,7 +235,7 @@ export class CachedCollection extends Emitter< } async setupListener() { - (Notifications[this.eventType] as any)(this.eventName, async (action: 'removed' | 'changed', record: any) => { + sdk.stream(this.eventType, [this.eventName], (async (action: 'removed' | 'changed', record: any) => { this.log('record received', action, record); const newRecord = this.handleReceived(record, action); @@ -250,7 +253,7 @@ export class CachedCollection extends Emitter< this.collection.upsert({ _id } as any, newRecord); } await this.save(); - }); + }) as (...args: unknown[]) => void); } trySync(delay = 10) { diff --git a/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts b/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts index 23221a49a293..91b848ffefde 100644 --- a/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts @@ -7,10 +7,8 @@ import { RoomManager } from '../../../../client/lib/RoomManager'; import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator'; import { fireGlobalEvent } from '../../../../client/lib/utils/fireGlobalEvent'; import { getConfig } from '../../../../client/lib/utils/getConfig'; -import { router } from '../../../../client/providers/RouterProvider'; import { callbacks } from '../../../../lib/callbacks'; -import { CachedChatRoom, ChatMessage, ChatSubscription, CachedChatSubscription, ChatRoom } from '../../../models/client'; -import { Notifications } from '../../../notifications/client'; +import { CachedChatRoom, ChatMessage, ChatSubscription, CachedChatSubscription } from '../../../models/client'; import { sdk } from '../../../utils/client/lib/SDKClient'; import { upsertMessage, RoomHistoryManager } from './RoomHistoryManager'; import { mainReady } from './mainReady'; @@ -38,8 +36,8 @@ function close(typeName: string) { if (openedRooms[typeName]) { if (openedRooms[typeName].rid) { sdk.stop('room-messages', openedRooms[typeName].rid); - Notifications.unRoom(openedRooms[typeName].rid, 'deleteMessage'); - Notifications.unRoom(openedRooms[typeName].rid, 'deleteMessageBulk'); + sdk.stop('notify-room', `${openedRooms[typeName].rid}/deleteMessage`); + sdk.stop('notify-room', `${openedRooms[typeName].rid}/deleteMessageBulk`); } openedRooms[typeName].ready = false; @@ -80,41 +78,6 @@ function getOpenedRoomByRid(rid: IRoom['_id']) { .find((openedRoom) => openedRoom.rid === rid); } -const handleTrackSettingsChange = (msg: IMessage) => { - const openedRoom = RoomManager.opened; - if (openedRoom !== msg.rid) { - return; - } - - void Tracker.nonreactive(async () => { - if (msg.t === 'room_changed_privacy') { - const type = router.getRouteName() === 'channel' ? 'c' : 'p'; - await close(type + router.getRouteParameters().name); - - const subscription = ChatSubscription.findOne({ rid: msg.rid }); - if (!subscription) { - throw new Error('Subscription not found'); - } - router.navigate({ - pattern: subscription.t === 'c' ? '/channel/:name/:tab?/:context?' : '/group/:name/:tab?/:context?', - params: { name: subscription.name }, - search: router.getSearchParameters(), - }); - } - - if (msg.t === 'r') { - const room = ChatRoom.findOne(msg.rid); - if (!room) { - throw new Error('Room not found'); - } - if (room.name !== router.getRouteParameters().name) { - await close(room.t + router.getRouteParameters().name); - roomCoordinator.openRouteLink(room.t, room, router.getSearchParameters()); - } - } - }); -}; - const computation = Tracker.autorun(() => { const ready = CachedChatRoom.ready.get() && mainReady.get(); if (ready !== true) { @@ -152,8 +115,6 @@ const computation = Tracker.autorun(() => { } } - handleTrackSettingsChange({ ...msg }); - await callbacks.run('streamMessage', { ...msg, name: room.name || '' }); fireGlobalEvent('new-message', { @@ -173,20 +134,21 @@ const computation = Tracker.autorun(() => { }); // when we receive a messages imported event we just clear the room history and fetch it again - Notifications.onRoom(record.rid, 'messagesImported', async () => { + sdk.stream('notify-room', [`${record.rid}/messagesImported`], async () => { await RoomHistoryManager.clear(record.rid); await RoomHistoryManager.getMore(record.rid); }); - Notifications.onRoom(record.rid, 'deleteMessage', (msg) => { + sdk.stream('notify-room', [`${record.rid}/deleteMessage`], (msg) => { ChatMessage.remove({ _id: msg._id }); // remove thread refenrece from deleted message ChatMessage.update({ tmid: msg._id }, { $unset: { tmid: 1 } }, { multi: true }); }); - Notifications.onRoom( - record.rid, - 'deleteMessageBulk', + + sdk.stream( + 'notify-room', + [`${record.rid}/deleteMessageBulk`], ({ rid, ts, excludePinned, ignoreDiscussion, users, ids, showDeletedStatus }) => { const query: Mongo.Selector = { rid }; @@ -215,7 +177,8 @@ const computation = Tracker.autorun(() => { return ChatMessage.remove(query); }, ); - Notifications.onRoom(record.rid, 'messagesRead', ({ tmid, until }) => { + + sdk.stream('notify-room', [`${record.rid}/messagesRead`], ({ tmid, until }) => { if (tmid) { return ChatMessage.update( { diff --git a/apps/meteor/app/ui-utils/client/lib/messageBox.ts b/apps/meteor/app/ui-utils/client/lib/messageBox.ts index 3f3c545af57e..3418adef1c1c 100644 --- a/apps/meteor/app/ui-utils/client/lib/messageBox.ts +++ b/apps/meteor/app/ui-utils/client/lib/messageBox.ts @@ -1,4 +1,5 @@ import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import type { Keys as IconName } from '@rocket.chat/icons'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import type { ChatAPI } from '../../../../client/lib/chats/ChatAPI'; @@ -6,7 +7,7 @@ import type { ChatAPI } from '../../../../client/lib/chats/ChatAPI'; export type MessageBoxAction = { label: TranslationKey; id: string; - icon?: string; + icon: IconName; action: (params: { rid: IRoom['_id']; tmid?: IMessage['_id']; event: Event; chat: ChatAPI }) => void; condition?: () => boolean; }; diff --git a/apps/meteor/app/ui/client/lib/UserAction.ts b/apps/meteor/app/ui/client/lib/UserAction.ts index ab58d641c149..00ff3113e586 100644 --- a/apps/meteor/app/ui/client/lib/UserAction.ts +++ b/apps/meteor/app/ui/client/lib/UserAction.ts @@ -3,8 +3,8 @@ import { debounce } from 'lodash'; import { Meteor } from 'meteor/meteor'; import { ReactiveDict } from 'meteor/reactive-dict'; -import { Notifications } from '../../../notifications/client'; import { settings } from '../../../settings/client'; +import { sdk } from '../../../utils/client/lib/SDKClient'; const TIMEOUT = 15000; const RENEW = TIMEOUT / 3; @@ -38,7 +38,7 @@ const shownName = function (user: IUser | null | undefined): string | undefined const emitActivities = debounce(async (rid: string, extras: IExtras): Promise => { const activities = roomActivities.get(extras?.tmid || rid) || new Set(); - Notifications.notifyRoom(rid, USER_ACTIVITY, shownName(Meteor.user() as unknown as IUser), [...activities], extras); + sdk.publish('notify-room', [`${rid}/${USER_ACTIVITY}`, shownName(Meteor.user() as unknown as IUser), [...activities], extras]); }, 500); function handleStreamAction(rid: string, username: string, activityTypes: string[], extras?: IExtras): void { @@ -65,10 +65,11 @@ function handleStreamAction(rid: string, username: string, activityTypes: string performingUsers.set(rid, roomActivities); } export const UserAction = new (class { - addStream(rid: string): void { + addStream(rid: string): () => void { if (rooms.get(rid)) { - return; + throw new Error('UserAction - addStream should only be called once per room'); } + const handler = function (username: string, activityType: string[], extras?: object): void { const user = Meteor.users.findOne(Meteor.userId() || undefined, { fields: { name: 1, username: 1 }, @@ -79,7 +80,15 @@ export const UserAction = new (class { handleStreamAction(rid, username, activityType, extras); }; rooms.set(rid, handler); - Notifications.onRoom(rid, USER_ACTIVITY, handler); + + const { stop } = sdk.stream('notify-room', [`${rid}/${USER_ACTIVITY}`], handler); + return () => { + if (!rooms.get(rid)) { + return; + } + stop(); + rooms.delete(rid); + }; } performContinuously(rid: string, activityType: string, extras: IExtras = {}): void { @@ -156,15 +165,6 @@ export const UserAction = new (class { void emitActivities(rid, extras); } - cancel(rid: string): void { - if (!rooms.get(rid)) { - return; - } - - Notifications.unRoom(rid, USER_ACTIVITY); - rooms.delete(rid); - } - get(roomId: string): IRoomActivity | undefined { return performingUsers.get(roomId); } diff --git a/apps/meteor/app/ui/client/lib/userCard.tsx b/apps/meteor/app/ui/client/lib/userCard.ts similarity index 70% rename from apps/meteor/app/ui/client/lib/userCard.tsx rename to apps/meteor/app/ui/client/lib/userCard.ts index e4fd5b343140..90b3d4ec5201 100644 --- a/apps/meteor/app/ui/client/lib/userCard.tsx +++ b/apps/meteor/app/ui/client/lib/userCard.ts @@ -1,14 +1,12 @@ import type { ComponentProps } from 'react'; -import React, { Suspense, createElement, lazy } from 'react'; +import { createElement } from 'react'; import { createPortal } from 'react-dom'; -import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { registerPortal } from '../../../../client/lib/portals/portalsSubscription'; import { queueMicrotask } from '../../../../client/lib/utils/queueMicrotask'; +import UserCardHolder from '../../../../client/views/room/UserCardHolder'; -const UserCard = lazy(() => import('../../../../client/views/room/UserCard')); - -type UserCardProps = ComponentProps; +type UserCardProps = ReturnType['getProps']>; let props: UserCardProps; @@ -29,16 +27,6 @@ const subscribeToProps = (callback: () => void) => { }; }; -const UserCardWithProps = () => { - const props = useSyncExternalStore(subscribeToProps, getProps); - - return ( - - - - ); -}; - const createContainer = () => { const container = document.createElement('div'); container.id = 'react-user-card'; @@ -67,8 +55,8 @@ export const openUserCard = (params: Omit) => { } if (!unregisterPortal) { - const children = createElement(UserCardWithProps); - const portal = <>{createPortal(children, container)}; + const children = createElement(UserCardHolder, { getProps, subscribeToProps }); + const portal = createPortal(children, container); unregisterPortal = registerPortal(container, portal); } diff --git a/apps/meteor/app/user-status/client/index.ts b/apps/meteor/app/user-status/client/index.ts deleted file mode 100644 index 122887520cc8..000000000000 --- a/apps/meteor/app/user-status/client/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import './notifications/deleteCustomUserStatus'; -import './notifications/updateCustomUserStatus'; -import './lib/customUserStatus'; - -export { userStatus } from './lib/userStatus'; diff --git a/apps/meteor/app/user-status/client/lib/customUserStatus.js b/apps/meteor/app/user-status/client/lib/customUserStatus.js deleted file mode 100644 index 8d429d22c3ec..000000000000 --- a/apps/meteor/app/user-status/client/lib/customUserStatus.js +++ /dev/null @@ -1,62 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -import { sdk } from '../../../utils/client/lib/SDKClient'; -import { userStatus } from './userStatus'; - -userStatus.packages.customUserStatus = { - list: [], -}; - -export const deleteCustomUserStatus = function (customUserStatusData) { - delete userStatus.list[customUserStatusData._id]; - - const arrayIndex = userStatus.packages.customUserStatus.list.indexOf(customUserStatusData._id); - if (arrayIndex !== -1) { - userStatus.packages.customUserStatusData.list.splice(arrayIndex, 1); - } -}; - -export const updateCustomUserStatus = function (customUserStatusData) { - const newUserStatus = { - name: customUserStatusData.name, - id: customUserStatusData._id, - statusType: customUserStatusData.statusType, - localizeName: false, - }; - - const arrayIndex = userStatus.packages.customUserStatus.list.indexOf(newUserStatus.id); - if (arrayIndex === -1) { - userStatus.packages.customUserStatus.list.push(newUserStatus); - } else { - userStatus.packages.customUserStatus.list[arrayIndex] = newUserStatus; - } - - userStatus.list[newUserStatus.id] = newUserStatus; -}; - -Meteor.startup(() => { - Tracker.autorun(() => { - if (!Meteor.userId()) { - return; - } - - void sdk.call('listCustomUserStatus').then((result) => { - if (!result) { - return; - } - - for (const customStatus of result) { - const newUserStatus = { - name: customStatus.name, - id: customStatus._id, - statusType: customStatus.statusType, - localizeName: false, - }; - - userStatus.packages.customUserStatus.list.push(newUserStatus); - userStatus.list[newUserStatus.id] = newUserStatus; - } - }); - }); -}); diff --git a/apps/meteor/app/user-status/client/lib/userStatus.ts b/apps/meteor/app/user-status/client/lib/userStatus.ts deleted file mode 100644 index eec57acd29f1..000000000000 --- a/apps/meteor/app/user-status/client/lib/userStatus.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { UserStatus } from '@rocket.chat/core-typings'; - -type Status = { - name: string; - localizeName: boolean; - id: string; - statusType: UserStatus; -}; - -type UserStatusTypes = { - packages: any; - list: { - [status: string]: Status; - }; -}; - -export const userStatus: UserStatusTypes = { - packages: { - base: { - render(html: string): string { - return html; - }, - }, - }, - - list: { - online: { - name: UserStatus.ONLINE, - localizeName: true, - id: UserStatus.ONLINE, - statusType: UserStatus.ONLINE, - }, - away: { - name: UserStatus.AWAY, - localizeName: true, - id: UserStatus.AWAY, - statusType: UserStatus.AWAY, - }, - busy: { - name: UserStatus.BUSY, - localizeName: true, - id: UserStatus.BUSY, - statusType: UserStatus.BUSY, - }, - offline: { - name: UserStatus.OFFLINE, - localizeName: true, - id: UserStatus.OFFLINE, - statusType: UserStatus.OFFLINE, - }, - }, -} as const; diff --git a/apps/meteor/app/user-status/client/notifications/deleteCustomUserStatus.js b/apps/meteor/app/user-status/client/notifications/deleteCustomUserStatus.js deleted file mode 100644 index 24d503d57d72..000000000000 --- a/apps/meteor/app/user-status/client/notifications/deleteCustomUserStatus.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Notifications } from '../../../notifications/client'; -import { deleteCustomUserStatus } from '../lib/customUserStatus'; - -Meteor.startup(() => Notifications.onLogged('deleteCustomUserStatus', (data) => deleteCustomUserStatus(data.userStatusData))); diff --git a/apps/meteor/app/user-status/client/notifications/updateCustomUserStatus.js b/apps/meteor/app/user-status/client/notifications/updateCustomUserStatus.js deleted file mode 100644 index f5949b038948..000000000000 --- a/apps/meteor/app/user-status/client/notifications/updateCustomUserStatus.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Notifications } from '../../../notifications/client'; -import { updateCustomUserStatus } from '../lib/customUserStatus'; - -Meteor.startup(() => Notifications.onLogged('updateCustomUserStatus', (data) => updateCustomUserStatus(data.userStatusData))); diff --git a/apps/meteor/app/user-status/server/methods/listCustomUserStatus.ts b/apps/meteor/app/user-status/server/methods/listCustomUserStatus.ts index bb11a1be73bd..3a962121d65c 100644 --- a/apps/meteor/app/user-status/server/methods/listCustomUserStatus.ts +++ b/apps/meteor/app/user-status/server/methods/listCustomUserStatus.ts @@ -6,7 +6,7 @@ import { Meteor } from 'meteor/meteor'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - listCustomUserStatus(): Promise; + listCustomUserStatus(): ICustomUserStatus[]; } } diff --git a/apps/meteor/app/utils/client/lib/SDKClient.ts b/apps/meteor/app/utils/client/lib/SDKClient.ts index e9e20bbe658b..18ff309970df 100644 --- a/apps/meteor/app/utils/client/lib/SDKClient.ts +++ b/apps/meteor/app/utils/client/lib/SDKClient.ts @@ -45,114 +45,166 @@ const isChangedCollectionPayload = ( return true; }; -export const createSDK = (rest: RestClientInterface) => { - const ev = new Emitter(); +type EventMap = StreamKeys> = { + [key in `stream-${N}/${K}`]: StreamerCallbackArgs; +}; + +type StreamMapValue = { + stop: () => void; + onChange: ReturnType['onChange']; + ready: () => Promise; + isReady: boolean; + unsubList: Set<() => void>; +}; + +const createNewMeteorStream = (streamName: StreamNames, key: StreamKeys, args: unknown[]): StreamMapValue => { + const ee = new Emitter(); + const meta = { + ready: false, + }; + const sub = Meteor.connection.subscribe( + `stream-${streamName}`, + key, + { useCollection: false, args }, + { + onReady: (args: any) => { + meta.ready = true; + ee.emit('ready', [undefined, args]); + }, + onError: (err: any) => { + console.error(err); + ee.emit('ready', [err]); + }, + }, + ); + + const onChange: ReturnType['onChange'] = (cb) => { + if (meta.ready) { + cb({ + msg: 'ready', + + subs: [], + }); + return; + } + ee.once('ready', ([error, result]) => { + if (error) { + cb({ + msg: 'nosub', + + id: '', + error, + }); + return; + } - const streams = new Map void>(); + cb(result); + }); + }; + + const ready = () => { + if (meta.ready) { + return Promise.resolve(); + } + return new Promise((r) => { + ee.once('ready', r); + }); + }; + + return { + stop: sub.stop, + onChange, + ready, + get isReady() { + return meta.ready; + }, + unsubList: new Set(), + }; +}; + +const createStreamManager = () => { + // Emitter that replicates stream messages to registered callbacks + const streamProxy = new Emitter(); + + // Collection of unsubscribe callbacks for each stream. + // const proxyUnsubLists = new Map void>>(); + + const streams = new Map(); Meteor.connection._stream.on('message', (rawMsg: string) => { const msg = DDPCommon.parseDDP(rawMsg); if (!isChangedCollectionPayload(msg)) { return; } - ev.emit(`${msg.collection}/${msg.fields.eventName}`, msg.fields.args); + streamProxy.emit(`${msg.collection}/${msg.fields.eventName}` as any, msg.fields.args as any); }); const stream: SDK['stream'] = >( name: N, data: [key: K, ...args: unknown[]], - cb: (...args: StreamerCallbackArgs) => void, + callback: (...args: StreamerCallbackArgs) => void, + _options?: { + retransmit?: boolean | undefined; + retransmitToSelf?: boolean | undefined; + }, ): ReturnType => { const [key, ...args] = data; - const streamName = `stream-${name}`; - const streamKey = `${streamName}/${key}`; - - const ee = new Emitter(); + const eventLiteral = `stream-${name}/${key}` as const; - const meta = { - ready: false, + const proxyCallback = (args?: unknown): void => { + if (!args || !Array.isArray(args)) { + throw new Error('Invalid streamer callback'); + } + callback(...(args as StreamerCallbackArgs)); }; - const sub = Meteor.connection.subscribe( - streamName, - key, - { useCollection: false, args }, - { - onReady: (args: any) => { - meta.ready = true; - ee.emit('ready', [undefined, args]); - }, - onError: (err: any) => { - console.error(err); - ee.emit('ready', [err]); - }, - }, - ); + streamProxy.on(eventLiteral, proxyCallback); - const onChange: ReturnType['onChange'] = (cb) => { - if (meta.ready) { - cb({ - msg: 'ready', + const stop = (): void => { + streamProxy.off(eventLiteral, proxyCallback); - subs: [], - }); + // If someone is still listening, don't unsubscribe + if (streamProxy.has(eventLiteral)) { return; } - ee.once('ready', ([error, result]) => { - if (error) { - cb({ - msg: 'nosub', - - id: '', - error, - }); - return; - } - - cb(result); - }); - }; - const ready = () => { - if (meta.ready) { - return Promise.resolve(); + if (stream) { + stream.stop(); + streams.delete(eventLiteral); } - return new Promise((r) => { - ee.once('ready', r); - }); - }; - - const removeEv = ev.on(`${streamKey}`, (args) => cb(...args)); - - const stop = () => { - streams.delete(`${streamKey}`); - sub.stop(); - removeEv(); }; - streams.set(`${streamKey}`, stop); + const stream = streams.get(eventLiteral) || createNewMeteorStream(name, key, args); + stream.unsubList.add(stop); + if (!streams.has(eventLiteral)) { + streams.set(eventLiteral, stream); + } return { id: '', name, params: data as any, stop, - ready, - onChange, - get isReady() { - return meta.ready; - }, + ready: stream.ready, + onChange: stream.onChange, + isReady: stream.isReady, }; }; - const stop = (name: string, key: string) => { - const streamKey = `stream-${name}/${key}`; - const stop = streams.get(streamKey); - if (stop) { - stop(); + const stopAll = (streamName: string, key: string) => { + const stream = streams.get(`stream-${streamName}/${key}`); + + if (stream) { + stream.unsubList.forEach((stop) => stop()); } }; + return { stream, stopAll }; +}; + +export const createSDK = (rest: RestClientInterface) => { + const { stream, stopAll } = createStreamManager(); + const publish = (name: string, args: unknown[]) => { Meteor.call(`stream-${name}`, ...args); }; @@ -163,7 +215,7 @@ export const createSDK = (rest: RestClientInterface) => { return { rest, - stop, + stop: stopAll, stream, publish, call, diff --git a/apps/meteor/app/utils/lib/i18n.ts b/apps/meteor/app/utils/lib/i18n.ts index a491159e49e9..9d3fbc59d245 100644 --- a/apps/meteor/app/utils/lib/i18n.ts +++ b/apps/meteor/app/utils/lib/i18n.ts @@ -1,3 +1,4 @@ +import type { RocketchatI18nKeys } from '@rocket.chat/i18n'; import i18next from 'i18next'; import sprintf from 'i18next-sprintf-postprocessor'; @@ -5,9 +6,13 @@ import { isObject } from '../../../lib/utils/isObject'; export const i18n = i18next.use(sprintf); -export const addSprinfToI18n = function (t: (key: string, ...replaces: any) => string) { +export const addSprinfToI18n = function (t: (typeof i18n)['t']) { return function (key: string, ...replaces: any): string { - if (replaces[0] === undefined || (isObject(replaces[0]) && !Array.isArray(replaces[0]))) { + if (replaces[0] === undefined) { + return t(key, ...replaces); + } + + if (isObject(replaces[0]) && !Array.isArray(replaces[0])) { return t(key, ...replaces); } @@ -19,3 +24,123 @@ export const addSprinfToI18n = function (t: (key: string, ...replaces: any) => s }; export const t = addSprinfToI18n(i18n.t.bind(i18n)); + +/** + * Extract the translation keys from a flat object and group them by namespace + * + * Example: + * + * ```js + * const source = { + * 'core.key1': 'value1', + * 'core.key2': 'value2', + * 'onboarding.key1': 'value1', + * 'onboarding.key2': 'value2', + * 'registration.key1': 'value1', + * 'registration.key2': 'value2', + * 'cloud.key1': 'value1', + * 'cloud.key2': 'value2', + * 'subscription.key1': 'value1', + * 'subscription.key2': 'value2', + * }; + * + * const result = extractTranslationNamespaces(source); + * + * console.log(result); + * + * // { + * // core: { + * // key1: 'value1', + * // key2: 'value2' + * // }, + * // onboarding: { + * // key1: 'value1', + * // key2: 'value2' + * // }, + * // registration: { + * // key1: 'value1', + * // key2: 'value2' + * // }, + * // cloud: { + * // key1: 'value1', + * // key2: 'value2' + * // }, + * // subscription: { + * // key1: 'value1', + * // key2: 'value2' + * // } + * // } + * ``` + * + * @param source the flat object with the translation keys + */ +export const extractTranslationNamespaces = (source: Record): Record> => { + const result: Record> = { + core: {}, + onboarding: {}, + registration: {}, + cloud: {}, + subscription: {}, + }; + + for (const [key, value] of Object.entries(source)) { + const prefix = availableTranslationNamespaces.find((namespace) => key.startsWith(`${namespace}.`)); + const keyWithoutNamespace = prefix ? key.slice(prefix.length + 1) : key; + const ns = prefix ?? defaultTranslationNamespace; + result[ns][keyWithoutNamespace] = value; + } + + return result; +}; + +/** + * Extract only the translation keys that match the given namespaces + * + * @param source the flat object with the translation keys + * @param namespaces the namespaces to extract + */ +export const extractTranslationKeys = (source: Record, namespaces: string | string[] = []): { [key: string]: any } => { + const all = extractTranslationNamespaces(source); + return Array.isArray(namespaces) + ? (namespaces as TranslationNamespace[]).reduce((result, namespace) => ({ ...result, ...all[namespace] }), {}) + : all[namespaces as TranslationNamespace]; +}; + +export type TranslationNamespace = + | (Extract extends `${infer T}.${string}` ? (T extends Lowercase ? T : never) : never) + | 'core'; + +const namespacesMap: Record = { + core: true, + onboarding: true, + registration: true, + cloud: true, + subscription: true, +}; + +export const availableTranslationNamespaces = Object.keys(namespacesMap) as TranslationNamespace[]; +export const defaultTranslationNamespace: TranslationNamespace = 'core'; + +export const applyCustomTranslations = ( + i18n: typeof i18next, + parsedCustomTranslations: Record>, + { namespaces, languages }: { namespaces?: string[]; languages?: string[] } = {}, +) => { + for (const [lng, translations] of Object.entries(parsedCustomTranslations)) { + if (languages && !languages.includes(lng)) { + continue; + } + + for (const [key, value] of Object.entries(translations)) { + const prefix = availableTranslationNamespaces.find((namespace) => key.startsWith(`${namespace}.`)); + const keyWithoutNamespace = prefix ? key.slice(prefix.length + 1) : key; + const ns = prefix ?? defaultTranslationNamespace; + + if (namespaces && !namespaces.includes(ns)) { + continue; + } + + i18n.addResourceBundle(lng, ns, { [keyWithoutNamespace]: value }, true, true); + } + } +}; diff --git a/apps/meteor/app/webdav/client/actionButton.ts b/apps/meteor/app/webdav/client/actionButton.ts deleted file mode 100644 index 9a2f8152e5c0..000000000000 --- a/apps/meteor/app/webdav/client/actionButton.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { imperativeModal } from '../../../client/lib/imperativeModal'; -import { messageArgs } from '../../../client/lib/utils/messageArgs'; -import SaveToWebdav from '../../../client/views/room/webdav/SaveToWebdavModal'; -import { WebdavAccounts } from '../../models/client'; -import { settings } from '../../settings/client'; -import { MessageAction } from '../../ui-utils/client'; -import { getURL } from '../../utils/client'; - -Meteor.startup(() => { - MessageAction.addButton({ - id: 'webdav-upload', - icon: 'upload', - label: 'Save_To_Webdav', - condition: ({ message, subscription }) => { - if (subscription == null) { - return false; - } - if (WebdavAccounts.findOne() == null) { - return false; - } - if (!message.file) { - return false; - } - - return settings.get('Webdav_Integration_Enabled'); - }, - action(_, props) { - const { message = messageArgs(this).msg } = props; - const [attachment] = message.attachments || []; - const url = getURL(attachment.title_link as string, { full: true }); - imperativeModal.open({ - component: SaveToWebdav, - props: { - data: { - attachment, - url, - }, - onClose: imperativeModal.close, - }, - }); - }, - order: 100, - group: 'menu', - }); -}); diff --git a/apps/meteor/app/webdav/client/index.js b/apps/meteor/app/webdav/client/index.js deleted file mode 100644 index b4f7aa4ef133..000000000000 --- a/apps/meteor/app/webdav/client/index.js +++ /dev/null @@ -1,15 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -import { settings } from '../../settings/client'; - -Meteor.startup(() => { - Tracker.autorun((c) => { - if (!settings.get('Webdav_Integration_Enabled')) { - return; - } - c.stop(); - import('./startup/sync'); - import('./actionButton'); - }); -}); diff --git a/apps/meteor/app/webdav/client/startup/sync.js b/apps/meteor/app/webdav/client/startup/sync.js deleted file mode 100644 index e048472aad71..000000000000 --- a/apps/meteor/app/webdav/client/startup/sync.js +++ /dev/null @@ -1,20 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; - -import { WebdavAccounts } from '../../../models/client'; -import { Notifications } from '../../../notifications/client'; -import { sdk } from '../../../utils/client/lib/SDKClient'; - -const events = { - changed: (account) => WebdavAccounts.upsert({ _id: account._id }, account), - removed: ({ _id }) => WebdavAccounts.remove({ _id }), -}; - -Tracker.autorun(async () => { - if (!Meteor.userId()) { - return; - } - const { accounts } = await sdk.rest.get('/v1/webdav.getMyAccounts'); - accounts.forEach((account) => WebdavAccounts.insert(account)); - Notifications.onUser('webdav', ({ type, account }) => events[type](account)); -}); diff --git a/apps/meteor/app/webrtc/client/WebRTCClass.js b/apps/meteor/app/webrtc/client/WebRTCClass.js index 721c7591c647..eb9772966575 100644 --- a/apps/meteor/app/webrtc/client/WebRTCClass.js +++ b/apps/meteor/app/webrtc/client/WebRTCClass.js @@ -7,8 +7,8 @@ import GenericModal from '../../../client/components/GenericModal'; import { imperativeModal } from '../../../client/lib/imperativeModal'; import { goToRoomById } from '../../../client/lib/utils/goToRoomById'; import { ChatSubscription } from '../../models/client'; -import { Notifications } from '../../notifications/client'; import { settings } from '../../settings/client'; +import { sdk } from '../../utils/client/lib/SDKClient'; import { t } from '../../utils/lib/i18n'; import { WEB_RTC_EVENTS } from '../lib/constants'; import { ChromeScreenShare } from './screenShare'; @@ -18,7 +18,7 @@ class WebRTCTransportClass extends Emitter { super(); this.debug = false; this.webrtcInstance = webrtcInstance; - Notifications.onRoom(this.webrtcInstance.room, WEB_RTC_EVENTS.WEB_RTC, (type, data) => { + sdk.stream('notify-room', [`${this.webrtcInstance.room}/${WEB_RTC_EVENTS.WEB_RTC}`], (type, data) => { this.log('WebRTCTransportClass - onRoom', type, data); this.emit(type, data); }); @@ -41,30 +41,42 @@ class WebRTCTransportClass extends Emitter { startCall(data) { this.log('WebRTCTransportClass - startCall', this.webrtcInstance.room, this.webrtcInstance.selfId); - Notifications.notifyUsersOfRoom(this.webrtcInstance.room, WEB_RTC_EVENTS.WEB_RTC, WEB_RTC_EVENTS.CALL, { - from: this.webrtcInstance.selfId, - room: this.webrtcInstance.room, - media: data.media, - monitor: data.monitor, - }); + sdk.publish('notify-room-users', [ + `${this.webrtcInstance.room}/${WEB_RTC_EVENTS.WEB_RTC}`, + WEB_RTC_EVENTS.CALL, + { + from: this.webrtcInstance.selfId, + room: this.webrtcInstance.room, + media: data.media, + monitor: data.monitor, + }, + ]); } joinCall(data) { this.log('WebRTCTransportClass - joinCall', this.webrtcInstance.room, this.webrtcInstance.selfId); if (data.monitor === true) { - Notifications.notifyUser(data.to, WEB_RTC_EVENTS.WEB_RTC, WEB_RTC_EVENTS.JOIN, { - from: this.webrtcInstance.selfId, - room: this.webrtcInstance.room, - media: data.media, - monitor: data.monitor, - }); + sdk.publish('notify-user', [ + `${data.to}/${WEB_RTC_EVENTS.WEB_RTC}`, + WEB_RTC_EVENTS.JOIN, + { + from: this.webrtcInstance.selfId, + room: this.webrtcInstance.room, + media: data.media, + monitor: data.monitor, + }, + ]); } else { - Notifications.notifyUsersOfRoom(this.webrtcInstance.room, WEB_RTC_EVENTS.WEB_RTC, WEB_RTC_EVENTS.JOIN, { - from: this.webrtcInstance.selfId, - room: this.webrtcInstance.room, - media: data.media, - monitor: data.monitor, - }); + sdk.publish('notify-room-users', [ + `${this.webrtcInstance.room}/${WEB_RTC_EVENTS.WEB_RTC}`, + WEB_RTC_EVENTS.JOIN, + { + from: this.webrtcInstance.selfId, + room: this.webrtcInstance.room, + media: data.media, + monitor: data.monitor, + }, + ]); } } @@ -72,20 +84,20 @@ class WebRTCTransportClass extends Emitter { data.from = this.webrtcInstance.selfId; data.room = this.webrtcInstance.room; this.log('WebRTCTransportClass - sendCandidate', data); - Notifications.notifyUser(data.to, WEB_RTC_EVENTS.WEB_RTC, WEB_RTC_EVENTS.CANDIDATE, data); + sdk.publish('notify-user', [`${data.to}/${WEB_RTC_EVENTS.WEB_RTC}`, WEB_RTC_EVENTS.CANDIDATE, data]); } sendDescription(data) { data.from = this.webrtcInstance.selfId; data.room = this.webrtcInstance.room; this.log('WebRTCTransportClass - sendDescription', data); - Notifications.notifyUser(data.to, WEB_RTC_EVENTS.WEB_RTC, WEB_RTC_EVENTS.DESCRIPTION, data); + sdk.publish('notify-user', [`${data.to}/${WEB_RTC_EVENTS.WEB_RTC}`, WEB_RTC_EVENTS.DESCRIPTION, data]); } sendStatus(data) { this.log('WebRTCTransportClass - sendStatus', data, this.webrtcInstance.room); data.from = this.webrtcInstance.selfId; - Notifications.notifyRoom(this.webrtcInstance.room, WEB_RTC_EVENTS.WEB_RTC, WEB_RTC_EVENTS.STATUS, data); + sdk.publish('notify-room', [`${this.webrtcInstance.room}/${WEB_RTC_EVENTS.WEB_RTC}`, WEB_RTC_EVENTS.STATUS, data]); } onRemoteCall(fn) { @@ -969,7 +981,7 @@ const WebRTC = new (class { Meteor.startup(() => { Tracker.autorun(() => { if (Meteor.userId()) { - Notifications.onUser(WEB_RTC_EVENTS.WEB_RTC, (type, data) => { + sdk.stream('notify-user', [`${Meteor.userId()}/${WEB_RTC_EVENTS.WEB_RTC}`], (type, data) => { if (data.room == null) { return; } diff --git a/apps/meteor/app/webrtc/client/actionLink.tsx b/apps/meteor/app/webrtc/client/actionLink.tsx index f9848518b868..d4575f2dd60f 100644 --- a/apps/meteor/app/webrtc/client/actionLink.tsx +++ b/apps/meteor/app/webrtc/client/actionLink.tsx @@ -3,7 +3,6 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { actionLinks } from '../../../client/lib/actionLinks'; import { dispatchToastMessage } from '../../../client/lib/toast'; import { ChatRoom } from '../../models/client'; -import { Notifications } from '../../notifications/client'; import { sdk } from '../../utils/client/lib/SDKClient'; import { t } from '../../utils/lib/i18n'; @@ -31,5 +30,5 @@ actionLinks.register('endLivechatWebRTCCall', async (message: IMessage) => { return; } await sdk.rest.put(`/v1/livechat/webrtc.call/${message._id}`, { rid: _id, status: 'ended' }); - Notifications.notifyRoom(_id, 'webrtc' as any, 'callStatus', { callStatus: 'ended' }); + sdk.publish('notify-room', [`${_id}/webrtc`, 'callStatus', { callStatus: 'ended' }]); }); diff --git a/apps/meteor/app/webrtc/lib/constants.ts b/apps/meteor/app/webrtc/lib/constants.ts index 1beb27756f88..fd3f26933d96 100644 --- a/apps/meteor/app/webrtc/lib/constants.ts +++ b/apps/meteor/app/webrtc/lib/constants.ts @@ -5,4 +5,4 @@ export const WEB_RTC_EVENTS = { JOIN: 'join', CANDIDATE: 'candidate', DESCRIPTION: 'description', -}; +} as const; diff --git a/apps/meteor/app/wordpress/client/lib.ts b/apps/meteor/app/wordpress/client/lib.ts index 7dd5215ccc60..b213d5fb88c2 100644 --- a/apps/meteor/app/wordpress/client/lib.ts +++ b/apps/meteor/app/wordpress/client/lib.ts @@ -3,7 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import _ from 'underscore'; -import { CustomOAuth } from '../../custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../custom-oauth/client/CustomOAuth'; import { settings } from '../../settings/client'; const config: OauthConfig = { diff --git a/apps/meteor/client/components/GenericCard/GenericCard.tsx b/apps/meteor/client/components/GenericCard/GenericCard.tsx index 007c7da9f15e..335b8e6be959 100644 --- a/apps/meteor/client/components/GenericCard/GenericCard.tsx +++ b/apps/meteor/client/components/GenericCard/GenericCard.tsx @@ -1,6 +1,5 @@ -import { Card, CardTitle, CardBody, CardControls, CardHeader } from '@rocket.chat/fuselage'; +import { Card, CardTitle, CardBody, CardControls, CardHeader, FramedIcon } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { FramedIcon } from '@rocket.chat/ui-client'; import type { ComponentProps, ReactElement } from 'react'; import React from 'react'; @@ -11,17 +10,21 @@ type GenericCardProps = { body: string; buttons?: ReactElement[]; icon?: ComponentProps['icon']; - type?: ComponentProps['type']; + type?: 'info' | 'success' | 'warning' | 'danger' | 'neutral'; } & ComponentProps; export const GenericCard: React.FC = ({ title, body, buttons, icon, type, ...props }) => { const cardId = useUniqueId(); const descriptionId = useUniqueId(); + const iconType = type && { + [type]: true, + }; + return ( - {icon && } + {icon && } {title} {body} diff --git a/apps/meteor/client/components/GenericMenu/GenericMenu.tsx b/apps/meteor/client/components/GenericMenu/GenericMenu.tsx index f660b4b85f35..9d8367f7ad98 100644 --- a/apps/meteor/client/components/GenericMenu/GenericMenu.tsx +++ b/apps/meteor/client/components/GenericMenu/GenericMenu.tsx @@ -8,8 +8,9 @@ import GenericMenuItem from './GenericMenuItem'; import { useHandleMenuAction } from './hooks/useHandleMenuAction'; type GenericMenuCommonProps = { - icon?: ComponentProps['icon']; title: string; + icon?: ComponentProps['icon']; + disabled?: boolean; }; type GenericMenuConditionalProps = | { @@ -27,7 +28,7 @@ type GenericMenuConditionalProps = type GenericMenuProps = GenericMenuCommonProps & GenericMenuConditionalProps & Omit, 'children'>; -const GenericMenu = ({ title, icon = 'menu', onAction, ...props }: GenericMenuProps) => { +const GenericMenu = ({ title, icon = 'menu', disabled, onAction, ...props }: GenericMenuProps) => { const t = useTranslation(); const sections = 'sections' in props && props.sections; @@ -44,8 +45,8 @@ const GenericMenu = ({ title, icon = 'menu', onAction, ...props }: GenericMenuPr const isMenuEmpty = !(sections && sections.length > 0) && !(items && items.length > 0); - if (isMenuEmpty) { - return ; + if (isMenuEmpty || disabled) { + return ; } return ( diff --git a/apps/meteor/client/components/GenericMenu/GenericMenuItem.tsx b/apps/meteor/client/components/GenericMenu/GenericMenuItem.tsx index ae79d5a4a78b..ec987a1ee28d 100644 --- a/apps/meteor/client/components/GenericMenu/GenericMenuItem.tsx +++ b/apps/meteor/client/components/GenericMenu/GenericMenuItem.tsx @@ -1,5 +1,5 @@ import { MenuItemColumn, MenuItemContent, MenuItemIcon, MenuItemInput } from '@rocket.chat/fuselage'; -import type { ComponentProps, ReactNode } from 'react'; +import type { ComponentProps, MouseEvent, ReactNode } from 'react'; import React from 'react'; export type GenericMenuItemProps = { @@ -7,7 +7,7 @@ export type GenericMenuItemProps = { icon?: ComponentProps['name']; content?: ReactNode; addon?: ReactNode; - onClick?: () => void; + onClick?: (e?: MouseEvent) => void; status?: ReactNode; disabled?: boolean; description?: ReactNode; diff --git a/apps/meteor/client/components/GenericTable/GenericTable.tsx b/apps/meteor/client/components/GenericTable/GenericTable.tsx index 00e7ed4f7cba..6fa3f247aa29 100644 --- a/apps/meteor/client/components/GenericTable/GenericTable.tsx +++ b/apps/meteor/client/components/GenericTable/GenericTable.tsx @@ -1,20 +1,22 @@ import { Box, Table } from '@rocket.chat/fuselage'; -import type { ReactNode, TableHTMLAttributes } from 'react'; -import React, { forwardRef } from 'react'; +import type { ComponentProps } from 'react'; +import React, { type ForwardedRef, type ReactNode, forwardRef } from 'react'; import ScrollableContentWrapper from '../ScrollableContentWrapper'; type GenericTableProps = { fixed?: boolean; children: ReactNode; -} & TableHTMLAttributes; +} & ComponentProps; -export const GenericTable = forwardRef(function GenericTable({ fixed = true, children, ...props }, ref) { +export const GenericTable = forwardRef(function GenericTable( + { fixed = true, children, ...props }: GenericTableProps, + ref: ForwardedRef, +) { return ( - {/* TODO: Fix fuselage */} - +
{children}
diff --git a/apps/meteor/client/components/ImageGallery/ImageGallery.tsx b/apps/meteor/client/components/ImageGallery/ImageGallery.tsx index 0675532432aa..07ef83a12176 100644 --- a/apps/meteor/client/components/ImageGallery/ImageGallery.tsx +++ b/apps/meteor/client/components/ImageGallery/ImageGallery.tsx @@ -1,5 +1,6 @@ +import type { IUpload } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; -import { Box, IconButton, Palette, Throbber } from '@rocket.chat/fuselage'; +import { Box, ButtonGroup, IconButton, Palette, Throbber } from '@rocket.chat/fuselage'; import React, { useRef, useState } from 'react'; import { FocusScope } from 'react-aria'; import { createPortal } from 'react-dom'; @@ -13,8 +14,7 @@ import 'swiper/modules/navigation/navigation.min.css'; import 'swiper/modules/keyboard/keyboard.min.css'; import 'swiper/modules/zoom/zoom.min.css'; -import ImageGalleryLoader from './ImageGalleryLoader'; -import { useImageGallery } from './hooks/useImageGallery'; +import { usePreventPropagation } from '../../hooks/usePreventPropagation'; const swiperStyle = css` .swiper { @@ -40,13 +40,6 @@ const swiperStyle = css` color: var(--rcx-color-font-pure-white, #ffffff) !important; } - .rcx-swiper-close-button { - position: absolute; - z-index: 10; - top: 10px; - right: 10px; - } - .rcx-swiper-prev-button, .rcx-swiper-next-button { position: absolute; @@ -94,25 +87,56 @@ const swiperStyle = css` color: ${Palette.text['font-pure-white']}; } + + .rcx-swiper-controls { + position: absolute; + top: 0; + right: 0; + padding: 10px; + z-index: 2; + + width: 100%; + display: flex; + justify-content: flex-end; + transition: background-color 0.2s; + &:hover { + background-color: ${Palette.surface['surface-overlay']}; + transition: background-color 0.2s; + } + } `; -const ImageGallery = () => { +export const ImageGallery = ({ images, onClose, loadMore }: { images: IUpload[]; onClose: () => void; loadMore?: () => void }) => { const swiperRef = useRef(null); const [, setSwiperInst] = useState(); + const [zoomScale, setZoomScale] = useState(1); - const { isLoading, loadMore, images, onClose } = useImageGallery(); + const handleZoom = (ratio: number) => { + if (swiperRef.current?.swiper.zoom) { + const { scale, in: zoomIn } = swiperRef.current?.swiper.zoom; + setZoomScale(scale + ratio); + return zoomIn(scale + ratio); + } + }; - if (isLoading) { - return ; - } + const handleZoomIn = () => handleZoom(1); + const handleZoomOut = () => handleZoom(-1); + const handleResize = () => handleZoom(-(zoomScale - 1)); + + const preventPropagation = usePreventPropagation(); return createPortal( -
- - - +
+ + {zoomScale !== 1 && } + + + + + + { prevEl: '.rcx-swiper-prev-button', }} keyboard - zoom + zoom={{ toggle: false }} lazyPreloaderClass='rcx-lazy-preloader' runCallbacksOnInit onKeyPress={(_, keyCode) => String(keyCode) === '27' && onClose()} @@ -131,7 +155,7 @@ const ImageGallery = () => { {images?.map(({ _id, url }) => (
- +
@@ -145,5 +169,3 @@ const ImageGallery = () => { document.body, ); }; - -export default ImageGallery; diff --git a/apps/meteor/client/components/ImageGallery/ImageGalleryError.tsx b/apps/meteor/client/components/ImageGallery/ImageGalleryError.tsx new file mode 100644 index 000000000000..b0c85ed13572 --- /dev/null +++ b/apps/meteor/client/components/ImageGallery/ImageGalleryError.tsx @@ -0,0 +1,26 @@ +import { css } from '@rocket.chat/css-in-js'; +import { IconButton, ModalBackdrop } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; +import { createPortal } from 'react-dom'; + +import GenericError from '../GenericError/GenericError'; + +const closeButtonStyle = css` + position: absolute; + z-index: 10; + top: 10px; + right: 10px; +`; + +export const ImageGalleryError = ({ onClose }: { onClose: () => void }) => { + const t = useTranslation(); + + return createPortal( + + + + , + document.body, + ); +}; diff --git a/apps/meteor/client/components/ImageGallery/ImageGalleryLoader.tsx b/apps/meteor/client/components/ImageGallery/ImageGalleryLoading.tsx similarity index 84% rename from apps/meteor/client/components/ImageGallery/ImageGalleryLoader.tsx rename to apps/meteor/client/components/ImageGallery/ImageGalleryLoading.tsx index 131b82780f22..6e5f2d0463b8 100644 --- a/apps/meteor/client/components/ImageGallery/ImageGalleryLoader.tsx +++ b/apps/meteor/client/components/ImageGallery/ImageGalleryLoading.tsx @@ -10,7 +10,7 @@ const closeButtonStyle = css` right: 10px; `; -const ImageGalleryLoader = ({ onClose }: { onClose: () => void }) => +export const ImageGalleryLoading = ({ onClose }: { onClose: () => void }) => createPortal( @@ -18,5 +18,3 @@ const ImageGalleryLoader = ({ onClose }: { onClose: () => void }) => , document.body, ); - -export default ImageGalleryLoader; diff --git a/apps/meteor/client/components/ImageGallery/hooks/useImageGallery.ts b/apps/meteor/client/components/ImageGallery/hooks/useImageGallery.ts deleted file mode 100644 index 9d058a010fdc..000000000000 --- a/apps/meteor/client/components/ImageGallery/hooks/useImageGallery.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useMemo, useContext } from 'react'; - -import { ImageGalleryContext } from '../../../contexts/ImageGalleryContext'; -import { useRecordList } from '../../../hooks/lists/useRecordList'; -import { useRoom } from '../../../views/room/contexts/RoomContext'; -import { useImagesList } from './useImagesList'; - -export const useImageGallery = () => { - const { _id: rid } = useRoom(); - const { imageId, onClose } = useContext(ImageGalleryContext); - - const { filesList, loadMoreItems } = useImagesList(useMemo(() => ({ roomId: rid, startingFromId: imageId }), [imageId, rid])); - const { phase, items: filesItems } = useRecordList(filesList); - - return { - images: filesItems, - isLoading: phase === 'loading', - loadMore: () => loadMoreItems(filesItems.length - 1), - onClose, - }; -}; diff --git a/apps/meteor/client/components/ImageGallery/index.ts b/apps/meteor/client/components/ImageGallery/index.ts index db657797badb..fb1b333e5e15 100644 --- a/apps/meteor/client/components/ImageGallery/index.ts +++ b/apps/meteor/client/components/ImageGallery/index.ts @@ -1 +1,3 @@ -export { default } from './ImageGallery'; +export * from './ImageGallery'; +export * from './ImageGalleryError'; +export * from './ImageGalleryLoading'; diff --git a/apps/meteor/client/components/NotFoundState.tsx b/apps/meteor/client/components/NotFoundState.tsx index 9ed42fc14366..7140a0f67b53 100644 --- a/apps/meteor/client/components/NotFoundState.tsx +++ b/apps/meteor/client/components/NotFoundState.tsx @@ -22,9 +22,11 @@ const NotFoundState = ({ title, subtitle }: NotFoundProps): ReactElement => { {title} {subtitle} - - {t('Homepage')} - + + + {t('Homepage')} + + ); diff --git a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx index 5d0b8e400836..0fd882f2c5cc 100644 --- a/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx @@ -1,4 +1,4 @@ -import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, Serialized } from '@rocket.chat/core-typings'; import { Field, FieldGroup, @@ -29,7 +29,7 @@ const CloseChatModal = ({ onCancel, onConfirm, }: { - department?: ILivechatDepartment | null; + department?: Serialized; visitorEmail?: string; onCancel: () => void; onConfirm: ( diff --git a/apps/meteor/client/components/Omnichannel/modals/CloseChatModalData.tsx b/apps/meteor/client/components/Omnichannel/modals/CloseChatModalData.tsx index eadaa353c806..0edc7bc7af91 100644 --- a/apps/meteor/client/components/Omnichannel/modals/CloseChatModalData.tsx +++ b/apps/meteor/client/components/Omnichannel/modals/CloseChatModalData.tsx @@ -1,9 +1,8 @@ -import type { ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; -import type { ReactElement } from 'react'; +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; import React from 'react'; -import { AsyncStatePhase } from '../../../hooks/useAsyncState'; -import { useEndpointData } from '../../../hooks/useEndpointData'; import { FormSkeleton } from '../Skeleton'; import CloseChatModal from './CloseChatModal'; @@ -21,32 +20,14 @@ const CloseChatModalData = ({ tags?: string[], preferences?: { omnichannelTranscriptPDF: boolean; omnichannelTranscriptEmail: boolean }, ) => Promise; -}): ReactElement => { - const { value: data, phase: state } = useEndpointData('/v1/livechat/department/:_id', { keys: { _id: departmentId } }); +}) => { + const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: departmentId }); + const { data, isLoading } = useQuery(['/v1/livechat/department/:_id', departmentId], () => getDepartment({})); - if ([state].includes(AsyncStatePhase.LOADING)) { + if (isLoading) { return ; } - // TODO: chapter day: fix issue with rest typing - // TODO: This is necessary because of a weird problem - // There is an endpoint livechat/department/${departmentId}/agents - // that is causing the problem. type A | type B | undefined - - return ( - - ); + return ; }; export default CloseChatModalData; diff --git a/apps/meteor/client/components/Page/PageHeader.tsx b/apps/meteor/client/components/Page/PageHeader.tsx index 25d20381e52e..1a2f53739c9a 100644 --- a/apps/meteor/client/components/Page/PageHeader.tsx +++ b/apps/meteor/client/components/Page/PageHeader.tsx @@ -1,5 +1,5 @@ import { Box, IconButton } from '@rocket.chat/fuselage'; -import { HeaderToolbox, useDocumentTitle } from '@rocket.chat/ui-client'; +import { HeaderToolbar, useDocumentTitle } from '@rocket.chat/ui-client'; import { useLayout, useTranslation } from '@rocket.chat/ui-contexts'; import type { FC, ComponentProps, ReactNode } from 'react'; import React, { useContext } from 'react'; @@ -31,9 +31,9 @@ const PageHeader: FC = ({ children = undefined, title, onClickB > {isMobile && ( - + - + )} {onClickBack && } diff --git a/apps/meteor/client/components/TextCopy.tsx b/apps/meteor/client/components/TextCopy.tsx index 049ef6a9447a..467e954ddd65 100644 --- a/apps/meteor/client/components/TextCopy.tsx +++ b/apps/meteor/client/components/TextCopy.tsx @@ -1,7 +1,9 @@ import { Box, Button, Scrollable } from '@rocket.chat/fuselage'; -import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, ReactElement } from 'react'; -import React, { useCallback } from 'react'; +import React from 'react'; + +import useClipboardWithToast from '../hooks/useClipboardWithToast'; const defaultWrapperRenderer = (text: string): ReactElement => ( @@ -14,19 +16,14 @@ type TextCopyProps = { wrapper?: (text: string) => ReactElement; } & ComponentProps; -// TODO: useClipboard instead of navigator API. const TextCopy = ({ text, wrapper = defaultWrapperRenderer, ...props }: TextCopyProps): ReactElement => { const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - const onClick = useCallback(() => { - try { - navigator.clipboard.writeText(text); - dispatchToastMessage({ type: 'success', message: t('Copied') }); - } catch (e) { - dispatchToastMessage({ type: 'error', message: e }); - } - }, [dispatchToastMessage, t, text]); + const { copy } = useClipboardWithToast(text); + + const handleClick = () => { + copy(); + }; return ( {wrapper(text)} - - - - - - - - {t('Use_url_for_avatar')} - + + + + - + + + + diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestion.ts b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestion.ts new file mode 100644 index 000000000000..e2ac26c6d0f3 --- /dev/null +++ b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestion.ts @@ -0,0 +1,6 @@ +export type UserAvatarSuggestion = { + blob: string; + contentType: string; + service: string; + url: string; +}; diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.tsx b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.tsx index 04b0c92acd95..4ccb7d304683 100644 --- a/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.tsx +++ b/apps/meteor/client/components/avatar/UserAvatarEditor/UserAvatarSuggestions.tsx @@ -1,43 +1,31 @@ -import type { AvatarObject } from '@rocket.chat/core-typings'; -import { Box, Button, Margins, Avatar } from '@rocket.chat/fuselage'; +import { Button, Avatar } from '@rocket.chat/fuselage'; import React, { useCallback } from 'react'; -import { useAvatarSuggestions } from '../../../hooks/useAvatarSuggestions'; +import type { UserAvatarSuggestion } from './UserAvatarSuggestion'; +import { useUserAvatarSuggestions } from './useUserAvatarSuggestions'; type UserAvatarSuggestionsProps = { - setAvatarObj: (obj: AvatarObject) => void; - setNewAvatarSource: (source: string) => void; disabled?: boolean; + onSelectOne?: (suggestion: UserAvatarSuggestion) => void; }; -const UserAvatarSuggestions = ({ setAvatarObj, setNewAvatarSource, disabled }: UserAvatarSuggestionsProps) => { - const handleClick = useCallback( - (suggestion) => () => { - setAvatarObj(suggestion); - setNewAvatarSource(suggestion.blob); - }, - [setAvatarObj, setNewAvatarSource], - ); +function UserAvatarSuggestions({ disabled, onSelectOne }: UserAvatarSuggestionsProps) { + const { data: suggestions = [] } = useUserAvatarSuggestions(); - const { data } = useAvatarSuggestions(); - const suggestions = Object.values(data?.suggestions || {}); + const handleClick = useCallback((suggestion: UserAvatarSuggestion) => () => onSelectOne?.(suggestion), [onSelectOne]); return ( - - {suggestions && - suggestions.length > 0 && - suggestions.map( - (suggestion) => - suggestion.blob && ( - - ), - )} - + <> + {suggestions.map( + (suggestion) => + suggestion.blob && ( + + ), + )} + ); -}; +} export default UserAvatarSuggestions; diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/readFileAsDataURL.ts b/apps/meteor/client/components/avatar/UserAvatarEditor/readFileAsDataURL.ts new file mode 100644 index 000000000000..d2bb3f7beed1 --- /dev/null +++ b/apps/meteor/client/components/avatar/UserAvatarEditor/readFileAsDataURL.ts @@ -0,0 +1,16 @@ +export const readFileAsDataURL = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = (event) => { + const result = event.target?.result; + if (typeof result === 'string') { + resolve(result); + return; + } + reject(new Error('Failed to read file')); + }; + reader.onerror = (event) => { + reject(new Error(`Failed to read file: ${event}`)); + }; + reader.readAsDataURL(file); + }); diff --git a/apps/meteor/client/components/avatar/UserAvatarEditor/useUserAvatarSuggestions.ts b/apps/meteor/client/components/avatar/UserAvatarEditor/useUserAvatarSuggestions.ts new file mode 100644 index 000000000000..42bb09406d3b --- /dev/null +++ b/apps/meteor/client/components/avatar/UserAvatarEditor/useUserAvatarSuggestions.ts @@ -0,0 +1,12 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +export const useUserAvatarSuggestions = () => { + const getAvatarSuggestions = useEndpoint('GET', '/v1/users.getAvatarSuggestion'); + + return useQuery({ + queryKey: ['account', 'profile', 'avatar-suggestions'], + queryFn: async () => getAvatarSuggestions(), + select: (data) => Object.values(data.suggestions), + }); +}; diff --git a/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.styles.css b/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.styles.css deleted file mode 100644 index f9a363aaa28a..000000000000 --- a/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.styles.css +++ /dev/null @@ -1,18 +0,0 @@ -.ConnectionStatusBar { - position: fixed; - z-index: 1000000; - top: 0; - - width: 100%; - padding: 2px; - - text-align: center; - - color: #916302; - border-bottom-width: 1px; - background-color: #fffdf9; - - &__retry-link { - color: var(--color-blue); - } -} diff --git a/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.tsx b/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.tsx index 2737a6fc4d4a..6046aecc0bae 100644 --- a/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.tsx +++ b/apps/meteor/client/components/connectionStatus/ConnectionStatusBar.tsx @@ -1,82 +1,53 @@ -import { Icon } from '@rocket.chat/fuselage'; -import { useConnectionStatus, useTranslation } from '@rocket.chat/ui-contexts'; -import type { MouseEventHandler, FC } from 'react'; -import React, { useEffect, useRef, useState } from 'react'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import { useConnectionStatus } from '@rocket.chat/ui-contexts'; +import type { MouseEvent } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; -import './ConnectionStatusBar.styles.css'; +import { useReconnectCountdown } from './useReconnectCountdown'; -// TODO: frontend chapter day - fix unknown translation keys - -const getReconnectCountdown = (retryTime: number): number => { - const timeDiff = retryTime - Date.now(); - return (timeDiff > 0 && Math.round(timeDiff / 1000)) || 0; -}; - -const useReconnectCountdown = ( - retryTime: number | undefined, - status: 'connected' | 'connecting' | 'failed' | 'waiting' | 'offline', -): number => { - const reconnectionTimerRef = useRef>(); - const [reconnectCountdown, setReconnectCountdown] = useState(() => (retryTime ? getReconnectCountdown(retryTime) : 0)); - - useEffect(() => { - if (status === 'waiting') { - if (reconnectionTimerRef.current) { - return; - } - - reconnectionTimerRef.current = setInterval(() => { - retryTime && setReconnectCountdown(getReconnectCountdown(retryTime)); - }, 500); - return; - } - - reconnectionTimerRef.current && clearInterval(reconnectionTimerRef.current); - reconnectionTimerRef.current = undefined; - }, [retryTime, status]); - - useEffect( - () => (): void => { - reconnectionTimerRef.current && clearInterval(reconnectionTimerRef.current); - }, - [], - ); - - return reconnectCountdown; -}; - -const ConnectionStatusBar: FC = function ConnectionStatusBar() { +function ConnectionStatusBar() { const { connected, retryTime, status, reconnect } = useConnectionStatus(); const reconnectCountdown = useReconnectCountdown(retryTime, status); - const t = useTranslation(); + const { t } = useTranslation(); if (connected) { return null; } - const handleRetryClick: MouseEventHandler = (event) => { + const handleRetryClick = (event: MouseEvent) => { event.preventDefault(); reconnect?.(); }; return ( -
- - {t('meteor_status' as Parameters[0], { context: status })} - - - {status === 'waiting' && <> {t('meteor_status_reconnect_in', { count: reconnectCountdown })}} - - {['waiting', 'offline'].includes(status) && ( - <> - {' '} - - {t('meteor_status_try_now' as Parameters[0], { context: status })} - - - )} -
+ + {' '} + + {t('meteor_status', { context: status })} + {status === 'waiting' && <> {t('meteor_status_reconnect_in', { count: reconnectCountdown })}} + {['waiting', 'offline'].includes(status) && ( + <> + {' '} + + {t('meteor_status_try_now', { context: status })} + + + )} + + ); -}; +} export default ConnectionStatusBar; diff --git a/apps/meteor/client/components/connectionStatus/useReconnectCountdown.ts b/apps/meteor/client/components/connectionStatus/useReconnectCountdown.ts new file mode 100644 index 000000000000..7a409e752ce4 --- /dev/null +++ b/apps/meteor/client/components/connectionStatus/useReconnectCountdown.ts @@ -0,0 +1,39 @@ +import { useEffect, useRef, useState } from 'react'; + +const getReconnectCountdown = (retryTime: number): number => { + const timeDiff = retryTime - Date.now(); + return (timeDiff > 0 && Math.round(timeDiff / 1000)) || 0; +}; + +export const useReconnectCountdown = ( + retryTime: number | undefined, + status: 'connected' | 'connecting' | 'failed' | 'waiting' | 'offline', +): number => { + const reconnectionTimerRef = useRef>(); + const [reconnectCountdown, setReconnectCountdown] = useState(() => (retryTime ? getReconnectCountdown(retryTime) : 0)); + + useEffect(() => { + if (status === 'waiting') { + if (reconnectionTimerRef.current) { + return; + } + + reconnectionTimerRef.current = setInterval(() => { + retryTime && setReconnectCountdown(getReconnectCountdown(retryTime)); + }, 500); + return; + } + + reconnectionTimerRef.current && clearInterval(reconnectionTimerRef.current); + reconnectionTimerRef.current = undefined; + }, [retryTime, status]); + + useEffect( + () => (): void => { + reconnectionTimerRef.current && clearInterval(reconnectionTimerRef.current); + }, + [], + ); + + return reconnectCountdown; +}; diff --git a/apps/meteor/client/components/message/IgnoredContent.tsx b/apps/meteor/client/components/message/IgnoredContent.tsx index 31b62639b849..b1973b13178a 100644 --- a/apps/meteor/client/components/message/IgnoredContent.tsx +++ b/apps/meteor/client/components/message/IgnoredContent.tsx @@ -17,7 +17,7 @@ const IgnoredContent = ({ onShowMessageIgnored }: IgnoredContentProps): ReactEle }; return ( - +

{t('Message_Ignored')} diff --git a/apps/meteor/client/components/message/MessageContentBody.tsx b/apps/meteor/client/components/message/MessageContentBody.tsx index 5552e6da0745..b3c612fbe725 100644 --- a/apps/meteor/client/components/message/MessageContentBody.tsx +++ b/apps/meteor/client/components/message/MessageContentBody.tsx @@ -1,5 +1,4 @@ -import { css } from '@rocket.chat/css-in-js'; -import { MessageBody, Box, Palette, Skeleton } from '@rocket.chat/fuselage'; +import { MessageBody, Skeleton } from '@rocket.chat/fuselage'; import { Markup } from '@rocket.chat/gazzodown'; import React, { Suspense } from 'react'; @@ -10,59 +9,14 @@ type MessageContentBodyProps = Pick { - // TODO: this style should go to Fuselage repository - const messageBodyAdditionalStyles = css` - > blockquote { - padding-inline: 8px; - border: 1px solid ${Palette.stroke['stroke-extra-light']}; - border-radius: 2px; - background-color: ${Palette.surface['surface-tint']}; - border-inline-start-color: ${Palette.stroke['stroke-medium']}; - - &:hover, - &:focus { - background-color: ${Palette.surface['surface-hover']}; - border-color: ${Palette.stroke['stroke-light']}; - border-inline-start-color: ${Palette.stroke['stroke-medium']}; - } - } - > ul.task-list { - > li::before { - display: none; - } - - > li > .rcx-check-box > .rcx-check-box__input:focus + .rcx-check-box__fake { - z-index: 1; - } - - list-style: none; - margin-inline-start: 0; - padding-inline-start: 0; - } - a { - color: ${Palette.text['font-info']}; - &:hover { - text-decoration: underline; - } - &:focus { - box-shadow: 0 0 0 2px ${Palette.stroke['stroke-extra-light-highlight']}; - border-radius: 2px; - } - } - `; - - return ( - - - }> - - - - - - - ); -}; +const MessageContentBody = ({ mentions, channels, md, searchText }: MessageContentBodyProps) => ( + + }> + + + + + +); export default MessageContentBody; diff --git a/apps/meteor/client/components/message/MessageToolboxHolder.tsx b/apps/meteor/client/components/message/MessageToolbarHolder.tsx similarity index 75% rename from apps/meteor/client/components/message/MessageToolboxHolder.tsx rename to apps/meteor/client/components/message/MessageToolbarHolder.tsx index 06a9fbf42b77..4251660d0f9f 100644 --- a/apps/meteor/client/components/message/MessageToolboxHolder.tsx +++ b/apps/meteor/client/components/message/MessageToolbarHolder.tsx @@ -1,5 +1,5 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import { MessageToolboxWrapper } from '@rocket.chat/fuselage'; +import { MessageToolbarWrapper } from '@rocket.chat/fuselage'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import React, { Suspense, lazy, memo, useRef, useState } from 'react'; @@ -8,14 +8,14 @@ import type { MessageActionContext } from '../../../app/ui-utils/client/lib/Mess import { useChat } from '../../views/room/contexts/ChatContext'; import { useIsVisible } from '../../views/room/hooks/useIsVisible'; -type MessageToolboxHolderProps = { +type MessageToolbarHolderProps = { message: IMessage; context?: MessageActionContext; }; -const MessageToolbox = lazy(() => import('./toolbox/MessageToolbox')); +const MessageToolbar = lazy(() => import('./toolbar/MessageToolbar')); -const MessageToolboxHolder = ({ message, context }: MessageToolboxHolderProps): ReactElement => { +const MessageToolbarHolder = ({ message, context }: MessageToolbarHolderProps): ReactElement => { const ref = useRef(null); const [isVisible] = useIsVisible(ref); @@ -35,10 +35,10 @@ const MessageToolboxHolder = ({ message, context }: MessageToolboxHolderProps): }); return ( - + {showToolbox && depsQueryResult.isSuccess && depsQueryResult.data.room && ( - )} - + ); }; -export default memo(MessageToolboxHolder); +export default memo(MessageToolbarHolder); diff --git a/apps/meteor/client/components/message/content/Attachments.tsx b/apps/meteor/client/components/message/content/Attachments.tsx index 2c1b6675cb7b..ea9c03b9e7d3 100644 --- a/apps/meteor/client/components/message/content/Attachments.tsx +++ b/apps/meteor/client/components/message/content/Attachments.tsx @@ -6,15 +6,14 @@ import AttachmentsItem from './attachments/AttachmentsItem'; type AttachmentsProps = { attachments: MessageAttachmentBase[]; - collapsed?: boolean; id?: string | undefined; }; -const Attachments = ({ attachments, collapsed, id }: AttachmentsProps): ReactElement => { +const Attachments = ({ attachments, id }: AttachmentsProps): ReactElement => { return ( <> {attachments?.map((attachment, index) => ( - + ))} ); diff --git a/apps/meteor/client/components/message/content/DiscussionMetrics.tsx b/apps/meteor/client/components/message/content/DiscussionMetrics.tsx index 5589d1278905..dc84828e607a 100644 --- a/apps/meteor/client/components/message/content/DiscussionMetrics.tsx +++ b/apps/meteor/client/components/message/content/DiscussionMetrics.tsx @@ -23,7 +23,7 @@ const DiscussionMetrics = ({ lm, count, rid, drid }: DiscussionMetricsProps): Re goToRoom(drid)}> - {count ? t('message_counter', { counter: count, count }) : t('Reply')} + {count ? t('message_counter', { count }) : t('Reply')} diff --git a/apps/meteor/client/components/message/content/Reactions.tsx b/apps/meteor/client/components/message/content/Reactions.tsx index 712504ec7a2c..5f2e82b6b742 100644 --- a/apps/meteor/client/components/message/content/Reactions.tsx +++ b/apps/meteor/client/components/message/content/Reactions.tsx @@ -1,9 +1,9 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { MessageReactions, MessageReactionAction } from '@rocket.chat/fuselage'; import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useContext } from 'react'; -import { useOpenEmojiPicker, useReactionsFilter, useUserHasReacted } from '../list/MessageListContext'; +import { MessageListContext, useOpenEmojiPicker, useUserHasReacted } from '../list/MessageListContext'; import Reaction from './reactions/Reaction'; import { useToggleReactionMutation } from './reactions/useToggleReactionMutation'; @@ -13,9 +13,8 @@ type ReactionsProps = { const Reactions = ({ message }: ReactionsProps): ReactElement => { const hasReacted = useUserHasReacted(message); - const filterReactions = useReactionsFilter(message); const openEmojiPicker = useOpenEmojiPicker(message); - + const { username } = useContext(MessageListContext); const toggleReactionMutation = useToggleReactionMutation(); return ( @@ -27,7 +26,8 @@ const Reactions = ({ message }: ReactionsProps): ReactElement => { counter={reactions.usernames.length} hasReacted={hasReacted} name={name} - names={filterReactions(name)} + names={reactions.usernames.filter((user) => user !== username)} + messageId={message._id} onClick={() => toggleReactionMutation.mutate({ mid: message._id, reaction: name })} /> ))} diff --git a/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx b/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx index 589549d4bcc1..dd4f9bac9d28 100644 --- a/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx +++ b/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx @@ -4,7 +4,7 @@ import type { ReactElement } from 'react'; import React, { memo } from 'react'; import DefaultAttachment from './DefaultAttachment'; -import { FileAttachment } from './FileAttachment'; +import FileAttachment from './FileAttachment'; import { QuoteAttachment } from './QuoteAttachment'; type AttachmentsItemProps = { diff --git a/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx b/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx index 942ace9055d3..f80fa62890ec 100644 --- a/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/FileAttachment.tsx @@ -1,23 +1,25 @@ -import type { FileAttachmentProps } from '@rocket.chat/core-typings'; -import { isFileAudioAttachment, isFileImageAttachment, isFileVideoAttachment } from '@rocket.chat/core-typings'; -import type { FC } from 'react'; +import { type FileAttachmentProps, isFileAudioAttachment, isFileImageAttachment, isFileVideoAttachment } from '@rocket.chat/core-typings'; import React from 'react'; -import { AudioAttachment } from './file/AudioAttachment'; -import { GenericFileAttachment } from './file/GenericFileAttachment'; -import { ImageAttachment } from './file/ImageAttachment'; -import { VideoAttachment } from './file/VideoAttachment'; +import AudioAttachment from './file/AudioAttachment'; +import GenericFileAttachment from './file/GenericFileAttachment'; +import ImageAttachment from './file/ImageAttachment'; +import VideoAttachment from './file/VideoAttachment'; -export const FileAttachment: FC = (attachment) => { +const FileAttachment = (attachment: FileAttachmentProps) => { if (isFileImageAttachment(attachment)) { return ; } + if (isFileAudioAttachment(attachment)) { return ; } + if (isFileVideoAttachment(attachment)) { return ; } - return ; // TODO: fix this + return ; }; + +export default FileAttachment; diff --git a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx index 67232cbd441f..7c2c2011cac9 100644 --- a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx @@ -1,6 +1,7 @@ import type { MessageQuoteAttachment } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; import { Box, Palette } from '@rocket.chat/fuselage'; +import { useUserPreference } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; @@ -37,6 +38,7 @@ type QuoteAttachmentProps = { export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElement => { const formatTime = useTimeAgo(); + const displayAvatarPreference = useUserPreference('displayAvatars'); return ( <> @@ -50,7 +52,7 @@ export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElem borderInlineStartColor='light' > - + {displayAvatarPreference && } @@ -68,7 +70,7 @@ export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElem {attachment.md ? : attachment.text.substring(attachment.text.indexOf('\n') + 1)} {attachment.attachments && ( - + )} diff --git a/apps/meteor/client/components/message/content/attachments/default/ActionAttachtment.tsx b/apps/meteor/client/components/message/content/attachments/default/ActionAttachtment.tsx index df3b2674b897..99b1f3de7290 100644 --- a/apps/meteor/client/components/message/content/attachments/default/ActionAttachtment.tsx +++ b/apps/meteor/client/components/message/content/attachments/default/ActionAttachtment.tsx @@ -10,27 +10,29 @@ export const ActionAttachment: FC = ({ actions }) => { const handleLinkClick = useExternalLink(); return ( - - {actions - .filter( - ({ type, msg_in_chat_window: msgInChatWindow, url, image_url: image, text }) => - type === 'button' && (image || text) && (url || msgInChatWindow), - ) - .map(({ text, url, msgId, msg, msg_processing_type: processingType = 'sendMessage', image_url: image }, index) => { - const content = image ? : text; - if (url) { + + + {actions + .filter( + ({ type, msg_in_chat_window: msgInChatWindow, url, image_url: image, text }) => + type === 'button' && (image || text) && (url || msgInChatWindow), + ) + .map(({ text, url, msgId, msg, msg_processing_type: processingType = 'sendMessage', image_url: image }, index) => { + const content = image ? : text; + if (url) { + return ( + + ); + } return ( - + ); - } - return ( - - {content} - - ); - })} - + })} + + ); }; diff --git a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx index 4dd06de46655..0f2082f96e31 100644 --- a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx @@ -1,14 +1,13 @@ import type { AudioAttachmentProps } from '@rocket.chat/core-typings'; import { AudioPlayer } from '@rocket.chat/fuselage'; import { useMediaUrl } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; import MessageContentBody from '../../../MessageContentBody'; -export const AudioAttachment: FC = ({ +const AudioAttachment = ({ title, audio_url: url, audio_type: type, @@ -18,7 +17,7 @@ export const AudioAttachment: FC = ({ title_link: link, title_link_download: hasDownload, collapsed, -}) => { +}: AudioAttachmentProps) => { const getURL = useMediaUrl(); return ( <> @@ -29,3 +28,5 @@ export const AudioAttachment: FC = ({ ); }; + +export default AudioAttachment; diff --git a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx index cbf1749b9dcd..4301520c6173 100644 --- a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx @@ -7,7 +7,7 @@ import { MessageGenericPreviewDescription, } from '@rocket.chat/fuselage'; import { useMediaUrl } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; +import type { UIEvent } from 'react'; import React from 'react'; import { getFileExtension } from '../../../../../../lib/utils/getFileExtension'; @@ -16,7 +16,11 @@ import MessageCollapsible from '../../../MessageCollapsible'; import MessageContentBody from '../../../MessageContentBody'; import AttachmentSize from '../structure/AttachmentSize'; -export const GenericFileAttachment: FC = ({ +const openDocumentViewer = window.RocketChatDesktop?.openDocumentViewer; + +type GenericFileAttachmentProps = MessageAttachmentBase; + +const GenericFileAttachment = ({ title, description, descriptionMd, @@ -25,9 +29,24 @@ export const GenericFileAttachment: FC = ({ size, format, collapsed, -}) => { +}: GenericFileAttachmentProps) => { const getURL = useMediaUrl(); + const handleTitleClick = (event: UIEvent): void => { + if (openDocumentViewer && link && format === 'PDF') { + event.preventDefault(); + openDocumentViewer(getURL(link), format, ''); + } + }; + + const getExternalUrl = () => { + if (!hasDownload || !link) return undefined; + + if (openDocumentViewer) return `${getURL(link)}?download`; + + return getURL(link); + }; + return ( <> {descriptionMd ? : } @@ -36,7 +55,7 @@ export const GenericFileAttachment: FC = ({ } > - + {title} {size && ( @@ -50,3 +69,5 @@ export const GenericFileAttachment: FC = ({ ); }; + +export default GenericFileAttachment; diff --git a/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx index 13cd375c7eeb..a7e4036e1288 100644 --- a/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/ImageAttachment.tsx @@ -1,6 +1,5 @@ import type { ImageAttachmentProps } from '@rocket.chat/core-typings'; import { useMediaUrl } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; import MarkdownText from '../../../../MarkdownText'; @@ -9,7 +8,7 @@ import MessageContentBody from '../../../MessageContentBody'; import AttachmentImage from '../structure/AttachmentImage'; import { useLoadImage } from './hooks/useLoadImage'; -export const ImageAttachment: FC = ({ +const ImageAttachment = ({ id, title, image_url: url, @@ -24,7 +23,7 @@ export const ImageAttachment: FC { +}: ImageAttachmentProps) => { const [loadImage, setLoadImage] = useLoadImage(); const getURL = useMediaUrl(); @@ -45,3 +44,5 @@ export const ImageAttachment: FC ); }; + +export default ImageAttachment; diff --git a/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx index dd32db8d8523..5954baf092d0 100644 --- a/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx @@ -1,7 +1,6 @@ import type { VideoAttachmentProps } from '@rocket.chat/core-typings'; import { Box, MessageGenericPreview } from '@rocket.chat/fuselage'; import { useMediaUrl } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; import React from 'react'; import { userAgentMIMETypeFallback } from '../../../../../lib/utils/userAgentMIMETypeFallback'; @@ -9,7 +8,7 @@ import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; import MessageContentBody from '../../../MessageContentBody'; -export const VideoAttachment: FC = ({ +const VideoAttachment = ({ title, video_url: url, video_type: type, @@ -19,7 +18,7 @@ export const VideoAttachment: FC = ({ title_link: link, title_link_download: hasDownload, collapsed, -}) => { +}: VideoAttachmentProps) => { const getURL = useMediaUrl(); return ( @@ -35,3 +34,5 @@ export const VideoAttachment: FC = ({ ); }; + +export default VideoAttachment; diff --git a/apps/meteor/client/components/message/content/reactions/Reaction.tsx b/apps/meteor/client/components/message/content/reactions/Reaction.tsx index cf68e65b8829..19744674bcd2 100644 --- a/apps/meteor/client/components/message/content/reactions/Reaction.tsx +++ b/apps/meteor/client/components/message/content/reactions/Reaction.tsx @@ -1,51 +1,30 @@ import { MessageReaction as MessageReactionTemplate, MessageReactionEmoji, MessageReactionCounter } from '@rocket.chat/fuselage'; -import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useTooltipClose, useTooltipOpen, useTranslation } from '@rocket.chat/ui-contexts'; +import { useTooltipClose, useTooltipOpen } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import React, { useRef } from 'react'; +import React, { useRef, useContext } from 'react'; import { getEmojiClassNameAndDataTitle } from '../../../../lib/utils/renderEmoji'; -import MarkdownText from '../../../MarkdownText'; +import { MessageListContext } from '../../list/MessageListContext'; +import ReactionTooltip from './ReactionTooltip'; // TODO: replace it with proper usage of i18next plurals -const getTranslationKey = (users: string[], mine: boolean): TranslationKey => { - if (users.length === 0) { - if (mine) { - return 'You_reacted_with'; - } - } - - if (users.length > 10) { - if (mine) { - return 'You_users_and_more_Reacted_with'; - } - return 'Users_and_more_reacted_with'; - } - - if (mine) { - return 'You_and_users_Reacted_with'; - } - return 'Users_reacted_with'; -}; - type ReactionProps = { hasReacted: (name: string) => boolean; counter: number; name: string; names: string[]; + messageId: string; onClick: () => void; }; -const Reaction = ({ hasReacted, counter, name, names, ...props }: ReactionProps): ReactElement => { - const t = useTranslation(); +const Reaction = ({ hasReacted, counter, name, names, messageId, ...props }: ReactionProps): ReactElement => { const ref = useRef(null); const openTooltip = useTooltipOpen(); const closeTooltip = useTooltipClose(); + const { showRealName, username } = useContext(MessageListContext); const mine = hasReacted(name); - const key = getTranslationKey(names, mine); - const emojiProps = getEmojiClassNameAndDataTitle(name); return ( @@ -55,18 +34,21 @@ const Reaction = ({ hasReacted, counter, name, names, ...props }: ReactionProps) mine={mine} tabIndex={0} role='button' - onMouseOver={(e): void => { + // if data-tooltip is not set, the tooltip will close on first mouse enter + data-tooltip + onMouseEnter={async (e) => { e.stopPropagation(); e.preventDefault(); + ref.current && openTooltip( - 10 ? names.length - 10 : names.length, - users: names.slice(0, 10).join(', '), - emoji: name, - })} - variant='inline' + , ref.current, ); diff --git a/apps/meteor/client/components/message/content/reactions/ReactionTooltip.tsx b/apps/meteor/client/components/message/content/reactions/ReactionTooltip.tsx new file mode 100644 index 000000000000..e36d5640e7cb --- /dev/null +++ b/apps/meteor/client/components/message/content/reactions/ReactionTooltip.tsx @@ -0,0 +1,88 @@ +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import React from 'react'; + +import { useGetMessageByID } from '../../../../views/room/contextualBar/Threads/hooks/useGetMessageByID'; +import MarkdownText from '../../../MarkdownText'; + +type ReactionTooltipProps = { + emojiName: string; + usernames: string[]; + username: string | undefined; + mine: boolean; + showRealName: boolean; + messageId: string; +}; + +const getTranslationKey = (users: string[], mine: boolean): TranslationKey => { + if (users.length === 0) { + if (mine) { + return 'You_reacted_with'; + } + } + + if (users.length > 10) { + if (mine) { + return 'You_users_and_more_Reacted_with'; + } + return 'Users_and_more_reacted_with'; + } + + if (mine) { + return 'You_and_users_Reacted_with'; + } + return 'Users_reacted_with'; +}; + +const ReactionTooltip = ({ emojiName, usernames, mine, messageId, showRealName, username }: ReactionTooltipProps) => { + const t = useTranslation(); + + const key = getTranslationKey(usernames, mine); + + const getMessage = useGetMessageByID(); + + const { data: users } = useQuery( + ['chat.getMessage', 'reactions', messageId, usernames], + async () => { + // This happens if the only reaction is from the current user + if (!usernames.length) { + return []; + } + + if (!showRealName) { + return usernames; + } + + const data = await getMessage(messageId); + + const { reactions } = data; + + if (!reactions) { + return []; + } + + if (username) { + const index = reactions[emojiName].usernames.indexOf(username); + index >= 0 && reactions[emojiName].names?.splice(index, 1); + return (reactions[emojiName].names || usernames).filter(Boolean); + } + + return reactions[emojiName].names || usernames; + }, + { staleTime: 1000 * 60 * 5 }, + ); + + return ( + 10 ? usernames.length - 10 : usernames.length, + users: users?.slice(0, 10).join(', ') || '', + emoji: emojiName, + })} + variant='inline' + /> + ); +}; + +export default ReactionTooltip; diff --git a/apps/meteor/client/components/message/list/MessageListContext.tsx b/apps/meteor/client/components/message/list/MessageListContext.tsx index 156679fe5463..82cca7377bb6 100644 --- a/apps/meteor/client/components/message/list/MessageListContext.tsx +++ b/apps/meteor/client/components/message/list/MessageListContext.tsx @@ -7,7 +7,6 @@ export type MessageListContextValue = { useShowFollowing: ({ message }: { message: IMessage }) => boolean; useMessageDateFormatter: () => (date: Date) => string; useUserHasReacted: (message: IMessage) => (reaction: string) => boolean; - useReactionsFilter: (message: IMessage) => (reaction: string) => string[]; useOpenEmojiPicker: (message: IMessage) => (event: React.MouseEvent) => void; showRoles: boolean; showRealName: boolean; @@ -25,6 +24,7 @@ export type MessageListContextValue = { autoTranslateLanguage?: string; showColors: boolean; jumpToMessageParam?: string; + username: string | undefined; scrollMessageList?: (callback: (wrapper: HTMLDivElement | null) => ScrollToOptions | void) => void; }; @@ -38,15 +38,12 @@ export const MessageListContext = createContext({ (date: Date): string => date.toString(), useOpenEmojiPicker: () => (): void => undefined, - useReactionsFilter: - (message) => - (reaction: string): string[] => - message.reactions ? message.reactions[reaction]?.usernames || [] : [], showRoles: false, showRealName: false, showUsername: false, showColors: false, scrollMessageList: () => undefined, + username: undefined, }); export const useShowTranslated: MessageListContextValue['useShowTranslated'] = (...args) => @@ -69,5 +66,3 @@ export const useUserHasReacted: MessageListContextValue['useUserHasReacted'] = ( useContext(MessageListContext).useUserHasReacted(message); export const useOpenEmojiPicker: MessageListContextValue['useOpenEmojiPicker'] = (...args) => useContext(MessageListContext).useOpenEmojiPicker(...args); -export const useReactionsFilter: MessageListContextValue['useReactionsFilter'] = (message: IMessage) => - useContext(MessageListContext).useReactionsFilter(message); diff --git a/apps/meteor/client/components/message/toolbox/DesktopToolboxDropdown.tsx b/apps/meteor/client/components/message/toolbar/DesktopToolbarDropdown.tsx similarity index 70% rename from apps/meteor/client/components/message/toolbox/DesktopToolboxDropdown.tsx rename to apps/meteor/client/components/message/toolbar/DesktopToolbarDropdown.tsx index 0310cf44c013..8c80bc9005bf 100644 --- a/apps/meteor/client/components/message/toolbox/DesktopToolboxDropdown.tsx +++ b/apps/meteor/client/components/message/toolbar/DesktopToolbarDropdown.tsx @@ -2,13 +2,13 @@ import { Tile, PositionAnimated } from '@rocket.chat/fuselage'; import type { ReactNode, Ref, RefObject } from 'react'; import React, { forwardRef } from 'react'; -type DesktopToolboxDropdownProps = { +type DesktopToolbarDropdownProps = { children: ReactNode; reference: RefObject; }; -const DesktopToolboxDropdown = forwardRef(function ToolboxDropdownDesktop( - { reference, children }: DesktopToolboxDropdownProps, +const DesktopToolbarDropdown = forwardRef(function DesktopToolbarDropdown( + { reference, children }: DesktopToolbarDropdownProps, ref: Ref, ) { return ( @@ -20,4 +20,4 @@ const DesktopToolboxDropdown = forwardRef(function ToolboxDropdownDesktop( ); }); -export default DesktopToolboxDropdown; +export default DesktopToolbarDropdown; diff --git a/apps/meteor/client/components/message/toolbox/MessageActionMenu.tsx b/apps/meteor/client/components/message/toolbar/MessageActionMenu.tsx similarity index 93% rename from apps/meteor/client/components/message/toolbox/MessageActionMenu.tsx rename to apps/meteor/client/components/message/toolbar/MessageActionMenu.tsx index 937667b817c7..a88d9beb1d99 100644 --- a/apps/meteor/client/components/message/toolbox/MessageActionMenu.tsx +++ b/apps/meteor/client/components/message/toolbar/MessageActionMenu.tsx @@ -1,11 +1,11 @@ -import { MessageToolboxItem, Option, OptionDivider, OptionTitle } from '@rocket.chat/fuselage'; +import { MessageToolbarItem, Option, OptionDivider, OptionTitle } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ComponentProps, MouseEvent, MouseEventHandler, ReactElement } from 'react'; import React, { Fragment, useCallback, useRef, useState } from 'react'; import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/MessageAction'; import { useEmbeddedLayout } from '../../../hooks/useEmbeddedLayout'; -import ToolboxDropdown from './ToolboxDropdown'; +import ToolbarDropdown from './ToolbarDropdown'; type MessageActionConfigOption = Omit & { action: ((event: MouseEvent) => void) & MouseEventHandler; @@ -76,7 +76,7 @@ const MessageActionMenu = ({ options, onChangeMenuVisibility, ...props }: Messag }, [handleChangeMenuVisibility]); return ( <> - handleChangeMenuVisibility(!visible)} @@ -86,7 +86,7 @@ const MessageActionMenu = ({ options, onChangeMenuVisibility, ...props }: Messag /> {visible && ( <> - + {groupOptions.map(([section, options], index, arr) => ( {section === 'apps' && Apps} @@ -110,7 +110,7 @@ const MessageActionMenu = ({ options, onChangeMenuVisibility, ...props }: Messag {index !== arr.length - 1 && } ))} - + )} diff --git a/apps/meteor/client/components/message/toolbox/MessageToolbox.tsx b/apps/meteor/client/components/message/toolbar/MessageToolbar.tsx similarity index 82% rename from apps/meteor/client/components/message/toolbox/MessageToolbox.tsx rename to apps/meteor/client/components/message/toolbar/MessageToolbar.tsx index 3b9cdd84c25d..335fdf1ea158 100644 --- a/apps/meteor/client/components/message/toolbox/MessageToolbox.tsx +++ b/apps/meteor/client/components/message/toolbar/MessageToolbar.tsx @@ -1,8 +1,8 @@ import type { IMessage, IRoom, ISubscription, ITranslatedMessage } from '@rocket.chat/core-typings'; import { isThreadMessage, isRoomFederated, isVideoConfMessage } from '@rocket.chat/core-typings'; -import { MessageToolbox as FuselageMessageToolbox, MessageToolboxItem } from '@rocket.chat/fuselage'; +import { MessageToolbar as FuselageMessageToolbar, MessageToolbarItem } from '@rocket.chat/fuselage'; import { useFeaturePreview } from '@rocket.chat/ui-client'; -import { useUser, useSettings, useTranslation, useMethod } from '@rocket.chat/ui-contexts'; +import { useUser, useSettings, useTranslation, useMethod, useLayoutHiddenActions } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import React, { memo, useMemo } from 'react'; @@ -17,6 +17,7 @@ import { useAutoTranslate } from '../../../views/room/MessageList/hooks/useAutoT import { useChat } from '../../../views/room/contexts/ChatContext'; import { useRoomToolbox } from '../../../views/room/contexts/RoomToolboxContext'; import MessageActionMenu from './MessageActionMenu'; +import { useWebDAVMessageAction } from './useWebDAVMessageAction'; const getMessageContext = (message: IMessage, room: IRoom, context?: MessageActionContext): MessageActionContext => { if (context) { @@ -38,7 +39,7 @@ const getMessageContext = (message: IMessage, room: IRoom, context?: MessageActi return 'message'; }; -type MessageToolboxProps = { +type MessageToolbarProps = { message: IMessage & Partial; messageContext?: MessageActionContext; room: IRoom; @@ -46,13 +47,13 @@ type MessageToolboxProps = { onChangeMenuVisibility: (visible: boolean) => void; }; -const MessageToolbox = ({ +const MessageToolbar = ({ message, messageContext, room, subscription, onChangeMenuVisibility, -}: MessageToolboxProps): ReactElement | null => { +}: MessageToolbarProps): ReactElement | null => { const t = useTranslation(); const user = useUser() ?? undefined; const settings = useSettings(); @@ -70,13 +71,21 @@ const MessageToolbox = ({ const actionButtonApps = useMessageActionAppsActionButtons(context); + const { messageToolbox: hiddenActions } = useLayoutHiddenActions(); + + // TODO: move this to another place + useWebDAVMessageAction(); + const actionsQueryResult = useQuery(['rooms', room._id, 'messages', message._id, 'actions'] as const, async () => { const props = { message, room, user, subscription, settings: mapSettings, chat }; const toolboxItems = await MessageAction.getAll(props, context, 'message'); const menuItems = await MessageAction.getAll(props, context, 'menu'); - return { message: toolboxItems, menu: menuItems }; + return { + message: toolboxItems.filter((action) => !hiddenActions.includes(action.id)), + menu: menuItems.filter((action) => !hiddenActions.includes(action.id)), + }; }); const toolbox = useRoomToolbox(); @@ -85,7 +94,7 @@ const MessageToolbox = ({ const autoTranslateOptions = useAutoTranslate(subscription); - if (selecting) { + if (selecting || (!actionsQueryResult.data?.message.length && !actionsQueryResult.data?.menu.length)) { return null; } @@ -97,7 +106,7 @@ const MessageToolbox = ({ }; return ( - + {quickReactionsEnabled && isReactionAllowed && quickReactions.slice(0, 3).map(({ emoji, image }) => { @@ -105,7 +114,7 @@ const MessageToolbox = ({ })} {actionsQueryResult.isSuccess && actionsQueryResult.data.message.map((action) => ( - action.action(e, { message, tabbar: toolbox, room, chat, autoTranslateOptions })} key={action.id} icon={action.icon} @@ -124,8 +133,8 @@ const MessageToolbox = ({ data-qa-type='message-action-menu-options' /> )} - + ); }; -export default memo(MessageToolbox); +export default memo(MessageToolbar); diff --git a/apps/meteor/client/components/message/toolbox/MobileToolboxDropdown.tsx b/apps/meteor/client/components/message/toolbar/MobileToolbarDropdown.tsx similarity index 77% rename from apps/meteor/client/components/message/toolbox/MobileToolboxDropdown.tsx rename to apps/meteor/client/components/message/toolbar/MobileToolbarDropdown.tsx index d97d1ff46aee..0d62a0a8e9de 100644 --- a/apps/meteor/client/components/message/toolbox/MobileToolboxDropdown.tsx +++ b/apps/meteor/client/components/message/toolbar/MobileToolbarDropdown.tsx @@ -4,12 +4,12 @@ import React, { forwardRef } from 'react'; import ScrollableContentWrapper from '../../ScrollableContentWrapper'; -type MobileToolboxDropdownProps = { +type MobileToolbarDropdownProps = { children: ReactNode; }; -const MobileToolboxDropdown = forwardRef(function MobileToolboxDropdown( - { children, ...props }: MobileToolboxDropdownProps, +const MobileToolbarDropdown = forwardRef(function MobileToolbarDropdown( + { children, ...props }: MobileToolbarDropdownProps, ref: Ref, ) { return ( @@ -35,4 +35,4 @@ const MobileToolboxDropdown = forwardRef(function MobileToolboxDropdown( ); }); -export default MobileToolboxDropdown; +export default MobileToolbarDropdown; diff --git a/apps/meteor/client/components/message/toolbox/ToolboxDropdown.tsx b/apps/meteor/client/components/message/toolbar/ToolbarDropdown.tsx similarity index 64% rename from apps/meteor/client/components/message/toolbox/ToolboxDropdown.tsx rename to apps/meteor/client/components/message/toolbar/ToolbarDropdown.tsx index eee619454f77..411441c0d1f6 100644 --- a/apps/meteor/client/components/message/toolbox/ToolboxDropdown.tsx +++ b/apps/meteor/client/components/message/toolbar/ToolbarDropdown.tsx @@ -4,25 +4,25 @@ import { useLayout } from '@rocket.chat/ui-contexts'; import type { ReactNode, ReactElement } from 'react'; import React, { useRef } from 'react'; -import DesktopToolboxDropdown from './DesktopToolboxDropdown'; -import MobileToolboxDropdown from './MobileToolboxDropdown'; +import DesktopToolbarDropdown from './DesktopToolbarDropdown'; +import MobileToolbarDropdown from './MobileToolbarDropdown'; -type ToolboxDropdownProps = { +type ToolbarDropdownProps = { children: ReactNode; reference: React.RefObject; handleClose: () => void; }; -const ToolboxDropdown = ({ +const ToolbarDropdown = ({ children, handleClose, reference, -}: ToolboxDropdownProps): ReactElement => { +}: ToolbarDropdownProps): ReactElement => { const { isMobile } = useLayout(); const target = useRef(null); const boxRef = useRef(null); - const Dropdown = isMobile ? MobileToolboxDropdown : DesktopToolboxDropdown; + const Dropdown = isMobile ? MobileToolbarDropdown : DesktopToolbarDropdown; useOutsideClick([boxRef], handleClose); @@ -35,4 +35,4 @@ const ToolboxDropdown = ({ ); }; -export default ToolboxDropdown; +export default ToolbarDropdown; diff --git a/apps/meteor/client/components/message/toolbar/useWebDAVMessageAction.tsx b/apps/meteor/client/components/message/toolbar/useWebDAVMessageAction.tsx new file mode 100644 index 000000000000..a2be70077054 --- /dev/null +++ b/apps/meteor/client/components/message/toolbar/useWebDAVMessageAction.tsx @@ -0,0 +1,44 @@ +import { useSetModal, useSetting } from '@rocket.chat/ui-contexts'; +import React, { useEffect } from 'react'; + +import { MessageAction } from '../../../../app/ui-utils/client/lib/MessageAction'; +import { getURL } from '../../../../app/utils/client'; +import { useWebDAVAccountIntegrationsQuery } from '../../../hooks/webdav/useWebDAVAccountIntegrationsQuery'; +import { messageArgs } from '../../../lib/utils/messageArgs'; +import SaveToWebdavModal from '../../../views/room/webdav/SaveToWebdavModal'; + +export const useWebDAVMessageAction = () => { + const enabled = useSetting('Webdav_Integration_Enabled', false); + + const { data } = useWebDAVAccountIntegrationsQuery({ enabled }); + + const setModal = useSetModal(); + + useEffect(() => { + if (!enabled) { + return; + } + + MessageAction.addButton({ + id: 'webdav-upload', + icon: 'upload', + label: 'Save_To_Webdav', + condition: ({ message, subscription }) => { + return !!subscription && !!data?.length && !!message.file; + }, + action(_, props) { + const { message = messageArgs(this).msg } = props; + const [attachment] = message.attachments || []; + const url = getURL(attachment.title_link as string, { full: true }); + + setModal( setModal(undefined)} />); + }, + order: 100, + group: 'menu', + }); + + return () => { + MessageAction.removeButton('webdav-upload'); + }; + }, [data?.length, enabled, setModal]); +}; diff --git a/apps/meteor/client/components/message/variants/RoomMessage.tsx b/apps/meteor/client/components/message/variants/RoomMessage.tsx index 2e82753051ae..af8a6c08297c 100644 --- a/apps/meteor/client/components/message/variants/RoomMessage.tsx +++ b/apps/meteor/client/components/message/variants/RoomMessage.tsx @@ -17,7 +17,7 @@ import { useJumpToMessage } from '../../../views/room/MessageList/hooks/useJumpT import { useChat } from '../../../views/room/contexts/ChatContext'; import IgnoredContent from '../IgnoredContent'; import MessageHeader from '../MessageHeader'; -import MessageToolboxHolder from '../MessageToolboxHolder'; +import MessageToolbarHolder from '../MessageToolbarHolder'; import StatusIndicators from '../StatusIndicators'; import MessageAvatar from '../header/MessageAvatar'; import RoomMessageContent from './room/RoomMessageContent'; @@ -104,7 +104,7 @@ const RoomMessage = ({ )} - {!message.private && } + {!message.private && } ); }; diff --git a/apps/meteor/client/components/message/variants/ThreadMessage.tsx b/apps/meteor/client/components/message/variants/ThreadMessage.tsx index bc39de79e48d..7c2b219b546f 100644 --- a/apps/meteor/client/components/message/variants/ThreadMessage.tsx +++ b/apps/meteor/client/components/message/variants/ThreadMessage.tsx @@ -11,7 +11,7 @@ import { useJumpToMessage } from '../../../views/room/MessageList/hooks/useJumpT import { useChat } from '../../../views/room/contexts/ChatContext'; import IgnoredContent from '../IgnoredContent'; import MessageHeader from '../MessageHeader'; -import MessageToolboxHolder from '../MessageToolboxHolder'; +import MessageToolbarHolder from '../MessageToolbarHolder'; import StatusIndicators from '../StatusIndicators'; import MessageAvatar from '../header/MessageAvatar'; import ThreadMessageContent from './thread/ThreadMessageContent'; @@ -72,7 +72,7 @@ const ThreadMessage = ({ message, sequential, unread, showUserAvatar }: ThreadMe {ignored ? : } - {!message.private && } + {!message.private && } ); }; diff --git a/apps/meteor/client/definitions/IOAuthProvider.ts b/apps/meteor/client/definitions/IOAuthProvider.ts new file mode 100644 index 000000000000..00bc3be2b040 --- /dev/null +++ b/apps/meteor/client/definitions/IOAuthProvider.ts @@ -0,0 +1,9 @@ +import type { Meteor } from 'meteor/meteor'; + +export interface IOAuthProvider { + readonly name: string; + requestCredential( + options: Meteor.LoginWithExternalServiceOptions | undefined, + credentialRequestCompleteCallback: (credentialTokenOrError?: string | Error) => void, + ): void; +} diff --git a/apps/meteor/client/definitions/IRocketChatDesktop.ts b/apps/meteor/client/definitions/IRocketChatDesktop.ts new file mode 100644 index 000000000000..52bc172d719b --- /dev/null +++ b/apps/meteor/client/definitions/IRocketChatDesktop.ts @@ -0,0 +1,10 @@ +type OutlookEventsResponse = { status: 'success' | 'canceled' }; + +export interface IRocketChatDesktop { + openInternalVideoChatWindow?: (url: string, options: { providerName: string | undefined }) => void; + getOutlookEvents?: (date: Date) => Promise; + setOutlookExchangeUrl?: (url: string, userId: string) => Promise; + hasOutlookCredentials?: () => Promise; + clearOutlookCredentials?: () => void; + openDocumentViewer?: (url: string, format: string, options: any) => void; +} diff --git a/apps/meteor/client/definitions/global.d.ts b/apps/meteor/client/definitions/global.d.ts new file mode 100644 index 000000000000..8b20108e8e48 --- /dev/null +++ b/apps/meteor/client/definitions/global.d.ts @@ -0,0 +1,8 @@ +import type { IRocketChatDesktop } from './IRocketChatDesktop'; + +declare global { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Window { + RocketChatDesktop?: IRocketChatDesktop; + } +} diff --git a/apps/meteor/client/hooks/roomActions/useOmnichannelExternalFrameRoomAction.ts b/apps/meteor/client/hooks/roomActions/useOmnichannelExternalFrameRoomAction.ts index 7f03b369b449..fe1e704f0b1c 100644 --- a/apps/meteor/client/hooks/roomActions/useOmnichannelExternalFrameRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useOmnichannelExternalFrameRoomAction.ts @@ -3,7 +3,7 @@ import { lazy, useMemo } from 'react'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; -const ExternalFrameContainer = lazy(() => import('../../../app/livechat/client/externalFrame/ExternalFrameContainer')); +const ExternalFrameContainer = lazy(() => import('../../views/omnichannel/ExternalFrameContainer')); export const useOmnichannelExternalFrameRoomAction = () => { const enabled = useSetting('Omnichannel_External_Frame_Enabled', false); diff --git a/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx index dc72ef26c66b..92cc93e339fd 100644 --- a/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx +++ b/apps/meteor/client/hooks/roomActions/useThreadRoomAction.tsx @@ -1,5 +1,5 @@ import type { BadgeProps } from '@rocket.chat/fuselage'; -import { HeaderToolboxAction, HeaderToolboxActionBadge } from '@rocket.chat/ui-client'; +import { HeaderToolbarAction, HeaderToolbarActionBadge } from '@rocket.chat/ui-client'; import { useSetting } from '@rocket.chat/ui-contexts'; import React, { lazy, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -46,7 +46,7 @@ export const useThreadRoomAction = () => { tabComponent: Threads, order: 2, renderToolboxItem: ({ id, className, index, icon, title, toolbox: { tab }, action, disabled, tooltip }) => ( - { disabled={disabled} tooltip={tooltip} > - {!!unread && {unread}} - + {!!unread && {unread}} + ), }; }, [enabled, t, unread, variant]); diff --git a/apps/meteor/client/hooks/useAppActionButtons.ts b/apps/meteor/client/hooks/useAppActionButtons.ts index 5647ca36656e..5ee20f7772bf 100644 --- a/apps/meteor/client/hooks/useAppActionButtons.ts +++ b/apps/meteor/client/hooks/useAppActionButtons.ts @@ -76,7 +76,7 @@ export const useMessageboxAppsActionButtons = () => { return applyButtonFilters(action); }) .map((action) => { - const item: MessageBoxAction = { + const item: Omit = { id: getIdForActionButton(action), label: Utilities.getI18nKeyForApp(action.labelI18n, action.appId), action: (params) => { diff --git a/apps/meteor/client/hooks/useAvatarSuggestions.ts b/apps/meteor/client/hooks/useAvatarSuggestions.ts deleted file mode 100644 index 223cab8ca4b4..000000000000 --- a/apps/meteor/client/hooks/useAvatarSuggestions.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; - -export const useAvatarSuggestions = () => { - const getAvatarSuggestions = useEndpoint('GET', '/v1/users.getAvatarSuggestion'); - - return useQuery(['getAvatarSuggestion'], async () => getAvatarSuggestions()); -}; diff --git a/apps/meteor/client/hooks/useEndpointAction.ts b/apps/meteor/client/hooks/useEndpointAction.ts index 7cc1a68f1020..c7c371a04e1f 100644 --- a/apps/meteor/client/hooks/useEndpointAction.ts +++ b/apps/meteor/client/hooks/useEndpointAction.ts @@ -1,7 +1,7 @@ -import type { Method, OperationParams, PathPattern, UrlParams } from '@rocket.chat/rest-typings'; +import type { Method, PathPattern, UrlParams } from '@rocket.chat/rest-typings'; import type { EndpointFunction } from '@rocket.chat/ui-contexts'; import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback } from 'react'; +import { useMutation } from '@tanstack/react-query'; type UseEndpointActionOptions = (undefined extends UrlParams ? { @@ -16,26 +16,21 @@ export function useEndpointAction = { keys: {} as UrlParams }, -): EndpointFunction { +) { const sendData = useEndpoint(method, pathPattern, options.keys as UrlParams); const dispatchToastMessage = useToastMessageDispatch(); - return useCallback( - async (params: OperationParams | undefined) => { - try { - const data = await sendData(params as OperationParams); - - if (options.successMessage) { - dispatchToastMessage({ type: 'success', message: options.successMessage }); - } - - return data; - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - throw error; + const mutation = useMutation(sendData, { + onSuccess: () => { + if (options.successMessage) { + dispatchToastMessage({ type: 'success', message: options.successMessage }); } }, - [dispatchToastMessage, sendData, options.successMessage], - ); + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + }); + + return mutation.mutateAsync as EndpointFunction; } diff --git a/apps/meteor/client/hooks/useFileInput.ts b/apps/meteor/client/hooks/useFileInput.ts new file mode 100644 index 000000000000..c9662b820d8f --- /dev/null +++ b/apps/meteor/client/hooks/useFileInput.ts @@ -0,0 +1,23 @@ +import { useRef, useEffect } from 'react'; +import type { AllHTMLAttributes } from 'react'; + +export const useFileInput = (props: AllHTMLAttributes) => { + const ref = useRef(); + + useEffect(() => { + const fileInput = document.createElement('input'); + fileInput.setAttribute('style', 'display: none;'); + Object.entries(props).forEach(([key, value]) => { + fileInput.setAttribute(key, value); + }); + document.body.appendChild(fileInput); + ref.current = fileInput; + + return (): void => { + ref.current = undefined; + fileInput.remove(); + }; + }, [props]); + + return ref; +}; diff --git a/apps/meteor/client/hooks/webdav/useWebDAVAccountIntegrationsQuery.ts b/apps/meteor/client/hooks/webdav/useWebDAVAccountIntegrationsQuery.ts new file mode 100644 index 000000000000..171490b33d18 --- /dev/null +++ b/apps/meteor/client/hooks/webdav/useWebDAVAccountIntegrationsQuery.ts @@ -0,0 +1,55 @@ +import type { IWebdavAccountIntegration } from '@rocket.chat/core-typings'; +import { useUserId, useEndpoint, useStream } from '@rocket.chat/ui-contexts'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo } from 'react'; + +type UseWebDAVAccountIntegrationsQueryOptions = Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' +>; + +export const useWebDAVAccountIntegrationsQuery = ({ enabled = true, ...options }: UseWebDAVAccountIntegrationsQueryOptions = {}) => { + const uid = useUserId(); + + const queryKey = useMemo(() => ['webdav', 'account-integrations'] as const, []); + + const getMyAccounts = useEndpoint('GET', '/v1/webdav.getMyAccounts'); + + const integrationsQuery = useQuery({ + queryKey, + queryFn: async (): Promise => { + const { accounts } = await getMyAccounts(); + return accounts; + }, + enabled: !!uid && enabled, + staleTime: Infinity, + ...options, + }); + + const queryClient = useQueryClient(); + + const subscribeToNotifyUser = useStream('notify-user'); + + useEffect(() => { + if (!uid || !enabled) { + return; + } + + return subscribeToNotifyUser(`${uid}/webdav`, ({ type, account }) => { + switch (type) { + case 'changed': + queryClient.invalidateQueries(queryKey); + break; + + case 'removed': + queryClient.setQueryData(queryKey, (old = []) => { + return old.filter((oldAccount) => oldAccount._id !== account._id); + }); + break; + } + }); + }, [enabled, queryClient, queryKey, uid, subscribeToNotifyUser]); + + return integrationsQuery; +}; diff --git a/apps/meteor/client/importPackages.ts b/apps/meteor/client/importPackages.ts index fc9ce52e9993..5119f0bba191 100644 --- a/apps/meteor/client/importPackages.ts +++ b/apps/meteor/client/importPackages.ts @@ -1,11 +1,7 @@ import '../app/cors/client'; -import '../app/2fa/client'; import '../app/apple/client'; import '../app/authorization/client'; import '../app/autotranslate/client'; -import '../app/cas/client'; -import '../app/crowd/client'; -import '../app/custom-oauth/client/custom_oauth_client'; import '../app/custom-sounds/client'; import '../app/dolphin/client'; import '../app/drupal/client'; @@ -33,18 +29,14 @@ import '../app/slashcommands-open/client'; import '../app/slashcommands-topic/client'; import '../app/slashcommands-unarchiveroom/client'; import '../app/tokenpass/client'; -import '../app/webdav/client'; import '../app/webrtc/client'; import '../app/wordpress/client'; -import '../app/meteor-accounts-saml/client'; import '../app/e2e/client'; import '../app/discussion/client'; import '../app/threads/client'; -import '../app/user-status/client'; import '../app/utils/client'; import '../app/settings/client'; import '../app/models/client'; -import '../app/notifications/client'; import '../app/ui-utils/client'; import '../app/ui-cached-collection/client'; import '../app/reactions/client'; diff --git a/apps/meteor/client/lib/2fa/overrideLoginMethod.ts b/apps/meteor/client/lib/2fa/overrideLoginMethod.ts index fcda6907cbcf..7cf01ba3370c 100644 --- a/apps/meteor/client/lib/2fa/overrideLoginMethod.ts +++ b/apps/meteor/client/lib/2fa/overrideLoginMethod.ts @@ -1,46 +1,105 @@ -import { t } from '../../../app/utils/lib/i18n'; -import { dispatchToastMessage } from '../toast'; -import { process2faReturn } from './process2faReturn'; -import { isTotpInvalidError, isTotpRequiredError } from './utils'; - -type LoginCallback = { - (error: unknown): void; - (error: unknown, result: unknown): void; -}; +import { isTotpInvalidError, isTotpMaxAttemptsError, isTotpRequiredError } from './utils'; -type LoginMethod = (...args: [...args: A, cb: LoginCallback]) => void; +type LoginError = globalThis.Error | Meteor.Error | Meteor.TypedError; -type LoginMethodWithTotp = (...args: [...args: A, code: string, cb: LoginCallback]) => void; +export type LoginCallback = (error: LoginError | undefined, result?: unknown) => void; -export const overrideLoginMethod = ( - loginMethod: LoginMethod, - loginArgs: A, - callback: LoginCallback, - loginMethodTOTP: LoginMethodWithTotp, - emailOrUsername: string, -): void => { - loginMethod.call(null, ...loginArgs, async (error: unknown, result?: unknown) => { +export const overrideLoginMethod = ( + loginMethod: (...args: [...args: TArgs, cb: LoginCallback]) => void, + loginArgs: TArgs, + callback: LoginCallback | undefined, + loginMethodTOTP: (...args: [...args: TArgs, code: string, cb: LoginCallback]) => void, +) => { + loginMethod(...loginArgs, async (error: LoginError | undefined, result?: unknown) => { if (!isTotpRequiredError(error)) { - callback(error); + callback?.(error); return; } + const { process2faReturn } = await import('./process2faReturn'); + await process2faReturn({ error, result, - emailOrUsername, + emailOrUsername: typeof loginArgs[0] === 'string' ? loginArgs[0] : undefined, originalCallback: callback, onCode: (code: string) => { - loginMethodTOTP?.call(null, ...loginArgs, code, (error: unknown) => { + loginMethodTOTP(...loginArgs, code, (error: LoginError | undefined, result?: unknown) => { + if (!error) { + callback?.(undefined, result); + return; + } + if (isTotpInvalidError(error)) { - dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') }); - callback(null); + callback?.(error); return; } - callback(error); + Promise.all([import('../../../app/utils/lib/i18n'), import('../toast')]).then(([{ t }, { dispatchToastMessage }]) => { + if (isTotpMaxAttemptsError(error)) { + dispatchToastMessage({ + type: 'error', + message: t('totp-max-attempts'), + }); + callback?.(undefined); + return; + } + + dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') }); + callback?.(undefined); + }); }); }, }); }); }; + +export const handleLogin = Promise>( + login: TLoginFunction, + loginWithTOTP: (...args: [...args: Parameters, code: string]) => ReturnType, +) => { + return (...args: [...loginArgs: Parameters, callback?: LoginCallback]) => { + const loginArgs = args.slice(0, -1) as Parameters; + const callback = args.slice(-1)[0] as LoginCallback | undefined; + + return login(...loginArgs) + .catch(async (error: LoginError | undefined) => { + if (!isTotpRequiredError(error)) { + return Promise.reject(error); + } + + const { process2faAsyncReturn } = await import('./process2faReturn'); + return process2faAsyncReturn({ + emailOrUsername: typeof loginArgs[0] === 'string' ? loginArgs[0] : undefined, + error, + onCode: (code: string) => loginWithTOTP(...loginArgs, code), + }); + }) + .then((result: unknown) => callback?.(undefined, result)) + .catch((error: LoginError | undefined) => { + if (!isTotpInvalidError(error)) { + callback?.(error); + return; + } + + Promise.all([import('../../../app/utils/lib/i18n'), import('../toast')]).then(([{ t }, { dispatchToastMessage }]) => { + dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') }); + callback?.(undefined); + }); + }); + }; +}; + +export const callLoginMethod = (options: Omit) => + new Promise((resolve, reject) => { + Accounts.callLoginMethod({ + ...options, + userCallback: (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }, + }); + }); diff --git a/apps/meteor/client/lib/2fa/process2faReturn.ts b/apps/meteor/client/lib/2fa/process2faReturn.ts index 95f7f1dcb361..57a8d98b05b0 100644 --- a/apps/meteor/client/lib/2fa/process2faReturn.ts +++ b/apps/meteor/client/lib/2fa/process2faReturn.ts @@ -3,6 +3,7 @@ import { Meteor } from 'meteor/meteor'; import { lazy } from 'react'; import { imperativeModal } from '../imperativeModal'; +import type { LoginCallback } from './overrideLoginMethod'; import { isTotpInvalidError, isTotpRequiredError } from './utils'; const TwoFactorModal = lazy(() => import('../../components/TwoFactorModal')); @@ -35,6 +36,23 @@ function assertModalProps(props: { } } +const getProps = ( + method: 'totp' | 'email' | 'password', + emailOrUsername?: { username: string } | { email: string } | { id: string } | string, +) => { + switch (method) { + case 'totp': + return { method }; + case 'email': + return { + method, + emailOrUsername: typeof emailOrUsername === 'string' ? emailOrUsername : Meteor.user()?.username, + }; + case 'password': + return { method }; + } +}; + export async function process2faReturn({ error, result, @@ -42,23 +60,19 @@ export async function process2faReturn({ onCode, emailOrUsername, }: { - error: unknown; + error: globalThis.Error | Meteor.Error | Meteor.TypedError | undefined; result: unknown; - originalCallback: { - (error: unknown): void; - (error: unknown, result: unknown): void; - }; + originalCallback: LoginCallback | undefined; onCode: (code: string, method: string) => void; - emailOrUsername: string | null | undefined; + emailOrUsername: { username: string } | { email: string } | { id: string } | string | null | undefined; }): Promise { if (!(isTotpRequiredError(error) || isTotpInvalidError(error)) || !hasRequiredTwoFactorMethod(error)) { - originalCallback(error, result); + originalCallback?.(error, result); return; } const props = { - method: error.details.method, - emailOrUsername: emailOrUsername || error.details.emailOrUsername || Meteor.user()?.username, + ...getProps(error.details.method, emailOrUsername || error.details.emailOrUsername), // eslint-disable-next-line no-nested-ternary invalidAttempt: isTotpInvalidError(error), }; @@ -69,7 +83,7 @@ export async function process2faReturn({ onCode(code, props.method); } catch (error) { process2faReturn({ - error, + error: error as globalThis.Error | Meteor.Error | Meteor.TypedError | undefined, result, originalCallback, onCode, diff --git a/apps/meteor/client/lib/2fa/utils.ts b/apps/meteor/client/lib/2fa/utils.ts index e57037a14899..ab2234f2e589 100644 --- a/apps/meteor/client/lib/2fa/utils.ts +++ b/apps/meteor/client/lib/2fa/utils.ts @@ -1,6 +1,3 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - export const isTotpRequiredError = ( error: unknown, ): error is Meteor.Error & ({ error: 'totp-required' } | { errorType: 'totp-required' }) => @@ -17,23 +14,3 @@ export const isTotpMaxAttemptsError = ( ): error is Meteor.Error & ({ error: 'totp-max-attempts' } | { errorType: 'totp-max-attempts' }) => (error as { error?: unknown } | undefined)?.error === 'totp-max-attempts' || (error as { errorType?: unknown } | undefined)?.errorType === 'totp-max-attempts'; - -const isLoginCancelledError = (error: unknown): error is Meteor.Error => - error instanceof Meteor.Error && error.error === Accounts.LoginCancelledError.numericError; - -export const reportError = (error: T, callback?: (error?: T) => void): void => { - if (callback) { - callback(error); - return; - } - - throw error; -}; - -export const convertError = (error: T): Accounts.LoginCancelledError | T => { - if (isLoginCancelledError(error)) { - return new Accounts.LoginCancelledError(error.reason); - } - - return error; -}; diff --git a/apps/meteor/client/lib/VideoConfManager.ts b/apps/meteor/client/lib/VideoConfManager.ts index 42aa92a7be0e..7ae1c04db6df 100644 --- a/apps/meteor/client/lib/VideoConfManager.ts +++ b/apps/meteor/client/lib/VideoConfManager.ts @@ -3,7 +3,6 @@ import { Emitter } from '@rocket.chat/emitter'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; -import { Notifications } from '../../app/notifications/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { getConfig } from './utils/getConfig'; @@ -507,14 +506,14 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter { - return Notifications.notifyUser(uid, 'video-conference', { action, params }); + return sdk.publish('notify-user', [`${uid}/video-conference`, { action, params }]); } private async connectUser(userId: string): Promise { debug && console.log(`[VideoConf] connecting user ${userId}`); this.userId = userId; - const { stop, ready } = Notifications.onUser('video-conference', (data) => this.onVideoConfNotification(data)); + const { stop, ready } = sdk.stream('notify-user', [`${userId}/video-conference`], (data) => this.onVideoConfNotification(data)); await ready(); diff --git a/apps/meteor/client/lib/loginServices.ts b/apps/meteor/client/lib/loginServices.ts new file mode 100644 index 000000000000..ad5ee926ccc7 --- /dev/null +++ b/apps/meteor/client/lib/loginServices.ts @@ -0,0 +1,147 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import { capitalize } from '@rocket.chat/string-helpers'; +import type { LoginService } from '@rocket.chat/ui-contexts'; + +import { sdk } from '../../app/utils/client/lib/SDKClient'; + +type LoginServicesEvents = { + changed: undefined; + loaded: LoginServiceConfiguration[]; +}; + +type LoadState = 'loaded' | 'loading' | 'error' | 'none'; + +const maxRetries = 3; +const timeout = 10000; + +class LoginServices extends Emitter { + private retries = 0; + + private services: LoginServiceConfiguration[] = []; + + private serviceButtons: LoginService[] = []; + + private state: LoadState = 'none'; + + private config: Record> = { + 'apple': { title: 'Apple', icon: 'apple' }, + 'facebook': { title: 'Facebook', icon: 'facebook' }, + 'twitter': { title: 'Twitter', icon: 'twitter' }, + 'google': { title: 'Google', icon: 'google' }, + 'github': { title: 'Github', icon: 'github' }, + 'github_enterprise': { title: 'Github Enterprise', icon: 'github' }, + 'gitlab': { title: 'Gitlab', icon: 'gitlab' }, + 'dolphin': { title: 'Dolphin', icon: 'dophin' }, + 'drupal': { title: 'Drupal', icon: 'drupal' }, + 'nextcloud': { title: 'Nextcloud', icon: 'nextcloud' }, + 'tokenpass': { title: 'Tokenpass', icon: 'tokenpass' }, + 'meteor-developer': { title: 'Meteor', icon: 'meteor' }, + 'wordpress': { title: 'WordPress', icon: 'wordpress' }, + 'linkedin': { title: 'Linkedin', icon: 'linkedin' }, + }; + + private setServices(state: LoadState, services: LoginServiceConfiguration[]) { + this.services = services; + this.state = state; + + this.generateServiceButtons(); + + if (state === 'loaded') { + this.retries = 0; + this.emit('loaded', services); + } + } + + private generateServiceButtons(): void { + const filtered = this.services.filter((config) => !('showButton' in config) || config.showButton !== false) || []; + const sorted = filtered.sort(({ service: service1 }, { service: service2 }) => service1.localeCompare(service2)); + this.serviceButtons = sorted.map((service) => { + // Remove the appId attribute if present + const { appId: _, ...serviceData } = { + ...service, + appId: undefined, + }; + + // Get the hardcoded title and icon, or fallback to capitalizing the service name + const serviceConfig = this.config[service.service] || { + title: capitalize(service.service), + }; + + return { + ...serviceData, + ...serviceConfig, + }; + }); + + this.emit('changed'); + } + + public getLoginService = LoginServiceConfiguration>(serviceName: string): T | undefined { + if (!this.ready) { + return; + } + + return this.services.find(({ service }) => service === serviceName) as T | undefined; + } + + public async loadLoginService = LoginServiceConfiguration>( + serviceName: string, + ): Promise { + if (this.ready) { + return this.getLoginService(serviceName); + } + + return new Promise((resolve, reject) => { + this.onLoad(() => resolve(this.getLoginService(serviceName))); + + setTimeout(() => reject(new Error('LoadLoginService timeout')), timeout); + }); + } + + public get ready() { + return this.state === 'loaded'; + } + + public getLoginServiceButtons(): LoginService[] { + if (!this.ready) { + if (this.state === 'none') { + void this.loadServices(); + } + } + + return this.serviceButtons; + } + + public onLoad(callback: (services: LoginServiceConfiguration[]) => void) { + if (this.ready) { + return callback(this.services); + } + + void this.loadServices(); + this.once('loaded', callback); + } + + public async loadServices(): Promise { + if (this.state === 'error') { + if (this.retries >= maxRetries) { + return; + } + this.retries++; + } else if (this.state !== 'none') { + return; + } + + try { + this.state = 'loading'; + const { configurations } = await sdk.rest.get('/v1/service.configurations'); + + this.setServices('loaded', configurations); + } catch (e) { + this.setServices('error', []); + throw e; + } + } +} + +export const loginServices = new LoginServices(); diff --git a/apps/meteor/client/lib/openCASLoginPopup.ts b/apps/meteor/client/lib/openCASLoginPopup.ts new file mode 100644 index 000000000000..d82a48599e4b --- /dev/null +++ b/apps/meteor/client/lib/openCASLoginPopup.ts @@ -0,0 +1,62 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../app/settings/client'; + +const openCenteredPopup = (url: string, width: number, height: number) => { + const screenX = window.screenX ?? window.screenLeft; + const screenY = window.screenY ?? window.screenTop; + const outerWidth = window.outerWidth ?? document.body.clientWidth; + const outerHeight = window.outerHeight ?? document.body.clientHeight - 22; + // XXX what is the 22? Probably the height of the title bar. + // Use `outerWidth - width` and `outerHeight - height` for help in + // positioning the popup centered relative to the current window + const left = screenX + (outerWidth - width) / 2; + const top = screenY + (outerHeight - height) / 2; + const features = `width=${width},height=${height},left=${left},top=${top},scrollbars=yes`; + + const newwindow = window.open(url, 'Login', features); + + if (!newwindow) { + throw new Error('Could not open popup'); + } + + newwindow.focus(); + + return newwindow; +}; + +const getPopupUrl = (credentialToken: string): string => { + const loginUrl = settings.get('CAS_login_url'); + + if (!loginUrl) { + throw new Error('CAS_login_url not set'); + } + + const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; + const serviceUrl = `${appUrl}/_cas/${credentialToken}`; + const url = new URL(loginUrl); + url.searchParams.set('service', serviceUrl); + + return url.href; +}; + +const waitForPopupClose = (popup: Window) => { + return new Promise((resolve) => { + const checkPopupOpen = setInterval(() => { + if (popup.closed || popup.closed === undefined) { + clearInterval(checkPopupOpen); + resolve(); + } + }, 100); + }); +}; + +export const openCASLoginPopup = async (credentialToken: string) => { + const popupWidth = settings.get('CAS_popup_width') || 800; + const popupHeight = settings.get('CAS_popup_height') || 600; + + const popupUrl = getPopupUrl(credentialToken); + const popup = openCenteredPopup(popupUrl, popupWidth, popupHeight); + + await waitForPopupClose(popup); +}; diff --git a/apps/meteor/client/lib/portals/portalsSubscription.ts b/apps/meteor/client/lib/portals/portalsSubscription.ts index 24295d624936..513393eb983a 100644 --- a/apps/meteor/client/lib/portals/portalsSubscription.ts +++ b/apps/meteor/client/lib/portals/portalsSubscription.ts @@ -1,9 +1,9 @@ import { Emitter } from '@rocket.chat/emitter'; import { Random } from '@rocket.chat/random'; -import type { ReactElement } from 'react'; +import type { ReactPortal } from 'react'; type SubscribedPortal = { - portal: ReactElement; + portal: ReactPortal; key: string; }; @@ -11,7 +11,7 @@ type PortalsSubscription = { subscribe: (callback: () => void) => () => void; getSnapshot: () => SubscribedPortal[]; has: (key: unknown) => boolean; - set: (key: unknown, portal: ReactElement) => void; + set: (key: unknown, portal: ReactPortal) => void; delete: (key: unknown) => void; }; @@ -43,7 +43,7 @@ export const unregisterPortal = (key: unknown): void => { portalsSubscription.delete(key); }; -export const registerPortal = (key: unknown, portal: ReactElement): (() => void) => { +export const registerPortal = (key: unknown, portal: ReactPortal): (() => void) => { portalsSubscription.set(key, portal); return (): void => { unregisterPortal(key); diff --git a/apps/meteor/client/lib/presence.ts b/apps/meteor/client/lib/presence.ts index 8cb367bf1e52..dbaddcbe405b 100644 --- a/apps/meteor/client/lib/presence.ts +++ b/apps/meteor/client/lib/presence.ts @@ -6,8 +6,6 @@ import { Meteor } from 'meteor/meteor'; import { sdk } from '../../app/utils/client/lib/SDKClient'; -export const STATUS_MAP = [UserStatus.OFFLINE, UserStatus.ONLINE, UserStatus.AWAY, UserStatus.BUSY, UserStatus.DISABLED]; - type InternalEvents = { remove: IUser['_id']; reset: undefined; diff --git a/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts b/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts index 560d604534ed..b0276e753922 100644 --- a/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts +++ b/apps/meteor/client/lib/settings/PrivateSettingsCachedCollection.ts @@ -1,18 +1,18 @@ import type { ISetting } from '@rocket.chat/core-typings'; -import { Notifications } from '../../../app/notifications/client'; import { CachedCollection } from '../../../app/ui-cached-collection/client'; +import { sdk } from '../../../app/utils/client/lib/SDKClient'; class PrivateSettingsCachedCollection extends CachedCollection { constructor() { super({ name: 'private-settings', - eventType: 'onLogged', + eventType: 'notify-logged', }); } async setupListener(): Promise { - Notifications.onLogged(this.eventName as 'private-settings-changed', async (t: string, { _id, ...record }: { _id: string }) => { + sdk.stream('notify-logged', [this.eventName as 'private-settings-changed'], async (t: string, { _id, ...record }: { _id: string }) => { this.log('record received', t, { _id, ...record }); this.collection.upsert({ _id }, record); this.sync(); diff --git a/apps/meteor/client/lib/settings/PublicSettingsCachedCollection.ts b/apps/meteor/client/lib/settings/PublicSettingsCachedCollection.ts index 7eab4b1dc7a9..c01523252f85 100644 --- a/apps/meteor/client/lib/settings/PublicSettingsCachedCollection.ts +++ b/apps/meteor/client/lib/settings/PublicSettingsCachedCollection.ts @@ -6,7 +6,7 @@ class PublicSettingsCachedCollection extends CachedCollection { constructor() { super({ name: 'public-settings', - eventType: 'onAll', + eventType: 'notify-all', userRelated: false, }); } diff --git a/apps/meteor/client/lib/userData.ts b/apps/meteor/client/lib/userData.ts index 5ca61d131f69..ee90f493a30c 100644 --- a/apps/meteor/client/lib/userData.ts +++ b/apps/meteor/client/lib/userData.ts @@ -2,7 +2,6 @@ import type { ILivechatAgent, IUser, Serialized } from '@rocket.chat/core-typing import { ReactiveVar } from 'meteor/reactive-var'; import { Users } from '../../app/models/client'; -import { Notifications } from '../../app/notifications/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; export const isSyncReady = new ReactiveVar(false); @@ -60,7 +59,7 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise { + const result = sdk.stream('notify-user', [`${uid}/userData`], (data) => { switch (data.type) { case 'inserted': // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/apps/meteor/client/lib/userStatuses.ts b/apps/meteor/client/lib/userStatuses.ts new file mode 100644 index 000000000000..631c1d1ea044 --- /dev/null +++ b/apps/meteor/client/lib/userStatuses.ts @@ -0,0 +1,90 @@ +import { UserStatus } from '@rocket.chat/core-typings'; +import type { ICustomUserStatus } from '@rocket.chat/core-typings'; + +import { sdk } from '../../app/utils/client/lib/SDKClient'; + +export type UserStatusDescriptor = { + id: string; + name: string; + statusType: UserStatus; + localizeName: boolean; +}; + +export class UserStatuses implements Iterable { + public invisibleAllowed = true; + + private store: Map = new Map( + [UserStatus.ONLINE, UserStatus.AWAY, UserStatus.BUSY, UserStatus.OFFLINE].map((status) => [ + status, + { + id: status, + name: status, + statusType: status, + localizeName: true, + }, + ]), + ); + + public delete(id: string): void { + this.store.delete(id); + } + + public put(customUserStatus: UserStatusDescriptor): void { + this.store.set(customUserStatus.id, customUserStatus); + } + + public createFromCustom(customUserStatus: ICustomUserStatus): UserStatusDescriptor { + if (!this.isValidType(customUserStatus.statusType)) { + throw new Error('Invalid user status type'); + } + + return { + name: customUserStatus.name, + id: customUserStatus._id, + statusType: customUserStatus.statusType as UserStatus, + localizeName: false, + }; + } + + public isValidType(type: string): type is UserStatus { + return (Object.values(UserStatus) as string[]).includes(type); + } + + public *[Symbol.iterator]() { + for (const value of this.store.values()) { + if (this.invisibleAllowed || value.statusType !== UserStatus.OFFLINE) { + yield value; + } + } + } + + public async sync() { + const result = await sdk.call('listCustomUserStatus'); + if (!result) { + return; + } + + for (const customStatus of result) { + this.put(this.createFromCustom(customStatus)); + } + } + + public watch(cb?: () => void) { + const updateSubscription = sdk.stream('notify-logged', ['updateCustomUserStatus'], (data) => { + this.put(this.createFromCustom(data.userStatusData)); + cb?.(); + }); + + const deleteSubscription = sdk.stream('notify-logged', ['deleteCustomUserStatus'], (data) => { + this.delete(data.userStatusData._id); + cb?.(); + }); + + return () => { + updateSubscription.stop(); + deleteSubscription.stop(); + }; + } +} + +export const userStatuses = new UserStatuses(); diff --git a/apps/meteor/client/lib/utils/applyCustomTranslations.ts b/apps/meteor/client/lib/utils/applyCustomTranslations.ts deleted file mode 100644 index f629ed1aaace..000000000000 --- a/apps/meteor/client/lib/utils/applyCustomTranslations.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { settings } from '../../../app/settings/client'; -import { i18n } from '../../../app/utils/lib/i18n'; - -const parseToJSON = (customTranslations: string) => { - try { - return JSON.parse(customTranslations); - } catch (e) { - return false; - } -}; - -export const applyCustomTranslations = (): void => { - const customTranslations: string | undefined = settings.get('Custom_Translations'); - - if (!customTranslations || !parseToJSON(customTranslations)) { - return; - } - - try { - const parsedCustomTranslations: Record = JSON.parse(customTranslations); - - for (const [lang, translations] of Object.entries(parsedCustomTranslations)) { - i18n.addResourceBundle(lang, 'core', translations); - } - } catch (e) { - console.error('Invalid setting Custom_Translations', e); - } -}; diff --git a/apps/meteor/client/lib/utils/window.RocketChatDesktop.d.ts b/apps/meteor/client/lib/utils/window.RocketChatDesktop.d.ts deleted file mode 100644 index a7f625fc0705..000000000000 --- a/apps/meteor/client/lib/utils/window.RocketChatDesktop.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -type OutlookEventsResponse = { status: 'success' | 'canceled' }; - -// eslint-disable-next-line @typescript-eslint/naming-convention -interface Window { - RocketChatDesktop: - | { - openInternalVideoChatWindow?: (url: string, options: { providerName: string | undefined }) => void; - getOutlookEvents?: (date: Date) => Promise; - setOutlookExchangeUrl?: (url: string, userId: string) => Promise; - hasOutlookCredentials?: () => Promise; - clearOutlookCredentials?: () => void; - } - | undefined; -} diff --git a/apps/meteor/client/lib/wrapRequestCredentialFn.ts b/apps/meteor/client/lib/wrapRequestCredentialFn.ts new file mode 100644 index 000000000000..12102187de30 --- /dev/null +++ b/apps/meteor/client/lib/wrapRequestCredentialFn.ts @@ -0,0 +1,52 @@ +import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import { Accounts } from 'meteor/accounts-base'; +import type { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +import { loginServices } from './loginServices'; + +type RequestCredentialOptions = Meteor.LoginWithExternalServiceOptions; +type RequestCredentialCallback = (credentialTokenOrError?: string | Error) => void; + +type RequestCredentialConfig> = { + config: T; + loginStyle: string; + options: RequestCredentialOptions; + credentialRequestCompleteCallback?: RequestCredentialCallback; +}; + +export function wrapRequestCredentialFn>( + serviceName: string, + fn: (params: RequestCredentialConfig) => void, +) { + const wrapped = async ( + options: RequestCredentialOptions, + credentialRequestCompleteCallback?: RequestCredentialCallback, + ): Promise => { + const config = await loginServices.loadLoginService(serviceName); + if (!config) { + credentialRequestCompleteCallback?.(new Accounts.ConfigError()); + return; + } + + const loginStyle = OAuth._loginStyle(serviceName, config, options); + fn({ + config, + loginStyle, + options, + credentialRequestCompleteCallback, + }); + }; + + return ( + options?: RequestCredentialOptions | RequestCredentialCallback, + credentialRequestCompleteCallback?: RequestCredentialCallback, + ) => { + if (!credentialRequestCompleteCallback && typeof options === 'function') { + void wrapped({}, options); + return; + } + + void wrapped(options as RequestCredentialOptions, credentialRequestCompleteCallback); + }; +} diff --git a/apps/meteor/client/main.ts b/apps/meteor/client/main.ts index 4183195fb263..0a35c44a10be 100644 --- a/apps/meteor/client/main.ts +++ b/apps/meteor/client/main.ts @@ -9,7 +9,7 @@ FlowRouter.notFound = { }; import('./polyfills') - .then(() => Promise.all([import('./lib/meteorCallWrapper'), import('../lib/oauthRedirectUriClient')])) + .then(() => import('./meteorOverrides')) .then(() => import('../ee/client/ecdh')) .then(() => import('./importPackages')) .then(() => Promise.all([import('./methods'), import('./startup')])) diff --git a/apps/meteor/client/lib/meteorCallWrapper.ts b/apps/meteor/client/meteorOverrides/ddpOverREST.ts similarity index 63% rename from apps/meteor/client/lib/meteorCallWrapper.ts rename to apps/meteor/client/meteorOverrides/ddpOverREST.ts index b5a2f8785a69..9bd2021ec027 100644 --- a/apps/meteor/client/lib/meteorCallWrapper.ts +++ b/apps/meteor/client/meteorOverrides/ddpOverREST.ts @@ -6,7 +6,11 @@ import { sdk } from '../../app/utils/client/lib/SDKClient'; const bypassMethods: string[] = ['setUserStatus', 'logout']; -function shouldBypass({ method, params }: Meteor.IDDPMessage): boolean { +const shouldBypass = ({ msg, method, params }: Meteor.IDDPMessage): boolean => { + if (msg !== 'method') { + return true; + } + if (method === 'login' && params[0]?.resume) { return true; } @@ -20,14 +24,12 @@ function shouldBypass({ method, params }: Meteor.IDDPMessage): boolean { } return false; -} +}; -function wrapMeteorDDPCalls(): void { - const { _send } = Meteor.connection; - - Meteor.connection._send = function _DDPSendOverREST(message): void { - if (message.msg !== 'method' || shouldBypass(message)) { - return _send.call(Meteor.connection, message); +const withDDPOverREST = (_send: (this: Meteor.IMeteorConnection, message: Meteor.IDDPMessage) => void) => { + return function _sendOverREST(this: Meteor.IMeteorConnection, message: Meteor.IDDPMessage): void { + if (shouldBypass(message)) { + return _send.call(this, message); } const endpoint = Tracker.nonreactive(() => (!Meteor.userId() ? 'method.callAnon' : 'method.call')); @@ -36,19 +38,20 @@ function wrapMeteorDDPCalls(): void { message: DDPCommon.stringifyDDP({ ...message }), }; - const processResult = (_message: any): void => { + const processResult = (_message: string): void => { // Prevent error on reconnections and method retry. // On those cases the API will be called 2 times but // the handler will be deleted after the first execution. - if (!Meteor.connection._methodInvokers[message.id]) { + if (!this._methodInvokers[message.id]) { return; } - Meteor.connection._livedata_data({ + this._livedata_data({ msg: 'updated', methods: [message.id], }); - Meteor.connection.onMessage(_message); + this.onMessage(_message); }; + const method = encodeURIComponent(message.method.replace(/\//g, ':')); sdk.rest @@ -56,7 +59,7 @@ function wrapMeteorDDPCalls(): void { .then(({ message: _message }) => { processResult(_message); if (message.method === 'login') { - const parsedMessage = DDPCommon.parseDDP(_message as any) as { result?: { token?: string } }; + const parsedMessage = DDPCommon.parseDDP(_message) as { result?: { token?: string } }; if (parsedMessage.result?.token) { Meteor.loginWithToken(parsedMessage.result.token); } @@ -66,6 +69,8 @@ function wrapMeteorDDPCalls(): void { console.error(error); }); }; -} +}; -window.USE_REST_FOR_DDP_CALLS && wrapMeteorDDPCalls(); +if (window.USE_REST_FOR_DDP_CALLS) { + Meteor.connection._send = withDDPOverREST(Meteor.connection._send); +} diff --git a/apps/meteor/client/meteorOverrides/index.ts b/apps/meteor/client/meteorOverrides/index.ts new file mode 100644 index 000000000000..9a1b0eb1f7be --- /dev/null +++ b/apps/meteor/client/meteorOverrides/index.ts @@ -0,0 +1,16 @@ +import './ddpOverREST'; +import './totpOnCall'; +import './oauthRedirectUri'; +import './userAndUsers'; +import './login/cas'; +import './login/crowd'; +import './login/facebook'; +import './login/github'; +import './login/google'; +import './login/ldap'; +import './login/linkedin'; +import './login/meteorDeveloperAccount'; +import './login/oauth'; +import './login/password'; +import './login/saml'; +import './login/twitter'; diff --git a/apps/meteor/client/meteorOverrides/login/cas.ts b/apps/meteor/client/meteorOverrides/login/cas.ts new file mode 100644 index 000000000000..93a9f1d5b236 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/cas.ts @@ -0,0 +1,20 @@ +import { Random } from '@rocket.chat/random'; +import { Meteor } from 'meteor/meteor'; + +import { callLoginMethod } from '../../lib/2fa/overrideLoginMethod'; + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithCas(_?: unknown, callback?: (err?: any) => void): void; + } +} + +Meteor.loginWithCas = (_, callback) => { + const credentialToken = Random.id(); + import('../../lib/openCASLoginPopup') + .then(({ openCASLoginPopup }) => openCASLoginPopup(credentialToken)) + .then(() => callLoginMethod({ methodArguments: [{ cas: { credentialToken } }] })) + .then(() => callback?.()) + .catch(callback); +}; diff --git a/apps/meteor/client/meteorOverrides/login/crowd.ts b/apps/meteor/client/meteorOverrides/login/crowd.ts new file mode 100644 index 000000000000..9b1d4b83d402 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/crowd.ts @@ -0,0 +1,49 @@ +import { Meteor } from 'meteor/meteor'; + +import { callLoginMethod, handleLogin, type LoginCallback } from '../../lib/2fa/overrideLoginMethod'; + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithCrowd( + userDescriptor: { username: string } | { email: string } | { id: string } | string, + password: string, + callback?: LoginCallback, + ): void; + } +} + +const loginWithCrowd = (userDescriptor: { username: string } | { email: string } | { id: string } | string, password: string) => { + const loginRequest = { + crowd: true, + username: userDescriptor, + crowdPassword: password, + }; + + return callLoginMethod({ methodArguments: [loginRequest] }); +}; + +const loginWithCrowdAndTOTP = ( + userDescriptor: { username: string } | { email: string } | { id: string } | string, + password: string, + code: string, +) => { + const loginRequest = { + crowd: true, + username: userDescriptor, + crowdPassword: password, + }; + + return callLoginMethod({ + methodArguments: [ + { + totp: { + login: loginRequest, + code, + }, + }, + ], + }); +}; + +Meteor.loginWithCrowd = handleLogin(loginWithCrowd, loginWithCrowdAndTOTP); diff --git a/apps/meteor/client/meteorOverrides/login/facebook.ts b/apps/meteor/client/meteorOverrides/login/facebook.ts new file mode 100644 index 000000000000..72a91775818e --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/facebook.ts @@ -0,0 +1,56 @@ +import type { FacebookOAuthConfiguration } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { Facebook } from 'meteor/facebook-oauth'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; +import { createOAuthTotpLoginMethod } from './oauth'; + +const { loginWithFacebook } = Meteor; +const loginWithFacebookAndTOTP = createOAuthTotpLoginMethod(Facebook); +Meteor.loginWithFacebook = (options, callback) => { + overrideLoginMethod(loginWithFacebook, [options], callback, loginWithFacebookAndTOTP); +}; + +Facebook.requestCredential = wrapRequestCredentialFn( + 'facebook', + ({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const options = requestOptions as Meteor.LoginWithExternalServiceOptions & { + absoluteUrlOptions?: Record; + params?: Record; + auth_type?: string; + }; + + const credentialToken = Random.secret(); + const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i.test(navigator.userAgent); + const display = mobile ? 'touch' : 'popup'; + + const scope = options?.requestPermissions ? options.requestPermissions.join(',') : 'email'; + + const API_VERSION = Meteor.settings?.public?.packages?.['facebook-oauth']?.apiVersion || '17.0'; + + const loginUrlParameters: Record = { + client_id: config.appId, + redirect_uri: OAuth._redirectUri('facebook', config, options.params, options.absoluteUrlOptions), + display, + scope, + state: OAuth._stateParam(loginStyle, credentialToken, options?.redirectUrl), + // Handle authentication type (e.g. for force login you need auth_type: "reauthenticate") + ...(options.auth_type && { auth_type: options.auth_type }), + }; + + const loginUrl = `https://www.facebook.com/v${API_VERSION}/dialog/oauth?${Object.keys(loginUrlParameters) + .map((param) => `${encodeURIComponent(param)}=${encodeURIComponent(loginUrlParameters[param])}`) + .join('&')}`; + + OAuth.launchLogin({ + loginService: 'facebook', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/github.ts b/apps/meteor/client/meteorOverrides/login/github.ts new file mode 100644 index 000000000000..2a1aa3903317 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/github.ts @@ -0,0 +1,42 @@ +import { Random } from '@rocket.chat/random'; +import { Accounts } from 'meteor/accounts-base'; +import { Github } from 'meteor/github-oauth'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; +import { createOAuthTotpLoginMethod } from './oauth'; + +const { loginWithGithub } = Meteor; +const loginWithGithubAndTOTP = createOAuthTotpLoginMethod(Github); +Meteor.loginWithGithub = (options, callback) => { + overrideLoginMethod(loginWithGithub, [options], callback, loginWithGithubAndTOTP); +}; + +Github.requestCredential = wrapRequestCredentialFn('github', ({ config, loginStyle, options, credentialRequestCompleteCallback }) => { + const credentialToken = Random.secret(); + const scope = options?.requestPermissions || ['user:email']; + const flatScope = scope.map(encodeURIComponent).join('+'); + + let allowSignup = ''; + if (Accounts._options?.forbidClientAccountCreation) { + allowSignup = '&allow_signup=false'; + } + + const loginUrl = + `https://github.com/login/oauth/authorize` + + `?client_id=${config.clientId}` + + `&scope=${flatScope}` + + `&redirect_uri=${OAuth._redirectUri('github', config)}` + + `&state=${OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl)}${allowSignup}`; + + OAuth.launchLogin({ + loginService: 'github', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { width: 900, height: 450 }, + }); +}); diff --git a/apps/meteor/client/meteorOverrides/login/google.ts b/apps/meteor/client/meteorOverrides/login/google.ts new file mode 100644 index 000000000000..2742cade15d2 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/google.ts @@ -0,0 +1,126 @@ +import { Random } from '@rocket.chat/random'; +import { Accounts } from 'meteor/accounts-base'; +import { Google } from 'meteor/google-oauth'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +import { overrideLoginMethod, type LoginCallback } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; +import { createOAuthTotpLoginMethod } from './oauth'; + +declare module 'meteor/accounts-base' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Accounts { + export const _options: { + restrictCreationByEmailDomain?: string | (() => string); + forbidClientAccountCreation?: boolean | undefined; + }; + } +} + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithGoogle( + options?: + | Meteor.LoginWithExternalServiceOptions & { + loginUrlParameters?: { + include_granted_scopes?: boolean; + hd?: string; + }; + }, + callback?: LoginCallback, + ): void; + } +} + +const { loginWithGoogle } = Meteor; + +const innerLoginWithGoogleAndTOTP = createOAuthTotpLoginMethod(Google); + +const loginWithGoogleAndTOTP = ( + options: + | (Meteor.LoginWithExternalServiceOptions & { + loginUrlParameters?: { + include_granted_scopes?: boolean; + hd?: string; + }; + }) + | undefined, + code: string, + callback?: LoginCallback, +) => { + if (Meteor.isCordova && Google.signIn) { + // After 20 April 2017, Google OAuth login will no longer work from + // a WebView, so Cordova apps must use Google Sign-In instead. + // https://github.com/meteor/meteor/issues/8253 + Google.signIn(options, callback); + return; + } // Use Google's domain-specific login page if we want to restrict creation to + + // a particular email domain. (Don't use it if restrictCreationByEmailDomain + // is a function.) Note that all this does is change Google's UI --- + // accounts-base/accounts_server.js still checks server-side that the server + // has the proper email address after the OAuth conversation. + if (typeof Accounts._options.restrictCreationByEmailDomain === 'string') { + options = Object.assign({}, options || {}); + options.loginUrlParameters = Object.assign({}, options.loginUrlParameters || {}); + options.loginUrlParameters.hd = Accounts._options.restrictCreationByEmailDomain; + } + + innerLoginWithGoogleAndTOTP(options, code, callback); +}; + +Meteor.loginWithGoogle = (options, callback) => { + overrideLoginMethod(loginWithGoogle, [options], callback, loginWithGoogleAndTOTP); +}; + +Google.requestCredential = wrapRequestCredentialFn( + 'google', + ({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const credentialToken = Random.secret(); + const options = requestOptions as Meteor.LoginWithExternalServiceOptions & { + loginUrlParameters?: { + include_granted_scopes?: boolean; + hd?: string; + }; + prompt?: string; + }; + + const scope = ['email', ...(options.requestPermissions || ['profile'])].join(' '); + + const loginUrlParameters: Record = { + ...options.loginUrlParameters, + ...(options.requestOfflineToken !== undefined && { + access_type: options.requestOfflineToken ? 'offline' : 'online', + }), + ...((options.prompt || options.forceApprovalPrompt) && { prompt: options.prompt || 'consent' }), + ...(options.loginHint && { login_hint: options.loginHint }), + response_type: 'code', + client_id: config.clientId, + scope, + redirect_uri: OAuth._redirectUri('google', config), + state: OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl), + }; + + Object.assign(loginUrlParameters, { + response_type: 'code', + client_id: config.clientId, + scope, + redirect_uri: OAuth._redirectUri('google', config), + state: OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl), + }); + const loginUrl = `https://accounts.google.com/o/oauth2/auth?${Object.keys(loginUrlParameters) + .map((param) => `${encodeURIComponent(param)}=${encodeURIComponent(loginUrlParameters[param])}`) + .join('&')}`; + + OAuth.launchLogin({ + loginService: 'google', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { height: 600 }, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/ldap.ts b/apps/meteor/client/meteorOverrides/login/ldap.ts new file mode 100644 index 000000000000..77a16ce3675d --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/ldap.ts @@ -0,0 +1,52 @@ +import { Meteor } from 'meteor/meteor'; + +import { callLoginMethod, handleLogin, type LoginCallback } from '../../lib/2fa/overrideLoginMethod'; + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithLDAP( + username: string | { username: string } | { email: string } | { id: string }, + ldapPass: string, + callback?: LoginCallback, + ): void; + } +} + +const loginWithLDAP = (username: string | { username: string } | { email: string } | { id: string }, ldapPass: string) => + callLoginMethod({ + methodArguments: [ + { + ldap: true, + username, + ldapPass, + ldapOptions: {}, + }, + ], + }); + +const loginWithLDAPAndTOTP = ( + username: string | { username: string } | { email: string } | { id: string }, + ldapPass: string, + code: string, +) => { + const loginRequest = { + ldap: true, + username, + ldapPass, + ldapOptions: {}, + }; + + return callLoginMethod({ + methodArguments: [ + { + totp: { + login: loginRequest, + code, + }, + }, + ], + }); +}; + +Meteor.loginWithLDAP = handleLogin(loginWithLDAP, loginWithLDAPAndTOTP); diff --git a/apps/meteor/client/meteorOverrides/login/linkedin.ts b/apps/meteor/client/meteorOverrides/login/linkedin.ts new file mode 100644 index 000000000000..a10b8182feec --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/linkedin.ts @@ -0,0 +1,48 @@ +import type { LinkedinOAuthConfiguration } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; +import { Linkedin } from 'meteor/pauli:linkedin-oauth'; + +import type { LoginCallback } from '../../lib/2fa/overrideLoginMethod'; +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; +import { createOAuthTotpLoginMethod } from './oauth'; + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithLinkedin(options?: Meteor.LoginWithExternalServiceOptions, callback?: LoginCallback): void; + } +} +const { loginWithLinkedin } = Meteor; +const loginWithLinkedinAndTOTP = createOAuthTotpLoginMethod(Linkedin); +Meteor.loginWithLinkedin = (options, callback) => { + overrideLoginMethod(loginWithLinkedin, [options], callback, loginWithLinkedinAndTOTP); +}; + +Linkedin.requestCredential = wrapRequestCredentialFn( + 'linkedin', + ({ options, credentialRequestCompleteCallback, config, loginStyle }) => { + const credentialToken = Random.secret(); + + const { requestPermissions } = options; + const scope = (requestPermissions || ['openid', 'email', 'profile']).join('+'); + + const loginUrl = `https://www.linkedin.com/uas/oauth2/authorization?response_type=code&client_id=${ + config.clientId + }&redirect_uri=${OAuth._redirectUri('linkedin', config)}&state=${OAuth._stateParam(loginStyle, credentialToken)}&scope=${scope}`; + + OAuth.launchLogin({ + credentialRequestCompleteCallback, + credentialToken, + loginService: 'linkedin', + loginStyle, + loginUrl, + popupOptions: { + width: 390, + height: 628, + }, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts b/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts new file mode 100644 index 000000000000..56823fee6b6a --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts @@ -0,0 +1,43 @@ +import { Meteor } from 'meteor/meteor'; +import { MeteorDeveloperAccounts } from 'meteor/meteor-developer-oauth'; +import { OAuth } from 'meteor/oauth'; + +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; +import { createOAuthTotpLoginMethod } from './oauth'; + +const { loginWithMeteorDeveloperAccount } = Meteor; +const loginWithMeteorDeveloperAccountAndTOTP = createOAuthTotpLoginMethod(MeteorDeveloperAccounts); +Meteor.loginWithMeteorDeveloperAccount = (options, callback) => { + overrideLoginMethod(loginWithMeteorDeveloperAccount, [options], callback, loginWithMeteorDeveloperAccountAndTOTP); +}; + +MeteorDeveloperAccounts.requestCredential = wrapRequestCredentialFn( + 'meteor-developer', + ({ config, loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const options = requestOptions as Record; + + const credentialToken = Random.secret(); + + let loginUrl = + `${MeteorDeveloperAccounts._server}/oauth2/authorize?` + + `state=${OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl)}` + + `&response_type=code&` + + `client_id=${config.clientId}${options.details ? `&details=${options.details}` : ''}`; + + if (options.loginHint) { + loginUrl += `&user_email=${encodeURIComponent(options.loginHint)}`; + } + + loginUrl += `&redirect_uri=${OAuth._redirectUri('meteor-developer', config)}`; + + OAuth.launchLogin({ + loginService: 'meteor-developer', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { width: 497, height: 749 }, + }); + }, +); diff --git a/apps/meteor/client/meteorOverrides/login/oauth.ts b/apps/meteor/client/meteorOverrides/login/oauth.ts new file mode 100644 index 000000000000..a3f9d72c9cbf --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/oauth.ts @@ -0,0 +1,127 @@ +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; + +import type { IOAuthProvider } from '../../definitions/IOAuthProvider'; +import type { LoginCallback } from '../../lib/2fa/overrideLoginMethod'; + +const isLoginCancelledError = (error: unknown): error is Meteor.Error => + error instanceof Meteor.Error && error.error === Accounts.LoginCancelledError.numericError; + +export const convertError = (error: T): Accounts.LoginCancelledError | T => { + if (isLoginCancelledError(error)) { + return new Accounts.LoginCancelledError(error.reason); + } + + return error; +}; + +let lastCredentialToken: string | null = null; +let lastCredentialSecret: string | null | undefined = null; + +const meteorOAuthRetrieveCredentialSecret = OAuth._retrieveCredentialSecret; +OAuth._retrieveCredentialSecret = (credentialToken: string): string | null => { + let secret = meteorOAuthRetrieveCredentialSecret.call(OAuth, credentialToken); + if (!secret) { + const localStorageKey = `${OAuth._storageTokenPrefix}${credentialToken}`; + secret = localStorage.getItem(localStorageKey); + localStorage.removeItem(localStorageKey); + } + + return secret; +}; + +const tryLoginAfterPopupClosed = ( + credentialToken: string, + callback?: (error?: globalThis.Error | Meteor.Error | Meteor.TypedError) => void, + totpCode?: string, + credentialSecret?: string | null, +) => { + credentialSecret = credentialSecret || OAuth._retrieveCredentialSecret(credentialToken) || null; + const methodArgument = { + oauth: { + credentialToken, + credentialSecret, + }, + ...(typeof totpCode === 'string' && + !!totpCode && { + totp: { + code: totpCode, + }, + }), + }; + + lastCredentialToken = credentialToken; + lastCredentialSecret = credentialSecret; + + if (typeof totpCode === 'string' && !!totpCode) { + methodArgument.totp = { + code: totpCode, + }; + } + + Accounts.callLoginMethod({ + methodArguments: [methodArgument], + userCallback: (err) => { + callback?.(convertError(err)); + }, + }); +}; + +const credentialRequestCompleteHandler = + (callback?: (error?: globalThis.Error | Meteor.Error | Meteor.TypedError) => void, totpCode?: string) => + (credentialTokenOrError?: string | globalThis.Error | Meteor.Error | Meteor.TypedError) => { + if (!credentialTokenOrError) { + callback?.(new Meteor.Error('No credential token passed')); + return; + } + + if (credentialTokenOrError instanceof Error) { + callback?.(credentialTokenOrError); + return; + } + + tryLoginAfterPopupClosed(credentialTokenOrError, callback, totpCode); + }; + +export const createOAuthTotpLoginMethod = + (provider: IOAuthProvider) => (options: Meteor.LoginWithExternalServiceOptions | undefined, code: string, callback?: LoginCallback) => { + if (lastCredentialToken && lastCredentialSecret) { + tryLoginAfterPopupClosed(lastCredentialToken, callback, code, lastCredentialSecret); + } else { + const credentialRequestCompleteCallback = credentialRequestCompleteHandler(callback, code); + provider.requestCredential(options, credentialRequestCompleteCallback); + } + + lastCredentialToken = null; + lastCredentialSecret = null; + }; + +Accounts.oauth.credentialRequestCompleteHandler = credentialRequestCompleteHandler; + +Accounts.onPageLoadLogin(async (loginAttempt: any) => { + if (loginAttempt?.error?.error !== 'totp-required') { + return; + } + + const { methodArguments } = loginAttempt; + if (!methodArguments?.length) { + return; + } + + const oAuthArgs = methodArguments.find((arg: any) => arg.oauth); + const { credentialToken, credentialSecret } = oAuthArgs.oauth; + const cb = loginAttempt.userCallback; + + const { process2faReturn } = await import('../../lib/2fa/process2faReturn'); + + await process2faReturn({ + error: loginAttempt.error, + originalCallback: cb, + onCode: (code) => { + tryLoginAfterPopupClosed(credentialToken, cb, code, credentialSecret); + }, + emailOrUsername: undefined, + result: undefined, + }); +}); diff --git a/apps/meteor/client/meteorOverrides/login/password.ts b/apps/meteor/client/meteorOverrides/login/password.ts new file mode 100644 index 000000000000..f1c6e32f2282 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/password.ts @@ -0,0 +1,67 @@ +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { overrideLoginMethod, type LoginCallback } from '../../lib/2fa/overrideLoginMethod'; + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithPassword( + userDescriptor: { username: string } | { email: string } | { id: string } | string, + password: string, + callback?: LoginCallback, + ): void; + } +} + +const loginWithPasswordAndTOTP = ( + userDescriptor: { username: string } | { email: string } | { id: string } | string, + password: string, + code: string, + callback?: LoginCallback, +) => { + if (typeof userDescriptor === 'string') { + if (userDescriptor.indexOf('@') === -1) { + userDescriptor = { username: userDescriptor }; + } else { + userDescriptor = { email: userDescriptor }; + } + } + + Accounts.callLoginMethod({ + methodArguments: [ + { + totp: { + login: { + user: userDescriptor, + password: Accounts._hashPassword(password), + }, + code, + }, + }, + ], + userCallback(error) { + if (!error) { + callback?.(undefined); + return; + } + + if (callback) { + callback(error); + return; + } + + throw error; + }, + }); +}; + +const { loginWithPassword } = Meteor; + +Meteor.loginWithPassword = ( + userDescriptor: { username: string } | { email: string } | { id: string } | string, + password: string, + callback?: LoginCallback, +) => { + overrideLoginMethod(loginWithPassword, [userDescriptor, password], callback, loginWithPasswordAndTOTP); +}; diff --git a/apps/meteor/client/meteorOverrides/login/saml.ts b/apps/meteor/client/meteorOverrides/login/saml.ts new file mode 100644 index 000000000000..dd8b04c4006d --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/saml.ts @@ -0,0 +1,113 @@ +import type { SAMLConfiguration } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; + +import { type LoginCallback, callLoginMethod, handleLogin } from '../../lib/2fa/overrideLoginMethod'; +import { loginServices } from '../../lib/loginServices'; + +declare module 'meteor/meteor' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Meteor { + function loginWithSamlToken(credentialToken: string, callback?: LoginCallback): void; + + function loginWithSaml(options: { provider: string; credentialToken?: string }): void; + } +} + +declare module 'meteor/accounts-base' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Accounts { + export let saml: { + credentialToken?: string; + credentialSecret?: string; + }; + } +} + +declare module 'meteor/service-configuration' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Configuration { + logoutBehaviour?: 'SAML' | 'Local'; + idpSLORedirectURL?: string; + } +} + +if (!Accounts.saml) { + Accounts.saml = {}; +} + +const { logout } = Meteor; + +Meteor.logout = async function (...args) { + const { sdk } = await import('../../../app/utils/client/lib/SDKClient'); + // #TODO: Use SAML settings directly instead of relying on the login service + const samlService = await loginServices.loadLoginService('saml'); + if (samlService) { + const provider = (samlService.clientConfig as { provider?: string } | undefined)?.provider; + if (provider) { + if (samlService.logoutBehaviour == null || samlService.logoutBehaviour === 'SAML') { + if (samlService.idpSLORedirectURL) { + console.info('SAML session terminated via SLO'); + sdk + .call('samlLogout', provider) + .then((result) => { + if (!result) { + logout.apply(Meteor); + return; + } + + // Remove the userId from the client to prevent calls to the server while the logout is processed. + // If the logout fails, the userId will be reloaded on the resume call + Meteor._localStorage.removeItem(Accounts.USER_ID_KEY); + + // A nasty bounce: 'result' has the SAML LogoutRequest but we need a proper 302 to redirected from the server. + window.location.replace(Meteor.absoluteUrl(`_saml/sloRedirect/${provider}/?redirect=${encodeURIComponent(result)}`)); + }) + .catch(() => logout.apply(Meteor)); + return; + } + } + + if (samlService.logoutBehaviour === 'Local') { + console.info('SAML session not terminated, only the Rocket.Chat session is going to be killed'); + } + } + } + return logout.apply(Meteor, args); +}; + +Meteor.loginWithSaml = (options) => { + options = options || {}; + const credentialToken = `id-${Random.id()}`; + options.credentialToken = credentialToken; + + window.location.href = `_saml/authorize/${options.provider}/${options.credentialToken}`; +}; + +const loginWithSamlToken = (credentialToken: string) => + callLoginMethod({ + methodArguments: [ + { + saml: true, + credentialToken, + }, + ], + }); + +const loginWithSamlTokenAndTOTP = (credentialToken: string, code: string) => + callLoginMethod({ + methodArguments: [ + { + totp: { + login: { + saml: true, + credentialToken, + }, + code, + }, + }, + ], + }); + +Meteor.loginWithSamlToken = handleLogin(loginWithSamlToken, loginWithSamlTokenAndTOTP); diff --git a/apps/meteor/client/meteorOverrides/login/twitter.ts b/apps/meteor/client/meteorOverrides/login/twitter.ts new file mode 100644 index 000000000000..e19ce234e5e9 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/twitter.ts @@ -0,0 +1,56 @@ +import type { TwitterOAuthConfiguration } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { Meteor } from 'meteor/meteor'; +import { OAuth } from 'meteor/oauth'; +import { Twitter } from 'meteor/twitter-oauth'; + +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { wrapRequestCredentialFn } from '../../lib/wrapRequestCredentialFn'; +import { createOAuthTotpLoginMethod } from './oauth'; + +const { loginWithTwitter } = Meteor; +const loginWithTwitterAndTOTP = createOAuthTotpLoginMethod(Twitter); +Meteor.loginWithTwitter = (options, callback) => { + overrideLoginMethod(loginWithTwitter, [options], callback, loginWithTwitterAndTOTP); +}; + +Twitter.requestCredential = wrapRequestCredentialFn( + 'twitter', + ({ loginStyle, options: requestOptions, credentialRequestCompleteCallback }) => { + const options = requestOptions as Record; + const credentialToken = Random.secret(); + + let loginPath = `_oauth/twitter/?requestTokenAndRedirect=true&state=${OAuth._stateParam( + loginStyle, + credentialToken, + options?.redirectUrl, + )}`; + + if (Meteor.isCordova) { + loginPath += '&cordova=true'; + if (/Android/i.test(navigator.userAgent)) { + loginPath += '&android=true'; + } + } + + // Support additional, permitted parameters + if (options) { + const hasOwn = Object.prototype.hasOwnProperty; + Twitter.validParamsAuthenticate.forEach((param: string) => { + if (hasOwn.call(options, param)) { + loginPath += `&${param}=${encodeURIComponent(options[param])}`; + } + }); + } + + const loginUrl = Meteor.absoluteUrl(loginPath); + + OAuth.launchLogin({ + loginService: 'twitter', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + }); + }, +); diff --git a/apps/meteor/lib/oauthRedirectUriClient.ts b/apps/meteor/client/meteorOverrides/oauthRedirectUri.ts similarity index 80% rename from apps/meteor/lib/oauthRedirectUriClient.ts rename to apps/meteor/client/meteorOverrides/oauthRedirectUri.ts index cb5581210432..23f53acfe1d7 100644 --- a/apps/meteor/lib/oauthRedirectUriClient.ts +++ b/apps/meteor/client/meteorOverrides/oauthRedirectUri.ts @@ -1,5 +1,12 @@ import { OAuth } from 'meteor/oauth'; +declare module 'meteor/oauth' { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace OAuth { + function _redirectUri(serviceName: string, config: any, params: any, absoluteUrlOptions: any): string; + } +} + const { _redirectUri } = OAuth; OAuth._redirectUri = (serviceName: string, config: any, params: unknown, absoluteUrlOptions: unknown): string => { diff --git a/apps/meteor/client/meteorOverrides/totpOnCall.ts b/apps/meteor/client/meteorOverrides/totpOnCall.ts new file mode 100644 index 000000000000..247b3897842f --- /dev/null +++ b/apps/meteor/client/meteorOverrides/totpOnCall.ts @@ -0,0 +1,63 @@ +import { Meteor } from 'meteor/meteor'; + +import { t } from '../../app/utils/lib/i18n'; +import type { LoginCallback } from '../lib/2fa/overrideLoginMethod'; +import { process2faReturn, process2faAsyncReturn } from '../lib/2fa/process2faReturn'; +import { isTotpInvalidError } from '../lib/2fa/utils'; + +const withSyncTOTP = (call: (name: string, ...args: any[]) => any) => { + const callWithTotp = + (methodName: string, args: unknown[], callback: LoginCallback) => + (twoFactorCode: string, twoFactorMethod: string): unknown => + call( + methodName, + ...args, + { twoFactorCode, twoFactorMethod }, + (error: globalThis.Error | Meteor.Error | Meteor.TypedError | undefined, result: unknown): void => { + if (isTotpInvalidError(error)) { + callback(new Error(twoFactorMethod === 'password' ? t('Invalid_password') : t('Invalid_two_factor_code'))); + return; + } + + callback(error, result); + }, + ); + + const callWithoutTotp = (methodName: string, args: unknown[], callback: LoginCallback) => (): unknown => + call( + methodName, + ...args, + async (error: globalThis.Error | Meteor.Error | Meteor.TypedError | undefined, result: unknown): Promise => { + await process2faReturn({ + error, + result, + onCode: callWithTotp(methodName, args, callback), + originalCallback: callback, + emailOrUsername: undefined, + }); + }, + ); + + return function (methodName: string, ...args: unknown[]): unknown { + const callback = args.length > 0 && typeof args[args.length - 1] === 'function' ? (args.pop() as LoginCallback) : (): void => undefined; + + return callWithoutTotp(methodName, args, callback)(); + }; +}; + +const withAsyncTOTP = (callAsync: (name: string, ...args: any[]) => Promise) => { + return async function callAsyncWithTOTP(methodName: string, ...args: unknown[]): Promise { + try { + return await callAsync(methodName, ...args); + } catch (error: unknown) { + return process2faAsyncReturn({ + error, + onCode: (twoFactorCode, twoFactorMethod) => Meteor.callAsync(methodName, ...args, { twoFactorCode, twoFactorMethod }), + emailOrUsername: undefined, + }); + } + }; +}; + +Meteor.call = withSyncTOTP(Meteor.call); +Meteor.callAsync = withAsyncTOTP(Meteor.callAsync); diff --git a/apps/meteor/client/meteorOverrides/userAndUsers.ts b/apps/meteor/client/meteorOverrides/userAndUsers.ts new file mode 100644 index 000000000000..84bd85ff38d2 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/userAndUsers.ts @@ -0,0 +1,14 @@ +import { Users } from '../../app/models/client/models/Users'; + +Meteor.users = Users as typeof Meteor.users; + +// overwrite Meteor.users collection so records on it don't get erased whenever the client reconnects to websocket +Meteor.user = function user(): Meteor.User | null { + const uid = Meteor.userId(); + + if (!uid) { + return null; + } + + return (Users.findOne({ _id: uid }) ?? null) as Meteor.User | null; +}; diff --git a/apps/meteor/client/providers/AppsProvider.tsx b/apps/meteor/client/providers/AppsProvider.tsx index b4c04ddf6059..61baaefc4a49 100644 --- a/apps/meteor/client/providers/AppsProvider.tsx +++ b/apps/meteor/client/providers/AppsProvider.tsx @@ -123,7 +123,11 @@ const AppsProvider: FC = ({ children }) => { }; if (installedApp) { - installedApps.push(record); + if (installedApp.private) { + privateApps.push(record); + } else { + installedApps.push(record); + } } marketplaceApps.push(record); diff --git a/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx b/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx new file mode 100644 index 000000000000..c76f06bcd3b4 --- /dev/null +++ b/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx @@ -0,0 +1,86 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import { capitalize } from '@rocket.chat/string-helpers'; +import { AuthenticationContext, useSetting } from '@rocket.chat/ui-contexts'; +import { Meteor } from 'meteor/meteor'; +import type { ContextType, ReactElement, ReactNode } from 'react'; +import React, { useMemo } from 'react'; + +import { loginServices } from '../../lib/loginServices'; +import { useLDAPAndCrowdCollisionWarning } from './hooks/useLDAPAndCrowdCollisionWarning'; + +export type LoginMethods = keyof typeof Meteor extends infer T ? (T extends `loginWith${string}` ? T : never) : never; + +type AuthenticationProviderProps = { + children: ReactNode; +}; + +const AuthenticationProvider = ({ children }: AuthenticationProviderProps): ReactElement => { + const isLdapEnabled = useSetting('LDAP_Enable'); + const isCrowdEnabled = useSetting('CROWD_Enable'); + + const loginMethod: LoginMethods = (isLdapEnabled && 'loginWithLDAP') || (isCrowdEnabled && 'loginWithCrowd') || 'loginWithPassword'; + + useLDAPAndCrowdCollisionWarning(); + + const contextValue = useMemo( + (): ContextType => ({ + loginWithToken: (token: string): Promise => + new Promise((resolve, reject) => + Meteor.loginWithToken(token, (err) => { + if (err) { + return reject(err); + } + resolve(undefined); + }), + ), + loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string): Promise => + new Promise((resolve, reject) => { + Meteor[loginMethod](user, password, (error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }), + loginWithService: (serviceConfig: T): (() => Promise) => { + const loginMethods: Record = { + 'meteor-developer': 'MeteorDeveloperAccount', + }; + + const { service: serviceName } = serviceConfig; + const clientConfig = ('clientConfig' in serviceConfig && serviceConfig.clientConfig) || {}; + + const loginWithService = `loginWith${loginMethods[serviceName] || capitalize(String(serviceName || ''))}`; + + const method: (config: unknown, cb: (error: any) => void) => Promise = (Meteor as any)[loginWithService] as any; + + if (!method) { + return () => Promise.reject(new Error('Login method not found')); + } + + return () => + new Promise((resolve, reject) => { + method(clientConfig, (error: any): void => { + if (!error) { + resolve(true); + return; + } + reject(error); + }); + }); + }, + + queryLoginServices: { + getCurrentValue: () => loginServices.getLoginServiceButtons(), + subscribe: (onStoreChange: () => void) => loginServices.on('changed', onStoreChange), + }, + }), + [loginMethod], + ); + + return ; +}; + +export default AuthenticationProvider; diff --git a/apps/meteor/client/providers/UserProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx b/apps/meteor/client/providers/AuthenticationProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx similarity index 93% rename from apps/meteor/client/providers/UserProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx rename to apps/meteor/client/providers/AuthenticationProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx index fbcabc2825fb..afb0eea54fda 100644 --- a/apps/meteor/client/providers/UserProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx +++ b/apps/meteor/client/providers/AuthenticationProvider/hooks/useLDAPAndCrowdCollisionWarning.tsx @@ -2,7 +2,7 @@ import { useSetting } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { useEffect } from 'react'; -import type { LoginMethods } from '../UserProvider'; +import type { LoginMethods } from '../AuthenticationProvider'; export function useLDAPAndCrowdCollisionWarning() { const isLdapEnabled = useSetting('LDAP_Enable'); diff --git a/apps/meteor/client/providers/EmojiPickerProvider.tsx b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx similarity index 92% rename from apps/meteor/client/providers/EmojiPickerProvider.tsx rename to apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx index f7d14b317d54..35cb32077a84 100644 --- a/apps/meteor/client/providers/EmojiPickerProvider.tsx +++ b/apps/meteor/client/providers/EmojiPickerProvider/EmojiPickerProvider.tsx @@ -2,10 +2,11 @@ import { useDebouncedState, useLocalStorage } from '@rocket.chat/fuselage-hooks' import type { ReactNode, ReactElement, ContextType } from 'react'; import React, { useState, useCallback, useMemo, useEffect } from 'react'; -import type { EmojiByCategory } from '../../app/emoji/client'; -import { emoji, getFrequentEmoji, updateRecent, createEmojiList, createPickerEmojis, CUSTOM_CATEGORY } from '../../app/emoji/client'; -import { EmojiPickerContext } from '../contexts/EmojiPickerContext'; -import EmojiPicker from '../views/composer/EmojiPicker/EmojiPicker'; +import type { EmojiByCategory } from '../../../app/emoji/client'; +import { emoji, getFrequentEmoji, updateRecent, createEmojiList, createPickerEmojis, CUSTOM_CATEGORY } from '../../../app/emoji/client'; +import { EmojiPickerContext } from '../../contexts/EmojiPickerContext'; +import EmojiPicker from '../../views/composer/EmojiPicker/EmojiPicker'; +import { useUpdateCustomEmoji } from './useUpdateCustomEmoji'; const DEFAULT_ITEMS_LIMIT = 90; @@ -23,6 +24,8 @@ const EmojiPickerProvider = ({ children }: { children: ReactNode }): ReactElemen getFrequentEmoji(frequentEmojis.map(([emoji]) => emoji)), ); + useUpdateCustomEmoji(); + const addFrequentEmojis = useCallback( (emoji: string) => { const empty: [string, number][] = frequentEmojis.some(([emojiName]) => emojiName === emoji) ? [] : [[emoji, 0]]; diff --git a/apps/meteor/client/providers/EmojiPickerProvider/index.ts b/apps/meteor/client/providers/EmojiPickerProvider/index.ts new file mode 100644 index 000000000000..4437d6b749d4 --- /dev/null +++ b/apps/meteor/client/providers/EmojiPickerProvider/index.ts @@ -0,0 +1 @@ +export { default } from './EmojiPickerProvider'; diff --git a/apps/meteor/client/providers/EmojiPickerProvider/useUpdateCustomEmoji.ts b/apps/meteor/client/providers/EmojiPickerProvider/useUpdateCustomEmoji.ts new file mode 100644 index 000000000000..a0a0946006db --- /dev/null +++ b/apps/meteor/client/providers/EmojiPickerProvider/useUpdateCustomEmoji.ts @@ -0,0 +1,17 @@ +import { useStream } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { updateEmojiCustom, deleteEmojiCustom } from '../../../app/emoji-custom/client/lib/emojiCustom'; + +export const useUpdateCustomEmoji = () => { + const notify = useStream('notify-logged'); + useEffect(() => { + const unsubUpdate = notify('updateEmojiCustom', (data) => updateEmojiCustom(data.emojiData)); + const unsubDelete = notify('deleteEmojiCustom', (data) => deleteEmojiCustom(data.emojiData)); + + return () => { + unsubUpdate(); + unsubDelete(); + }; + }, [notify]); +}; diff --git a/apps/meteor/client/providers/ImageGalleryProvider.tsx b/apps/meteor/client/providers/ImageGalleryProvider.tsx index 6d14e28c53ce..1cd07f29882c 100644 --- a/apps/meteor/client/providers/ImageGalleryProvider.tsx +++ b/apps/meteor/client/providers/ImageGalleryProvider.tsx @@ -1,7 +1,8 @@ import React, { type ReactNode, useEffect, useState } from 'react'; -import ImageGallery from '../components/ImageGallery/ImageGallery'; +import { ImageGallery } from '../components/ImageGallery'; import { ImageGalleryContext } from '../contexts/ImageGalleryContext'; +import ImageGalleryData from '../views/room/ImageGallery/ImageGalleryData'; type ImageGalleryProviderProps = { children: ReactNode; @@ -9,34 +10,39 @@ type ImageGalleryProviderProps = { const ImageGalleryProvider = ({ children }: ImageGalleryProviderProps) => { const [imageId, setImageId] = useState(); + const [quotedImageUrl, setQuotedImageUrl] = useState(); useEffect(() => { - document.addEventListener('click', (event: Event) => { + const handleImageClick = (event: Event) => { const target = event?.target as HTMLElement | null; + + if (target?.closest('.rcx-attachment__details')) { + return setQuotedImageUrl(target.dataset.id); + } if (target?.classList.contains('gallery-item')) { - return setImageId(target.dataset.id || target?.parentElement?.parentElement?.dataset.id); + const id = target.closest('.gallery-item-container')?.getAttribute('data-id') || undefined; + return setImageId(target.dataset.id || id); } - if (target?.classList.contains('gallery-item-container')) { return setImageId(target.dataset.id); } - if ( - target?.classList.contains('gallery-item') && - target?.parentElement?.parentElement?.classList.contains('gallery-item-container') - ) { - return setImageId(target.dataset.id || target?.parentElement?.parentElement?.dataset.id); + if (target?.classList.contains('rcx-avatar__element') && target.closest('.gallery-item')) { + const avatarTarget = target.closest('.gallery-item-container')?.getAttribute('data-id') || undefined; + return setImageId(avatarTarget); } + }; + document.addEventListener('click', handleImageClick); - if (target?.classList.contains('rcx-avatar__element') && target?.parentElement?.classList.contains('gallery-item')) { - return setImageId(target.dataset.id || target?.parentElement?.parentElement?.dataset.id); - } - }); + return () => document.removeEventListener('click', handleImageClick); }, []); return ( setImageId(undefined) }}> {children} - {!!imageId && } + {!!quotedImageUrl && ( + setQuotedImageUrl(undefined)} /> + )} + {!!imageId && } ); }; diff --git a/apps/meteor/client/providers/LayoutProvider.tsx b/apps/meteor/client/providers/LayoutProvider.tsx index 5cc113e172c5..a4f8fa84f9ff 100644 --- a/apps/meteor/client/providers/LayoutProvider.tsx +++ b/apps/meteor/client/providers/LayoutProvider.tsx @@ -3,10 +3,18 @@ import { LayoutContext, useRouter, useSetting } from '@rocket.chat/ui-contexts'; import type { FC } from 'react'; import React, { useMemo, useState, useEffect } from 'react'; +const hiddenActionsDefaultValue = { + roomToolbox: [], + messageToolbox: [], + composerToolbox: [], + userToolbox: [], +}; + const LayoutProvider: FC = ({ children }) => { const showTopNavbarEmbeddedLayout = Boolean(useSetting('UI_Show_top_navbar_embedded_layout')); const [isCollapsed, setIsCollapsed] = useState(false); const breakpoints = useBreakpoints(); // ["xs", "sm", "md", "lg", "xl", xxl"] + const [hiddenActions, setHiddenActions] = useState(hiddenActionsDefaultValue); const router = useRouter(); // Once the layout is embedded, it can't be changed @@ -18,6 +26,18 @@ const LayoutProvider: FC = ({ children }) => { setIsCollapsed(isMobile); }, [isMobile]); + useEffect(() => { + const eventHandler = (event: MessageEvent) => { + if (event.data?.event !== 'overrideUi') { + return; + } + + setHiddenActions({ ...hiddenActionsDefaultValue, ...event.data.hideActions }); + }; + window.addEventListener('message', eventHandler); + return () => window.removeEventListener('message', eventHandler); + }, []); + return ( { contextualBarExpanded: breakpoints.includes('sm'), // eslint-disable-next-line no-nested-ternary contextualBarPosition: breakpoints.includes('sm') ? (breakpoints.includes('lg') ? 'relative' : 'absolute') : 'fixed', + hiddenActions, }), - [isMobile, isEmbedded, showTopNavbarEmbeddedLayout, isCollapsed, breakpoints, router], + [isMobile, isEmbedded, showTopNavbarEmbeddedLayout, isCollapsed, breakpoints, router, hiddenActions], )} /> ); diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index aa12af905521..9ae22651f1f3 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { OmnichannelRoomIconProvider } from '../components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider'; import ActionManagerProvider from './ActionManagerProvider'; +import AuthenticationProvider from './AuthenticationProvider/AuthenticationProvider'; import AuthorizationProvider from './AuthorizationProvider'; import AvatarUrlProvider from './AvatarUrlProvider'; import { CallProvider } from './CallProvider'; @@ -36,27 +37,29 @@ const MeteorProvider: FC = ({ children }) => ( - - - - - - - - - - - {children} - - - - - - - - - - + + + + + + + + + + + + {children} + + + + + + + + + + + diff --git a/apps/meteor/client/providers/OmnichannelProvider.tsx b/apps/meteor/client/providers/OmnichannelProvider.tsx index fe1915f49eb3..47ccb3d39c88 100644 --- a/apps/meteor/client/providers/OmnichannelProvider.tsx +++ b/apps/meteor/client/providers/OmnichannelProvider.tsx @@ -13,7 +13,6 @@ import React, { useState, useEffect, useMemo, useCallback, memo, useRef } from ' import { LivechatInquiry } from '../../app/livechat/client/collections/LivechatInquiry'; import { initializeLivechatInquiryStream } from '../../app/livechat/client/lib/stream/queueManager'; import { getOmniChatSortQuery } from '../../app/livechat/lib/inquiries'; -import { Notifications } from '../../app/notifications/client'; import { KonchatNotification } from '../../app/ui/client/lib/KonchatNotification'; import { useHasLicenseModule } from '../../ee/client/hooks/useHasLicenseModule'; import { ClientLogger } from '../../lib/ClientLogger'; @@ -110,6 +109,7 @@ const OmnichannelProvider: FC = ({ children }) => { const manuallySelected = enabled && canViewOmnichannelQueue && !!routeConfig && routeConfig.showQueue && !routeConfig.autoAssignAgent && agentAvailable; + const streamNotifyUser = useStream('notify-user'); useEffect(() => { if (!manuallySelected) { return; @@ -120,8 +120,11 @@ const OmnichannelProvider: FC = ({ children }) => { }; initializeLivechatInquiryStream(user?._id); - return Notifications.onUser('departmentAgentData', handleDepartmentAgentData).stop; - }, [manuallySelected, user?._id]); + if (!user?._id) { + return; + } + return streamNotifyUser(`${user._id}/departmentAgentData`, handleDepartmentAgentData); + }, [manuallySelected, streamNotifyUser, user?._id]); const queue = useReactiveValue( useCallback(() => { diff --git a/apps/meteor/client/providers/ServerProvider.tsx b/apps/meteor/client/providers/ServerProvider.tsx index 8fab8415849d..8eb5e2e37b6b 100644 --- a/apps/meteor/client/providers/ServerProvider.tsx +++ b/apps/meteor/client/providers/ServerProvider.tsx @@ -1,5 +1,4 @@ import type { Serialized } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; import type { Method, PathFor, OperationParams, OperationResult, UrlParams, PathPattern } from '@rocket.chat/rest-typings'; import type { ServerMethodName, @@ -59,57 +58,16 @@ const callEndpoint = ( const uploadToEndpoint = (endpoint: PathFor<'POST'>, formData: any): Promise => sdk.rest.post(endpoint as any, formData); -type EventMap = StreamKeys> = { - [key in `${N}/${K}`]: StreamerCallbackArgs; -}; - -const ee = new Emitter(); - -const events = new Map void>(); - -const getStream = ( - streamName: N, - _options?: { - retransmit?: boolean | undefined; - retransmitToSelf?: boolean | undefined; - }, -) => { - return >(eventName: K, callback: (...args: StreamerCallbackArgs) => void): (() => void) => { - const eventLiteral = `${streamName}/${eventName}` as const; - const emitterCallback = (args?: unknown): void => { - if (!args || !Array.isArray(args)) { - throw new Error('Invalid streamer callback'); - } - callback(...(args as StreamerCallbackArgs)); - }; - - ee.on(eventLiteral, emitterCallback); - - const streamHandler = (...args: StreamerCallbackArgs): void => { - ee.emit(eventLiteral, args); - }; - - const stop = (): void => { - // If someone is still listening, don't unsubscribe - ee.off(eventLiteral, emitterCallback); - - if (ee.has(eventLiteral)) { - return; - } - - const unsubscribe = events.get(eventLiteral); - if (unsubscribe) { - unsubscribe(); - events.delete(eventLiteral); - } - }; - - if (!events.has(eventLiteral)) { - events.set(eventLiteral, sdk.stream(streamName, [eventName], streamHandler).stop); - } - return stop; - }; -}; +const getStream = + ( + streamName: N, + _options?: { + retransmit?: boolean | undefined; + retransmitToSelf?: boolean | undefined; + }, + ) => + >(eventName: K, callback: (...args: StreamerCallbackArgs) => void): (() => void) => + sdk.stream(streamName, [eventName], callback).stop; const contextValue = { info, diff --git a/apps/meteor/client/providers/TranslationProvider.tsx b/apps/meteor/client/providers/TranslationProvider.tsx index f9fdf299a5d6..7f98c374f949 100644 --- a/apps/meteor/client/providers/TranslationProvider.tsx +++ b/apps/meteor/client/providers/TranslationProvider.tsx @@ -1,8 +1,8 @@ -import { useLocalStorage, useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useLocalStorage } 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 { normalizeLanguage } from '@rocket.chat/tools'; -import type { TranslationKey, TranslationContextValue } from '@rocket.chat/ui-contexts'; +import type { TranslationContextValue } 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'; @@ -14,99 +14,73 @@ 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 { + i18n, + addSprinfToI18n, + extractTranslationKeys, + applyCustomTranslations, + availableTranslationNamespaces, + defaultTranslationNamespace, + extractTranslationNamespaces, +} from '../../app/utils/lib/i18n'; import { AppClientOrchestratorInstance } from '../../ee/client/apps/orchestrator'; -import { applyCustomTranslations } from '../lib/utils/applyCustomTranslations'; import { isRTLScriptLanguage } from '../lib/utils/isRTLScriptLanguage'; i18n.use(I18NextHttpBackend).use(initReactI18next).use(sprintf); -type TranslationNamespace = Extract extends `${infer T}.${string}` - ? T extends Lowercase - ? T - : never - : never; - -const namespacesDefault = ['core', 'onboarding', 'registration', 'cloud'] as TranslationNamespace[]; - -const parseToJSON = (customTranslations: string): Record> | false => { - try { - return JSON.parse(customTranslations); - } catch (e) { - return false; - } -}; - -const localeCache = new Map>(); - -const useI18next = (lng: string): typeof i18next => { +const useCustomTranslations = (i18n: typeof i18next) => { const customTranslations = useSetting('Custom_Translations'); - const parsedCustomTranslations = useMemo(() => { + const parsedCustomTranslations = useMemo((): Record> | undefined => { if (!customTranslations || typeof customTranslations !== 'string') { - return; + return undefined; } - return parseToJSON(customTranslations); + try { + return JSON.parse(customTranslations); + } catch (e) { + console.error(e); + return undefined; + } }, [customTranslations]); - const extractKeys = useMutableCallback( - (source: Record, lngs?: string | string[], namespaces: string | string[] = []): { [key: string]: any } => { - const result: { [key: string]: any } = {}; - - for (const [key, value] of Object.entries(source)) { - const [prefix] = key.split('.'); - - if (prefix && Array.isArray(namespaces) ? namespaces.includes(prefix) : prefix === namespaces) { - result[key.slice(prefix.length + 1)] = value; - continue; - } - - if (Array.isArray(namespaces) ? namespaces.includes('core') : namespaces === 'core') { - result[key] = value; - } - } + useEffect(() => { + if (!parsedCustomTranslations) { + return; + } - if (lngs && parsedCustomTranslations) { - for (const language of Array.isArray(lngs) ? lngs : [lngs]) { - if (!parsedCustomTranslations[language]) { - continue; - } + applyCustomTranslations(i18n, parsedCustomTranslations); - for (const [key, value] of Object.entries(parsedCustomTranslations[language])) { - const prefix = (Array.isArray(namespaces) ? namespaces : [namespaces]).find((namespace) => key.startsWith(`${namespace}.`)); + const handleLanguageChanged = (): void => { + applyCustomTranslations(i18n, parsedCustomTranslations); + }; - if (prefix) { - result[key.slice(prefix.length + 1)] = value; - continue; - } + i18n.on('languageChanged', handleLanguageChanged); - if (Array.isArray(namespaces) ? namespaces.includes('core') : namespaces === 'core') { - result[key] = value; - } - } - } - } + return () => { + i18n.off('languageChanged', handleLanguageChanged); + }; + }, [i18n, parsedCustomTranslations]); +}; - return result; - }, - ); +const localeCache = new Map>(); +const useI18next = (lng: string): typeof i18next => { if (!i18n.isInitialized) { i18n.init({ lng, fallbackLng: 'en', - ns: namespacesDefault, + ns: availableTranslationNamespaces, + defaultNS: defaultTranslationNamespace, nsSeparator: '.', resources: { - en: extractKeys(en), + en: extractTranslationNamespaces(en), }, partialBundledLanguages: true, - defaultNS: 'core', backend: { loadPath: 'i18n/{{lng}}.json', - parse: (data: string, lngs?: string | string[], namespaces: string | string[] = []) => - extractKeys(JSON.parse(data), lngs, namespaces), + parse: (data: string, _lngs?: string | string[], namespaces: string | string[] = []) => + extractTranslationKeys(JSON.parse(data), namespaces), request: (_options, url, _payload, callback) => { const params = url.split('/'); @@ -137,47 +111,12 @@ const useI18next = (lng: string): typeof i18next => { } useEffect(() => { - if (i18n.language !== lng) { - i18n.changeLanguage(lng); - } + i18n.changeLanguage(lng); }, [lng]); - useEffect(() => { - if (!parsedCustomTranslations) { - return; - } - - for (const [ln, translations] of Object.entries(parsedCustomTranslations)) { - if (!translations) { - continue; - } - const namespaces = Object.entries(translations).reduce((acc, [key, value]): Record> => { - const namespace = key.split('.')[0]; - - if (namespacesDefault.includes(namespace as unknown as TranslationNamespace)) { - acc[namespace] = acc[namespace] ?? {}; - acc[namespace][key] = value; - acc[namespace][key.slice(namespace.length + 1)] = value; - return acc; - } - acc.project = acc.project ?? {}; - acc.project[key] = value; - return acc; - }, {} as Record>); - - for (const [namespace, translations] of Object.entries(namespaces)) { - i18n.addResourceBundle(ln, namespace, translations); - } - } - }, [parsedCustomTranslations]); - return i18n; }; -type TranslationProviderProps = { - children: ReactNode; -}; - const useAutoLanguage = () => { const serverLanguage = useSetting('Language'); const browserLanguage = normalizeLanguage(window.navigator.userLanguage ?? window.navigator.language); @@ -206,11 +145,17 @@ const getLanguageName = (code: string, lng: string): string => { } }; +type TranslationProviderProps = { + children: ReactNode; +}; + const TranslationProvider = ({ children }: TranslationProviderProps): ReactElement => { const loadLocale = useMethod('loadLocale'); const language = useAutoLanguage(); const i18nextInstance = useI18next(language); + useCustomTranslations(i18nextInstance); + const availableLanguages = useMemo( () => [ { @@ -290,8 +235,8 @@ const TranslationProviderInner = ({ () => ({ language: i18n.language, languages: availableLanguages, - loadLanguage: async (language: string): Promise => { - i18n.changeLanguage(language).then(() => applyCustomTranslations()); + loadLanguage: async (language: string) => { + i18n.changeLanguage(language); }, translate: Object.assign(addSprinfToI18n(t), { has: ((key, options) => key && i18n.exists(key, options)) as TranslationContextValue['translate']['has'], diff --git a/apps/meteor/client/providers/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index 09f631ffa6a6..62ed7070737d 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -1,7 +1,7 @@ import type { IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; -import type { LoginService, SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { UserContext, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import { UserContext, useEndpoint } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import type { ContextType, ReactElement, ReactNode } from 'react'; import React, { useEffect, useMemo } from 'react'; @@ -13,32 +13,15 @@ import { afterLogoutCleanUpCallback } from '../../../lib/callbacks/afterLogoutCl import { useReactiveValue } from '../../hooks/useReactiveValue'; import { createReactiveSubscriptionFactory } from '../../lib/createReactiveSubscriptionFactory'; import { useCreateFontStyleElement } from '../../views/account/accessibility/hooks/useCreateFontStyleElement'; +import { useClearRemovedRoomsHistory } from './hooks/useClearRemovedRoomsHistory'; +import { useDeleteUser } from './hooks/useDeleteUser'; import { useEmailVerificationWarning } from './hooks/useEmailVerificationWarning'; -import { useLDAPAndCrowdCollisionWarning } from './hooks/useLDAPAndCrowdCollisionWarning'; +import { useUpdateAvatar } from './hooks/useUpdateAvatar'; const getUserId = (): string | null => Meteor.userId(); const getUser = (): IUser | null => Meteor.user() as IUser | null; -const capitalize = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1); - -const config: Record> = { - 'apple': { title: 'Apple', icon: 'apple' }, - 'facebook': { title: 'Facebook', icon: 'facebook' }, - 'twitter': { title: 'Twitter', icon: 'twitter' }, - 'google': { title: 'Google', icon: 'google' }, - 'github': { title: 'Github', icon: 'github' }, - 'github_enterprise': { title: 'Github Enterprise', icon: 'github' }, - 'gitlab': { title: 'Gitlab', icon: 'gitlab' }, - 'dolphin': { title: 'Dolphin', icon: 'dophin' }, - 'drupal': { title: 'Drupal', icon: 'drupal' }, - 'nextcloud': { title: 'Nextcloud', icon: 'nextcloud' }, - 'tokenpass': { title: 'Tokenpass', icon: 'tokenpass' }, - 'meteor-developer': { title: 'Meteor', icon: 'meteor' }, - 'wordpress': { title: 'WordPress', icon: 'wordpress' }, - 'linkedin': { title: 'Linkedin', icon: 'linkedin' }, -}; - const logout = (): Promise => new Promise((resolve, reject) => { const user = getUser(); @@ -53,16 +36,11 @@ const logout = (): Promise => }); }); -export type LoginMethods = keyof typeof Meteor; - type UserProviderProps = { children: ReactNode; }; const UserProvider = ({ children }: UserProviderProps): ReactElement => { - const isLdapEnabled = useSetting('LDAP_Enable'); - const isCrowdEnabled = useSetting('CROWD_Enable'); - const userId = useReactiveValue(getUserId); const user = useReactiveValue(getUser); const [userLanguage, setUserLanguage] = useLocalStorage('userLanguage', ''); @@ -73,10 +51,11 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { const createFontStyleElement = useCreateFontStyleElement(); createFontStyleElement(user?.settings?.preferences?.fontSize); - const loginMethod: LoginMethods = (isLdapEnabled && 'loginWithLDAP') || (isCrowdEnabled && 'loginWithCrowd') || 'loginWithPassword'; - - useLDAPAndCrowdCollisionWarning(); useEmailVerificationWarning(user ?? undefined); + useClearRemovedRoomsHistory(userId); + + useDeleteUser(); + useUpdateAvatar(); const contextValue = useMemo( (): ContextType => ({ @@ -96,75 +75,9 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { return ChatRoom.find(query, options).fetch(); }), - loginWithToken: (token: string): Promise => - new Promise((resolve, reject) => - Meteor.loginWithToken(token, (err) => { - if (err) { - return reject(err); - } - resolve(undefined); - }), - ), - loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string): Promise => - new Promise((resolve, reject) => { - Meteor[loginMethod](user, password, (error: Error | Meteor.Error | Meteor.TypedError | undefined) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - }), logout, - loginWithService: ({ service, clientConfig = {} }: T): (() => Promise) => { - const loginMethods = { - 'meteor-developer': 'MeteorDeveloperAccount', - }; - - const loginWithService = `loginWith${(loginMethods as any)[service] || capitalize(String(service || ''))}`; - - const method: (config: unknown, cb: (error: any) => void) => Promise = (Meteor as any)[loginWithService] as any; - - if (!method) { - return () => Promise.reject(new Error('Login method not found')); - } - - return () => - new Promise((resolve, reject) => { - method(clientConfig, (error: any): void => { - if (!error) { - resolve(true); - return; - } - reject(error); - }); - }); - }, - queryAllServices: createReactiveSubscriptionFactory(() => - ServiceConfiguration.configurations - .find( - { - showButton: { $ne: false }, - }, - { - sort: { - service: 1, - }, - }, - ) - .fetch() - .map( - ({ appId: _, ...service }) => - ({ - title: capitalize(String((service as any).service || '')), - ...service, - ...(config[(service as any).service] ?? {}), - } as any), - ), - ), }), - [userId, user, loginMethod], + [userId, user], ); useEffect(() => { diff --git a/apps/meteor/client/providers/UserProvider/hooks/useClearRemovedRoomsHistory.ts b/apps/meteor/client/providers/UserProvider/hooks/useClearRemovedRoomsHistory.ts new file mode 100644 index 000000000000..50d12c3d334b --- /dev/null +++ b/apps/meteor/client/providers/UserProvider/hooks/useClearRemovedRoomsHistory.ts @@ -0,0 +1,20 @@ +import { useStream } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { RoomHistoryManager } from '../../../../app/ui-utils/client'; + +export const useClearRemovedRoomsHistory = (userId: string | null) => { + const subscribeToNotifyUser = useStream('notify-user'); + + useEffect(() => { + if (!userId) { + return; + } + + return subscribeToNotifyUser(`${userId}/subscriptions-changed`, (event, data) => { + if (event === 'removed' && data.rid) { + RoomHistoryManager.clear(data.rid); + } + }); + }, [userId, subscribeToNotifyUser]); +}; diff --git a/apps/meteor/client/providers/UserProvider/hooks/useDeleteUser.ts b/apps/meteor/client/providers/UserProvider/hooks/useDeleteUser.ts new file mode 100644 index 000000000000..e86fe9951a26 --- /dev/null +++ b/apps/meteor/client/providers/UserProvider/hooks/useDeleteUser.ts @@ -0,0 +1,32 @@ +import { useStream } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { ChatMessage } from '../../../../app/models/client'; + +export const useDeleteUser = () => { + const notify = useStream('notify-logged'); + + useEffect(() => { + return notify('Users:Deleted', ({ userId, messageErasureType, replaceByUser }) => { + if (messageErasureType === 'Unlink' && replaceByUser) { + return ChatMessage.update( + { + 'u._id': userId, + }, + { + $set: { + 'alias': replaceByUser.alias, + 'u._id': replaceByUser._id, + 'u.username': replaceByUser.username, + 'u.name': undefined, + }, + }, + { multi: true }, + ); + } + ChatMessage.remove({ + 'u._id': userId, + }); + }); + }, [notify]); +}; diff --git a/apps/meteor/client/providers/UserProvider/hooks/useUpdateAvatar.ts b/apps/meteor/client/providers/UserProvider/hooks/useUpdateAvatar.ts new file mode 100644 index 000000000000..292880e23da8 --- /dev/null +++ b/apps/meteor/client/providers/UserProvider/hooks/useUpdateAvatar.ts @@ -0,0 +1,15 @@ +import { useStream } from '@rocket.chat/ui-contexts'; +import { Meteor } from 'meteor/meteor'; +import { useEffect } from 'react'; + +export const useUpdateAvatar = () => { + const notify = useStream('notify-logged'); + useEffect(() => { + return notify('updateAvatar', (data) => { + if ('username' in data) { + const { username, etag } = data; + username && Meteor.users.update({ username }, { $set: { avatarETag: etag } }); + } + }); + }, [notify]); +}; diff --git a/apps/meteor/client/sidebar/Sidebar.stories.tsx b/apps/meteor/client/sidebar/Sidebar.stories.tsx index f147ed86b4e4..d8c5788bae86 100644 --- a/apps/meteor/client/sidebar/Sidebar.stories.tsx +++ b/apps/meteor/client/sidebar/Sidebar.stories.tsx @@ -1,5 +1,5 @@ import type { ISetting } from '@rocket.chat/core-typings'; -import type { LoginService, SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; +import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import { UserContext, SettingsContext } from '@rocket.chat/ui-contexts'; import type { Meta, Story } from '@storybook/react'; import type { ObjectId } from 'mongodb'; @@ -98,10 +98,6 @@ const userContextValue: ContextType = { querySubscription: () => [() => () => undefined, () => undefined], queryRoom: () => [() => () => undefined, () => undefined], - queryAllServices: () => [() => (): void => undefined, (): LoginService[] => []], - loginWithService: () => () => Promise.reject('loginWithService not implemented'), - loginWithPassword: async () => Promise.reject('loginWithPassword not implemented'), - loginWithToken: async () => Promise.reject('loginWithToken not implemented'), logout: () => Promise.resolve(), }; diff --git a/apps/meteor/client/sidebar/footer/voip/index.tsx b/apps/meteor/client/sidebar/footer/voip/index.tsx index 70ad626044a4..9ae7d91c7c2b 100644 --- a/apps/meteor/client/sidebar/footer/voip/index.tsx +++ b/apps/meteor/client/sidebar/footer/voip/index.tsx @@ -1,7 +1,7 @@ import type { VoIpCallerInfo } from '@rocket.chat/core-typings'; import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { useVoipFooterMenu } from '../../../../ee/client/hooks/useVoipFooterMenu'; import { @@ -62,18 +62,6 @@ export const VoipFooter = (): ReactElement | null => { return subtitles[state] || ''; }; - const getCallsInQueueText = useMemo((): string => { - if (queueCounter === 0) { - return t('Calls_in_queue_empty'); - } - - if (queueCounter === 1) { - return t('Calls_in_queue', { calls: queueCounter }); - } - - return t('Calls_in_queue_plural', { calls: queueCounter }); - }, [queueCounter, t]); - if (!('caller' in callerInfo)) { return ; } @@ -91,7 +79,7 @@ export const VoipFooter = (): ReactElement | null => { togglePause={togglePause} createRoom={createRoom} openRoom={openRoom} - callsInQueue={getCallsInQueueText} + callsInQueue={t('Calls_in_queue', { count: queueCounter })} dispatchEvent={dispatchEvent} openedRoomInfo={openedRoomInfo} isEnterprise={isEnterprise} diff --git a/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx b/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx index 853de76b665a..626d1202a8e0 100644 --- a/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx +++ b/apps/meteor/client/sidebar/header/CreateDirectMessage.tsx @@ -1,7 +1,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Box, Modal, Button, FieldGroup, Field, FieldRow, FieldLabel, FieldError } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useTranslation, useEndpoint, useToastMessageDispatch, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import React, { memo } from 'react'; import { useForm, Controller } from 'react-hook-form'; @@ -11,6 +11,7 @@ import { goToRoomById } from '../../lib/utils/goToRoomById'; const CreateDirectMessage = ({ onClose }: { onClose: () => void }) => { const t = useTranslation(); + const directMaxUsers = useSetting('DirectMesssage_maxUsers') || 1; const membersFieldId = useUniqueId(); const dispatchToastMessage = useToastMessageDispatch(); @@ -55,7 +56,13 @@ const CreateDirectMessage = ({ onClose }: { onClose: () => void }) => { + users.length + 1 > directMaxUsers + ? t('error-direct-message-max-user-exceeded', { maxUsers: directMaxUsers }) + : undefined, + }} control={control} render={({ field: { name, onChange, value, onBlur } }) => ( { return ( - + {uid && ( <> diff --git a/apps/meteor/client/sidebar/header/hooks/useStatusItems.tsx b/apps/meteor/client/sidebar/header/hooks/useStatusItems.tsx index 217c5c53f573..026f7c80400e 100644 --- a/apps/meteor/client/sidebar/header/hooks/useStatusItems.tsx +++ b/apps/meteor/client/sidebar/header/hooks/useStatusItems.tsx @@ -1,77 +1,87 @@ -import type { IUser, ValueOf } from '@rocket.chat/core-typings'; -import { UserStatus as UserStatusEnum } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; -import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useEndpoint, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import { useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; -import { userStatus } from '../../../../app/user-status/client'; import { callbacks } from '../../../../lib/callbacks'; import type { GenericMenuItemProps } from '../../../components/GenericMenu/GenericMenuItem'; import MarkdownText from '../../../components/MarkdownText'; import { UserStatus } from '../../../components/UserStatus'; +import { userStatuses } from '../../../lib/userStatuses'; +import type { UserStatusDescriptor } from '../../../lib/userStatuses'; import { useStatusDisabledModal } from '../../../views/admin/customUserStatus/hooks/useStatusDisabledModal'; import { useCustomStatusModalHandler } from './useCustomStatusModalHandler'; -const isDefaultStatus = (id: string): boolean => (Object.values(UserStatusEnum) as string[]).includes(id); -const isDefaultStatusName = (_name: string, id: string): _name is UserStatusEnum => isDefaultStatus(id); -const translateStatusName = (t: ReturnType, status: (typeof userStatus.list)['']): string => { - if (isDefaultStatusName(status.name, status.id)) { - return t(status.name as TranslationKey); - } +export const useStatusItems = (): GenericMenuItemProps[] => { + // We should lift this up to somewhere else if we want to use it in other places - return status.name; -}; + userStatuses.invisibleAllowed = useSetting('Accounts_AllowInvisibleStatusOption', true); -export const useStatusItems = (user: IUser): GenericMenuItemProps[] => { - const t = useTranslation(); - const presenceDisabled = useSetting('Presence_broadcast_disabled'); - const setStatus = useEndpoint('POST', '/v1/users.setStatus'); + const queryClient = useQueryClient(); - const setStatusAction = (status: (typeof userStatus.list)['']): void => { - setStatus({ status: status.statusType, message: !isDefaultStatus(status.id) ? status.name : '' }); - void callbacks.run('userStatusManuallySet', status); - }; + useEffect( + () => + userStatuses.watch(() => { + queryClient.setQueryData(['user-statuses'], Array.from(userStatuses)); + }), + [queryClient], + ); - const filterInvisibleStatus = !useSetting('Accounts_AllowInvisibleStatusOption') - ? (status: ValueOf<(typeof userStatus)['list']>): boolean => status.name !== 'invisible' - : (): boolean => true; + const { t } = useTranslation(); - const handleCustomStatus = useCustomStatusModalHandler(); + const setStatus = useEndpoint('POST', '/v1/users.setStatus'); + const setStatusMutation = useMutation({ + mutationFn: async (status: UserStatusDescriptor) => { + void setStatus({ status: status.statusType, message: userStatuses.isValidType(status.id) ? '' : status.name }); + void callbacks.run('userStatusManuallySet', status); + }, + }); - const handleStatusDisabledModal = useStatusDisabledModal(); + const presenceDisabled = useSetting('Presence_broadcast_disabled', false); - const presenceDisabledItem = { - id: 'presence-disabled', - content: ( - - - {t('User_status_disabled')} - - - {t('Learn_more')} - - - ), - }; + const { data: statuses } = useQuery({ + queryKey: ['user-statuses'], + queryFn: async () => { + await userStatuses.sync(); + return Array.from(userStatuses); + }, + staleTime: Infinity, + select: (statuses) => + statuses.map((status): GenericMenuItemProps => { + const content = status.localizeName ? t(status.name) : status.name; + return { + id: status.id, + status: , + content: , + disabled: presenceDisabled, + onClick: () => setStatusMutation.mutate(status), + }; + }), + }); - const statusItems = Object.values(userStatus.list) - .filter(filterInvisibleStatus) - .map((status) => { - const name = status.localizeName ? translateStatusName(t, status) : status.name; - const modifier = status.statusType || user?.status; - return { - id: status.id, - status: , - content: , - onClick: () => setStatusAction(status), - disabled: presenceDisabled, - }; - }); + const handleStatusDisabledModal = useStatusDisabledModal(); + const handleCustomStatus = useCustomStatusModalHandler(); return [ - ...(presenceDisabled ? [presenceDisabledItem] : []), - ...statusItems, + ...(presenceDisabled + ? [ + { + id: 'presence-disabled', + content: ( + + + {t('User_status_disabled')} + + + {t('Learn_more')} + + + ), + }, + ] + : []), + ...(statuses ?? []), { id: 'custom-status', icon: 'emoji', content: t('Custom_Status'), onClick: handleCustomStatus, disabled: presenceDisabled }, ]; }; diff --git a/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx b/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx index 111065b0ac46..de43d30306e9 100644 --- a/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx +++ b/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx @@ -11,7 +11,7 @@ import { useStatusItems } from './useStatusItems'; export const useUserMenu = (user: IUser) => { const t = useTranslation(); - const statusItems = useStatusItems(user); + const statusItems = useStatusItems(); const accountItems = useAccountItems(); const logout = useLogout(); diff --git a/apps/meteor/client/startup/UserDeleted.ts b/apps/meteor/client/startup/UserDeleted.ts deleted file mode 100644 index bbaeb6bc0229..000000000000 --- a/apps/meteor/client/startup/UserDeleted.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { ChatMessage } from '../../app/models/client'; -import { Notifications } from '../../app/notifications/client'; - -Meteor.startup(() => { - Notifications.onLogged('Users:Deleted', ({ userId, messageErasureType, replaceByUser }) => { - if (messageErasureType === 'Unlink' && replaceByUser) { - return ChatMessage.update( - { - 'u._id': userId, - }, - { - $set: { - 'alias': replaceByUser.alias, - 'u._id': replaceByUser._id, - 'u.username': replaceByUser.username, - 'u.name': undefined, - }, - }, - { multi: true }, - ); - } - ChatMessage.remove({ - 'u._id': userId, - }); - }); -}); diff --git a/apps/meteor/client/startup/actionButtons/pinMessage.ts b/apps/meteor/client/startup/actionButtons/pinMessage.tsx similarity index 66% rename from apps/meteor/client/startup/actionButtons/pinMessage.ts rename to apps/meteor/client/startup/actionButtons/pinMessage.tsx index 970eb28349c0..b383b4a3c648 100644 --- a/apps/meteor/client/startup/actionButtons/pinMessage.ts +++ b/apps/meteor/client/startup/actionButtons/pinMessage.tsx @@ -4,10 +4,12 @@ import { hasAtLeastOnePermission } from '../../../app/authorization/client'; import { settings } from '../../../app/settings/client'; import { MessageAction } from '../../../app/ui-utils/client'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; +import { imperativeModal } from '../../lib/imperativeModal'; import { queryClient } from '../../lib/queryClient'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { dispatchToastMessage } from '../../lib/toast'; import { messageArgs } from '../../lib/utils/messageArgs'; +import PinMessageModal from '../../views/room/modals/PinMessageModal'; Meteor.startup(() => { MessageAction.addButton({ @@ -18,13 +20,25 @@ Meteor.startup(() => { context: ['pinned', 'message', 'message-mobile', 'threads', 'direct', 'videoconf', 'videoconf-threads'], async action(_, props) { const { message = messageArgs(this).msg } = props; - message.pinned = true; - try { - await sdk.call('pinMessage', message); - queryClient.invalidateQueries(['rooms', message.rid, 'pinned-messages']); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } + const onConfirm = async () => { + message.pinned = true; + try { + await sdk.call('pinMessage', message); + queryClient.invalidateQueries(['rooms', message.rid, 'pinned-messages']); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + imperativeModal.close(); + }; + + imperativeModal.open({ + component: PinMessageModal, + props: { + message, + onConfirm, + onCancel: () => imperativeModal.close(), + }, + }); }, condition({ message, subscription, room }) { if (!settings.get('Message_AllowPinning') || message.pinned || !subscription) { diff --git a/apps/meteor/client/startup/customOAuth.ts b/apps/meteor/client/startup/customOAuth.ts index 5b0e3dfb4261..1b9060f84e3a 100644 --- a/apps/meteor/client/startup/customOAuth.ts +++ b/apps/meteor/client/startup/customOAuth.ts @@ -1,25 +1,20 @@ import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; -import { CustomOAuth } from '../../app/custom-oauth/client/custom_oauth_client'; +import { CustomOAuth } from '../../app/custom-oauth/client/CustomOAuth'; +import { loginServices } from '../lib/loginServices'; Meteor.startup(() => { - ServiceConfiguration.configurations - .find({ - custom: true, - }) - .observe({ - async added(record) { - const { isOauthCustomConfiguration } = await import('@rocket.chat/rest-typings'); - if (!isOauthCustomConfiguration(record)) { - return; - } + loginServices.onLoad((services) => { + for (const service of services) { + if (!('custom' in service && service.custom)) { + return; + } - new CustomOAuth(record.service, { - serverURL: record.serverURL, - authorizePath: record.authorizePath, - scope: record.scope, - }); - }, - }); + new CustomOAuth(service.service, { + serverURL: service.serverURL, + authorizePath: service.authorizePath, + scope: service.scope, + }); + } + }); }); diff --git a/apps/meteor/client/startup/e2e.ts b/apps/meteor/client/startup/e2e.ts index ddfbada67c2a..f9cf156f8d8b 100644 --- a/apps/meteor/client/startup/e2e.ts +++ b/apps/meteor/client/startup/e2e.ts @@ -4,8 +4,8 @@ import { Tracker } from 'meteor/tracker'; import { e2e } from '../../app/e2e/client/rocketchat.e2e'; import { Subscriptions, ChatRoom } from '../../app/models/client'; -import { Notifications } from '../../app/notifications/client'; import { settings } from '../../app/settings/client'; +import { sdk } from '../../app/utils/client/lib/SDKClient'; import { onClientBeforeSendMessage } from '../lib/onClientBeforeSendMessage'; import { onClientMessageReceived } from '../lib/onClientMessageReceived'; import { isLayoutEmbedded } from '../lib/utils/isLayoutEmbedded'; @@ -38,23 +38,25 @@ Meteor.startup(() => { let observable: Meteor.LiveQueryHandle | null = null; let offClientMessageReceived: undefined | (() => void); let offClientBeforeSendMessage: undefined | (() => void); + let unsubNotifyUser: undefined | (() => void); Tracker.autorun(() => { if (!e2e.isReady()) { offClientMessageReceived?.(); - Notifications.unUser('e2ekeyRequest'); + unsubNotifyUser?.(); + unsubNotifyUser = undefined; observable?.stop(); offClientBeforeSendMessage?.(); return; } - Notifications.onUser('e2ekeyRequest', async (roomId, keyId): Promise => { + unsubNotifyUser = sdk.stream('notify-user', [`${Meteor.userId()}/e2ekeyRequest`], async (roomId, keyId): Promise => { const e2eRoom = await e2e.getInstanceByRoomId(roomId); if (!e2eRoom) { return; } e2eRoom.provideKeyToUser(keyId); - }); + }).stop; observable = Subscriptions.find().observe({ changed: async (sub: ISubscription) => { diff --git a/apps/meteor/client/startup/forceLogout.ts b/apps/meteor/client/startup/forceLogout.ts index 9226229ae418..f882354062cd 100644 --- a/apps/meteor/client/startup/forceLogout.ts +++ b/apps/meteor/client/startup/forceLogout.ts @@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { Session } from 'meteor/session'; import { Tracker } from 'meteor/tracker'; -import { Notifications } from '../../app/notifications/client'; +import { sdk } from '../../app/utils/client/lib/SDKClient'; Meteor.startup(() => { Tracker.autorun(() => { @@ -12,7 +12,7 @@ Meteor.startup(() => { return; } Session.set('force_logout', false); - Notifications.onUser('force_logout', () => { + sdk.stream('notify-user', [`${userId}/force_logout`], () => { Session.set('force_logout', true); }); }); diff --git a/apps/meteor/client/startup/iframeCommands.ts b/apps/meteor/client/startup/iframeCommands.ts index cb946ba44176..f0db83ccdcbf 100644 --- a/apps/meteor/client/startup/iframeCommands.ts +++ b/apps/meteor/client/startup/iframeCommands.ts @@ -2,7 +2,6 @@ import type { UserStatus, IUser } from '@rocket.chat/core-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { LocationPathname } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { settings } from '../../app/settings/client'; import { AccountBox } from '../../app/ui-utils/client/lib/AccountBox'; @@ -10,6 +9,7 @@ import { sdk } from '../../app/utils/client/lib/SDKClient'; import { afterLogoutCleanUpCallback } from '../../lib/callbacks/afterLogoutCleanUpCallback'; import { capitalize, ltrim, rtrim } from '../../lib/utils/stringUtils'; import { baseURI } from '../lib/baseURI'; +import { loginServices } from '../lib/loginServices'; import { router } from '../providers/RouterProvider'; const commands = { @@ -55,7 +55,7 @@ const commands = { } if (typeof data.service === 'string' && window.ServiceConfiguration) { - const customOauth = ServiceConfiguration.configurations.findOne({ service: data.service }); + const customOauth = loginServices.getLoginService(data.service); if (customOauth) { const customLoginWith = (Meteor as any)[`loginWith${capitalize(customOauth.service, true)}`]; diff --git a/apps/meteor/client/startup/incomingMessages.ts b/apps/meteor/client/startup/incomingMessages.ts index 88e83b9cf5e8..e9659cc24724 100644 --- a/apps/meteor/client/startup/incomingMessages.ts +++ b/apps/meteor/client/startup/incomingMessages.ts @@ -2,8 +2,8 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { ChatMessage } from '../../app/models/client'; -import { Notifications } from '../../app/notifications/client'; import { CachedCollectionManager } from '../../app/ui-cached-collection/client'; +import { sdk } from '../../app/utils/client/lib/SDKClient'; Meteor.startup(() => { Tracker.autorun(() => { @@ -11,7 +11,9 @@ Meteor.startup(() => { return; } - Notifications.onUser('message', (msg: IMessage) => { + // Only event I found triggers this is from ephemeral messages + // Other types of messages come from another stream + sdk.stream('notify-user', [`${Meteor.userId()}/message`], (msg: IMessage) => { msg.u = msg.u || { username: 'rocket.cat' }; msg.private = true; @@ -20,7 +22,7 @@ Meteor.startup(() => { }); CachedCollectionManager.onLogin(() => { - Notifications.onUser('subscriptions-changed', (_action, sub) => { + sdk.stream('notify-user', [`${Meteor.userId()}/subscriptions-changed`], (_action, sub) => { ChatMessage.update( { rid: sub.rid, diff --git a/apps/meteor/client/startup/index.ts b/apps/meteor/client/startup/index.ts index 61eaa0da16ed..bf6814617e4a 100644 --- a/apps/meteor/client/startup/index.ts +++ b/apps/meteor/client/startup/index.ts @@ -10,13 +10,11 @@ import './e2e'; import './forceLogout'; import './iframeCommands'; import './incomingMessages'; -import './ldap'; import './loadMissedMessages'; import './loginViaQuery'; import './messageObserve'; import './messageTypes'; import './notifications'; -import './oauth'; import './otr'; import './reloadRoomAfterLogin'; import './roles'; @@ -27,6 +25,5 @@ import './slashCommands'; import './startup'; import './streamMessage'; import './unread'; -import './UserDeleted'; import './userRoles'; import './userStatusManuallySet'; diff --git a/apps/meteor/client/startup/ldap.ts b/apps/meteor/client/startup/ldap.ts deleted file mode 100644 index 13f6048bb2eb..000000000000 --- a/apps/meteor/client/startup/ldap.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Accounts } from 'meteor/accounts-base'; -import { Meteor } from 'meteor/meteor'; - -(Meteor as any).loginWithLDAP = function (username: string, password: string, callback?: (err?: any) => void): void { - Accounts.callLoginMethod({ - methodArguments: [ - { - ldap: true, - username, - ldapPass: password, - ldapOptions: {}, - }, - ], - userCallback: callback, - }); -}; diff --git a/apps/meteor/client/startup/notifications/index.ts b/apps/meteor/client/startup/notifications/index.ts index e94aaacdd783..866dae4d52f3 100644 --- a/apps/meteor/client/startup/notifications/index.ts +++ b/apps/meteor/client/startup/notifications/index.ts @@ -1,4 +1,2 @@ import './konchatNotifications'; import './notification'; -import './updateAvatar'; -import './usersNameChanged'; diff --git a/apps/meteor/client/startup/notifications/konchatNotifications.ts b/apps/meteor/client/startup/notifications/konchatNotifications.ts index 723901b7f70b..cd16d4264479 100644 --- a/apps/meteor/client/startup/notifications/konchatNotifications.ts +++ b/apps/meteor/client/startup/notifications/konchatNotifications.ts @@ -4,10 +4,10 @@ import { Tracker } from 'meteor/tracker'; import { lazy } from 'react'; import { CachedChatSubscription } from '../../../app/models/client'; -import { Notifications } from '../../../app/notifications/client'; import { settings } from '../../../app/settings/client'; import { KonchatNotification } from '../../../app/ui/client/lib/KonchatNotification'; import { getUserPreference } from '../../../app/utils/client'; +import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { RoomManager } from '../../lib/RoomManager'; import { imperativeModal } from '../../lib/imperativeModal'; import { fireGlobalEvent } from '../../lib/utils/fireGlobalEvent'; @@ -71,17 +71,18 @@ Meteor.startup(() => { }; Tracker.autorun(() => { if (!Meteor.userId() || !settings.get('Outlook_Calendar_Enabled')) { - return Notifications.unUser('calendar'); + sdk.stop('notify-user', `${Meteor.userId()}/calendar`); } - Notifications.onUser('calendar', notifyUserCalendar); + sdk.stream('notify-user', [`${Meteor.userId()}/calendar`], notifyUserCalendar); }); Tracker.autorun(() => { if (!Meteor.userId()) { return; } - Notifications.onUser('notification', (notification) => { + + sdk.stream('notify-user', [`${Meteor.userId()}/notification`], (notification) => { const openedRoomId = ['channel', 'group', 'direct'].includes(router.getRouteName()!) ? RoomManager.opened : undefined; // This logic is duplicated in /client/startup/unread.coffee. @@ -111,7 +112,7 @@ Meteor.startup(() => { void notifyNewRoom(sub); }); - Notifications.onUser('subscriptions-changed', (action, sub) => { + sdk.stream('notify-user', [`${Meteor.userId()}/subscriptions-changed`], (action, sub) => { if (action === 'removed') { return; } diff --git a/apps/meteor/client/startup/notifications/updateAvatar.ts b/apps/meteor/client/startup/notifications/updateAvatar.ts deleted file mode 100644 index b26e184aca20..000000000000 --- a/apps/meteor/client/startup/notifications/updateAvatar.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Meteor } from 'meteor/meteor'; - -import { Notifications } from '../../../app/notifications/client'; - -Meteor.startup(() => { - Notifications.onLogged('updateAvatar', (data) => { - if ('username' in data) { - const { username, etag } = data; - username && Meteor.users.update({ username }, { $set: { avatarETag: etag } }); - } - }); -}); diff --git a/apps/meteor/client/startup/notifications/usersNameChanged.ts b/apps/meteor/client/startup/notifications/usersNameChanged.ts deleted file mode 100644 index a1dacf9a1945..000000000000 --- a/apps/meteor/client/startup/notifications/usersNameChanged.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; - -import { Messages, Subscriptions } from '../../../app/models/client'; -import { Notifications } from '../../../app/notifications/client'; - -type UsersNameChangedEvent = Partial; - -Meteor.startup(() => { - Notifications.onLogged('Users:NameChanged', ({ _id, name, username }: UsersNameChangedEvent) => { - Messages.update( - { - 'u._id': _id, - }, - { - $set: { - 'u.username': username, - 'u.name': name, - }, - }, - { - multi: true, - }, - ); - - Messages.update( - { - 'editedBy._id': _id, - }, - { - $set: { - 'editedBy.username': username, - }, - }, - { - multi: true, - }, - ); - - Messages.update( - { - mentions: { - $elemMatch: { _id }, - }, - }, - { - $set: { - 'mentions.$.username': username, - 'mentions.$.name': name, - }, - }, - { - multi: true, - }, - ); - - Subscriptions.update( - { - name: username, - t: 'd', - }, - { - $set: { - fname: name, - }, - }, - ); - }); -}); diff --git a/apps/meteor/client/startup/oauth.ts b/apps/meteor/client/startup/oauth.ts deleted file mode 100644 index 23f5ec8246b4..000000000000 --- a/apps/meteor/client/startup/oauth.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { OAuth } from 'meteor/oauth'; - -// OAuth._retrieveCredentialSecret is a meteor method modified to also check the global localStorage -// This was necessary because of the "Forget User Session on Window Close" setting. -// The setting changes Meteor._localStorage to use the browser's session storage instead, but that doesn't happen on the Oauth's popup code. - -Meteor.startup(() => { - const meteorOAuthRetrieveCredentialSecret = OAuth._retrieveCredentialSecret; - OAuth._retrieveCredentialSecret = (credentialToken: string): string | null => { - let secret = meteorOAuthRetrieveCredentialSecret.call(OAuth, credentialToken); - if (!secret) { - const localStorageKey = `${OAuth._storageTokenPrefix}${credentialToken}`; - secret = localStorage.getItem(localStorageKey); - localStorage.removeItem(localStorageKey); - } - - return secret; - }; -}); diff --git a/apps/meteor/client/startup/userRoles.ts b/apps/meteor/client/startup/userRoles.ts index a311148a6563..77ba6978d485 100644 --- a/apps/meteor/client/startup/userRoles.ts +++ b/apps/meteor/client/startup/userRoles.ts @@ -2,7 +2,6 @@ import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import { UserRoles, ChatMessage } from '../../app/models/client'; -import { Notifications } from '../../app/notifications/client'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { dispatchToastMessage } from '../lib/toast'; @@ -20,7 +19,7 @@ Meteor.startup(() => { dispatchToastMessage({ type: 'error', message: error }); }); - Notifications.onLogged('roles-change', (role) => { + sdk.stream('notify-logged', ['roles-change'], (role) => { if (role.type === 'added') { if (!role.scope) { if (!role.u) { diff --git a/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx b/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx index 62bf2df74ecf..9bb4e57317cf 100644 --- a/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx +++ b/apps/meteor/client/views/account/accessibility/AccessibilityPage.tsx @@ -16,6 +16,7 @@ import { ToggleSwitch, } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { ExternalLink } from '@rocket.chat/ui-client'; import { useTranslation, useToastMessageDispatch, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import React, { useMemo } from 'react'; @@ -52,6 +53,7 @@ const AccessibilityPage = () => { const clockModeId = useUniqueId(); const hideUsernamesId = useUniqueId(); const hideRolesId = useUniqueId(); + const linkListId = useUniqueId(); const { formState: { isDirty, dirtyFields, isSubmitting }, @@ -88,7 +90,23 @@ const AccessibilityPage = () => { - {t('Accessibility_activation')} + + {t('Accessibility_activation')} + +

{t('Learn_more_about_accessibility')}

+
    +
  • + {t('Accessibility_statement')} +
  • +
  • + {t('Glossary_of_simplified_terms')} +
  • +
  • + + {t('Accessibility_feature_documentation')} + +
  • +
diff --git a/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx b/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx index 312cc89b65a0..2c11b7a384cd 100644 --- a/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx +++ b/apps/meteor/client/views/account/integrations/AccountIntegrationsPage.tsx @@ -1,49 +1,54 @@ -import type { IWebdavAccountIntegration } from '@rocket.chat/core-typings'; import type { SelectOption } 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'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo } from 'react'; import { useForm, Controller } from 'react-hook-form'; -import { WebdavAccounts } from '../../../../app/models/client'; import { Page, PageHeader, PageScrollableContentWithShadow } from '../../../components/Page'; -import { useReactiveValue } from '../../../hooks/useReactiveValue'; +import { useWebDAVAccountIntegrationsQuery } from '../../../hooks/webdav/useWebDAVAccountIntegrationsQuery'; import { getWebdavServerName } from '../../../lib/getWebdavServerName'; +import { useRemoveWebDAVAccountIntegrationMutation } from './hooks/useRemoveWebDAVAccountIntegrationMutation'; -const getWebdavAccounts = (): IWebdavAccountIntegration[] => WebdavAccounts.find().fetch(); +const AccountIntegrationsPage = () => { + const { data: webdavAccountIntegrations } = useWebDAVAccountIntegrationsQuery(); -const AccountIntegrationsPage = (): ReactElement => { - const t = useTranslation(); - const { handleSubmit, control } = useForm(); - const dispatchToastMessage = useToastMessageDispatch(); - const accounts = useReactiveValue(getWebdavAccounts); - const removeWebdavAccount = useEndpoint('POST', '/v1/webdav.removeWebdavAccount'); + const { handleSubmit, control } = useForm<{ accountSelected: string }>(); + + const options: SelectOption[] = useMemo( + () => webdavAccountIntegrations?.map(({ _id, ...current }) => [_id, getWebdavServerName(current)]) ?? [], + [webdavAccountIntegrations], + ); - const options: SelectOption[] = useMemo(() => accounts?.map(({ _id, ...current }) => [_id, getWebdavServerName(current)]), [accounts]); + const dispatchToastMessage = useToastMessageDispatch(); + const t = useTranslation(); - const handleClickRemove = useMutableCallback(({ accountSelected }) => { - try { - removeWebdavAccount({ accountId: accountSelected }); + const removeMutation = useRemoveWebDAVAccountIntegrationMutation({ + onSuccess: () => { dispatchToastMessage({ type: 'success', message: t('Webdav_account_removed') }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error as Error }); - } + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + }); + + const handleSubmitForm = useEffectEvent(({ accountSelected }) => { + removeMutation.mutate({ accountSelected }); }); return ( - + {t('WebDAV_Accounts')} ( + rules={{ required: true }} + render={({ field: { onChange, value, name, ref } }) => ( { /> )} /> - diff --git a/apps/meteor/client/views/account/integrations/AccountIntegrationsRoute.tsx b/apps/meteor/client/views/account/integrations/AccountIntegrationsRoute.tsx index ba16360b3d36..00121c688345 100644 --- a/apps/meteor/client/views/account/integrations/AccountIntegrationsRoute.tsx +++ b/apps/meteor/client/views/account/integrations/AccountIntegrationsRoute.tsx @@ -6,7 +6,7 @@ import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; import AccountIntegrationsPage from './AccountIntegrationsPage'; const AccountIntegrationsRoute = (): ReactElement => { - const webdavEnabled = useSetting('Webdav_Integration_Enabled'); + const webdavEnabled = useSetting('Webdav_Integration_Enabled', false); if (!webdavEnabled) { return ; diff --git a/apps/meteor/client/views/account/integrations/hooks/useRemoveWebDAVAccountIntegrationMutation.tsx b/apps/meteor/client/views/account/integrations/hooks/useRemoveWebDAVAccountIntegrationMutation.tsx new file mode 100644 index 000000000000..d0eb6b6f2a12 --- /dev/null +++ b/apps/meteor/client/views/account/integrations/hooks/useRemoveWebDAVAccountIntegrationMutation.tsx @@ -0,0 +1,16 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import type { UseMutationOptions } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; + +type UseRemoveWebDAVAccountIntegrationMutationOptions = Omit, 'mutationFn'>; + +export const useRemoveWebDAVAccountIntegrationMutation = (options?: UseRemoveWebDAVAccountIntegrationMutationOptions) => { + const removeWebdavAccount = useEndpoint('POST', '/v1/webdav.removeWebdavAccount'); + + return useMutation({ + mutationFn: async ({ accountSelected }: { accountSelected: string }) => { + await removeWebdavAccount({ accountId: accountSelected }); + }, + ...options, + }); +}; diff --git a/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx index 0b82d7441323..5fd4540b5e57 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, FieldRow, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; +import { Accordion, ButtonGroup, Button, Box } from '@rocket.chat/fuselage'; import { useSetModal, useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback } from 'react'; @@ -75,20 +75,14 @@ const PreferencesMyDataSection = () => { return ( - - - - - - - - - - + + + + ); }; diff --git a/apps/meteor/client/views/account/profile/AccountProfilePage.tsx b/apps/meteor/client/views/account/profile/AccountProfilePage.tsx index c6d675a203ac..df1710b07509 100644 --- a/apps/meteor/client/views/account/profile/AccountProfilePage.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfilePage.tsx @@ -119,16 +119,18 @@ const AccountProfilePage = (): ReactElement => { - - - {allowDeleteOwnAccount && ( - - )} - + {allowDeleteOwnAccount && ( + + )} + + diff --git a/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx b/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx index 30cc87861838..e095efcba2d6 100644 --- a/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx +++ b/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx @@ -1,6 +1,6 @@ import { Box, Button, TextInput, Margins } from '@rocket.chat/fuselage'; import { useSafely } from '@rocket.chat/fuselage-hooks'; -import { useSetModal, useToastMessageDispatch, useUser, useMethod, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useSetModal, useToastMessageDispatch, useUser, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement, ComponentProps } from 'react'; import React, { useState, useCallback, useEffect } from 'react'; import { useForm } from 'react-hook-form'; @@ -16,7 +16,6 @@ const TwoFactorTOTP = (props: ComponentProps): ReactElement => { const user = useUser(); const setModal = useSetModal(); - const logoutOtherSessions = useEndpoint('POST', '/v1/users.logoutOtherClients'); const enableTotpFn = useMethod('2fa:enable'); const disableTotpFn = useMethod('2fa:disable'); const verifyCodeFn = useMethod('2fa:validateTempToken'); @@ -86,13 +85,12 @@ const TwoFactorTOTP = (props: ComponentProps): ReactElement => { return dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') }); } - logoutOtherSessions(); setModal(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } }, - [closeModal, dispatchToastMessage, logoutOtherSessions, setModal, t, verifyCodeFn], + [closeModal, dispatchToastMessage, setModal, t, verifyCodeFn], ); const handleRegenerateCodes = useCallback(() => { diff --git a/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx b/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx index b2e057d4cc91..63f1a48ce36a 100644 --- a/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx +++ b/apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx @@ -181,11 +181,13 @@ const EditCustomEmoji: FC = ({ close, onChange, data, ...p {t('Save')} - - - + + + + + ); diff --git a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx index 19d77e71b5ee..82f668a2d7d5 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -110,9 +110,7 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr - + diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index 25a73a3e0ebb..b358d0bb7c84 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -146,11 +146,13 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps): ReactEl {t('Save')} - - - + + + + + ); diff --git a/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusForm.tsx b/apps/meteor/client/views/admin/customUserStatus/CustomUserStatusForm.tsx index 9796438fde7e..78c2618b4c53 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, FieldLabel, FieldRow, FieldError, Select } from '@rocket.chat/fuselage'; +import { FieldGroup, Button, ButtonGroup, TextInput, Field, FieldLabel, FieldRow, FieldError, Select, Box } 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'; @@ -120,11 +120,13 @@ const CustomUserStatusForm = ({ onClose, onReload, status }: CustomUserStatusFor {_id && ( - - - + + + + + )} diff --git a/apps/meteor/client/views/admin/emailInbox/EmailInboxForm.tsx b/apps/meteor/client/views/admin/emailInbox/EmailInboxForm.tsx index 5101cb160de1..c5cbd6f6aa1d 100644 --- a/apps/meteor/client/views/admin/emailInbox/EmailInboxForm.tsx +++ b/apps/meteor/client/views/admin/emailInbox/EmailInboxForm.tsx @@ -468,7 +468,7 @@ const EmailInboxForm = ({ inboxData }: { inboxData?: IEmailInboxPayload }): Reac id={imapPortField} {...field} error={errors.imapPort?.message} - aria-aria-describedby={`${imapPortField}-error`} + aria-describedby={`${imapPortField}-error`} aria-required={true} aria-invalid={Boolean(errors.email)} /> @@ -576,7 +576,7 @@ const EmailInboxForm = ({ inboxData }: { inboxData?: IEmailInboxPayload }): Reac - + - + - + + + ); }; diff --git a/apps/meteor/client/views/admin/moderation/MessageReportInfo.tsx b/apps/meteor/client/views/admin/moderation/MessageReportInfo.tsx index 758b787e0e4c..899f33b42ba5 100644 --- a/apps/meteor/client/views/admin/moderation/MessageReportInfo.tsx +++ b/apps/meteor/client/views/admin/moderation/MessageReportInfo.tsx @@ -18,7 +18,7 @@ const MessageReportInfo = ({ msgId }: { msgId: string }): JSX.Element => { isSuccess: isSuccessReportsByMessage, isError: isErrorReportsByMessage, } = useQuery( - ['moderation.reports', { msgId }], + ['moderation', 'msgReports', 'fetchReasons', { msgId }], async () => { const reports = await getReportsByMessage({ msgId }); return reports; diff --git a/apps/meteor/client/views/admin/moderation/ModConsoleReportDetails.tsx b/apps/meteor/client/views/admin/moderation/ModConsoleReportDetails.tsx new file mode 100644 index 000000000000..bd40de76f015 --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/ModConsoleReportDetails.tsx @@ -0,0 +1,43 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Tabs, TabsItem, ContextualbarHeader, ContextualbarTitle } from '@rocket.chat/fuselage'; +import { useTranslation, useRouter, useRouteParameter } from '@rocket.chat/ui-contexts'; +import React, { useState } from 'react'; + +import { Contextualbar, ContextualbarClose } from '../../../components/Contextualbar'; +import UserMessages from './UserMessages'; +import UserReportInfo from './UserReports/UserReportInfo'; + +type ModConsoleReportDetailsProps = { + userId: IUser['_id']; + default: string; + onRedirect: (mid: string) => void; +}; + +const ModConsoleReportDetails = ({ userId, default: defaultTab, onRedirect }: ModConsoleReportDetailsProps) => { + const t = useTranslation(); + const [tab, setTab] = useState(defaultTab); + const moderationRoute = useRouter(); + + const activeTab = useRouteParameter('tab'); + + return ( + + + {t('Reports')} + moderationRoute.navigate(`/admin/moderation/${activeTab}`, { replace: true })} /> + + + setTab('messages')}> + {t('Messages')} + + setTab('users')}> + {t('User')} + + + {tab === 'messages' && } + {tab === 'users' && } + + ); +}; + +export default ModConsoleReportDetails; diff --git a/apps/meteor/client/views/admin/moderation/ModerationConsolePage.tsx b/apps/meteor/client/views/admin/moderation/ModerationConsolePage.tsx index 6a32478df374..df1c6d25f1ab 100644 --- a/apps/meteor/client/views/admin/moderation/ModerationConsolePage.tsx +++ b/apps/meteor/client/views/admin/moderation/ModerationConsolePage.tsx @@ -1,37 +1,62 @@ +import { Tabs, TabsItem } from '@rocket.chat/fuselage'; import { useTranslation, useRouteParameter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import React, { useCallback } from 'react'; -import { Contextualbar } from '../../../components/Contextualbar'; import { Page, PageHeader, PageContent } from '../../../components/Page'; import { getPermaLink } from '../../../lib/getPermaLink'; +import ModConsoleReportDetails from './ModConsoleReportDetails'; import ModerationConsoleTable from './ModerationConsoleTable'; -import UserMessages from './UserMessages'; +import ModConsoleUsersTable from './UserReports/ModConsoleUsersTable'; -const ModerationConsolePage = () => { +type TabType = 'users' | 'messages'; + +type ModerationConsolePageProps = { + tab: TabType; + onSelectTab?: (tab: TabType) => void; +}; + +const ModerationConsolePage = ({ tab = 'messages', onSelectTab }: ModerationConsolePageProps) => { const t = useTranslation(); const context = useRouteParameter('context'); const id = useRouteParameter('id'); const dispatchToastMessage = useToastMessageDispatch(); - const handleRedirect = async (mid: string) => { - try { - const permalink = await getPermaLink(mid); - // open the permalink in same tab - window.open(permalink, '_self'); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }; + const handleRedirect = useCallback( + async (mid: string) => { + try { + const permalink = await getPermaLink(mid); + window.open(permalink, '_self'); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, + [dispatchToastMessage], + ); + + const handleTabClick = useCallback( + (tab: TabType): undefined | (() => void) => (onSelectTab ? (): void => onSelectTab(tab) : undefined), + [onSelectTab], + ); return ( + + + + {t('Reported_Messages')} + + + {t('Reported_Users')} + + - + {tab === 'messages' && } + {tab === 'users' && } - {context && {context === 'info' && id && }} + {context === 'info' && id && } ); }; diff --git a/apps/meteor/client/views/admin/moderation/ModerationConsoleRoute.tsx b/apps/meteor/client/views/admin/moderation/ModerationConsoleRoute.tsx index 29fb50c095c5..d821c4cb90fa 100644 --- a/apps/meteor/client/views/admin/moderation/ModerationConsoleRoute.tsx +++ b/apps/meteor/client/views/admin/moderation/ModerationConsoleRoute.tsx @@ -1,16 +1,45 @@ -import { usePermission } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import { usePermission, useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; +import React, { useEffect } from 'react'; import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; import ModerationConsolePage from './ModerationConsolePage'; +const MODERATION_VALID_TABS = ['users', 'messages'] as const; + +const isValidTab = (tab: string | undefined): tab is (typeof MODERATION_VALID_TABS)[number] => MODERATION_VALID_TABS.includes(tab as any); + const ModerationRoute = () => { const canViewModerationConsole = usePermission('view-moderation-console'); + const router = useRouter(); + const tab = useRouteParameter('tab'); + + useEffect(() => { + if (!isValidTab(tab)) { + router.navigate( + { + pattern: '/admin/moderation/:tab?/:context?/:id?', + params: { tab: 'messages' }, + }, + { replace: true }, + ); + } + }, [tab, router]); if (!canViewModerationConsole) { return ; } - return ; + const onSelectTab = (tab: (typeof MODERATION_VALID_TABS)[number]) => { + router.navigate( + { + pattern: '/admin/moderation/:tab?/:context?/:id?', + params: { tab }, + }, + { replace: true }, + ); + }; + + return ; }; + export default ModerationRoute; diff --git a/apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx b/apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx index 4e877b3aefc7..aa449efb9eb5 100644 --- a/apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx +++ b/apps/meteor/client/views/admin/moderation/ModerationConsoleTable.tsx @@ -1,11 +1,11 @@ -import { Pagination, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; +import { Pagination } from '@rocket.chat/fuselage'; import { useDebouncedValue, useMediaQuery, useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint, useToastMessageDispatch, useRoute, useTranslation } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useToastMessageDispatch, useRouter } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { FC } from 'react'; import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; -import FilterByText from '../../../components/FilterByText'; import GenericNoResults from '../../../components/GenericNoResults'; import { GenericTable, @@ -17,13 +17,13 @@ import { import { usePagination } from '../../../components/GenericTable/hooks/usePagination'; import { useSort } from '../../../components/GenericTable/hooks/useSort'; import ModerationConsoleTableRow from './ModerationConsoleTableRow'; -import DateRangePicker from './helpers/DateRangePicker'; +import ModerationFilter from './helpers/ModerationFilter'; // TODO: Missing error state const ModerationConsoleTable: FC = () => { const [text, setText] = useState(''); - const moderationRoute = useRoute('moderation-console'); - const t = useTranslation(); + const router = useRouter(); + const { t } = useTranslation(); const isDesktopOrLarger = useMediaQuery('(min-width: 1024px)'); const { sortBy, sortDirection, setSort } = useSort<'reports.ts' | 'reports.message.u.username' | 'reports.description' | 'count'>( @@ -56,7 +56,7 @@ const ModerationConsoleTable: FC = () => { const dispatchToastMessage = useToastMessageDispatch(); - const { data, isLoading, isSuccess } = useQuery(['moderation.reports', query], async () => getReports(query), { + const { data, isLoading, isSuccess } = useQuery(['moderation', 'msgReports', 'fetchAll', query], async () => getReports(query), { onError: (error) => { dispatchToastMessage({ type: 'error', message: error }); }, @@ -64,13 +64,16 @@ const ModerationConsoleTable: FC = () => { }); const handleClick = useMutableCallback((id): void => { - moderationRoute.push({ - context: 'info', - id, + router.navigate({ + pattern: '/admin/moderation/:tab?/:context?/:id?', + params: { + tab: 'messages', + context: 'info', + id, + }, }); }); - // header sequence would be: name, reportedMessage, room, postdate, reports, actions const headers = useMemo( () => [ { onClick={setSort} sort='reports.message.u.username' > - {t('Name')} + {t('User')} , - isDesktopOrLarger && ( - - {t('Username')} - - ), + { {t('Moderation_Report_date')} , - {t('Moderation_Report_plural')} + {t('Moderation_Report_reports')} , , ], - [sortDirection, sortBy, setSort, t, isDesktopOrLarger], + [sortDirection, sortBy, setSort, t], ); return ( <> - setText(text)} /> - - {t('Date')} - - - - + {isLoading && ( {headers} diff --git a/apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx b/apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx index 53bb2fa130a2..56419c61223c 100644 --- a/apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx +++ b/apps/meteor/client/views/admin/moderation/ModerationConsoleTableRow.tsx @@ -1,11 +1,10 @@ import type { IModerationAudit, IUser } from '@rocket.chat/core-typings'; -import { Box } from '@rocket.chat/fuselage'; import React from 'react'; import { GenericTableCell, GenericTableRow } from '../../../components/GenericTable'; -import UserAvatar from '../../../components/avatar/UserAvatar'; import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime'; import ModerationConsoleActions from './ModerationConsoleActions'; +import UserColumn from './helpers/UserColumn'; export type ModerationConsoleRowProps = { report: IModerationAudit; @@ -30,34 +29,8 @@ const ModerationConsoleTableRow = ({ report, onClick, isDesktopOrLarger }: Moder return ( onClick(_id)} tabIndex={0} role='link' action> - - {username && ( - - - - )} - - - - {name || username} - - {!isDesktopOrLarger && name && ( - - {' '} - {`@${username}`}{' '} - - )} - - - + - {isDesktopOrLarger && ( - - - {username} - - - )} {message} {concatenatedRoomNames} {formatDateAndTime(ts)} diff --git a/apps/meteor/client/views/admin/moderation/UserMessages.tsx b/apps/meteor/client/views/admin/moderation/UserMessages.tsx index d3f08015611b..3b3efa68ec5e 100644 --- a/apps/meteor/client/views/admin/moderation/UserMessages.tsx +++ b/apps/meteor/client/views/admin/moderation/UserMessages.tsx @@ -1,29 +1,27 @@ import { Box, Callout, Message, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint, useRouter, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -import React from 'react'; +import React, { Fragment } from 'react'; -import { ContextualbarHeader, ContextualbarTitle, ContextualbarClose, ContextualbarFooter } from '../../../components/Contextualbar'; +import { ContextualbarFooter } from '../../../components/Contextualbar'; import GenericNoResults from '../../../components/GenericNoResults'; import MessageContextFooter from './MessageContextFooter'; import ContextMessage from './helpers/ContextMessage'; -// TODO: Missing Error State -const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid: string) => void }): JSX.Element => { +const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid: string) => void }) => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); - const moderationRoute = useRouter(); const getUserMessages = useEndpoint('GET', '/v1/moderation.user.reportedMessages'); const { data: report, refetch: reloadUserMessages, - isLoading: isLoadingUserMessages, - isSuccess: isSuccessUserMessages, + isLoading, + isSuccess, isError, } = useQuery( - ['moderation.userMessages', { userId }], + ['moderation', 'msgReports', 'fetchDetails', { userId }], async () => { const messages = await getUserMessages({ userId }); return messages; @@ -41,21 +39,15 @@ const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid return ( <> - - {t('Moderation_Message_context_header')} - moderationRoute.navigate('/admin/moderation', { replace: true })} /> - - {isLoadingUserMessages && {t('Loading')}} - - {isSuccessUserMessages && ( - + {isLoading && {t('Loading')}} + {isSuccess && ( + {report.messages.length > 0 && ( {t('Moderation_Duplicate_messages_warning')} )} - {!report.user && ( {t('Moderation_User_deleted_warning')} @@ -63,11 +55,10 @@ const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid )} )} - - {isSuccessUserMessages && + {isSuccess && report.messages.length > 0 && report.messages.map((message) => ( - + - + ))} - {isSuccessUserMessages && report.messages.length === 0 && } + {isSuccess && report.messages.length === 0 && } {isError && ( @@ -88,9 +79,11 @@ const UserMessages = ({ userId, onRedirect }: { userId: string; onRedirect: (mid )} - - {isSuccessUserMessages && report.messages.length > 0 && } - + {isSuccess && report.messages.length > 0 && ( + + + + )} ); }; diff --git a/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserActions.tsx b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserActions.tsx new file mode 100644 index 000000000000..eb1ec183fcfe --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserActions.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; + +import GenericMenu from '../../../../components/GenericMenu/GenericMenu'; +import useDeactivateUserAction from '../hooks/useDeactivateUserAction'; +import useDismissUserAction from '../hooks/useDismissUserAction'; +import useResetAvatarAction from '../hooks/useResetAvatarAction'; +import type { ModConsoleUserRowProps } from './ModConsoleUserTableRow'; + +const ModConsoleUserActions = ({ report, onClick }: Omit) => { + const t = useTranslation(); + const { + reportedUser: { _id: uid }, + } = report; + + return ( + <> + onClick(uid), + }, + ], + }, + { + items: [useDismissUserAction(uid, true), useDeactivateUserAction(uid, true), useResetAvatarAction(uid)], + }, + ]} + placement='bottom-end' + /> + + ); +}; + +export default ModConsoleUserActions; diff --git a/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserTableRow.tsx b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserTableRow.tsx new file mode 100644 index 000000000000..b37c5330d7e0 --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUserTableRow.tsx @@ -0,0 +1,37 @@ +import type { IUser, UserReport, Serialized } from '@rocket.chat/core-typings'; +import React from 'react'; + +import { GenericTableCell, GenericTableRow } from '../../../../components/GenericTable'; +import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime'; +import UserColumn from '../helpers/UserColumn'; +import ModConsoleUserActions from './ModConsoleUserActions'; + +export type ModConsoleUserRowProps = { + report: Serialized & { count: number }>; + onClick: (id: IUser['_id']) => void; + isDesktopOrLarger: boolean; +}; + +const ModConsoleUserTableRow = ({ report, onClick, isDesktopOrLarger }: ModConsoleUserRowProps): JSX.Element => { + const { reportedUser, count, ts } = report; + const { _id, username, name, createdAt, emails } = reportedUser; + + const formatDateAndTime = useFormatDateAndTime(); + + return ( + onClick(_id)} tabIndex={0} role='link' action> + + + + {formatDateAndTime(createdAt)} + {emails?.[0].address} + {formatDateAndTime(ts)} + {count} + e.stopPropagation()}> + + + + ); +}; + +export default ModConsoleUserTableRow; diff --git a/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUsersTable.tsx b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUsersTable.tsx new file mode 100644 index 000000000000..921cb7166e82 --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/UserReports/ModConsoleUsersTable.tsx @@ -0,0 +1,150 @@ +import { Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage'; +import { useDebouncedValue, useMediaQuery, useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint, useRouter } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import type { FC } from 'react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import GenericNoResults from '../../../../components/GenericNoResults'; +import { + GenericTable, + GenericTableLoadingTable, + GenericTableHeaderCell, + GenericTableBody, + GenericTableHeader, +} from '../../../../components/GenericTable'; +import { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; +import { useSort } from '../../../../components/GenericTable/hooks/useSort'; +import ModerationFilter from '../helpers/ModerationFilter'; +import ModConsoleUserTableRow from './ModConsoleUserTableRow'; + +const ModConsoleUsersTable: FC = () => { + const [text, setText] = useState(''); + const router = useRouter(); + const { t } = useTranslation(); + const isDesktopOrLarger = useMediaQuery('(min-width: 1024px)'); + + const { sortBy, sortDirection, setSort } = useSort< + 'reports.ts' | 'reports.reportedUser.username' | 'reports.reportedUser.createdAt' | 'count' + >('reports.ts'); + const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination(); + + const [dateRange, setDateRange] = useState<{ start: string | null; end: string | null }>({ + start: '', + end: '', + }); + const { start, end } = dateRange; + + const debouncedText = useDebouncedValue(text, 500); + + const query = { + selector: debouncedText, + sort: JSON.stringify({ [sortBy]: sortDirection === 'asc' ? 1 : -1 }), + count: itemsPerPage, + offset: current, + latest: end ? `${new Date(end).toISOString().slice(0, 10)}T23:59:59.999Z` : undefined, + oldest: start ? `${new Date(start).toISOString().slice(0, 10)}T00:00:00.000Z` : undefined, + }; + + const getReports = useEndpoint('GET', '/v1/moderation.userReports'); + + const { data, isLoading, isSuccess, isError, refetch } = useQuery( + ['moderation', 'userReports', 'fetchAll', query], + () => getReports(query), + { + keepPreviousData: true, + }, + ); + + const handleClick = useMutableCallback((id): void => { + router.navigate({ + pattern: '/admin/moderation/:tab?/:context?/:id?', + params: { tab: 'users', context: 'info', id }, + }); + }); + + const headers = ( + <> + + {t('User')} + + + {t('Created_at')} + + + {t('Email')} + + + {t('Moderation_Report_date')} + + + {t('Moderation_Report_plural')} + + + + ); + + return ( + <> + + + {isLoading && ( + + {headers} + {isLoading && } + + )} + {isSuccess && data.reports.length > 0 && ( + <> + + {headers} + + {data.reports.map((report) => ( + + ))} + + + + + )} + {isSuccess && data.reports.length === 0 && } + {isError && ( + + + {t('Something_went_wrong')} + + refetch()}>{t('Reload_page')} + + + )} + + ); +}; + +export default ModConsoleUsersTable; diff --git a/apps/meteor/client/views/admin/moderation/UserReports/UserContextFooter.tsx b/apps/meteor/client/views/admin/moderation/UserReports/UserContextFooter.tsx new file mode 100644 index 000000000000..9beb77c1eb13 --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/UserReports/UserContextFooter.tsx @@ -0,0 +1,30 @@ +import { Button, ButtonGroup, Box } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React from 'react'; +import type { FC } from 'react'; + +import GenericMenu from '../../../../components/GenericMenu/GenericMenu'; +import useDeactivateUserAction from '../hooks/useDeactivateUserAction'; +import useDismissUserAction from '../hooks/useDismissUserAction'; +import useResetAvatarAction from '../hooks/useResetAvatarAction'; + +const UserContextFooter: FC<{ userId: string; deleted: boolean }> = ({ userId, deleted }) => { + const t = useTranslation(); + + const dismissUserAction = useDismissUserAction(userId, true); + const deactivateUserAction = useDeactivateUserAction(userId, true); + + return ( + + + + + + + + ); +}; + +export default UserContextFooter; diff --git a/apps/meteor/client/views/admin/moderation/UserReports/UserReportInfo.tsx b/apps/meteor/client/views/admin/moderation/UserReports/UserReportInfo.tsx new file mode 100644 index 000000000000..5d1d99ba8f86 --- /dev/null +++ b/apps/meteor/client/views/admin/moderation/UserReports/UserReportInfo.tsx @@ -0,0 +1,116 @@ +import { + Box, + Callout, + StatesAction, + StatesActions, + StatesIcon, + StatesTitle, + ContextualbarFooter, + FieldGroup, + Field, + FieldLabel, + FieldRow, + ContextualbarSkeleton, +} from '@rocket.chat/fuselage'; +import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import React, { useMemo } from 'react'; + +import { ContextualbarScrollableContent } from '../../../../components/Contextualbar'; +import GenericNoResults from '../../../../components/GenericNoResults'; +import UserCard from '../../../../components/UserCard'; +import { useFormatDate } from '../../../../hooks/useFormatDate'; +import ReportReason from '../helpers/ReportReason'; +import UserColumn from '../helpers/UserColumn'; +import UserContextFooter from './UserContextFooter'; + +const UserReportInfo = ({ userId }: { userId: string }) => { + const t = useTranslation(); + const getUserReports = useEndpoint('GET', '/v1/moderation.user.reportsByUserId'); + const formatDateAndTime = useFormatDate(); + + const { + data: report, + refetch: reloadUsersReports, + isLoading, + isSuccess, + isError, + dataUpdatedAt, + } = useQuery(['moderation', 'userReports', 'fetchDetails', userId], async () => getUserReports({ userId })); + + const userProfile = useMemo(() => { + if (!report?.user) { + return null; + } + + const { username, name } = report.user; + return ; + }, [report?.user, dataUpdatedAt]); + + const userEmails = useMemo(() => { + if (!report?.user?.emails) { + return []; + } + return report.user.emails.map((email) => email.address); + }, [report]); + + if (isError) { + return ( + + + {t('Something_went_wrong')} + + reloadUsersReports()}>{t('Reload_page')} + + + ); + } + + return ( + <> + + {isLoading && } + {isSuccess && report.reports.length > 0 && ( + <> + {report.user ? ( + + {userProfile} + + {t('Roles')} + + {report.user.roles.map((role, index) => ( + {role} + ))} + + + + {t('Email')} + {userEmails.join(', ')} + + + {t('Created_at')} + {formatDateAndTime(report.user.createdAt)} + + + ) : ( + + {t('Moderation_User_deleted_warning')} + + )} + {report.reports.map((report, ind) => ( + + ))} + + )} + {isSuccess && report.reports.length === 0 && } + + {isSuccess && report.reports.length > 0 && ( + + + + )} + + ); +}; + +export default UserReportInfo; diff --git a/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx b/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx index 5d8ecfed60a9..6d895420df2c 100644 --- a/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx +++ b/apps/meteor/client/views/admin/moderation/helpers/ContextMessage.tsx @@ -1,6 +1,6 @@ import type { IMessage, MessageReport } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; -import { Message, MessageName, MessageToolboxItem, MessageToolboxWrapper, MessageUsername } from '@rocket.chat/fuselage'; +import { Message, MessageName, MessageToolbarItem, MessageToolbarWrapper, MessageUsername } from '@rocket.chat/fuselage'; import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; @@ -83,17 +83,17 @@ const ContextMessage = ({ - - - + + dismissMsgReport.action()} /> - onRedirect(message._id)} /> - deleteMessage()} /> - - + onRedirect(message._id)} /> + deleteMessage()} /> + + ); diff --git a/apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx b/apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx index 326cc3382b4b..74d3f206c8ca 100644 --- a/apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx +++ b/apps/meteor/client/views/admin/moderation/helpers/DateRangePicker.tsx @@ -1,12 +1,10 @@ -import { Box, InputBox, Menu, Margins, Option } from '@rocket.chat/fuselage'; +import { Select, Box, type SelectOption } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import type { Moment } from 'moment'; -import moment from 'moment'; -import type { ComponentProps } from 'react'; -import React, { useState, useMemo, useEffect } from 'react'; +import moment, { type Moment } from 'moment'; +import React, { useMemo, useEffect } from 'react'; -type DateRangePickerProps = Omit, 'onChange'> & { +type DateRangePickerProps = { onChange(range: { start: string; end: string }): void; }; @@ -24,101 +22,62 @@ const getWeekRange = (daysToSubtractFromStart: number, daysToSubtractFromEnd: nu end: formatToDateInput(moment().subtract(daysToSubtractFromEnd, 'day')), }); -const DateRangePicker = ({ onChange = () => undefined, ...props }: DateRangePickerProps) => { +const DateRangePicker = ({ onChange }: DateRangePickerProps) => { const t = useTranslation(); - const [range, setRange] = useState({ start: '', end: '' }); - - const { start, end } = range; - - const handleStart = useMutableCallback(({ currentTarget }) => { - const rangeObj = { - start: currentTarget.value, - end: range.end, - }; - setRange(rangeObj); - onChange(rangeObj); - }); - - const handleEnd = useMutableCallback(({ currentTarget }) => { - const rangeObj = { - end: currentTarget.value, - start: range.start, - }; - setRange(rangeObj); - onChange(rangeObj); - }); const handleRange = useMutableCallback((range) => { - setRange(range); onChange(range); }); + const timeOptions = useMemo(() => { + return [ + ['today', t('Today')], + ['yesterday', t('Yesterday')], + ['thisWeek', t('This_week')], + ['previousWeek', t('Previous_week')], + ['thisMonth', t('This_month')], + ['alldates', t('All')], + ].map(([value, label]) => [value, label] as SelectOption); + }, [t]); + useEffect(() => { handleRange({ - start: formatToDateInput(moment().subtract(1, 'month')), + start: formatToDateInput(moment(0)), end: todayDate, }); }, [handleRange]); - const options = useMemo( - () => ({ - today: { - icon: 'history', - label: t('Today'), - action: () => { - handleRange(getWeekRange(0, 0)); - }, - }, - yesterday: { - icon: 'history', - label: t('Yesterday'), - action: () => { - handleRange(getWeekRange(1, 1)); - }, - }, - thisWeek: { - icon: 'history', - label: t('This_week'), - action: () => { - handleRange(getWeekRange(7, 0)); - }, - }, - previousWeek: { - icon: 'history', - label: t('Previous_week'), - action: () => { - handleRange(getWeekRange(14, 7)); - }, - }, - thisMonth: { - icon: 'history', - label: t('This_month'), - action: () => { - handleRange(getMonthRange(0)); - }, - }, - lastMonth: { - icon: 'history', - label: t('Previous_month'), - action: () => { - handleRange(getMonthRange(1)); - }, - }, - }), - [handleRange, t], - ); + const handleOptionClick = useMutableCallback((action) => { + switch (action) { + case 'today': + handleRange(getWeekRange(0, 0)); + break; + case 'yesterday': + handleRange(getWeekRange(1, 1)); + break; + case 'thisWeek': + handleRange(getWeekRange(7, 0)); + break; + case 'previousWeek': + handleRange(getWeekRange(14, 7)); + break; + case 'thisMonth': + handleRange(getMonthRange(0)); + break; + case 'alldates': + handleRange({ + start: formatToDateInput(moment(0)), + end: todayDate, + }); + break; + default: + break; + } + }); return ( - - - - - ) => https://github.com/RocketChat/hubot-rocketchat", "Admin_disabled_encryption": "Din administrator har ikke aktivert ende-til-ende kryptering.", "Admin_Info": "Admin Info", + "admin-no-active-video-conf-provider": "**Konferansesamtale ikke aktivert**: Konfigurer konferansesamtaler for å gjøre det tilgjengelig på dette arbeidsområdet.", + "admin-video-conf-provider-not-configured": "**Konferansesamtale ikke aktivert**: Konfigurer konferansesamtaler for å gjøre det tilgjengelig på dette arbeidsområdet.", + "admin-no-videoconf-provider-app": "**Konferansesamtale ikke aktivert**: Konferansesamtaler-apper er tilgjengelige på Rocket.Chat-markedet.", "Administration": "Administrasjon", + "Address": "Adresse", + "Adjustable_font_size": "Justerbar skriftstørrelse", + "Adjustable_font_size_description": "Designet for de som foretrekker større eller mindre tekst for bedre lesbarhet. Denne fleksibiliteten fremmer inkludering ved å gi brukerne mulighet til å skreddersy brukergrensesnittet til deres spesifikke behov.", "Adult_images_are_not_allowed": "Voksenbilder er ikke tillatt", + "Aerospace_and_Defense": "Luftfart og forsvar", "After_OAuth2_authentication_users_will_be_redirected_to_this_URL": "Etter OAuth2-godkjenning, blir brukerne omdirigert til denne nettadressen", + "After_guest_registration": "Etter gjesteregistrering", "Agent": "Agent", "Agent_added": "Lagt til agent", "Agent_Info": "Agentinfo", + "Agent_messages": "Agentbeskjed", + "Agent_Name": "Agentnavn", + "Agent_Name_Placeholder": "Vennligst skriv inn et agentnavn...", "Agent_removed": "Fjernet agent", + "Agent_deactivated": "Agenten ble deaktivert", + "Agent_Without_Extensions": "Agent uten utvidelser", "Agents": "Agenter", + "Agree": "Godta", "Alerts": "varsler", "Alias": "Alias", "Alias_Format": "Aliasformat", @@ -357,8 +387,10 @@ "AutoLinker_Phone_Description": "Automatisk koblet til telefonnumre. f.eks `(123) 456-7890`", "All": "Alle", "AutoLinker_StripPrefix": "AutoLinker Strip Prefix", + "All_Apps": "Alle apper", "AutoLinker_StripPrefix_Description": "Kortvisning. f.eks https://rocket.chat => rocket.chat", "All_added_tokens_will_be_required_by_the_user": "Alle tilsatte tokens vil bli pålagt av brukeren", + "All_categories": "Alle Kategorier", "AutoLinker_Urls_Scheme": "AutoLinker Scheme: // URLs", "All_channels": "Alle kanaler", "AutoLinker_Urls_TLD": "AutoLinker TLD URLs", @@ -396,16 +428,22 @@ "Analytics_features_users_Description": "Sporer tilpassede hendelser relatert til handlinger relatert til brukere (passord tilbakestilt ganger, profil bilde endring, etc).", "Analytics_Google": "Google Analytics", "Analytics_Google_id": "Sporings-ID", + "Analytics_page_briefing_first_paragraph": "Rocket.Chat samler inn anonyme brukserdata, som tjeneste. og tidsbruk, for å forbedre produktet for alle.", + "Analytics_page_briefing_second_paragraph": "Vi beskytter ditt personvern ved å aldri samle inn personlige eller sensitive data. Denne delen viser hva som samles inn, og tydeliggjør vår forpliktelse til åpenhet og tillit.", + "Analyze_practical_usage": "Analysere praktisk bruksstatistikk om brukere, meldinger og kanaler", "and": "og", "And_more": "Og {{length}} mer", "Animals_and_Nature": "Dyr og natur", "Announcement": "Kunngjøring", + "Anonymous": "Anonym", + "Answer_call": "Svar anrop", "API": "API", "API_Add_Personal_Access_Token": "Legg til ny personlig Access Token", "API_Allow_Infinite_Count": "Tillat å få alt", "API_Allow_Infinite_Count_Description": "Bør samtaler til REST API få lov til å returnere alt i en samtale?", "API_Analytics": "Analytics", "API_CORS_Origin": "CORS Origin", + "API_Apply_permission_view-outside-room_on_users-list": "Tildel tillatelsen `view-outside-room` til api `users.list`", "API_Default_Count": "Standard Count", "API_Default_Count_Description": "Standardtallet for REST API-resultater oppstår hvis forbrukeren ikke oppgav noen.", "API_Drupal_URL": "Drupal Server URL", @@ -421,12 +459,15 @@ "API_Enable_CORS": "Aktiver CORS", "API_Enable_Direct_Message_History_EndPoint": "Aktiver sluttpunkt for direkte meldingshistorikk", "API_Enable_Direct_Message_History_EndPoint_Description": "Dette gjør det mulig for `/ api / v1 / im.history.others` som lar visning av direkte meldinger sendt av andre brukere som den som ringer ikke er en del av.", + "API_Enable_Personal_Access_Tokens": "Aktiver personlige adgangstokener til REST APIet", + "API_Enable_Personal_Access_Tokens_Description": "Aktiver personlige adgangstokener for bruk med REST APIet", "API_Enable_Rate_Limiter": "Aktiver Rate Limit", "API_Enable_Rate_Limiter_Dev": "Aktiver Rate Limit i utvikling", "API_Enable_Rate_Limiter_Dev_Description": "Bør begrense antallet kall til endepunktene i utviklingsmiljøet?", "API_Enable_Rate_Limiter_Limit_Calls_Default": "Standard antall kall til rate-limiter", "API_Enable_Rate_Limiter_Limit_Calls_Default_Description": "Tillatt antall kall innenfor hvert tidsrom for hvert endepunkt i REST API-et.", "API_Enable_Rate_Limiter_Limit_Time_Default": "Standard tidsgrense for rate-limiter (i ms)", + "API_Enable_Rate_Limiter_Limit_Time_Default_Description": "Standard tidsavbrudd for å begrense antall kall til hvert endepunkt i REST APIet (i ms)", "API_Enable_Shields": "Aktiver skjold", "API_Enable_Shields_Description": "Aktiver skjold som er tilgjengelige på `/ api / v1 / shield.svg`", "API_GitHub_Enterprise_URL": "Server URL", @@ -439,17 +480,21 @@ "API_Personal_Access_Tokens_Regenerate_Modal": "Hvis du har mistet eller glemt tokenet ditt kan du regenerere det, men husk at alle applikasjoner som bruker dette tokenet må oppdateres", "API_Personal_Access_Tokens_Remove_Modal": "Er du sikker på at du vil fjerne denne access-tokenen?", "API_Personal_Access_Tokens_To_REST_API": "Personlig access token til REST API", + "API_Rate_Limiter": "API-kallfrekvensbegrensing ", "API_Shield_Types": "Skjoldtyper", "API_Shield_Types_Description": "Typer skjold for å aktivere som en kommaseparert liste, velg fra `online`,` kanal` eller `*` for alle", "Apps_Framework_Development_Mode": "Aktiver utviklermodus", "API_Token": "API Token", + "Apps_Framework_Development_Mode_Description": "Utviklingsmodus tillater installasjon av apper som ikke er fra Rocket.Chats markedsplass.", "API_Tokenpass_URL": "Tokenpass Server URL", "API_Tokenpass_URL_Description": "Eksempel: `https://domain.com` (unntatt trailing slash)", "API_Upper_Count_Limit": "Maks. Rekordbeløp", "API_Upper_Count_Limit_Description": "Hva er det maksimale antall poster som REST API skal returnere (når det ikke er ubegrenset)?", + "API_Use_REST_For_DDP_Calls": "Bruk REST i stedet for websocket for Meteor-kall", "API_User_Limit": "Brukergrense for å legge til alle brukere til kanal", "API_Wordpress_URL": "WordPress URL", "api-bypass-rate-limit": "Forbigå rate-limit for REST API", + "api-bypass-rate-limit_description": "Tillatelse til å kalle APIet uten kallfrekvensbegrensning ", "Apiai_Key": "Api.ai Key", "Apiai_Language": "Api.ai Språk", "APIs": "APIer", @@ -490,15 +535,97 @@ "Apps_context_requested": "Forespurt", "Apps_context_private": "Private apper", "Apps_context_premium": "Premium", - "Apps_Count_Enabled": "{{count}} app aktivert", - "Apps_Count_Enabled_plural": "{{count}} apper aktivert", - "Private_Apps_Count_Enabled": "{{count}} privat app aktivert", - "Private_Apps_Count_Enabled_plural": "{{count}} private apper aktivert", + "Apps_Count_Enabled_one": "{{count}} app aktivert", + "Apps_Count_Enabled_other": "{{count}} apper aktivert", + "Private_Apps_Count_Enabled_one": "{{count}} privat app aktivert", + "Private_Apps_Count_Enabled_other": "{{count}} private apper aktivert", + "Apps_Count_Enabled_tooltip": "Community-arbeidsområder kan aktivere opptil {{number}} {{context}} apper", + "Apps_disabled_when_Premium_trial_ended": "Apper som ble deaktivert da Premium-prøveperioden avsluttet", + "Apps_disabled_when_Premium_trial_ended_description": "Community-arbeidsområder kan ha opptil 5 markedsplassapper og 3 private apper aktivert. Be arbeidsområdeadministratoren din om å reaktivere apper.", + "Apps_disabled_when_Premium_trial_ended_description_admin": "Community-arbeidsområder kan ha opptil 5 markedsplassapper og 3 private apper aktivert. Aktiver appene du trenger på nytt.", "Apps_Engine_Version": "Versjon av Apps Engine", + "Apps_Error_private_app_install_disabled": "Installasjon og oppdateringer av private apper er deaktivert for dette arbeidsområdet", + "Apps_Essential_Alert": "Denne appen er viktig for følgende hendelser:", + "Apps_Framework_Source_Package_Storage_Type_Description": "Velg hvor alle appenes kildekode skal lagres. Apper kan ha flere megabyte i størrelse hver.", + "Apps_Framework_Source_Package_Storage_Type_Alert": "Hvis du endrer hvor appene er lagret, kan det føre til ustabilitet i apper som allerede er installert", + "Apps_Framework_Source_Package_Storage_FileSystem_Path": "Katalog for lagring av app-kildepakke", + "Apps_Framework_Source_Package_Storage_FileSystem_Alert": "Sørg for at den valgte katalogen eksisterer og at Rocket.Chat har tilgang til den (f.eks. tillatelse til å lese/skrive)", "Apps_Game_Center": "Spillsenter", "Apps_Game_Center_Back": "Tilbake til spillsenteret", "Apps_Game_Center_Invite_Friends": "Inviter vennene dine til å bli med", + "Apps_Game_Center_Play_Game_Together": "@here La oss spille {{name}} sammen!", + "Apps_Interface_IPostExternalComponentClosed": "Hendelse som skjer etter at en ekstern komponent er lukket", + "Apps_Interface_IPostExternalComponentOpened": "Hendelse som skjer etter at en ekstern komponent er åpnet", + "Apps_Interface_IPostMessageDeleted": "Hendelse som skjer etter at en melding er slettet", + "Apps_Interface_IPostMessageSent": "Hendelse som skjer etter at en melding er sendt", + "Apps_Interface_IPostMessageUpdated": "Hendelse som skjer etter at en melding er oppdatert", + "Apps_Interface_IPostRoomCreate": "Hendelse som skjer etter at et rom er opprettet", + "Apps_Interface_IPostRoomDeleted": "Hendelse som skjer etter at et rom er slettet", + "Apps_Interface_IPostRoomUserJoined": "Hendelse som skjer etter at en bruker blir med i et rom (privat gruppe, offentlig kanal)", + "Apps_Interface_IPreMessageDeletePrevent": "Hendelse som skjer før en melding slettes", + "Apps_Interface_IPreMessageSentExtend": "Hendelse som skjer før en melding sendes", + "Apps_Interface_IPreMessageSentModify": "Hendelse som skjer før en melding sendes", + "Apps_License_Message_maxSeats": "Lisensen tar ikke imot gjeldende antall aktive brukere. Vennligst øk antall seter", + "Apps_License_Message_publicKey": "Det har oppstått en feil under dekryptering av lisensen. Synkroniser arbeidsområdet ditt i Connectivity Service og prøv på nytt", + "Apps_License_Message_renewal": "Lisensen er utløpt og må fornyes", + "Apps_Logs_TTL": "Antall dager å lagre logger fra apper", + "Apps_Logs_TTL_7days": "syv dager", + "Apps_Logs_TTL_14days": "14 dager", + "Apps_Logs_TTL_30days": "30 dager", + "Apps_Logs_TTL_Alert": "Avhengig av størrelsen på loggsamlingen, kan endring av denne innstillingen føre til treghet i noen øyeblikk", + "Apps_Marketplace_Deactivate_App_Prompt": "Vil du virkelig deaktivere denne appen?", + "Apps_Marketplace_Login_Required_Description": "Kjøp av apper fra Rocket.Chat Marketplace krever registrering av arbeidsområdet og innlogging.", + "Apps_Marketplace_Login_Required_Title": "Markedsplass-pålogging er påkrevd", + "Apps_Marketplace_Modify_App_Subscription": "Endre abonnement", + "Apps_Marketplace_pricingPlan_monthly": "{{price}} / måned", + "Apps_Marketplace_pricingPlan_monthly_perUser": "{{price}} / måned per bruker", + "Apps_Marketplace_pricingPlan_monthly_trialDays": "{{price}} / måned-{{trialDays}}-dagers prøveperiode", + "Apps_Marketplace_pricingPlan_monthly_perUser_trialDays": "{{price}} / måned per bruker-{{trialDays}}-dagers prøveperiode", + "Apps_Marketplace_pricingPlan_+*_monthly": " {{price}}+* / måned", + "Apps_Marketplace_pricingPlan_+*_monthly_trialDays": " {{price}}+* / måned-{{trialDays}}-dagers prøveperiode", + "Apps_Marketplace_pricingPlan_+*_monthly_perUser": " {{price}}+* / måned per bruker", + "Apps_Marketplace_pricingPlan_+*_monthly_perUser_trialDays": " {{price}}+* / måned per bruker-{{trialDays}}-dagers prøveperiode", + "Apps_Marketplace_pricingPlan_+*_yearly": " {{price}}+* / år", + "Apps_Marketplace_pricingPlan_+*_yearly_trialDays": " {{price}}+* / år-{{trialDays}}-dagers prøveperiode", + "Apps_Marketplace_pricingPlan_+*_yearly_perUser": " {{price}}+* / år per bruker", + "Apps_Marketplace_pricingPlan_+*_yearly_perUser_trialDays": " {{price}}+* / år per bruker-{{trialDays}}-dagers prøveperiode", + "Apps_Marketplace_pricingPlan_yearly_trialDays": "{{price}} / år-{{trialDays}}-dagers prøveperiode", + "Apps_Marketplace_pricingPlan_yearly_perUser_trialDays": "{{price}} / år per bruker-{{trialDays}}-dagers prøveperiode", + "Apps_Marketplace_Uninstall_App_Prompt": "Er du sikker på at du vil avinstallere denne appen?", + "Apps_Marketplace_Uninstall_Subscribed_App_Anyway": "Avinstaller det uansett", + "Apps_Marketplace_Uninstall_Subscribed_App_Prompt": "Denne appen har et aktivt abonnement. Avinstallering av appen vil ikke kansellere abonnementet. Hvis du ønsker å avinstallere appen, må du endre abonnementet ditt før du avinstallerer.", + "Apps_Permissions_Review_Modal_Title": "Nødvendige tillatelser", + "Apps_Permissions_Review_Modal_Subtitle": "Denne appen vil ha tilgang til følgende tillatelser. Aksepterer du?", + "Apps_Permissions_No_Permissions_Required": "Appen trenger ingen ytterligere tillatelser", + "Apps_Permissions_user_read": "Tilgang til brukerinformasjon", + "Apps_Permissions_user_write": "Endre brukerinformasjon", + "Apps_Permissions_upload_read": "Tilgang til filer lastet opp til denne serveren", + "Apps_Permissions_upload_write": "Last opp filer til denne serveren", + "Apps_Permissions_server-setting_read": "Tilgang til innstillingene på denne serveren", + "Apps_Permissions_server-setting_write": "Endre innstillingene på denne serveren", + "Apps_Permissions_room_read": "Tilgang til rominformasjon", + "Apps_Permissions_room_write": "Opprett og modifiser rom", + "Apps_Permissions_message_read": "Tilgang til meldinger", + "Apps_Permissions_message_write": "Sende og endre meldinger", + "Apps_Permissions_livechat-status_read": "Tilgang til Livechat-statusinformasjon", + "Apps_Permissions_livechat-custom-fields_write": "Endre Livechat egendefinert felt-instillinger", + "Apps_Permissions_livechat-visitor_read": "Tilgang til Livechat-besøksinformasjon", + "Apps_Permissions_livechat-visitor_write": "Endre Livechat-besøksinformasjon", + "Apps_Permissions_livechat-message_read": "Tilgang til Livechat-meldingsinformasjon", + "Apps_Permissions_livechat-message_write": "Endre Livechat-meldingsinformasjon", + "Apps_Permissions_livechat-room_read": "Tilgang til rominformasjon for Livechat", + "Apps_Permissions_livechat-room_write": "Endre Livechat rominformasjon", + "Apps_Permissions_livechat-department_read": "Tilgang til informasjon om Livechat-avdelingen", + "Apps_Permissions_livechat-department_multiple": "Tilgang til informasjon om flere Livechat-avdelinger", + "Apps_Permissions_slashcommand": "Registrer nye skråstrekkommandoer", + "Apps_Permissions_api": "Registrer nye HTTP-endepunkter", + "Apps_Permissions_networking": "Tilgang til dette servernettverket", + "Apps_Permissions_ui_interact": "Samhandle med brukergrensesnittet", "Apps_Settings": "Appens innstillinger", + "Apps_Manual_Update_Modal_Title": "Denne appen er allerede installert", + "Apps_Manual_Update_Modal_Body": "Vil du oppdatere den?", + "Apps_User_Already_Exists": "Brukernavnet \"{{username}}\" brukes allerede. Gi nytt navn til eller fjern brukeren som bruker navnet, for å installere denne appen", + "AutoLinker": "AutoLinker", "Apps_WhatIsIt": "Apper: Hva er de?", "Apps_WhatIsIt_paragraph1": "Et nytt ikon i administrasjonsområdet! Hva betyr dette og hva er apper?", "Apps_WhatIsIt_paragraph2": "For det første refererer Apper i denne sammenheng ikke til mobilapplikasjonene. Faktisk vil det være best å tenke på dem når det gjelder plugins eller avanserte integrasjoner.", @@ -509,15 +636,31 @@ "archive-room": "Arkiv-rom", "archive-room_description": "Tillatelse til å arkivere en kanal", "are_typing": "skriver", + "are_playing": "spiller av", + "is_playing": "spiller av", + "are_uploading": "laster opp", + "are_recording": "tar opp", + "is_uploading": "laster opp", + "is_recording": "tar opp", "Are_you_sure": "Er du sikker?", + "Are_you_sure_delete_department": "Er du sikker på at du vil slette denne avdelingen? Denne handlingen kan ikke reverseres. Skriv inn avdelingsnavnet for å bekrefte.", + "Are_you_sure_you_want_to_close_this_chat": "Er du sikker på at du vil lukke denne chatten?", "Are_you_sure_you_want_to_delete_your_account": "Er du sikker på at du vil slette din konto?", "Are_you_sure_you_want_to_disable_Facebook_integration": "Er du sikker på at du vil deaktivere Facebook-integrasjon?", + "Are_you_sure_you_want_to_pin_this_message": "Er du sikker på at du vil feste denne meldingen?", + "Are_you_sure_you_want_to_reset_the_name_of_all_priorities": "Er du sikker på at du vil tilbakestille navnet på alle prioriteringer?", + "Assets_Description": "Tilpass arbeidsområdets logo, ikon, favorittikon og mer.", "Assign_admin": "Tilordne admin", + "Assign_new_conversations_to_bot_agent": "Tilordne nye samtaler til robot-agenten", "assign-admin-role": "Tilordne Admin-rolle", "assign-admin-role_description": "Tillatelse til å tilordne administrasjonsrollen til andre brukere", + "assign-roles": "Tildel roller", + "assign-roles_description": "Tilgang til å tildele roller til andre brukere", + "Associate": "Forbinde", "at": "på", "At_least_one_added_token_is_required_by_the_user": "Minst ett tilleggstegn kreves av brukeren", "AtlassianCrowd": "Atlassian Crowd", + "AtlassianCrowd_Description": "Integrer Atlassian Crowd.", "Attachment_File_Uploaded": "Filopplastet", "Attribute_handling": "Attributthåndtering", "Audio": "Audio", @@ -525,68 +668,190 @@ "Audio_Notification_Value_Description": "Kan være hvilken som helst egendefinert lyd eller standard: bipp, chelle, ding, dråpe, høyttaler, årstider", "Audio_Notifications_Default_Alert": "Lydvarsler Standardvarsel", "Audio_Notifications_Value": "Standard melding melding lyd", + "Audio_record": "Lydopptak", + "Audit": "Revider", + "Auth": "Tilgangsstyring", "Auth_Token": "Auth Token", + "Authentication": "Autentisering", "Author": "Forfatter", "Author_Information": "Forfatterinformasjon", + "Author_Site": "Forfatterside", "Authorization_URL": "Autorisasjonsadresse", "Authorize": "Autorisere", + "Authorize_access_to_your_account": "Gi tilgang til din konto", + "Automatic_translation_not_available": "Automatisk oversettelse er ikke tilgjengelig", + "Automatic_translation_not_available_info": "Ende-til-ende-kryptering er aktivert for dette rommet, oversettelser fungerer ikke på krypterte meldinger ", "Auto_Load_Images": "Auto Load Images", + "Auto_Selection": "Automatisk valg", "Auto_Translate": "Auto-Trans", + "Calls_in_queue": "{{calls}} anrop i kø", "auto-translate": "Automatisk Oversett", "auto-translate_description": "Tillatelse til å bruke automatisk oversettelsesverktøy", "Automatic_Translation": "Automatisk oversettelse", "AutoTranslate": "Auto-Trans", "AutoTranslate_APIKey": "API-nøkkel", "AutoTranslate_Change_Language_Description": "Hvis du endrer språk for automatisk oversettelse, oversetter du ikke tidligere meldinger.", + "AutoTranslate_DeepL": "DeepL", + "AutoTranslate_Disabled_for_room": "Automatisk oversettelse er deaktivert for #{{roomName}}", "AutoTranslate_Enabled": "Aktiver automatisk-translate", "AutoTranslate_Enabled_Description": "Aktivering av automatisk oversettelse gjør at folk med `automatisk oversetter` tillatelse til å få alle meldinger automatisk oversatt til deres valgte språk. Avgifter kan gjelde, se [Googles dokumentasjon](https://cloud.google.com/translate/pricing)", + "AutoTranslate_Enabled_for_room": "Automatisk oversettelse er aktivert for #{{roomName}}", + "AutoTranslate_AutoEnableOnJoinRoom": "Automatisk oversettelse for ikke-standardspråklige medlemmer", + "AutoTranslate_AutoEnableOnJoinRoom_Description": "Dersom aktivert: Når en bruker blir med i et rom med et annet standardspråk enn brukerens språkpreferanse, blir meldinger automatisk oversatt for brukeren. ", + "AutoTranslate_Google": "Google", + "AutoTranslate_language_set_to": "Automatisk oversettelsesspråk satt til {{language}}", + "AutoTranslate_Microsoft": "Microsoft", + "AutoTranslate_Microsoft_API_Key": "Ocp-Apim-Abonnementsnøkkel", + "AutoTranslate_ServiceProvider": "Tjenesteleverandør", "Available": "Tilgjengelig", "Available_agents": "Tilgjengelige agenter", "Available_departments": "Tilgjengelige avdelinger", "Avatar": "Avatar", + "Avatars": "Avatarer", "Avatar_changed_successfully": "Avatar ble endret", "Avatar_URL": "Avatar-URL", + "Avatar_format_invalid": "Ugyldig format. Kun bildetype er tillatt", "Avatar_url_invalid_or_error": "Den oppgitte nettadressen er ugyldig eller ikke tilgjengelig. Vennligst prøv igjen, men med en annen url.", + "Avg_chat_duration": "Gjennomsnittlig chatvarighet", + "Avg_first_response_time": "Gjennomsnitt av førstebesvarelsestid", + "Avg_of_abandoned_chats": "Gjennomsnitt av forlatte chatter", + "Avg_of_available_service_time": "Gjennomsnitt av tjenestens tilgjengelige tid", + "Avg_of_chat_duration_time": "Gjennomsnittlig chatvarighetstid", + "Avg_of_service_time": "Gjennomsnitt av tjenestetid", + "Avg_of_waiting_time": "Gjennomsnitt av ventetid", + "Avg_reaction_time": "Gjennomsnitt av reaksjonstid", + "Avg_response_time": "Gjennomsnitt av responstid", "away": "borte", "Away": "Borte", "Back": "Tilbake", "Back_to_applications": "Tilbake til programmer", + "Back_to_calendar": "Tilbake til kalenderen", "Back_to_chat": "Tilbake til chat", + "Back_to_imports": "Tilbake til import", "Back_to_integration_detail": "Tilbake til integrasjonsdetaljene", "Back_to_integrations": "Tilbake til integrasjoner", "Back_to_login": "Tilbake til login", "Back_to_Manage_Apps": "Tilbake til Administrer apper", "Back_to_permissions": "Tilbake til rettigheter", + "Back_to_room": "Tilbake til Room", + "Back_to_threads": "Tilbake til tråder", "Backup_codes": "Sikkerhetskopieringskoder", "ban-user": "Ban forbruker", "ban-user_description": "Tillatelse til å forby en bruker fra en kanal", + "BBB_End_Meeting": "Avslutt møte", + "BBB_Enable_Teams": "Aktiver for Teams", + "BBB_Join_Meeting": "Bli med i møtet", + "BBB_Start_Meeting": "Start møte", + "BBB_Video_Call": "BBB-videoanrop", + "BBB_You_have_no_permission_to_start_a_call": "Du har ikke tillatelse til å starte en samtale", + "Be_the_first_to_join": "Bli med som den første", + "Belongs_To": "Tilhører", + "Best_first_response_time": "Beste første responstid", "Beta_feature_Depends_on_Video_Conference_to_be_enabled": "Beta-funksjonen. Avhenger av videokonferanse for å være aktivert.", + "Better": "Bedre", + "Bio": "Bio", + "Bio_Placeholder": "Bio-plassholder", + "Block": "Blokker", + "Block_Multiple_Failed_Logins_Attempts_Until_Block_By_Ip": "Antall mislykkede forsøk før blokkering av IP-adresse", + "Block_Multiple_Failed_Logins_Attempts_Until_Block_by_User": "Antall mislykkede forsøk før blokkering av bruker", + "Block_Multiple_Failed_Logins_By_Ip": "Blokker mislykkede påloggingsforsøk for IP", + "Block_Multiple_Failed_Logins_By_User": "Blokker mislykkede påloggingsforsøk for brukernavn", + "Block_Multiple_Failed_Logins_Enable_Collect_Login_data_Description": "Lagrer IP og brukernavn fra innloggingsforsøk til en samling i databasen", + "Block_Multiple_Failed_Logins_Enabled": "Aktiver innsamling av innloggingsdata", + "Block_Multiple_Failed_Logins_Ip_Whitelist": "IP-tillitsliste ", + "Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes": "Varighet av IP-adresseblokkering (i minutter)", + "Block_Multiple_Failed_Logins_Time_To_Unblock_By_Ip_In_Minutes_Description": "Dette er hvor lenge en IP-adresse er blokkert, og tiden det tar før telleren for antall feilede forsøk tilbakestilles ", + "Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes": "Varighet av brukerblokkering (i minutter)", + "Block_Multiple_Failed_Logins_Time_To_Unblock_By_User_In_Minutes_Description": "Dette er varigheten brukeren er blokkert, og tiden mellom mislykkede forsøk før telleren tilbakestilles", + "Block_Multiple_Failed_Logins_Notify_Failed": "Varsle om mislykkede påloggingsforsøk", + "Block_Multiple_Failed_Logins_Notify_Failed_Channel": "Channel for å sende varslene", + "Block_Multiple_Failed_Logins_Notify_Failed_Channel_Desc": "Dette er hvor varslinger vil bli mottatt. Sørg for at kanalen eksisterer. Kanalnavnet skal ikke inneholde \"#\"", "Block_User": "Blokker bruker", "Blockchain": "Blockchain", + "block-ip-device-management_description": "Tillatelse til å blokkere en IP-adresse", + "Block_IP_Address": "Blokker IP-adresse", + "Blocked_IP_Addresses": "Blokkerte IP-adresser", + "Blockstack_Description": "Gi arbeidsområdets medlemmer muligheten til å logge på uten å være avhengig av tredjeparter eller eksterne servere.", + "Blockstack_Auth_Description": "Authentiseringsbeskrivelse", + "Blockstack_Generate_Username": "Generer brukernavn", "Body": "Kropp", + "Bold": "Fet", "bot_request": "Bot forespørsel", "BotHelpers_userFields": "Brukerfelt", "BotHelpers_userFields_Description": "CSV av brukerfelt som kan nås ved hjelp av botshjelpemetoder.", "Bots": "bots", + "Bots_Description": "Angi feltene som kan refereres til og brukes når du utvikler roboter.", "Branch": "Branch", + "Broadcast": "Kringkaste", "Broadcast_channel": "Broadcast Channel", "Broadcast_channel_Description": "Kun autoriserte brukere kan skrive nye meldinger, men de andre brukerne vil kunne svare", "Broadcast_Connected_Instances": "Broadcast Connected Instances", + "Broadcasting_api_key": "Kringkastings-API-nøkkel", + "Broadcasting_client_id": "Kringkastingsklient-ID", + "Broadcasting_client_secret": "Kringkastingsklienthemmelighet", + "Broadcasting_enabled": "Kringkasting aktivert", + "Broadcasting_media_server_url": "Nettadresse for kringkastingsmedieserver", + "Browse_Files": "Bla gjennom filer", + "Browser_does_not_support_audio_element": "Nettleseren din støtter ikke lydelementet.", + "Browser_does_not_support_video_element": "Nettleseren din støtter ikke videoelementet.", + "Browser_does_not_support_recording_video": "Nettleseren din støtter ikke opptak av video", "Bugsnag_api_key": "Bugsnag API-nøkkel", "Build_Environment": "Bygg miljø", "bulk-register-user": "Bulk Create Channels", "bulk-register-user_description": "Tillatelse til å lage kanaler i bulk", + "Busiest_day": "Travleste dagen", + "Busiest_time": "Travleste tiden", + "Business_Hour": "Kontortid", + "Business_Hour_Removed": "Kontortid fjernet", + "Business_Hours": "Kontortid", + "Business_hours_enabled": "Forretningstid aktivert", + "Business_hours_is_disabled": "Forretningstid er deaktivert ", + "Business_hours_is_disabled_description": "Aktiver åpningstider i administrasjonspanelet for arbeidsområdet, for å fortelle kundene når du er tilgjengelig og når de kan forvente et svar.", "busy": "opptatt", "Busy": "Opptatt", + "Buy": "Kjøp", + "By": "Av", "by": "av", "cache_cleared": "Cache ryddet", + "Calendar_MeetingUrl_Regex": "Regulæruttrykk for møte-URL ", + "Calendar_MeetingUrl_Regex_Description": "Uttrykk som brukes til å oppdage møte-URLer i hendelsesbeskrivelser. Den første matchende gruppen med en gyldig URL vil bli brukt. HTML-kodede nettadresser vil bli dekodet automatisk.", + "Calendar_settings": "Kalenderinnstillinger", + "Call": "Ring", + "Call_again": "Ring igjen", + "Call_back": "Ring tilbake", + "Call_not_found": "Anropet ble ikke funnet", + "Call_not_found_error": "Dette kan skje når anrops-URLen ikke er gyldig, eller du har tilkoblingsproblemer. Sjekk med kilden til anrops-URLen og prøv igjen, eller snakk med administratoren for ditt arbeidsområde hvis problemet vedvarer", + "Calling": "Ringer", + "Call_ended": "Anrop avsluttet", + "Calls": "Samtaler", + "Calls_in_queue_zero": "Køen er tom", + "Call_declined": "Anrop avvist!", + "Call_history_provides_a_record_of_when_calls_took_place_and_who_joined": "Samtalehistorikk gir en oversikt over når samtaler fant sted og hvem som var med.", + "Call_Information": "Anropsinformasjon", + "Call_Already_Ended": "Samtale allerede avsluttet", + "Call_number_premium_only": "Ring nummer (kun Premium-abonnementer)", + "call-management_description": "Tillatelse til å starte et møte", + "Call_ongoing": "Samtale pågår", + "Call_started": "Samtale startet", + "Call_was_not_answered": "Anropet ble ikke besvart", + "Caller": "Innringer", + "Camera_access_not_allowed": "Kameratilgang ble ikke tillatt, sjekk nettleserinnstillingene.", + "Cam_on": "Kamera på", + "Cam_off": "Kamera av", "Cancel": "Avbryt", "Cancel_message_input": "Avbryt", + "Canceled": "Avbrutt", + "Cancel_subscription": "Avbryt abonnement", + "Create_department": "Opprett avdeling", + "Create_direct_message": "Opprett direkte melding", + "Create_SLA_policy": "Lag SLA-retningslinjer", "Cannot_invite_users_to_direct_rooms": "Kan ikke invitere brukere til å lede rom", "Cannot_open_conversation_with_yourself": "Kan ikke sende melding til deg selv", + "Cannot_share_your_location": "Kan ikke dele din posisjonen...", + "Cant_join": "Kan ikke bli med", "CAS_autoclose": "Autoclose Login Popup", "CAS_base_url": "SSO Base URL", - "CAS_base_url_Description": "Basisadressen til din eksterne SSO-tjeneste, for eksempel: https: //sso.example.undef/sso/", + "CAS_base_url_Description": "URLen til din eksterne SSO-tjeneste, for eksempel: https: //sso.example.undef/sso/", "CAS_button_color": "Innloggingsknapp Bakgrunnsfarge", "CAS_button_label_color": "Innloggingsknapp Tekstfarge", "CAS_button_label_text": "Innloggingsknappetikett", @@ -602,8 +867,12 @@ "CAS_Sync_User_Data_FieldMap_Description": "Bruk denne JSON-inngangen til å bygge interne attributter (nøkkel) fra eksterne attributter (verdi). Eksterne attributtnavn vedlagt med% vil bli interpolert i verdi strenger. \nEksempel, `{\"email\":\"%e-post% \", \"navn\":\"%firstname%, %lastname% \"}` \n \nAttributtkartet er alltid interpolert. I CAS 1.0 er kun «brukernavnet» attributtet tilgjengelig. Tilgjengelige interne attributter er: brukernavn, navn, e-post, rom; rom er en kommaseparert liste over rom for å delta i brukeropprettelsen, for eksempel: `{\"rooms\": \"% team%,%avdeling%\"}` vil bli med i CAS-brukere ved opprettelse til deres team- og avdelingskanal.", "CAS_version": "CAS versjon", "CAS_version_Description": "Bruk bare en støttet CAS-versjon som støttes av din CAS SSO-tjeneste.", + "Categories": "Kategorier", + "Categories*": "Kategorier*", "CDN_PREFIX": "CDN Prefix", "Certificates_and_Keys": "Sertifikater og nøkler", + "changed_room_announcement_to__room_announcement_": "endret romkunngjøring til: {{room_announcement}}", + "changed_room_description_to__room_description_": "endret rombeskrivelse til: {{room_description}}", "Change_Room_Type": "Endring av romtypen", "Changing_email": "Endre e-post", "channel": "kanal", @@ -619,20 +888,32 @@ "Channel_to_listen_on": "Kanal å lytte på", "Channel_Unarchived": "Kanal med navn `#%s` har blitt unarchived successfully", "Channels": "kanaler", + "Channels_added": "Kanaler ble lagt til", "Channels_are_where_your_team_communicate": "Kanaler er hvor teamet ditt kommuniserer", "Channels_list": "Liste over offentlige kanaler", "Chat_button": "Chat-knapp", "Chat_closed": "Chat avsluttet", + "Chat_closed_by_agent": "Chat stengt av agent", "Chat_closed_successfully": "Chat sluttet vellykket", + "Chat_History": "Chat historikk", "Chat_Now": "Chat nå", + "Chat_On_Hold_Successfully": "Denne chatten ble satt på vent", + "Chat_resumed": "Chat gjenopptatt", + "Chat_started": "Chat startet", "Chat_window": "Chat-vindu", "Chatops_Enabled": "Aktiver Chatops", "Chatops_Title": "Chatops Panel", "Chatops_Username": "Chatops Brukernavn", + "Chat_Duration": "Chattens varighet", + "Chats_removed": "Chatter fjernet", + "Check_if_the_spelling_is_correct": "Sjekk om stavemåten er riktig", + "Check_device_activity": "Sjekk enhetsaktivitet", "Choose_a_room": "Velg et rom", "Choose_messages": "Velg meldinger", "Choose_the_alias_that_will_appear_before_the_username_in_messages": "Velg aliaset som vil vises før brukernavnet i meldinger.", "Choose_the_username_that_this_integration_will_post_as": "Velg brukernavnet som denne integrasjonen vil legge inn som.", + "Choose_users": "Velg brukere", + "Clean_Usernames": "Fjern brukernavn", "clean-channel-history": "Rengjør kanalhistorikken", "clean-channel-history_description": "Tillatelse til å slette historien fra kanaler", "clear": "Clear", @@ -640,53 +921,131 @@ "clear_cache_now": "Fjern cache nå", "clear_history": "Slett logg", "Click_here": "Klikk her", + "Click_here_for_more_details_or_contact_sales_for_a_new_license": "Klikk her for mer informasjon eller kontakt {{email}} for en ny lisens.", "Click_here_for_more_info": "Klikk her for mer info", + "Click_here_to_clear_the_selection": "Klikk her for å fjerne valget", + "Click_here_to_enter_your_encryption_password": "Klikk her for å angi krypteringspassordet ditt", + "Click_here_to_view_and_copy_your_password": "Klikk her for å se og kopiere passordet ditt.", "Click_the_messages_you_would_like_to_send_by_email": "Klikk på meldingene du vil sende via e-post", "Click_to_join": "Klikk for å bli med!", + "Click_to_load": "Klikk for å laste", "Client_ID": "klient-ID", "Client_Secret": "Klientshemmelighet", + "Client": "Klient", "Clients_will_refresh_in_a_few_seconds": "Klienter vil oppdatere om noen få sekunder", "close": "Lukk", "Close": "Lukk", + "Close_chat": "Lukk chat", + "Close_room_description": "Du er i ferd med å lukke denne chatten. Er du sikker på at du vil fortsette?", "close-livechat-room": "Lukk Livechat Room", "close-livechat-room_description": "Tillatelse til å lukke den nåværende LiveChat-kanalen", "Close_menu": "Lukk meny", "close-others-livechat-room": "Lukk Livechat Room", "close-others-livechat-room_description": "Tillatelse til å lukke andre LiveChat-kanaler", + "Close_Window": "Lukk vindu", "Closed": "Lukket", + "Closed_At": "Stengt klokken", + "Closed_automatically": "Lukket automatisk av systemet", + "Closed_automatically_because_chat_was_onhold_for_seconds": "Lukket automatisk fordi chatten var på vent i {{onHoldTime}} sekunder", + "Closed_automatically_chat_queued_too_long": "Automatisk lukket av systemet (maksimal tid i kø overskredet)", "Closed_by_visitor": "Stengt av besøkende", "Closing_chat": "Avsluttende chat", + "Closing_chat_message": "Lukker chat-melding", + "Cloud_Apply_Offline_License": "Bruk frakoblet lisens", + "Cloud_Change_Offline_License": "Endre frakoblet lisens", + "Cloud_Invalid_license": "Ugyldig lisens!", + "Cloud_Apply_license": "Bruk lisens", + "Cloud_error_code": "Kode: {{errorCode}}", + "Cloud_registration_required": "Registrering nødvendig", + "Cloud_resend_email": "Send e-post på nytt", + "Cloud_Service_Agree_PrivacyTerms_Description": "Jeg godtar [vilkårene](https://rocket.chat/terms) og [personvernreglene](https://rocket.chat/privacy)", + "Cloud_status_page_description": "Hvis en bestemt skytjeneste har problemer, kan du se etter kjente problemer på statussiden vår på", + "Cloud_troubleshooting": "Feilsøking", + "Cloud_update_email": "Oppdater e-post", + "Cloud_what_is_it": "Hva er dette?", + "Copy_Link": "Kopier lenke", + "Copy_password": "Kopier passord", + "Cloud_what_is_it_additional": "I tillegg vil du kunne administrere lisenser, fakturering og support fra Rocket.Chat Cloud Console.", + "Cloud_what_is_it_description": "Rocket.Chat Cloud Connect lar deg koble ditt selvhostede Rocket.Chat Workspace til tjenester vi tilbyr i vår nettsky.", + "Cloud_what_is_it_services_like": "Tjenester som:", + "Cloud_workspace_connected": "Arbeidsområdet ditt er koblet til Rocket.Chat Cloud. Hvis du logger på Rocket.Chat Cloud-kontoen din her, kan du samhandle med tjenester som markedsplass.", + "Cloud_workspace_connected_plus_account": "Arbeidsområdet ditt er nå koblet til Rocket.Chat Cloud og en konto er tilknyttet.", + "Cloud_workspace_connected_without_account": "Arbeidsområdet ditt er nå koblet til Rocket.Chat Cloud. Hvis du vil, kan du logge på Rocket.Chat Cloud og knytte arbeidsområdet ditt til Cloud-kontoen din.", + "Cloud_workspace_disconnect": "Dersom du ikke lenger ønsker å bruke skytjenester, kan du koble fra arbeidsområdet ditt fra Rocket.Chat Cloud.", + "Cloud_workspace_support": "Hvis du har problemer med en skytjeneste, prøv å synkronisere først. Hvis problemet vedvarer, kan du kontakte support i Cloud Console.", + "Collaborative": "Samarbeidende", + "Collapse": "Kollapse", "Collapse_Embedded_Media_By_Default": "Skjul innebygd media som standard", "color": "Farge", "Color": "Farge", + "Colors": "Farger", "Commands": "kommandoer", "Comment_to_leave_on_closing_session": "Kommenter å forlate på avslutnings sesjon", + "Comment": "Kommentar", "Common_Access": "Felles tilgang", "Community": "Samfunnet", + "Free_Edition": "Gratisversjon", + "Composer_not_available_phone_calls": "Meldinger er ikke tilgjengelige på telefonsamtaler", "Condensed": "kondensert", + "Condition": "Betingelse", + "Completed": "Fullført", "Computer": "Datamaskin", + "Conference_call_has_ended": "_Samtalen er avsluttet._", + "Configure_Incoming_Mail_IMAP": "Konfigurer innkommende e-post (IMAP)", + "Configure_Outgoing_Mail_SMTP": "Konfigurer utgående e-post (SMTP)", + "Confirm": "Bekreft", + "Confirm_new_encryption_password": "Bekreft nytt krypteringspassord", "Confirm_new_password": "Bekrefte nytt passord", "Confirm_New_Password_Placeholder": "Vennligst skriv nytt passord igjen ...", "Confirm_password": "Bekreft passordet ditt", "Confirm_your_password": "Bekreft passordet ditt", + "Confirm_configuration_update": "Bekreft konfigurasjonsoppdatering", + "Confirm_new_workspace_description": "Identifikasjonsdata og skytilkoblingsdata vil bli tilbakestilt.

Advarsel: Lisensen kan bli påvirket hvis du endrer nettadressen til arbeidsområdet.", + "Confirm_new_workspace": "Bekreft nytt arbeidsområde", + "Confirmation": "Bekreftelse", + "Configure_video_conference": "Konfigurer konferansesamtale", + "Configuration_update_confirmed": "Konfigurasjonsoppdatering bekreftet", + "Configuration_update": "Konfigurasjonsoppdatering", + "Connect": "Koble til", + "Connected": "Tilkoblet", + "Connect_SSL_TLS": "Koble til med SSL/TLS", "Connection_Closed": "Tilkoblingen er stengt", "Connection_Reset": "Tilbakestilling av tilkobling", + "Connection_error": "Tilkoblingsfeil", + "Connection_failed": "LDAP-tilkobling feilet", "Consulting": "Consulting", + "Contact": "Kontakt", + "Contacts": "Kontakter", + "Contact_Name": "kontakt navn", "Contains_Security_Fixes": "Inneholder sikkerhetsoppdateringer", + "Contact_Info": "Kontaktinformasjon", "Content": "Innhold", "Continue": "Fortsette", + "Continue_Adding": "Fortsette å legge til?", "Continuous_sound_notifications_for_new_livechat_room": "Kontinuerlige lydvarsler for nytt livechat-rom", "Conversation": "Samtale", "Conversation_closed": "Samtalen avsluttet: {{comment}}.", + "Conversation_closed_without_comment": "Samtalen ble avsluttet", "Conversation_finished": "Samtalen er avsluttet", "Conversation_finished_message": "Samtalen avsluttet melding", "conversation_with_s": "samtalen med %s", + "Conversations": "Samtaler", + "Conversations_per_day": "Samtaler per dag", + "Convert": "Konverter", "Convert_Ascii_Emojis": "Konverter ASCII til Emoji", + "Convert_to_channel": "Konverter til Channel", + "Converted__roomName__to_team": "konverterte #{{roomName}} til et team", + "Converted__roomName__to_channel": "konverterte #{{roomName}} til en kanal", + "Converted__roomName__to_a_team": "konverterte #{{roomName}} til et team", + "Converted__roomName__to_a_channel": "konverterte #{{roomName}} til en kanal", + "Converting_team_to_channel": "Konverterer team til kanal", "Copied": "kopiert", "Copy": "Kopi", + "Copy_text": "Kopier tekst", "Copy_to_clipboard": "Kopiere til utklippstavle", "COPY_TO_CLIPBOARD": "KOPIERE TIL UTKLIPPSTAVLE", "Count": "Telle", + "Counters": "Tellere", "Country": "Land", "Country_Afghanistan": "Afghanistan", "Country_Albania": "Albania", @@ -930,69 +1289,121 @@ "Country_Zambia": "Zambia", "Country_Zimbabwe": "Zimbabwe", "Create": "Skape", + "Create_custom_field": "Opprett egendefinert felt", + "Create_channel": "Opprett Channel", + "Create_channels": "Opprett kanaler", "Create_A_New_Channel": "Opprett en ny kanal", "Create_new": "Lag ny", + "Create_new_members": "Opprett nye medlemmer", "Create_unique_rules_for_this_channel": "Opprett unike regler for denne kanalen", + "Create_unit": "Opprett enhet", "create-c": "Opprett offentlige kanaler", "create-c_description": "Tillatelse til å opprette offentlige kanaler", "create-d": "Lag direkte meldinger", "create-d_description": "Tillatelse til å starte direkte meldinger", + "create-invite-links": "Lag invitasjonslenker", + "create-invite-links_description": "Tillatelse til å opprette invitasjonslenker til kanaler", "create-p": "Opprett private kanaler", "create-p_description": "Tillatelse til å lage private kanaler", + "create-personal-access-tokens": "Opprett personlige tilgangstokener", + "create-personal-access-tokens_description": "Tillatelse til å opprette personlige tilgangstokener", + "create-team": "Opprett team", + "create-team_description": "Tillatelse til å opprette teams", "create-user": "Opprett bruker", "create-user_description": "Tillatelse til å opprette brukere", + "Created": "Opprettet", + "Created_as": "Opprettet som", "Created_at": "Opprettet på", "Created_at_s_by_s": "Opprettet på %s etter %s", "Created_at_s_by_s_triggered_by_s": "Laget til %s etter %s utløst av %s", + "Created_by": "Opprettet av", "CRM_Integration": "CRM Integrasjon", + "CROWD_Allow_Custom_Username": "Tillat egendefinerte brukernavn i Rocket.Chat", "CROWD_Reject_Unauthorized": "Avvis Uautorisert", "Crowd_sync_interval_Description": "Intervallet mellom synkroniseringer. Eksempel \"hver 24. time\" eller \"på den første dagen i uken\", flere eksempler på [Cron Text Parser] (http://bunkat.github.io/later/parsers.html#text)", + "CSV": "CSV", "Current_Chats": "Nåværende Chatter", + "Current_File": "Gjeldene fil", "Current_Status": "Nåværende status", + "Currently_we_dont_support_joining_servers_with_this_many_people": "For øyeblikket støtter vi ikke å koble sammen servere med så mange mennesker", "Custom": "Tilpasset", "Custom CSS": "Tilpasset CSS", "Custom_agent": "Tilpasset agent", + "Custom_dates": "Egendefinerte datoer", "Custom_Emoji": "Egendefinert Emoji", "Custom_Emoji_Add": "Legg til ny emoji", "Custom_Emoji_Added_Successfully": "Tilpasset emoji ble lagt til", "Custom_Emoji_Delete_Warning": "Slette en emoji kan ikke fortrykkes.", "Custom_Emoji_Error_Invalid_Emoji": "Ugyldig emoji", "Custom_Emoji_Error_Name_Or_Alias_Already_In_Use": "Den egendefinerte emoji eller en av aliasene er allerede i bruk.", + "Custom_Emoji_Error_Same_Name_And_Alias": "Det egendefinerte emojinavnet og aliasene deres skal være forskjellige.", "Custom_Emoji_Has_Been_Deleted": "Den egendefinerte emoji er slettet.", "Custom_Emoji_Info": "Egendefinert Emoji Info", "Custom_Emoji_Updated_Successfully": "Egendefinert emoji ble oppdatert", "Custom_Fields": "Egendefinerte felt", + "Custom_Field_Removed": "Egendefinert felt er fjernet", + "Custom_Field_Not_Found": "Egendefinert felt ble ikke funnet", + "Custom_Integration": "Tilpasset integrasjon", + "Custom_OAuth_has_been_added": "Egendefinert OAuth er lagt til", + "Custom_OAuth_has_been_removed": "Tilpasset OAuth er fjernet", "Custom_oauth_helper": "Når du konfigurerer OAuth-leverandøren din, må du informere en tilbakekallingsadresse. Bruk
%s
.", "Custom_oauth_unique_name": "Egendefinert oauth unikt navn", + "Custom_roles": "Egendefinerte roller", + "Custom_roles_upsell_add_custom_roles_workspace_description": "Egendefinerte roller lar deg angi tillatelser for personene i arbeidsområdet ditt. Angi alle rollene du trenger for å sikre at folk har et trygt miljø å jobbe i.", "Custom_Script_Logged_In": "Tilpasset script for logget inn brukere", "Custom_Script_Logged_Out": "Tilpasset script for logget ut brukere", "Custom_Scripts": "Egendefinerte skript", "Custom_Sound_Add": "Legg til tilpasset lyd", "Custom_Sound_Delete_Warning": "Slette en lyd kan ikke fortrykkes.", + "Custom_Sound_Edit": "Rediger egendefinert lyd", "Custom_Sound_Error_Invalid_Sound": "Ugyldig lyd", "Custom_Sound_Error_Name_Already_In_Use": "Det egendefinerte lydnavnet er allerede i bruk.", "Custom_Sound_Has_Been_Deleted": "Den egendefinerte lyden er slettet.", "Custom_Sound_Info": "Tilpasset lydinfo", "Custom_Sound_Saved_Successfully": "Tilpasset lyd lagret vellykket", + "Custom_Status": "Egendefinert status", "Custom_Translations": "Tilpassede oversettelser", "Custom_Translations_Description": "Bør være en gyldig JSON der nøkler er språk som inneholder en nøkkelord og oversettelser. Eksempel: \n `{\"en\": {\"Channels\": \"Rooms\"},\"pt\": {\"Channels\": \"Salas\"}}`", + "Custom_User_Status": "Egendefinert brukerstatus", + "Custom_User_Status_Add": "Legg til egendefinert brukerstatus", + "Custom_User_Status_Edit": "Rediger egendefinert brukerstatus", + "Custom_User_Status_Error_Invalid_User_Status": "Ugyldig brukerstatus", + "Custom_User_Status_Has_Been_Deleted": "Egendefinert brukerstatus er slettet", + "Customer_without_registered_email": "Kunden har ikke registrert e-postadresse", "Customize": "Tilpass", + "Customize_Content": "Tilpass innhold", "CustomSoundsFilesystem": "Egendefinert lyds filsystem", + "CustomSoundsFilesystem_Description": "Spesifiser hvordan egendefinerte lyder lagres.", "Daily_Active_Users": "Daglig aktive brukere", "Dashboard": "Dashbord", + "Data_processing_consent_text": "Samtykketekst for databehandling", "Date": "Dato", "Date_From": "Fra", "Date_to": "til", "DAU_value": "DAU {{value}}", "days": "dager", + "Days": "Dager", "DB_Migration": "Databaseoverføring", "DB_Migration_Date": "Databaseoverføringsdato", + "DDP_Rate_Limit_IP_Enabled": "Begrensning for IP: aktivert", + "DDP_Rate_Limit_IP_Interval_Time": "Begrensning for IP: intervalltid", + "DDP_Rate_Limit_IP_Requests_Allowed": "Begrensning for IP: forespørsler tillatt", "Deactivate": "Deaktiver", "Decline": "Avslå", + "default": "standard", "Default": "Misligholde", + "Default_provider": "Standardleverandør", + "Default_value": "Standardverdi", "Delete": "Slett", + "Deleting": "Sletter", + "Delete_account": "Slett konto", + "Delete_account?": "Slett konto?", + "Delete_all_closed_chats": "Slett alle lukkede chatter", + "Delete_Department?": "Vil du slette avdelingen?", "Delete_message": "Slett melding", "Delete_my_account": "Slett kontoen min", + "Delete_Role_Warning": "Dette kan ikke angres", + "Delete_Role_Warning_Not_Enterprise": "Dette kan ikke angres. Du vil ikke kunne opprette en ny egendefinert rolle, siden den funksjonaliteten ikke lenger er tilgjengelig for ditt nåværende abonnement.", "Delete_Room_Warning": "Hvis du sletter et rom, slettes alle meldinger som er lagt inn i rommet. Dette kan ikke angres.", "Delete_User_Warning": "Hvis du sletter en bruker, slettes alle meldinger fra den aktuelle brukeren. Dette kan ikke angres.", "Delete_User_Warning_Delete": "Hvis du sletter en bruker, slettes alle meldinger fra den aktuelle brukeren. Dette kan ikke angres.", @@ -1004,14 +1415,24 @@ "delete-d_description": "Tillatelse til å slette direkte meldinger", "delete-message": "Slett melding", "delete-message_description": "Tillatelse til å slette en melding i et rom", + "delete-own-message": "Slett egen melding", + "delete-own-message_description": "Tillatelse til å slette egen melding", "delete-p": "Slett private kanaler", "delete-p_description": "Tillatelse til å slette private kanaler", + "delete-team": "Slett team", + "delete-team_description": "Tillatelse til å slette team", "delete-user": "Slett bruker", "delete-user_description": "Tillatelse til å slette brukere", "Deleted": "Slettet!", + "Deleted_user": "Slettet bruker", + "Deleted__roomName__": "slettet #{{roomName}}", + "Deleted__roomName__room": "slettet #{{roomName}}", "Department": "Avdeling", + "Department_archived": "Avdeling arkivert", + "Department_name": "Avdelingsnavn", "Department_not_found": "Avdeling ikke funnet", "Department_removed": "Avdelingen fjernet", + "Department_Removal_Disabled": "Slettalternativet er deaktivert av administrator", "Departments": "avdelinger", "Deployment_ID": "Distribusjons-ID", "Description": "Beskrivelse", @@ -1023,12 +1444,34 @@ "Desktop_Notifications_Duration": "Tidsavbrudd for stasjonær varsling", "Desktop_Notifications_Duration_Description": "Sekunder for å vise skrivebordsmeddelelse. Dette kan påvirke OS X Notification Center. Angi 0 for å bruke standard nettleserinnstillinger og ikke påvirke OS X Notification Center.", "Desktop_Notifications_Enabled": "Bordmeldinger er aktivert", + "Details": "Detaljer", + "Device_Management": "Enhetsstyring", + "Device_Management_Allow_Login_Email_preference": "Tillat medlemmer av arbeidsområdet å slå av e-poster for innloggingsforsøk", + "Device_Management_Client": "Klient", + "Device_Management_Device": "Enhet", "line": "linje", + "Device_Management_Device_Unknown": "Ukjent", + "Device_Management_Enable_Login_Emails": "Aktiver e-poster for registrering av pålogging", + "Device_Management_Enable_Login_Emails_Description": "E-poster sendes til arbeidsområdemedlemmer hver gang nye pålogginger oppdages på deres kontoer.", + "Device_Management_IP": "IP", + "Device_Management_OS": "OS", + "Device_ID": "Enhets-ID", + "Device_Info": "Enhetsinformasjon", + "Device_Logged_Out": "Enheten logget ut", + "Devices": "Enheter", + "Device_settings": "Enhetsinnstillinger", + "Dialed_number_doesnt_exist": "Oppringt nummer eksisterer ikke", + "Dialed_number_is_incomplete": "Oppringt nummer er ufullstendig", "Different_Style_For_User_Mentions": "Ulike stil for brukeren nevner", "Livechat_Facebook_API_Key": "OmniChannel API-nøkkel", + "Direct": "Direkte", + "Direction": "Retning", "Livechat_Facebook_API_Secret": "OmniChannel API Secret", + "Direct_Message": "Direktemelding", "Livechat_Facebook_Enabled": "Facebook integrasjon aktivert", + "Direct_message_creation_description": "Du er i ferd med å opprette en chat med flere brukere. Legg til de du vil snakke med på ett sted via direktemeldinger.", "Direct_message_someone": "Direkte melding noen", + "Direct_message_you_have_joined": "Du har blitt med i en ny direktemelding med", "Direct_Messages": "Direktemeldinger", "Direct_Reply": "Direkte svar", "Direct_Reply_Debug": "Feilsøk Direkte Svar", @@ -1047,30 +1490,59 @@ "Direct_Reply_Username": "Brukernavn", "Direct_Reply_Username_Description": "Bruk absolutt e-post, tagging er ikke tillatt, det ville være over-skrevet", "Directory": "Directory", + "Disable": "Deaktiver", "Disable_Facebook_integration": "Deaktiver Facebook-integrasjon", "Disable_Notifications": "Deaktiver varslinger", "Disable_two-factor_authentication": "Deaktiver tofaktorautentisering", + "Disable_two-factor_authentication_email": "Deaktiver tofaktorautentisering via e-post", "Disabled": "Funksjonshemmet", "Disallow_reacting": "Tillat ikke å reagere", "Disallow_reacting_Description": "Tillater ikke å reagere", + "Discard": "Forkast", + "Disconnect": "Koble fra", + "Discussion": "Diskusjon", + "Discussion_Description": "Diskusjoner er en ekstra måte å organisere samtaler på, som gjør det mulig å invitere brukere fra eksterne kanaler til å delta i bestemte samtaler.", + "Discussion_description": "Hjelp til å holde oversikt over hva som skjer! Ved å opprette en diskusjon opprettes en underkanal til kanalen du valgte og begge kobles sammen.", + "Discussion_first_message_disabled_due_to_e2e": "Du kan begynne å sende ende-til-ende-krypterte meldinger i denne diskusjonen etter at den er opprettet.", + "Discussion_first_message_title": "Din melding", + "Discussion_name": "Diskusjonsnavn", + "Discussion_start": "Start en diskusjon", + "Discussion_target_channel": "Overordnet kanal eller gruppe", + "Discussion_target_channel_description": "Velg en kanal som er relatert til det du vil spørre om", + "Discussion_target_channel_prefix": "Du oppretter en diskusjon i", + "Discussion_title": "Opprett diskusjon", + "discussion-created": "{{message}}", "Discussions": "Diskusjoner", + "Display_avatars": "Vis avatarer", + "Display_chat_permissions": "Vis chattillatelser", "Display_offline_form": "Vis frakoblet skjema", "Display_unread_counter": "Vis antall uleste meldinger", "Displays_action_text": "Viser handlingstekst", + "Do_It_Later": "Gjør det senere", "Do_not_display_unread_counter": "Ikke vis noen teller på denne kanalen", + "Do_not_provide_this_code_to_anyone": "Ikke oppgi denne koden til noen.", + "Do_Nothing": "Ikke gjør noe", + "Do_nothing": "Gjør ingenting", "Do_you_want_to_accept": "Ønsker du å godta?", "Do_you_want_to_change_to_s_question": "Vil du bytte til %s?", + "Documentation": "Dokumentasjon", "Document_Domain": "Dokumentdomenet", "Domain": "Domene", "Domain_added": "domenet er lagt til", "Domain_removed": "Domene fjernet", "Domains": "domener", "Domains_allowed_to_embed_the_livechat_widget": "Kommaseparert liste over domener får lov til å legge inn livechat-widgeten. La være tom for å tillate alle domener.", + "Done": "Ferdig", "Dont_ask_me_again": "Ikke spør meg igjen!", "Dont_ask_me_again_list": "Ikke spør meg igjen listen", "Download": "Last ned", + "Download_Destkop_App": "Last ned desktop-appen", + "Download_Info": "Nedlastingsinformasjon", "Download_My_Data": "Last ned mine data", + "Download_Pending_Avatars": "Last ned ventende avatarer", + "Download_Pending_Files": "Last ned ventende filer", "Download_Snippet": "Last ned", + "Downloading_file_from_external_URL": "Laster ned fil fra ekstern URL", "Drop_to_upload_file": "Drop for å laste opp fil", "Dry_run": "Tørrkjøring", "Dry_run_description": "Vil bare sende en e-post til samme adresse som i Fra. E-posten må tilhøre en gyldig bruker.", @@ -1080,39 +1552,74 @@ "Duplicate_archived_private_group_name": "En arkivert Privatgruppe med navn '%s' eksisterer", "Duplicate_channel_name": "En kanal med navn '%s' eksisterer", "Markdown_Marked_GFM": "Aktiver merket GFM", + "Duplicate_file_name_found": "Duplikatfilnavn funnet.", "Markdown_Marked_Pedantic": "Aktiver merket pedantisk", "Markdown_Marked_SmartLists": "Aktiver merkede smarte lister", "Duplicate_private_group_name": "En privat gruppe med navn '%s' eksisterer", "Markdown_Marked_Smartypants": "Aktiver merkede Smartypants", + "Duplicated_Email_address_will_be_ignored": "Duplisert e-postadresse vil bli ignorert.", "Markdown_Marked_Tables": "Aktiver merkede tabeller", + "E2E Encryption": "E2E-kryptering", + "E2E_Encryption_enabled_for_room": "Ende-til-Ende-kryptering er aktivert for #{{roomName}}", + "E2E_Encryption_disabled_for_room": "Ende-til-Ende-kryptering deaktivert for #{{roomName}}", "Markdown_Parser": "Markdown Parser", "Markdown_SupportSchemesForLink": "Markdown Støtteordninger for Link", + "E2E Encryption_Description": "Hold samtaler private, sørger for at bare avsender og tiltenkte mottakere kan lese dem.", "Markdown_SupportSchemesForLink_Description": "Kommaseparert liste over tillatte ordninger", + "E2E_enable": "Aktiver E2E", + "E2E_disable": "Deaktiver E2E", + "E2E_Enable_description": "Aktiver alternativet for å opprette krypterte grupper og kunne endre grupper og direktemeldinger som skal krypteres", + "E2E_Enabled": "E2E aktivert", + "E2E_Enabled_Default_DirectRooms": "Aktiver kryptering for Direkterom som standard", + "E2E_Encryption_Password_Explanation": "Du kan nå opprette krypterte private grupper og direktemeldinger. Du kan også endre eksisterende private grupper eller direktemeldinger til krypterte.

Dette er ende-til-ende-kryptering, dvs. at nøkkelen til å kode/dekode meldingene dine vil ikke bli lagret på serveren. Av den grunn må du lagre passordet ditt et trygt sted. Du vil bli bedt om å angi den på andre enheter du ønsker å bruke ende-til-ende-kryptering på.", + "E2E_message_encrypted_placeholder": "Denne meldingen er ende-til-ende-kryptert. For å se den må du skrive inn krypteringsnøkkelen i kontoinnstillingene.", + "E2E_password_request_text": "For å få tilgang til dine krypterte private grupper og direktemeldinger, skriv inn krypteringspassordet ditt.
Du må skrive inn dette passordet for å kode/dekode meldingene dine på hver klient du bruker, siden nøkkelen ikke er lagret på serveren.", + "E2E_password_reveal_text": "Lag sikre private rom og direktemeldinger med ende-til-ende-kryptering.

Lagre passordet ditt på en sikker måte, siden nøkkelen til å kode/dekode meldingene dine ikke blir lagret på serveren. Du må angi den på andre enheter for å bruke e2e-kryptering. Finn ut mer

Endre passord når som helst fra hvilken som helst nettleser du har skrevet det inn på. Husk å lagre passordet ditt før du avviser denne meldingen.

Passordet ditt er: {{randomPassword}}", + "E2E_Reset_Email_Content": "Du er automatisk logget ut. Når du logger på igjen, vil Rocket.Chat generere en ny nøkkel og gjenopprette tilgangen din til et hvilket som helst kryptert rom som har ett eller flere medlemmer online. På grunn av E2E-krypteringens natur, vil Rocket.Chat ikke kunne gjenopprette tilgangen til et kryptert rom som ikke har noen medlemmer online.", + "E2E_Reset_Key_Explanation": "Dette alternativet vil fjerne din nåværende E2E-nøkkel og logge deg ut.
Når du logger på igjen, genererer Rocket.Chat deg en ny nøkkel og gjenoppretter tilgangen din til et hvilket som helst kryptert rom som har ett eller flere medlemmer online.
På grunn av E2E-krypteringens natur, vil Rocket.Chat ikke kunne gjenopprette tilgang til noe kryptert rom som ikke har noen medlemmer online.", + "E2E_Reset_Other_Key_Warning": "Tilbakestill gjeldende E2E-nøkkel vil logge ut brukeren. Når brukeren logger på igjen, vil Rocket.Chat generere en ny nøkkel og gjenopprette brukertilgangen til et hvilket som helst kryptert rom som har ett eller flere medlemmer online. På grunn av E2E-krypteringens natur, vil Rocket.Chat ikke kunne gjenopprette tilgangen til et kryptert rom som ikke har noen medlemmer online.", "Edit": "Rediger", + "Edit_Business_Hour": "Rediger arbeidstid", "Edit_Custom_Field": "Rediger egendefinert felt", "Edit_Department": "Rediger avdeling", "Message_AllowSnippeting": "Tillat meldingsutklipp", + "Edit_Invite": "Rediger invitasjon", "Edit_previous_message": "`%s` - Rediger forrige melding", + "Edit_Priority": "Rediger prioritet", + "Edit_SLA_Policy": "Rediger SLA-retningslinjer", + "Edit_Status": "Rediger status", "Edit_Tag": "Endre tagg", "Edit_Trigger": "Rediger utløser", "Edit_Unit": "Endre enhet", "Message_Attachments_GroupAttach": "Knapper for gruppevedlegg", "Message_Attachments_GroupAttachDescription": "Dette grupperer ikonene under en utvidbar meny. Tar opp mindre skjermplass.", + "Edit_User": "Rediger bruker", "edit-message": "Rediger melding", "edit-message_description": "Tillatelse til å redigere en melding i et rom", "edit-other-user-active-status": "Rediger annen brukeraktiv status", "edit-other-user-active-status_description": "Tillatelse til å aktivere eller deaktivere andre kontoer", + "edit-other-user-e2ee": "Rediger annen bruker E2E-kryptering", + "edit-other-user-e2ee_description": "Tillatelse til å endre andre brukeres E2E-kryptering.", "edit-other-user-info": "Rediger annen brukerinformasjon", "edit-other-user-info_description": "Tillatelse til å endre andre brukerens navn, brukernavn eller e-postadresse.", "edit-other-user-password": "Rediger annet brukerpassord", "edit-other-user-password_description": "Tillatelse til å endre andre brukeres passord. Krever redigering-andre-bruker-info tillatelse.", "edit-privileged-setting": "Rediger privilegert innstilling", "edit-privileged-setting_description": "Tillatelse til å redigere innstillinger", + "edit-team": "Rediger team", + "edit-team_description": "Tillatelse til å redigere team", + "edit-team-channel": "Rediger teamkanal", + "edit-team-channel_description": "Tillatelse til å redigere et teams kanal", + "edit-team-member": "Rediger teammedlem", + "edit-team-member_description": "Tillatelse til å redigere et teams medlemmer", "edit-room": "Rediger rom", "edit-room_description": "Tillatelse til å redigere et roms navn, emne, type (privat eller offentlig status) og status (aktiv eller arkivert)", + "edit-room-avatar": "Rediger romavatar", + "edit-room-avatar_description": "Tillatelse til å redigere et roms avatar.", "edit-room-retention-policy": "Rediger romets retensjonspolicy", "edit-room-retention-policy_description": "Tillatelse til å redigere et roms retensjonspolicy, for å automatisk slette meldinger i den", "multi_line": "multi line", + "Edit_Contact_Profile": "Rediger kontaktprofil", "edited": "redigert", "Editing_room": "Redigeringsrom", "Editing_user": "Redigerer bruker", @@ -1120,34 +1627,71 @@ "Education": "Utdannelse", "Message_ShowFormattingTips": "Vis formateringstips", "Email": "E-post", + "Email_Description": "Konfigurasjoner for å sende kringkastede e-poster fra Rocket.Chat.", "Email_address_to_send_offline_messages": "E-postadresse for å sende utkoblede meldinger", "Email_already_exists": "E-post finnes allerede", "Email_body": "E-post kroppen", "Email_Change_Disabled": "Din Rocket.Chat-administrator har deaktivert endringen av e-post", + "Email_Changed_Description": "Du kan bruke følgende plassholdere:\n - `[email]` for brukerens e-post.\n- `[Site_Name]` og `[Site_URL]` for applikasjonsnavn og URL.", + "Email_Changed_Email_Subject": "[Site_Name] – E-postadressen er endret", + "Email_changed_section": "E-postadresse endret", "Email_Footer_Description": "Du kan bruke følgende plassholdere: \n - `[Site_Name]` og `[Site_URL]` for henholdsvis programnavnet og nettadressen. ", "Email_from": "Fra", "Email_Header_Description": "Du kan bruke følgende plassholdere: \n - `[Site_Name]` og `[Site_URL]` for henholdsvis programnavnet og nettadressen. ", + "Email_Inbox": "E-post-innboks", + "Email_Inboxes": "E-post-innbokser", + "Email_Inbox_has_been_added": "E-postinnboks er lagt til", + "Email_Inbox_has_been_removed": "E-postinnboks er fjernet", "Email_Notification_Mode": "Frakoblede e-postvarsler", "Email_Notification_Mode_All": "Hver Nevn / DM", "Email_Notification_Mode_Disabled": "Funksjonshemmet", + "Email_notification_show_message": "Vis melding i e-postvarsel", + "Email_Notifications_Change_Disabled": "Rocket.Chat-administratoren din har deaktivert e-postvarsel", "Email_or_username": "E-post eller brukernavn", "Email_Placeholder": "Vennligst skriv inn E-postadressen din...", "Email_Placeholder_any": "Vennligst skriv inn e-postadresser ...", + "email_plain_text_only": "Send bare ren tekst-e-post", + "Enterprise_Description": "Oppdater Premium-lisensen din manuelt.", "Email_subject": "Emne", "Enterprise_License": "Enterpriselisens", "Enterprise_License_Description": "Hvis arbeidsområdet ditt er registrert og lisensen er levert av Rocket.Chat Cloud trenger du ikke å oppdatere lisensen manuelt her.", "Email_verified": "E-post bekreftet", + "Email_sent": "E-post sendt", "Emoji": "Emoji", + "Emoji_picker": "Emoji-velger", "EmojiCustomFilesystem": "Egendefinert Emoji-filsystem", + "EmojiCustomFilesystem_Description": "Spesifiser hvordan emojier lagres.", "Empty_title": "Tom tittel", "Enable": "Aktiver", "Enable_Auto_Away": "Aktiver automatisk unna", + "Extra_CSP_Domains": "Ekstra CSP-domener", "Enable_Desktop_Notifications": "Aktiver skrivebordsvarsler", + "Enable_omnichannel_auto_close_abandoned_rooms": "Aktiver automatisk stenging av rom som er forlatt av de besøkende", + "Enable_Password_History": "Aktiver passordhistorikk", "Enable_Svg_Favicon": "Aktiver SVG favicon", "Enable_two-factor_authentication": "Aktiver tofaktorautentisering", + "Enable_two-factor_authentication_email": "Aktiver tofaktorautentisering via e-post", "Enabled": "aktivert", + "Encrypted": "Kryptert", + "Encrypted_channel_Description": "Ende-til-ende kryptert kanal. Søk fungerer ikke med krypterte kanaler, og varsler viser kanskje ikke meldingsinnholdet.", + "Encrypted_key_title": "Klikk her for å deaktivere ende-til-ende-kryptering for denne kanalen (krever e2e-tillatelse)", "Encrypted_message": "Kryptert melding", + "Encrypted_not_available": "Ikke tilgjengelig for offentlige Channel", + "Encryption_key_saved_successfully": "Krypteringsnøkkelen din ble lagret.", + "EncryptionKey_Change_Disabled": "Du kan ikke angi et passord for krypteringsnøkkelen din, da din private nøkkel ikke er tilgjengelig på denne klienten. For å sette et nytt passord må du laste inn din private nøkkel ved å bruke ditt eksisterende passord eller bruke en klient der nøkkelen allerede er lastet inn.", + "End": "Avslutt", + "End_suspicious_sessions": "Avslutt alle mistenkelige økter", + "End_call": "Avslutt samtale", + "End_conversation": "Avslutt samtale", + "Expand_view": "Utvid visningen", + "Explore": "Utforsk", + "Explore_the_marketplace_to_find_awesome_apps": "Utforsk Marketplace for å finne fantastiske apper for Rocket.Chat", + "Export": "Eksporter", + "End_Call": "Avslutt samtale", "End_OTR": "Avslutt OTR", + "Ensure_secure_workspace_access": "Sørg for sikker tilgang til arbeidsområdet", + "Enter_a_custom_message": "Skriv inn en egendefinert melding", + "Enter_a_name": "Skriv inn et navn", "Enter_a_regex": "Skriv inn en regex", "Enter_a_room_name": "Skriv inn et romnavn", "Enter_a_username": "Skriv inn et brukernavn", @@ -1155,38 +1699,68 @@ "Enter_authentication_code": "Skriv inn autentiseringskode", "Enter_Behaviour": "Skriv inn nøkkeladferd", "Enter_Behaviour_Description": "Dette endres hvis enter-tasten sender en melding eller gjør en linjeskift", + "Enter_E2E_password": "Skriv inn Ende-Til-Ende-passord", "Enter_name_here": "Skriv inn navn her", "Enter_Normal": "Normal modus (send med Enter)", "Enter_to": "Skriv inn til", + "Enter_your_E2E_password": "Skriv inn ditt Ende-Til-Ende-passord", + "Enter_your_password_to_delete_your_account": "Skriv inn ditt passord for å slette kontoen din. Dette kan ikke angres.", + "Enter_your_username_to_delete_your_account": "Skriv inn ditt brukernavn for å slette kontoen. Dette kan ikke angres.", + "Premium_License": "Premium-lisens", + "Premium_License_alert": "Hvis en lisens fjernes, må arbeidsområdet startes på nytt for å tre i kraft.
Hvis arbeidsområdet er koblet til skyen, bør lisensen kanselleres der først, ellers vil skyen gi lisensen til arbeidsområdet igjen under omstart.", + "Premium_only": "Kun premium", "Entertainment": "Underholdning", "Error": "Feil", + "Error_something_went_wrong": "Oops! Noe gikk galt. Last inn siden på nytt eller kontakt en administrator.", "Error_404": "Feil: 404", "Error_changing_password": "Feil ved endring av passord", "Error_loading_pages": "Feil ved lasting av sider", + "Error_login_blocked_for_ip": "Innlogging er midlertidig blokkert for denne IP-adressen", + "Error_login_blocked_for_user": "Innlogging er midlertidig blokkert for denne brukeren", "Error_RocketChat_requires_oplog_tailing_when_running_in_multiple_instances": "Feil: Rocket.Chat krever oplog tailing når du kjører i flere tilfeller", "Error_RocketChat_requires_oplog_tailing_when_running_in_multiple_instances_details": "Sørg for at MongoDB er på ReplicaSet-modus og MONGO_OPLOG_URL miljøvariabel er definert riktig på applikasjonsserveren", + "Error_Site_URL": "Ugyldig Site_Url", "error-action-not-allowed": "{{action}} er ikke tillatt", "error-application-not-found": "Søknad ikke funnet", "error-archived-duplicate-name": "Det er en arkivert kanal med navn '{{room_name}}'", "error-avatar-invalid-url": "Ugyldig avatar URL: {{url}}", "error-avatar-url-handling": "Feil under behandling av avatarinnstilling fra en URL ({{url}}) for {{username}}", + "error-business-hour-finish-time-before-start-time": "Sluttid må være etter starttid", + "error-business-hour-finish-time-equals-start-time": "Start- og sluttid kan ikke være det samme", + "error-blocked-username": "**{{field}}** er blokkert og kan ikke brukes!", + "error-cannot-delete-app-user": "Sletting av appbruker er ikke tillatt. Avinstaller den tilsvarende appen for å fjerne den.", "error-cant-invite-for-direct-room": "Kan ikke invitere brukeren til å lede rom", "error-channels-setdefault-is-same": "Kanalinnstillingen er den samme som hva den ville bli endret til.", "error-channels-setdefault-missing-default-param": "BodyParam 'standard' er påkrevd", "error-could-not-change-email": "Kunne ikke endre e-post", "error-could-not-change-name": "Kunne ikke endre navn", "error-could-not-change-username": "Kunne ikke endre brukernavn", + "error-comment-is-required": "Kommentar er påkrevd", + "error-custom-field-name-already-exists": "Det egendefinerte feltnavnet er allerede i bruk", "error-delete-protected-role": "Kan ikke slette en beskyttet rolle", "error-department-not-found": "Avdeling ikke funnet", "error-direct-message-file-upload-not-allowed": "Fildeling ikke tillatt i direkte meldinger", "error-duplicate-channel-name": "En kanal med navn '{{channel_name}}' eksisterer", + "error-duplicate-priority-name": "En prioritet med samme navn finnes allerede", "error-edit-permissions-not-allowed": "Redigering av tillatelser er ikke tillatt", "error-email-domain-blacklisted": "E-postdomenet er svartelistet", + "error-email-body-not-initialized": "E-postteksten er ikke initialisert. Konfigurer e-postens topp- og bunntekst på e-postinnstillinger før du sender omfattende e-poster", "error-email-send-failed": "Feil ved å prøve å sende e-post: {{message}}", + "error-essential-app-disabled": "Feil: en Rocket.Chat-app som er avgjørende for dette er deaktivert. Kontakt administratoren din", + "error-failed-to-delete-department": "Kunne ikke slette avdelingen", "error-field-unavailable": "{{field}} er allerede i bruk :(", "error-file-too-large": "Filen er for stor", + "error-forwarding-chat": "Noe gikk galt under videresending av chatten. Prøv igjen senere.", + "error-forwarding-chat-same-department": "Den valgte avdelingen og den aktuelle romavdelingen er like", + "error-forwarding-department-target-not-allowed": "Videresending til målavdelingen er ikke tillatt.", + "error-guests-cant-have-other-roles": "Gjestebrukere kan ikke ha andre roller.", + "error-import-file-extract-error": "Kunne ikke pakke ut importfilen.", + "error-import-file-is-empty": "Importert fil ser ut til å være tom.", + "error-import-file-missing": "Filen som skal importeres ble ikke funnet på den angitte plassen.", "error-importer-not-defined": "Importøren ble ikke definert riktig, det mangler importklassen.", "error-input-is-not-a-valid-field": "{{input}} er ikke gyldig {{field}}", + "error-insufficient-permission": "Feil! Du har ikke ' {{permission}} ' tillatelsen, som er nødvendig for å utføre denne handlingen", + "error-invalid-account": "Ugyldig konto", "error-invalid-actionlink": "Ugyldig handlingskobling", "error-invalid-arguments": "Ugyldige argumenter", "error-invalid-asset": "Ugyldig ressurs", @@ -1194,11 +1768,15 @@ "error-invalid-channel-start-with-chars": "Ugyldig kanal. Start med @ eller #", "error-invalid-custom-field": "Ugyldig tilpasset felt", "error-invalid-custom-field-name": "Ugyldig egendefinert feltnavn. Bruk bare bokstaver, tall, bindestreker og understreker.", + "error-invalid-custom-field-value": "Ugyldig verdi for {{field}}-feltet", "error-invalid-date": "Ugyldig dato oppgitt.", + "error-invalid-dates": "\"Fra-dato\" kan ikke være etter \"til-dato\"", "error-invalid-description": "Ugyldig beskrivelse", "error-invalid-domain": "Ugyldig domene", "error-invalid-email": "Ugyldig e-post {{email}}", "error-invalid-email-address": "Ugyldig epostadresse", + "error-invalid-email-inbox": "Ugyldig e-postinnboks", + "error-email-inbox-not-found": "Finner ikke e-postinnboks", "error-invalid-file-height": "Ugyldig filhøyde", "error-invalid-file-type": "ugyldig filtype", "error-invalid-file-width": "Ugyldig filbredde", @@ -1208,7 +1786,11 @@ "error-invalid-method": "Ugyldig metode", "error-invalid-name": "Ugyldig navn", "error-invalid-password": "Ugyldig passord", + "error-invalid-param": "Ugyldig parameter", + "error-invalid-params": "Ugyldige parametere", "error-invalid-permission": "Ugyldig tillatelse", + "error-invalid-port-number": "Ugyldig portnummer", + "error-invalid-priority": "Ugyldig prioritet", "error-invalid-redirectUri": "Ugyldig omadresseringUri", "error-invalid-role": "Ugyldig rolle", "error-invalid-room": "Ugyldig rom", @@ -1221,9 +1803,14 @@ "error-invalid-urls": "Ugyldige nettadresser", "error-invalid-user": "Ugyldig bruker", "error-invalid-username": "Ugyldig brukernavn", + "error-invalid-value": "ugyldig verdi", "error-invalid-webhook-response": "Webhook-nettadressen reagerte med en annen status enn 200", + "error-license-user-limit-reached": "Maksimalt antall brukere er nådd.", "error-logged-user-not-in-room": "Du er ikke på rommet `%s`", + "error-max-departments-number-reached": "Du har nådd det maksimale antallet avdelinger som tillates av lisensen din. Kontakt sale@rocket.chat for en ny lisens.", + "error-max-guests-number-reached": "Du har nådd maksimalt antall gjestebrukere tillatt av lisensen din. Kontakt sale@rocket.chat for en ny lisens.", "error-max-number-simultaneous-chats-reached": "Det maksimale antallet samtidige chatter per agent er nådd.", + "error-max-rooms-per-guest-reached": "Maksimalt antall rom per gjest er nådd.", "error-message-deleting-blocked": "Meldingen slettet er blokkert", "error-message-editing-blocked": "Meldingsredigering er blokkert", "error-message-size-exceeded": "Meldingsstørrelsen overstiger Message_MaxAllowedSize", @@ -1231,7 +1818,10 @@ "error-no-tokens-for-this-user": "Det er ingen tokens for denne brukeren", "error-not-allowed": "Ikke tillatt", "error-not-authorized": "Ikke autorisert", + "Estimated_due_time": "Estimert forfallstid", + "error-password-in-history": "Oppgitt passord er tidligere brukt", "error-password-policy-not-met": "Passordet oppfyller ikke serverens retningslinjer", + "Estimated_due_time_in_minutes": "Estimert forfallstid (tid i minutter)", "error-password-policy-not-met-maxLength": "Passordet oppfyller ikke serverens policy med maksimal lengde (passord for lenge)", "error-password-policy-not-met-minLength": "Passordet oppfyller ikke serverens policy med minimumslengde (passord for kort)", "error-password-policy-not-met-oneLowercase": "Passordet oppfyller ikke serverens policy med minst en liten bokstav", @@ -1240,54 +1830,126 @@ "Please_go_to_the_Administration_page_then_Livechat_Facebook": "Gå til administrasjonssiden og deretter Livechat> Facebook", "error-password-policy-not-met-oneUppercase": "Passordet oppfyller ikke serverens policy med minst en stor bokstav", "error-password-policy-not-met-repeatingCharacters": "Passordet oppfyller ikke serverens retningslinjer for forbudte gjentatte tegn (du har for mange av de samme tegnene ved siden av hverandre)", + "error-password-same-as-current": "Inntastet passord er likt nåværende passord", + "error-personal-access-tokens-are-current-disabled": "Personlige tilgangstokener er for øyeblikket deaktivert", "error-push-disabled": "Push er deaktivert", "error-remove-last-owner": "Dette er den siste eieren. Vennligst sett inn en ny eier før du fjerner denne.", "error-role-in-use": "Kan ikke slette rolle fordi den er i bruk", "error-role-name-required": "Rolle navn er nødvendig", + "error-room-does-not-exist": "Dette rommet eksisterer ikke", + "error-role-already-present": "En rolle med dette navnet finnes allerede", + "error-room-already-closed": "Room er allerede stengt", "error-room-is-not-closed": "Rommet er ikke lukket", + "error-room-onHold": "Feil! Room er på vent", + "error-room-is-already-on-hold": "Feil! Room er allerede på vent", + "error-room-not-on-hold": "Feil! Room er ikke på vent", + "error-selected-agent-room-agent-are-same": "Den valgte agenten og romagenten er de samme", "error-the-field-is-required": "Feltet {{field}} er påkrevd.", "error-this-is-not-a-livechat-room": "Dette er ikke et Livechat-rom", + "error-this-is-a-premium-feature": "Dette er fra en premium-funksjon", + "error-token-already-exists": "Et token med dette navnet finnes alt", + "error-token-does-not-exists": "Tokenet finnes ikke", + "error-too-many-requests": "Feil, for mange forespørsler. Vennligst senke farten. Du må vente {{seconds}} sekunder før du prøver igjen.", + "error-user-deactivated": "Brukeren er ikke aktiv", "error-user-has-no-roles": "Brukeren har ingen roller", "error-user-is-not-activated": "Brukeren er ikke aktivert", + "error-user-is-offline": "Brukeren er frakoblet", "error-user-limit-exceeded": "Antall brukere du prøver å invitere til #kanalnavn overskrider grensen satt av administratoren", + "error-user-not-belong-to-department": "Bruker tilhører ikke denne avdelingen", "error-user-not-in-room": "Brukeren er ikke i dette rommet", "error-user-registration-disabled": "Brukerregistrering er deaktivert", "error-user-registration-secret": "Brukerregistrering er bare tillatt via hemmelig URL", + "error-no-owner-channel": "Bare eiere kan legge til denne kanalen i teamet", + "error-unable-to-update-priority": "Kan ikke oppdatere prioritering", "error-you-are-last-owner": "Du er den siste eieren. Vennligst sett inn ny eier før du forlater rommet.", + "error-saving-sla": "Det oppstod en feil under lagring av SLA", + "error-duplicated-sla": "En SLA med samme navn eller forfallstid eksisterer allerede", + "error-cannot-place-chat-on-hold": "Du kan ikke sette chatten på vent", + "error-unserved-rooms-cannot-be-placed-onhold": "Rommet kan ikke settes på vent før betjening", + "Workspace_exceeded_MAC_limit_disclaimer": "Arbeidsområdet har overskredet den månedlige grensen for aktive kontakter. Snakk med arbeidsområdeadministratoren din for å løse dette problemet.", + "You_do_not_have_permission_to_do_this": "Du har ikke tillatelse til å gjøre dette", + "You_do_not_have_permission_to_execute_this_command": "Du har ikke nødvendige tillatelser til å utføre kommandoen: `/{{command}}`", + "You_have_reached_the_limit_active_costumers_this_month": "Du har nådd grensen for aktive kunder denne måneden", + "Errors_and_Warnings": "Feil og advarsler", "Esc_to": "Esc til", + "Estimated_wait_time": "Beregnet ventetid", + "Event_notifications": "Hendelsesvarsler", "Event_Trigger": "Event Trigger", "Event_Trigger_Description": "Velg hvilken type hendelse som utløser denne Utgående WebHook-integrasjonen", "every_5_minutes": "En gang hvert 5. minutt", "every_10_seconds": "En gang hvert 10. sekund", + "every_30_seconds": "En gang hvert 30. sekund", + "every_10_minutes": "En gang hvert 10. minutt", "every_30_minutes": "En gang hvert 30. minutt", "every_day": "En gang hver dag", "every_hour": "En gang i timen", "every_minute": "En gang i minuttet", "every_second": "En gang hvert sekund", "every_six_hours": "En gang hver sjette time", + "every_12_hours": "En gang hver 12. time", + "every_24_hours": "En gang hver 24. timer", + "every_48_hours": "En gang hver 48. time", "Everyone_can_access_this_channel": "Alle kan få tilgang til denne kanalen", + "Exact": "Nøyaktig", "Example_s": "Eksempel: %s", "except_pinned": "(unntatt de som er festet)", "Exclude_Botnames": "Ekskluder Bots", "Exclude_Botnames_Description": "Ikke propagere meldinger fra bots hvis navn samsvarer med det vanlige uttrykket ovenfor. Hvis tomt er tomt, vil alle meldinger fra bots bli spredt.", "Exclude_pinned": "Ekskluder pinnede meldinger", "Execute_Synchronization_Now": "Utfør synkronisering nå", + "Exit_Full_Screen": "Avslutt fullskjerm", + "Expand": "Utvid", + "Experimental_Feature_Alert": "Dette er en eksperimentell funksjon! Vær oppmerksom på at den kan endres, gå i stykker eller til og med bli fjernet i fremtiden uten varsel.", + "Expired": "Utløpt", + "Expiration": "Utløp", + "Expiration_(Days)": "Utløp (dager)", + "Export_as_file": "Eksporter som fil", + "Export_Messages": "Eksporter meldinger", "Export_My_Data": "Eksporter mine data", + "expression": "Uttrykk", "Extended": "Utvidet", + "Extensions": "Utvidelser", + "Extension_Number": "Utvidelsesnummer", + "Extension_Status": "Utvidelsesstatus", + "External": "Ekstern", + "External_Domains": "Eksterne domener", "External_Queue_Service_URL": "Ekstern køtjeneste-URL", "External_Service": "Ekstern tjeneste", + "External_Users": "Eksterne brukere", + "Extremely_likely": "Ekstremt sannsynlig", + "Facebook": "Facebook", "Facebook_Page": "Facebook-side", + "Failed": "Mislyktes", + "Failed_to_activate_invite_token": "Kunne ikke aktivere invitasjonstoken", + "Failed_to_add_monitor": "Kunne ikke legge til monitor", + "Failed_To_Download_Files": "Kunne ikke laste ned filer", + "Failed_to_generate_invite_link": "Kunne ikke generere invitasjonslenke", "False": "Nei", "Favorite": "Favoritt", "Favorite_Rooms": "Aktiver favorittlokaler", "Favorites": "Favoritter", "Feature_Depends_on_Livechat_Visitor_navigation_as_a_message_to_be_enabled": "Denne funksjonen avhenger av \"Send besøkende navigasjonshistorikk som en melding\" for å være aktivert.", + "Federation_Example_matrix_server": "Eksempel: matrix.org", "FEDERATION_Domain": "Domene", + "FEDERATION_Public_Key": "Offentlig nøkkel", "FEDERATION_Status": "Status", "Retry_Count": "Prøv på nytt", + "Federation_Matrix_id": "AppService-ID", + "Federation_Matrix_hs_token": "Hjemmeserver-token", + "Federation_Matrix_as_token": "AppService-token", + "Federation_Matrix_homeserver_url": "Hjemmeserver-URL", + "Federation_Matrix_registration_file": "Registreringsfil", + "Federation_Matrix_giving_same_permission_warning": "Du gir denne brukeren de samme rettighetene som deg selv, du vil ikke kunne angre denne endringen. Vil du fortsette?", + "Federation_Matrix_losing_privileges": "Mister privilegier", + "Federation_Matrix_losing_privileges_warning": "Du vil ikke kunne angre denne handlingen, siden du nedgraderer deg selv. Hvis du er den siste privilegerte brukeren, vil du ikke kunne gjenvinne dette privilegiet. Ønsker du fortsatt å utføre handlingen?", + "Federation_Matrix_not_allowed_to_change_moderator": "Du har ikke lov til å endre moderator", + "Federation_Matrix_not_allowed_to_change_owner": "Du har ikke lov til å endre eier", + "Federation_Matrix_max_size_of_public_rooms_users_Alert": "Husk at jo større rommet du tillater brukere å bli med i, jo mer tid vil det ta å bli med i rommet, i tillegg til hvor mye ressurs det vil bruke. Les mer", "Field": "Felt", "Field_removed": "Felt fjernet", "Field_required": "Felt kreves", + "File": "Fil", + "File_Downloads_Started": "Filnedlastinger startet", "File_exceeds_allowed_size_of_bytes": "Filen overskrider tillatt størrelse på {{size}}.", "File_name_Placeholder": "Søk filer ...", "File_not_allowed_direct_messages": "Fildeling ikke tillatt i direkte meldinger.", @@ -1295,10 +1957,18 @@ "File_removed_by_prune": "Fil fjernet av beskjæring", "File_type_is_not_accepted": "Filtype er ikke akseptert.", "File_uploaded": "Fil opplastet", + "File_Upload_Disabled": "Filopplasting er deaktivert", + "File_URL": "Fil-URL", "files": "filer", "Files_only": "Bare fjern vedlagte filer, hold meldinger", + "FileSize_Bytes": "{{fileSize}} Bytes", + "FileSize_KB": "{{fileSize}} KB", + "FileSize_MB": "{{fileSize}} MB", "FileUpload": "Filopplasting", + "FileUpload_Cannot_preview_file": "Kan ikke forhåndsvise filen", "FileUpload_Disabled": "Filopplastinger er deaktivert.", + "FileUpload_Enable_json_web_token_for_files": "Aktiver Json Web Tokens-beskyttelse for filopplastinger", + "FileUpload_Restrict_to_room_members": "Begrens filer til rommenes medlemmer", "FileUpload_Enabled": "Filopplastinger aktivert", "FileUpload_Enabled_Direct": "Filopplastinger aktivert i direkte meldinger", "FileUpload_Error": "Filopplastingsfeil", @@ -1308,6 +1978,7 @@ "FileUpload_GoogleStorage_AccessId_Description": "Tilgangs-ID-en er vanligvis i et e-postformat, for eksempel: \"`example-test@example.iam.gserviceaccount.com`\"", "FileUpload_GoogleStorage_Bucket": "Google Storage Bucket Name", "FileUpload_GoogleStorage_Bucket_Description": "Navnet på bøtte som filene skal lastes opp til.", + "FileUpload_GoogleStorage_ProjectId": "Prosjekt-ID", "FileUpload_GoogleStorage_Proxy_Avatars": "Proxy Avatars", "FileUpload_GoogleStorage_Proxy_Avatars_Description": "Proxy-avatar-filoverføringer via serveren din i stedet for direkte tilgang til aktivets nettadresse", "FileUpload_GoogleStorage_Proxy_Uploads": "Proxy-opplastinger", @@ -1316,11 +1987,16 @@ "FileUpload_GoogleStorage_Secret_Description": "Vennligst følg [disse instruksjonene](https://github.com/CulturalMe/meteor-slingshot#google-cloud) og lim inn resultatet her.", "FileUpload_MaxFileSize": "Maksimal filopplastingsstørrelse (i byte)", "FileUpload_MaxFileSizeDescription": "Sett den til -1 for å fjerne begrensningen for filstørrelsen.", + "FileUpload_MediaType_NotAccepted__type__": "Medietypen er ikke akseptert: {{type}}", "FileUpload_MediaType_NotAccepted": "Medietyper ikke akseptert", + "FileUpload_MediaTypeBlackList": "Blokkerte medietyper", + "FileUpload_MediaTypeBlackListDescription": "Kommaseparert liste over medietyper. Denne innstillingen har prioritet over de aksepterte medietypene.", "FileUpload_MediaTypeWhiteList": "Godkjente medietyper", "FileUpload_MediaTypeWhiteListDescription": "Kommaseparert liste over medietyper. La det være tomt for å akseptere alle medietyper.", "FileUpload_ProtectFiles": "Beskytt opplastede filer", "FileUpload_ProtectFilesDescription": "Kun autentiserte brukere vil ha tilgang", + "FileUpload_RotateImages": "Roter bilder ved opplasting", + "FileUpload_RotateImages_Description": "Aktivering av denne innstillingen kan føre til tap av bildekvalitet", "FileUpload_S3_Acl": "Acl", "FileUpload_S3_AWSAccessKeyId": "Tilgangsnøkkel", "FileUpload_S3_AWSSecretAccessKey": "Hemmelig nøkkel", @@ -1347,10 +2023,17 @@ "FileUpload_Webdav_Upload_Folder_Path_Description": "WebDAV mappebane som filene skal lastes opp til", "FileUpload_Webdav_Username": "WebDAV Brukernavn", "Filter": "Filter", + "Filter_by_category": "Filtrer etter kategori", + "Filter_by_Custom_Fields": "Filtrer etter egendefinerte felt", + "Filter_By_Price": "Filtrer etter pris", + "Filter_By_Status": "Filtrer etter status", "Filters": "Filtre", "Financial_Services": "Finansielle tjenester", + "Finish": "Fullfør", + "Finish_Registration": "Fullfør registreringen", "First_Channel_After_Login": "Første kanal etter innlogging", "Flags": "Flags", + "Follow_message": "Følg melding", "Follow_social_profiles": "Følg våre sosiale profiler, gaffel oss på github og del dine tanker om rocket.chat-appen på vår trello bord.", "Fonts": "fonter", "Food_and_Drink": "Mat drikke", @@ -1364,35 +2047,78 @@ "Force_SSL_Description": "* Forsiktig! * _Force SSL_ skal aldri brukes med omvendt proxy. Hvis du har en omvendt proxy, bør du gjøre omadresseringen der. Dette alternativet finnes for distribusjoner som Heroku, som ikke tillater viderekoblingskonfigurasjonen på omvendt proxy.", "force-delete-message": "Tving slett melding", "force-delete-message_description": "Tillatelse til å slette en melding omgå alle begrensninger", + "Font_size": "Skriftstørrelse", "Forgot_password": "Glemt passordet", "Forgot_Password_Description": "Du kan bruke følgende plassholdere: \n - `[Forgot_Password_Url]` for URL-adressen for passordgjenoppretting. \n - [navn], [fname], [lname] for brukerens fulle navn, fornavn eller etternavn. \n - `[email]` for brukerens e-postadresse. \n - `[Site_Name]` og `[Site_URL]` for henholdsvis programnavnet og nettadressen.", "Forgot_Password_Email": "Klikk herfor å tilbakestille passordet ditt.", "Forgot_Password_Email_Subject": "[Site_Name] - Passordgjenoppretting", "Forgot_password_section": "Glemt passord", + "Format": "Format", "Forward": "Framover", "Forward_chat": "Videresend chat", + "Forward_message": "Videresend melding", "Forward_to_department": "Videresend til avdeling", "Forward_to_user": "Videresend til bruker", + "Forwarding": "Videresending", + "Free": "Gratis", + "Free_Apps": "Gratis-apper", "Frequently_Used": "Ofte brukt", "Friday": "fredag", "From": "Fra", "From_Email": "Fra e-post", "From_email_warning": "Advarsel: Feltet Fra er underlagt e-postserverinnstillingene dine.", + "Full_Name": "Fullt navn", + "Full_Screen": "Fullskjerm", "Gaming": "Gaming", "General": "Generell", + "General_Settings": "Generelle innstillinger", + "Generate_new_key": "Generer en ny nøkkel", + "Generate_New_Link": "Generer ny lenke", + "Generating_key": "Genererer nøkkel", + "Copy_link": "Kopier lenke", + "get-password-policy-forbidRepeatingCharacters": "Passordet bør ikke inneholde gjentakende tegn", + "get-password-policy-forbidRepeatingCharactersCount": "Passordet bør ikke inneholde mer enn {{forbidRepeatingCharactersCount}} gjentatte tegn", + "get-password-policy-maxLength": "Passordet bør maksimalt inneholde {{maxLength}} tegn", + "get-password-policy-minLength": "Passordet bør inneholde minst {{minLength}} tegn", + "get-password-policy-mustContainAtLeastOneLowercase": "Passordet bør inneholde minst én liten bokstav", + "get-password-policy-mustContainAtLeastOneNumber": "Passordet bør inneholde minst ett tall", + "get-password-policy-mustContainAtLeastOneSpecialCharacter": "Passordet bør inneholde minst ett spesialtegn", + "get-password-policy-mustContainAtLeastOneUppercase": "Passordet bør inneholde minst én stor bokstav", + "get-password-policy-minLength-label": "Minst {{limit}} tegn", + "get-password-policy-maxLength-label": "Maks {{limit}} tegn", + "get-password-policy-forbidRepeatingCharactersCount-label": "Maks. {{limit}} gjentatte tegn", + "get-password-policy-mustContainAtLeastOneLowercase-label": "Minst én liten bokstav", + "get-password-policy-mustContainAtLeastOneUppercase-label": "Minst en stor bokstav", + "get-password-policy-mustContainAtLeastOneNumber-label": "Minst ett tall", + "get-password-policy-mustContainAtLeastOneSpecialCharacter-label": "Minst ett symbol", + "get-server-info": "Hente serverinformasjon", + "get-server-info_description": "Tillatelse til å hente serverinformasjon", "github_no_public_email": "Du har ingen e-post som offentlig e-post i din GitHub-konto", + "github_HEAD": "HEAD", "Give_a_unique_name_for_the_custom_oauth": "Gi et unikt navn til den egendefinerte oauth", "strike": "streik", "Give_the_application_a_name_This_will_be_seen_by_your_users": "Gi søknaden et navn. Dette vil bli sett av brukerne.", "Global": "Global", + "Global Policy": "Global retningslinje ", "Global_purge_override_warning": "En global retensjonspolitikk er på plass. Hvis du lar \"Overstyr global retensjonspolitikk\" av, kan du bare bruke en policy som er strengere enn den globale politikken.", "Global_Search": "Globalt søk", "Go_to_your_workspace": "Gå til arbeidsområdet ditt", + "Go_to_accessibility_and_appearance": "Gå til tilgjengelighet og utseende", + "Google_Meet_Premium_only": "Google Meet (kun Premium)", + "Google_Play": "Google Play", + "Hold_Call": "Sett samtale på vent", + "Hold_Call_Premium_only": "Sett samtale på vent (kun Premium)", "GoogleCloudStorage": "Google Cloud Storage", "GoogleNaturalLanguage_ServiceAccount_Description": "Tjenestekonto-nøkkel JSON-fil. Mer informasjon finner du her [https://cloud.google.com/natural-language/docs/common/auth#set_up_a_service_account)", "GoogleTagManager_id": "Google Tag Manager ID", "Government": "Regjering", + "Graphql_CORS": "GraphQL CORS", + "Graphql_Enabled": "GraphQL aktivert", + "Graphql_Subscription_Port": "GraphQL abonnementsport", + "Grid_view": "Rutenett visning", "Snippet_Messages": "Utskriftsmeldinger", + "Group": "Gruppe", + "Group_by": "Grupper etter", "Group_by_Type": "Gruppe etter type", "snippet-message": "Utskriftsmelding", "snippet-message_description": "Tillatelse til å opprette tekstmelding", @@ -1400,41 +2126,63 @@ "Group_favorites": "Gruppe favoritter", "Group_mentions_disabled_x_members": "Gruppe nevner `@ alle` og` @ her` har blitt deaktivert for rom med flere enn {{total}} medlemmer.", "Group_mentions_only": "Gruppe nevner bare", + "Grouping": "Gruppering", + "Guest": "Gjest", "Hash": "hash", "Header": "Overskrift", "Header_and_Footer": "Topptekst og bunntekst", + "Pharmaceutical": "Farmasøytisk", + "Healthcare": "Helsevesen", "Helpers": "Hjelpere", + "Here_is_your_authentication_code": "Her er din autentiseringskode:", "Hex_Color_Preview": "Hex-fargeforhåndsvisning", + "Hi": "Hei", + "Hi_username": "Hei [navn]", "Hidden": "skjult", "Hide": "Skjul rom", "Hide_counter": "Skjul teller", "Hide_flextab": "Skjul høyre sidefelt med klikk", "Hide_Group_Warning": "Er du sikker på at du vil gjemme gruppen \"%s\"?", "Hide_Livechat_Warning": "Er du sikker på at du vil gjemme livechat med \"%s\"?", + "Hide_On_Workspace": "Skjul på arbeidsområdet", "Hide_Private_Warning": "Er du sikker på at du vil gjemme diskusjonen med \"%s\"?", "Hide_roles": "Skjul roller", "Hide_room": "Skjul rom", "Hide_Room_Warning": "Er du sikker på at du vil gjemme rommet \"%s\"?", + "Hide_System_Messages": "Skjul systemmeldinger", "Hide_Unread_Room_Status": "Skjul ulest romstatus", "Hide_usernames": "Skjul brukernavn", + "Hide_video": "Skjul video", + "High": "Høy", + "Highest": "Høyest", "Highlights": "Høydepunkter", "Highlights_How_To": "For å bli varslet når noen nevner et ord eller en setning, legg den til her. Du kan skille ord eller setninger med kommaer. Høydeord Ordene er ikke sosialfølsomme.", "Highlights_List": "Fremhev ord", "History": "Historie", + "Hold_Premium_only": "Vent (kun Premium-abonnement)", "Home": "Hjem", + "Homepage": "Hjemmeside", "Host": "Vert", "hours": "timer", "Hours": "timer", + "How_and_why_we_collect_usage_data": "Hvordan og hvorfor bruksdata samles inn", "How_friendly_was_the_chat_agent": "Hvor vennlig var chatteagenten?", "How_knowledgeable_was_the_chat_agent": "Hvor kunnskapsrik var chatagenten?", "How_long_to_wait_after_agent_goes_offline": "Hvor lenge å vente etter agent går offline", "How_responsive_was_the_chat_agent": "Hvor responsiv var chatagenten?", "How_satisfied_were_you_with_this_chat": "Hvor fornøyd var du med denne chatten?", "How_to_handle_open_sessions_when_agent_goes_offline": "Slik håndterer du åpne økter når agent går utenom", + "Http_timeout": "HTTP-tidsavbrudd (i millisekunder)", + "Http_timeout_value": "5000", + "HTML": "HTML", + "Icon": "Ikon", + "I_Saved_My_Password": "Jeg har lagret passordet mitt", "Idle_Time_Limit": "Inaktiv tidsbegrensning", "Idle_Time_Limit_Description": "Periode til status endres til vekk. Verdien må være i sekunder.", "if_they_are_from": "(hvis de er fra %s)", "If_this_email_is_registered": "Hvis denne e-posten er registrert, sender vi instruksjoner om hvordan du tilbakestiller passordet ditt. Hvis du ikke mottar en epost, vennligst kom tilbake og prøv igjen.", + "If_you_didnt_ask_for_reset_ignore_this_email": "Hvis du ikke har bedt om å tilbakestille passordet ditt, kan du ignorere denne e-posten.", + "If_you_didnt_try_to_login_in_your_account_please_ignore_this_email": "Hvis du ikke prøvde å logge på kontoen din, kan du ignorere denne e-posten.", "Iframe_Integration": "Iframe Integrasjon", "Iframe_Integration_receive_enable": "Aktiver mottak", "Iframe_Integration_receive_enable_Description": "Tillat foreldrevinduet å sende kommandoer til Rocket.Chat.", @@ -1446,12 +2194,15 @@ "Iframe_Integration_send_target_origin_Description": "Opprinnelse med protokollprefikset, hvilke kommandoer sendes til f.eks. 'https: // localhost', eller * for å tillate sending til hvor som helst.", "Ignore": "Overse", "Ignored": "ignorert", + "Ignore_Two_Factor_Authentication": "Ignorer tofaktorautentisering", + "Images": "Bilder", "IMAP_intercepter_already_running": "IMAP-intercepter kjører allerede", "IMAP_intercepter_Not_running": "IMAP-intercepter Ikke kjører", "Impersonate_next_agent_from_queue": "Legg til neste agent fra køen", "Impersonate_user": "Forsink brukeren", "Impersonate_user_description": "Når aktivert, integrering innlegg som brukeren som utløste integrasjon", "Import": "Import", + "Import_New_File": "Importer ny fil", "Importer_Archived": "arkivert", "Importer_CSV_Information": "CSV-importøren krever et bestemt format, vennligst les dokumentasjonen for hvordan du strukturerer zip-filen din:", "Importer_done": "Importerer komplett!", @@ -1462,6 +2213,7 @@ "Importer_import_cancelled": "Import avbrutt.", "Importer_import_failed": "Det oppsto en feil under kjøring av importen.", "Importer_importing_channels": "Importerer kanalene.", + "Importer_importing_files": "Importerer filene.", "Importer_importing_messages": "Importerer meldingene.", "Importer_importing_started": "Starter importen.", "Importer_importing_users": "Importerer brukerne.", @@ -1475,30 +2227,69 @@ "Importer_setup_error": "Det oppsto en feil under oppsett av importøren.", "Importer_Slack_Users_CSV_Information": "Filen som lastes opp må være Slack's Users-eksportfil, som er en CSV-fil. Se her for mer informasjon:", "Importer_Source_File": "Kildefilvalg", + "importer_status_done": "Fullført", + "importer_status_downloading_file": "Laster ned fil", + "importer_status_file_loaded": "Filen er lastet inn", "importer_status_finishing": "Nesten ferdig", + "importer_status_import_cancelled": "Avbrutt", "importer_status_import_failed": "Feil", + "importer_status_importing_channels": "Importerer kanaler", + "importer_status_importing_files": "Importerer filer", + "importer_status_importing_messages": "Importerer meldinger", + "importer_status_importing_started": "Importerer data", + "importer_status_importing_users": "Importerer brukere", + "importer_status_new": "Ikke startet", + "importer_status_preparing_started": "Leser filer", + "importer_status_preparing_users": "Leser brukerfil", + "importer_status_uploading": "Laster opp fil", + "importer_status_user_selection": "Klar til å velge hva som skal importeres", + "Importer_Upload_FileSize_Message": "Serverinnstillingene dine tillater opplasting av filer i alle størrelser opptil {{maxFileSize}}.", + "Importer_Upload_Unlimited_FileSize": "Deres serverinnstillinger tillater opplasting av filer i alle størrelser.", + "Importing_channels": "Importerer kanaler", + "Importing_Data": "Importerer data", + "Importing_messages": "Importerer meldinger", + "Importing_users": "Importerer brukere", + "Inactivity_Time": "Inaktivitetstid", + "In_progress": "Pågår", + "inbound-voip-calls": "Innkommende VoIP-anrop", + "inbound-voip-calls_description": "Tillatelse til innkommende VoIP-samtaler", + "Inbox_Info": "Innboks info", + "Include_Offline_Agents": "Inkluder frakoblede agenter", "Inclusive": "Inklusive", + "Incoming": "Innkommende", + "Incoming_call_from": "Innkommende anrop fra", "Incoming_Livechats": "Innkommende Livechats", "Incoming_WebHook": "Innkommende WebHook", "Industry": "Industri", + "Info": "Info", "initials_avatar": "Initialer Avatar", + "Inline_code": "Innline-kode", + "Install": "Installer", + "Install_anyway": "Installer allikevel ", "Install_Extension": "Installer utvidelse", "Install_FxOs": "Installer Rocket.Chat på din Firefox", "Install_FxOs_done": "Flott! Du kan nå bruke Rocket.Chat via ikonet på startskjermen. Ha det gøy med Rocket.Chat!", "Install_FxOs_error": "Beklager, det fungerte ikke som ønsket! Følgende feil oppstod:", "Install_FxOs_follow_instructions": "Vennligst bekreft appinstallasjonen på enheten din (trykk på \"Installer\" når du blir bedt om det).", + "Installing": "Installerer", "Install_package": "Installer pakken", "Installation": "Installasjon", + "Installed": "Installert", "Installed_at": "Installert på", "Instance_Record": "Instans Record", + "Instructions": "Instruksjoner", "Instructions_to_your_visitor_fill_the_form_to_send_a_message": "Instruksjoner til din besøkende fyller skjemaet for å sende en melding", + "Insert_Contact_Name": "Skriv inn kontaktnavn", + "Install_rocket_chat_on_your_preferred_desktop_platform": "Installer Rocket.Chat på din foretrukne skrivebordsplattform.", "Insurance": "Forsikring", "Integration_added": "Integrasjon er lagt til", "Integration_Advanced_Settings": "Avanserte innstillinger", + "Integration_Delete_Warning": "Sletting av en integrasjon kan ikke angres.", "Integration_disabled": "Integrasjon deaktivert", "Integration_History_Cleared": "Integrasjonshistorikk ble vellykket", "Integration_Incoming_WebHook": "Innkommende WebHook Integrasjon", "Integration_New": "Ny integrasjon", + "integration-scripts-disabled": "Integrasjonsskript er deaktivert", "Integration_Outgoing_WebHook": "Utgående WebHook Integrasjon", "Integration_Outgoing_WebHook_History": "Utgående WebHook Integration History", "Integration_Outgoing_WebHook_History_Data_Passed_To_Trigger": "Data bestått til integrasjon", @@ -1523,7 +2314,7 @@ "Integration_updated": "Integrasjon har blitt oppdatert.", "Integration_Word_Trigger_Placement": "Ordplassering hvor som helst", "Integration_Word_Trigger_Placement_Description": "Bør Ordet bli utløst når det plasseres hvor som helst i setningen annet enn begynnelsen?", - "Integrations": "integrasjoner", + "Integrations": "Integrasjoner", "Integrations_for_all_channels": "Skriv inn all_public_channels for å lytte på alle offentlige kanaler, all_private_groups for å lytte på alle private grupper, og all_direct_messages for å lytte til alle direkte meldinger.", "Integrations_Outgoing_Type_FileUploaded": "Filopplastet", "Integrations_Outgoing_Type_RoomArchived": "Rom arkivert", @@ -1545,10 +2336,14 @@ "Invalid_Department": "Ugyldig avdeling", "Invalid_email": "E-posten som er oppgitt, er ugyldig", "Invalid_Export_File": "Filen lastet opp er ikke en gyldig%s eksportfil.", + "Invalid_field": "Feltet må fylles ut", "Invalid_Import_File_Type": "Ugyldig import filtype.", "Invalid_name": "Navnet må ikke være tomt", "Invalid_notification_setting_s": "Ugyldig varslingsinnstilling:%s", + "Invalid_OAuth_client": "Ugyldig OAuth-klient", + "Invalid_or_expired_invite_token": "Ugyldig eller utløpt invitasjonstoken", "Invalid_pass": "Passordet må ikke være tomt", + "Invalid_password": "Ugyldig passord", "Invalid_reason": "Grunnen til å bli med må ikke være tom", "Invalid_room_name": "%s er ikke et gyldig romnavn", "Invalid_secret_URL_message": "Nettadressen som er oppgitt, er ugyldig.", @@ -1563,10 +2358,19 @@ "Invitation_HTML_Default": "

Du har blitt invitert til [Site_Name]

Gå til [Site_URL] og prøv den beste open source chat-løsningen tilgjengelig i dag!

", "Invitation_Subject": "Invitasjonsfag", "Invitation_Subject_Default": "Du har blitt invitert til [Site_Name]", + "Invite": "Invitasjon", + "Invites": "Invitasjoner", + "Invite_and_add_members_to_this_workspace_to_start_communicating": "Inviter og legg til medlemmer i dette arbeidsområdet for å begynne å kommunisere.", + "Invite_Link": "Invitasjonslenke", + "link": "lenke", + "Invite_link_generated": "Invitasjonslenken er generert", + "Invite_removed": "Invitasjonen ble fjernet", "Invite_user_to_join_channel": "Be en bruker til å bli med på denne kanalen", "Invite_user_to_join_channel_all_from": "Inviter alle brukere fra [#kanal] for å bli med på denne kanalen", "Invite_user_to_join_channel_all_to": "Inviter alle brukere fra denne kanalen til å delta i [#kanal]", "Invite_Users": "Invitere brukere", + "IP": "IP", + "IP_Address": "IP-adresse", "IRC_Channel_Join": "Output av kommandoen JOIN.", "IRC_Channel_Leave": "Output av DEL-kommandoen.", "IRC_Channel_Users": "Output av kommandoen NAMES.", @@ -1575,6 +2379,7 @@ "IRC_Enabled": "Forsøk på å integrere IRC-støtte. Hvis du endrer denne verdien, må du starte Rocket.Chat på nytt.", "IRC_Enabled_Alert": "IRC Support er et pågående arbeid. Bruk på et produksjonssystem anbefales ikke på dette tidspunktet.", "IRC_Federation": "IRC-føderasjonen", + "IRC_Federation_Description": "Koble til andre IRC-servere.", "IRC_Federation_Disabled": "IRC-føderasjonen er deaktivert.", "IRC_Hostname": "IRC-vertsserveren for å koble til.", "IRC_Login_Fail": "Output på en mislykket forbindelse til IRC-serveren.", @@ -1589,23 +2394,37 @@ "IssueLinks_LinkTemplate": "Mal for utgavekoblinger", "IssueLinks_LinkTemplate_Description": "Mal for utgavekoblinger; %s vil bli erstattet av problemnummeret.", "It_works": "Det fungerer", + "It_Security": "IT-ikkerhet", + "Italic": "Kursiv", "italics": "kursiv", + "Items_per_page:": "Elementer per side:", "Job_Title": "Jobbtittel", "Join": "Bli med", + "Join_with_password": "Bli med med passord", "Join_audio_call": "Bli med på lydanrop", + "Join_call": "Bli med i samtalen", "Join_Chat": "Bli med på Chat", "Join_default_channels": "Bli med i standardkanaler", "Join_the_Community": "Bli med i Fellesskapet", "Join_the_given_channel": "Bli med på den oppgitte kanalen", + "Join_rooms": "Bli med rom", "Join_video_call": "Bli med på videoanrop", + "Join_my_room_to_start_the_video_call": "Bli med i rommet mitt for å starte videosamtalen", "join-without-join-code": "Bli med uten å delta koden", "join-without-join-code_description": "Tillatelse til å omgå tilkoblingskoden i kanaler med tilkoblingskode aktivert", "Joined": "Ble med", + "joined": "ble med", + "Joined_at": "Ble med klokken", + "JSON": "JSON", "Jump": "Hoppe", "Jump_to_first_unread": "Gå til første uleste", "Jump_to_message": "Hopp til meldingen", "Jump_to_recent_messages": "Hopp til siste meldinger", "Just_invited_people_can_access_this_channel": "Bare inviterte personer kan få tilgang til denne kanalen.", + "kick-user-from-any-c-room": "Kast ut bruker fra alle offentlige Channel", + "kick-user-from-any-c-room_description": "Tillatelse til å sparke ut brukere fra alle offentlige kanaler", + "kick-user-from-any-p-room": "Spark brukeren fra private Channel", + "kick-user-from-any-p-room_description": "Tillatelse til å sparke en bruker fra private kanaler", "Katex_Dollar_Syntax": "Tillat Dollar Syntax", "Katex_Dollar_Syntax_Description": "Tillat bruk av $ katex blokk $ $ og $ inline katex $ syntakser", "Katex_Enabled": "Katex Aktivert", @@ -1621,6 +2440,8 @@ "Keyboard_Shortcuts_Keys_5": "Kommando(eller Alt) + Høyre pil", "Keyboard_Shortcuts_Keys_6": "Kommando(eller Alt) + Pil ned", "Keyboard_Shortcuts_Keys_7": "Skift+ Skriv inn", + "Keyboard_Shortcuts_Keys_8": "Shift (eller Ctrl) + ESC", + "Keyboard_Shortcuts_Mark_all_as_read": "Merker alle meldinger (i alle kanaler) som lest", "Keyboard_Shortcuts_Move_To_Beginning_Of_Message": "Flytt til begynnelsen av meldingen", "Keyboard_Shortcuts_Move_To_End_Of_Message": "Flytt til slutten av meldingen", "Keyboard_Shortcuts_New_Line_In_Message": "Ny linje i meldingen komponerer inngang", @@ -1629,23 +2450,79 @@ "Knowledge_Base": "Kunnskapsbase", "Label": "Etiketten", "Language": "Språk", + "Language_Bulgarian": "Bulgarsk", + "Language_Chinese": "Kinesisk", + "Language_Czech": "Tsjekkisk", + "Language_Danish": "Dansk", + "Language_Dutch": "Nederlandsk", + "Language_English": "Engelsk", + "Language_Estonian": "Estisk", + "Language_Finnish": "Finsk", + "Language_French": "Fransk", + "Language_German": "Tysk", + "Language_Greek": "Gresk", + "Language_Hungarian": "Ungarsk", + "Language_Italian": "Italiensk", + "Language_Japanese": "Japansk", + "Language_Latvian": "Latvisk", + "Language_Lithuanian": "Litauisk", "Language_Not_set": "Ingen spesifikk", + "Language_Polish": "Polsk", + "Language_Portuguese": "Portugisisk", + "Language_Romanian": "Rumensk", + "Language_Russian": "Russisk", + "Language_Slovak": "Slovakisk", + "Language_Slovenian": "Slovensk", + "Language_Spanish": "Spansk", + "Language_Swedish": "Svensk", "Language_Version": "Engelsk versjon", + "Last_7_days": "Siste 7 dager", + "Last_15_days": "Siste 15 dager", + "Last_30_days": "Siste 30 dager", + "Last_90_days": "Siste 90 dager", + "Last_6_months": "Siste 6 måneder", + "Last_year": "I fjor", + "Last_active": "Sist aktiv", + "Last_Call": "Siste samtale", "Last_login": "Siste innlogging", "Last_Message": "Siste melding", "Last_Message_At": "Siste melding på", "Last_seen": "Sist sett", "Launched_successfully": "Lansert vellykket", "Layout": "Oppsett", - "Layout_Home_Body": "Hjemmekroppen", + "Layout_Login_Hide_Logo": "Skjul logo", + "Layout_Login_Hide_Logo_Description": "Skjul logoen på påloggingssiden.", + "Layout_Login_Hide_Title": "Skjul tittel", + "Layout_Login_Hide_Title_Description": "Skjul tittelen på påloggingssiden.", + "Layout_Login_Template": "Påloggingsmal", + "Layout_Login_Template_Description": "Tilpass utseendet til påloggingssiden.", + "Layout_Login_Template_Vertical": "Vertikal", + "Layout_Login_Template_Horizontal": "Horisontal", "Layout_Home_Title": "Hjemtittel", "Layout_Login_Terms": "Innloggingsvilkår", "Layout_Privacy_Policy": "Personvernerklæring", + "Layout_Home_Custom_Block_Visible": "Vis egendefinert innhold til hjemmesiden", + "Layout_Custom_Body_Only": "Kun vis tilpasset innhold", "Layout_Sidenav_Footer": "Sidebeskrivelse Footer", "Layout_Sidenav_Footer_description": "Footer størrelse er 260 x 70px", "Layout_Sidenav_Footer_Dark_description": "Footer størrelse er 260 x 70px", "Layout_Terms_of_Service": "Vilkår for bruk", "LDAP": "LDAP", + "LDAP_Documentation": "LDAP-dokumentasjon", + "LDAP_Connection": "Forbindelse", + "LDAP_Connection_Authentication": "Autentisering", + "LDAP_Connection_Encryption": "Kryptering", + "LDAP_Connection_successful": "LDAP-tilkoblingen var vellykket ", + "LDAP_Connection_Timeouts": "Tidsavbrudd", + "LDAP_UserSearch": "Brukersøk", + "LDAP_UserSearch_Filter": "Søkefilter", + "LDAP_UserSearch_GroupFilter": "Gruppefilter", + "LDAP_DataSync": "Datasynkronisering", + "LDAP_DataSync_DataMap": "Kartlegging", + "LDAP_DataSync_Avatar": "Avatar", + "LDAP_DataSync_Advanced": "Avansert synkronisering", + "LDAP_DataSync_Roles": "Synkroniser roller", + "LDAP_DataSync_Channels": "Synkroniser kanaler", "LDAP_Authentication": "Aktiver", "LDAP_Authentication_Password": "Passord", "LDAP_Authentication_UserDN": "Bruker DN", @@ -1658,14 +2535,17 @@ "LDAP_Background_Sync_Interval_Description": "Intervallet mellom synkroniseringer. Eksempel \"hver 24. time\" eller \"på den første dagen i uken\", flere eksempler på [Cron Text Parser] (http://bunkat.github.io/later/parsers.html#text)", "LDAP_Background_Sync_Keep_Existant_Users_Updated": "Bakgrunnssynkronisering Oppdater eksisterende brukere", "LDAP_Background_Sync_Keep_Existant_Users_Updated_Description": "Vil synkronisere avataren, feltene, brukernavnet, etc (basert på konfigurasjonen) av alle brukere som allerede er importert fra LDAP på hver ** Sync Interval **", + "LDAP_Background_Sync_Merge_Existent_Users_Description": "Vil slå sammen alle brukere (basert på dine filterkriterier) som finnes i LDAP og også finnes i Rocket.Chat. For å aktivere dette, aktiver \"Slå sammen eksisterende brukere\"-innstillingen i kategorien Datasynkronisering.", "LDAP_BaseDN": "Base DN", "LDAP_BaseDN_Description": "Det fullt kvalifiserte Distinguished Name (DN) av en LDAP-subtree du vil søke etter brukere og grupper. Du kan legge til så mange som du liker; Hver gruppe må imidlertid defineres i samme domenebase som brukerne som tilhører den. Eksempel: `ou = Brukere + ou = Prosjekter, dc = Eksempel, dc = com`. Hvis du angir begrensede brukergrupper, er det bare brukere som tilhører disse gruppene. Vi anbefaler at du angir toppnivået til LDAP-katalogtreet ditt som domenebase og bruk søkefilter for å kontrollere tilgangen.", "LDAP_CA_Cert": "CA Cert", "LDAP_Connect_Timeout": "Tilkoblingstidsavbrudd (ms)", + "LDAP_DataSync_AutoLogout": "Automatisk utlogging av deaktiverte brukere", "LDAP_Default_Domain": "Standard domenenavn", "LDAP_Default_Domain_Description": "Hvis det leveres, vil standarddomenet brukes til å lage en unik e-post for brukere der e-post ikke ble importert fra LDAP. E-posten vil bli montert som `brukernavn@default_domai` eller`unique_id@default_domain`. \n Eksempel: `rocket.chat`", "LDAP_Enable": "Aktiver", "LDAP_Enable_Description": "Forsøk å bruke LDAP for autentisering.", + "LDAP_Enable_LDAP_Groups_To_RC_Teams": "Aktiver teamkartlegging fra LDAP til Rocket.Chat", "LDAP_Encryption": "kryptering", "LDAP_Encryption_Description": "Krypteringsmetoden brukes til å sikre kommunikasjon til LDAP-serveren. Eksempler er \"plain\" (ingen kryptering), `SSL / LDAPS` (kryptert fra starten) og` StartTLS` (oppgradering til kryptert kommunikasjon når den er tilkoblet).", "LDAP_Find_User_After_Login": "Finn bruker etter innlogging", @@ -1682,6 +2562,7 @@ "LDAP_Group_Filter_Group_Name_Description": "Gruppens navn som det tilhører brukeren", "LDAP_Group_Filter_ObjectClass": "Gruppe ObjectClass", "LDAP_Group_Filter_ObjectClass_Description": "Den *objektklasse* som identifiserer gruppene. \n f.eks. **OpenLDAP:** `groupOfUniqueNames`", + "LDAP_Groups_To_Rocket_Chat_Teams": "Teamkartlegging fra LDAP til Rocket.Chat.", "LDAP_Host": "Vert", "LDAP_Host_Description": "LDAP-verten, f.eks. `ldap.example.com` eller` 10.0.0.30`.", "LDAP_Idle_Timeout": "Idle Timeout (ms)", @@ -1694,6 +2575,7 @@ "LDAP_Merge_Existing_Users_Description": "* Forsiktig! * Når du importerer en bruker fra LDAP, og en bruker med samme brukernavn allerede eksisterer, blir LDAP-info og passord satt inn i den eksisterende brukeren.", "LDAP_Port": "Havn", "LDAP_Port_Description": "Port for å få tilgang til LDAP. f.eks .: `389` eller` 636` for LDAPS", + "LDAP_Prevent_Username_Changes": "Hindre LDAP-brukere fra å endre Rocket.Chat-brukernavnet sitt", "LDAP_Query_To_Get_User_Teams": "LDAP-spørring for å få brukergrupper", "LDAP_Reconnect": "koble", "LDAP_Reconnect_Description": "Prøv å koble til igjen automatisk når tilkoblingen avbrytes av en eller annen grunn mens du utfører operasjoner", @@ -1703,13 +2585,25 @@ "LDAP_Search_Page_Size_Description": "Maksimalt antall innføringer hver resultatside vil returnere for å bli behandlet", "LDAP_Search_Size_Limit": "Søk størrelsesgrense", "LDAP_Search_Size_Limit_Description": "Maksimalt antall oppføringer som skal returneres. \n **Oppmerksomhet** Dette nummeret skal være større enn **Søk på sidestørrelse**", + "LDAP_Sync_Custom_Fields": "Synkroniser egendefinerte felter", + "LDAP_Sync_AutoLogout_Enabled": "Aktiver automatisk utlogging", + "LDAP_Sync_AutoLogout_Interval": "Intervall for automatisk utlogging", "LDAP_Sync_Now": "Bakgrunnssynkronisering nå", "LDAP_Sync_Now_Description": "Vil utføre **Background Sync** nå i stedet for å vente **Sync Interval** selv om **Bakgrunnssynkronisering** er False. \n Denne handlingen er asynkron, se loggene for mer informasjon om prosess", + "LDAP_Sync_User_Active_State_Both": "Aktiver og deaktiver brukere", + "LDAP_Sync_User_Active_State_Disable": "Deaktiver brukere", + "LDAP_Sync_User_Active_State_Nothing": "Ikke gjør noe", "LDAP_Sync_User_Avatar": "Synkroniser User Avatar", + "LDAP_Sync_User_Data_Roles": "Synkroniser LDAP-grupper", + "LDAP_Sync_User_Data_Channels": "Automatisk synkroniser LDAP-grupper til kanaler", + "LDAP_Sync_User_Data_Channels_Admin": "Kanaladministrator", + "LDAP_Sync_User_Data_Roles_AutoRemove": "Fjern brukerroller automatisk", + "LDAP_Sync_User_Data_Roles_Filter": "Brukergruppefilter", "LDAP_Timeout": "Timeout (ms)", "LDAP_Timeout_Description": "Hvor mange milesekunder venter på et søkeresultat før du returnerer en feil", "LDAP_Unique_Identifier_Field": "Unikt identifikasjonsfelt", "LDAP_Unique_Identifier_Field_Description": "Hvilket felt vil bli brukt til å koble LDAP-brukeren og Rocket.Chat-brukeren. Du kan informere flere verdier adskilt av komma for å prøve å få verdien fra LDAP-posten. \n Standardverdien er `objectGUID,ibm-entryUUID,GUID,dominoUNID,nsuniqueId,uidNumber`", + "LDAP_User_Found": "LDAP-bruker funnet", "LDAP_User_Search_Field": "Søkefelt", "LDAP_User_Search_Field_Description": "LDAP-attributtet som identifiserer LDAP-brukeren som forsøker godkjenning. Dette feltet skal være `sAMAccountName` for de fleste Active Directory-installasjoner, men det kan være` uid` for andre LDAP-løsninger, for eksempel OpenLDAP. Du kan bruke `e-post 'til å identifisere brukere via e-post eller hva som helst attributt du vil. \n Du kan bruke flere verdier adskilt av komma for å tillate brukere å logge inn ved hjelp av flere identifikatorer som brukernavn eller e-post.", "LDAP_User_Search_Filter": "Filter", @@ -1717,9 +2611,19 @@ "LDAP_User_Search_Scope": "omfang", "LDAP_Username_Field": "Brukernavn felt", "LDAP_Username_Field_Description": "Hvilket felt vil bli brukt som *brukernavn* for nye brukere. Legg igjen tomt for å bruke brukernavnet informert på innloggingssiden. \n Du kan også bruke maltekoder, som `#{givenName}.#{Sn}`. \n Standardverdien er `sAMAccountName`.", + "LDAP_Username_To_Search": "Brukernavn å søke etter", "Lead_capture_email_regex": "Lead capture email regex", "Lead_capture_phone_regex": "Lead capture phone regex", + "Learn_more": "Lære mer", + "Learn_more_about_agents": "Finn ut mer om agenter", + "Learn_more_about_business_hours": "Finn ut mer om åpningstider", + "Learn_more_about_contacts": "Finn ut mer om kontakter", + "Learn_more_about_custom_fields": "Finn ut mer om egendefinerte felter", + "Learn_more_about_conversations": "Finn ut mer om samtaler", + "Learn_more_about_departments": "Lær mer om avdelinger", + "Learn_more_about_SLA_policies": "Lær mer om SLA-retningslinjer", "Leave": "Forlat rom", + "Leave_a_comment": "Legg igjen en kommentar", "Leave_Group_Warning": "Er du sikker på at du vil forlate gruppen \"%s\"?", "Leave_Livechat_Warning": "Er du sikker på at du vil forlate livechat med \"%s\"?", "Leave_Private_Warning": "Er du sikker på at du vil legge diskusjonen med \"%s\"?", @@ -1727,12 +2631,21 @@ "Leave_Room_Warning": "Er du sikker på at du vil forlate rommet \"%s\"?", "Leave_the_current_channel": "La den nåværende kanalen gå", "leave-c": "La kanaler", + "leave-c_description": "Tillatelse til å forlate kanaler", "leave-p": "Legg igjen private grupper", + "leave-p_description": "Tillatelse til å forlate private grupper", + "Let_them_know": "La dem vite", + "License": "Tillatelse", + "Line": "Linje", + "Link": "Lenke", "List_of_Channels": "Liste over kanaler", "List_of_Direct_Messages": "Liste over direkte meldinger", + "Livechat": "Livechat", "Livechat_agents": "Livechat agenter", "Livechat_Agents": "Agenter", + "Livechat_allow_manual_on_hold_Description": "Hvis aktivert, vil agenten få muligheten til å sette en chat på vent", "Livechat_AllowedDomainsList": "Livechat Tillatte Domener", + "Livechat_Appearance": "Livechat-utseende", "Livechat_Dashboard": "Livechat Dashboard", "Livechat_enabled": "Livechat aktivert", "Livechat_forward_open_chats": "Videresend åpne chatter", @@ -1740,8 +2653,12 @@ "Livechat_guest_count": "Gjesteteller", "Livechat_Inquiry_Already_Taken": "Livechat forespørsel allerede tatt", "Livechat_managers": "Livechat-ledere", + "Livechat_maximum_queue_wait_time": "Maksimal ventetid i kø", "Livechat_offline": "Livechat offline", + "Omnichannel_On_Hold_manually": "Chatten ble manuelt satt på vent av {{user}}", "Livechat_online": "Livechat online", + "Omnichannel_placed_chat_on_hold": "Chat på vent: {{comment}}", + "Omnichannel_hide_conversation_after_closing": "Skjul samtalen etter lukking", "Livechat_Queue": "Livechat Queue", "Livechat_registration_form": "Registreringsskjema", "Livechat_room_count": "Livechat Room Count", @@ -1749,6 +2666,12 @@ "Livechat_Take_Confirm": "Vil du ta denne klienten?", "Livechat_title": "Livechat-tittel", "Livechat_title_color": "Livechat-tittel Bakgrunnsfarge", + "Livechat_transfer_return_to_the_queue": "{{from}} satte chatten tilbake i køen", + "Livechat_transfer_to_agent": "{{from}} overførte chatten til {{to}}", + "Livechat_transfer_to_agent_with_a_comment": "{{from}} overførte chatten til {{to}} med en kommentar: {{comment}}", + "Livechat_transfer_to_agent_auto_transfer_unanswered_chat": "{{from}} overførte chatten til {{to}} siden den var ubesvart i {{duration}} sekunder", + "Livechat_transfer_to_department": "{{from}} overførte chatten til avdelingen {{to}}", + "Livechat_transfer_to_department_with_a_comment": "{{from}} overførte chatten til avdelingen {{to}} med en kommentar: {{comment}}", "Livechat_Users": "Livechat-brukere", "Livestream_close": "Lukk Livestream", "Livestream_enable_audio_only": "Aktiver kun lydmodus", @@ -1758,11 +2681,16 @@ "Livestream_switch_to_room": "Bytt til dagens romstrøm", "Livestream_url": "Livestream kilde URL", "Livestream_url_incorrect": "Livestream url er feil", + "Load_Balancing": "Lastbalansering", "Load_more": "Last mer", + "Loading": "Laster", "Loading_more_from_history": "Laster mer fra historien", "Loading_suggestion": "Laster inn forslag", "Loading...": "Laster inn ...", + "Local_Time": "Lokal tid", + "Local_Time_time": "Lokal tid: {{time}}", "Localization": "lokalisering", + "Location": "Lokasjon", "Log_Exceptions_to_Channel": "Log unntak fra kanal", "Log_Exceptions_to_Channel_Description": "En kanal som vil motta alle fanget unntak. La være tom for å ignorere unntak.", "Log_File": "Vis fil og linje", @@ -1775,12 +2703,16 @@ "Log_Trace_Subscriptions_Filter": "Spor abonnement filter", "Log_Trace_Subscriptions_Filter_Description": "Teksten her vil bli vurdert som RegExp (`ny RegExp ('text')`). Hold det tomt for å vise spor av hver samtale.", "Log_View_Limit": "Logggrense", - "Logged_out_of_other_clients_successfully": "Logget ut av andre klienter med hell", + "Logged_out_of_other_clients_successfully": "Logget ut av andre klienter", "Login": "Logg inn", "Login_with": "Logg inn med%s", "Logistics": "logistikk", "Logout": "Logg ut", "Logout_Others": "Logg ut fra andre logget på steder", + "Logout_Device": "Logg ut enhet", + "Logs": "Logger", + "Low": "Lav", + "Lowest": "Laveste", "Mail_Message_Invalid_emails": "Du har oppgitt en eller flere ugyldige e-poster:%s", "Mail_Message_Missing_to": "Du må velge en eller flere brukere eller gi en eller flere e-postadresser, skilt av kommaer.", "Mail_Message_No_messages_selected_select_all": "Du har ikke valgt noen meldinger", @@ -1819,26 +2751,39 @@ "MapView_Enabled_Description": "Aktivering av kartvisning vil vise en plasseringstasteknapp til venstre for chatinputfeltet.", "MapView_GMapsAPIKey": "Google Static Maps API-nøkkel", "MapView_GMapsAPIKey_Description": "Dette kan hentes fra Google Developers Console gratis.", + "Mark_all_as_read": "`%s` - Merk alle meldinger (i alle kanaler) som lest", "Mark_as_read": "Merk som lest", "Mark_as_unread": "Merk som ulest", "Mark_unread": "Merk som ulest", + "Marketplace_app_last_updated": "Sist oppdatert {{lastUpdated}}", "MAU_value": "MAU {{value}}", "Max_length_is": "Maks lengde er%s", "Max_number_of_chats_per_agent": "Maks antall samtidige chatter", "Max_number_of_chats_per_agent_description": "Maks antall samtidige chatter en agent kan delta i", + "Max_Retry": "Maksimalt antall forsøk på å koble til serveren på nytt", + "Maximum": "Maksimum", + "Maximum_number_of_guests_reached": "Maksimalt antall gjester er nådd", "Me": "Meg", "Media": "Media", "Medium": "Medium", + "Members": "Medlemmer", "Members_List": "Medlemsliste", "mention-all": "Nevne alt", "mention-all_description": "Tillatelse til å bruke @all nevne", + "Mentions_all_room_members": "Omtaler alle medlemmer av rommet", + "Mentions_online_room_members": "Omtaler alle påloggede medlemmer av rommet", + "Mentions_user": "Omtaler bruker", + "Mentions_channel": "Omtaler kanalen", + "Mentions_you": "Omtaler deg", "mention-here": "Nevn her", "mention-here_description": "Tillatelse til å bruke @here mention", "Mentions": "nevner", "Mentions_default": "Mentjoner (standard)", "Mentions_only": "Kun mentene", "Merge_Channels": "Flett kanaler", + "message": "melding", "Message": "Beskjed", + "Message_Description": "Konfigurer meldingsinnstillinger.", "Message_AllowBadWordsFilter": "Tillat melding om dårlig ord filtrering", "Message_AllowDeleting": "Tillat melding å slette", "Message_AllowDeleting_BlockDeleteInMinutes": "Blokker melding som slettes etter (n) minutter", @@ -1854,6 +2799,7 @@ "Message_AlwaysSearchRegExp": "Søk alltid med RegExp", "Message_AlwaysSearchRegExp_Description": "Vi anbefaler at du sier «True» hvis språket ikke støttes på [Søk etter MongoDB tekst](https://docs.mongodb.org/manual/reference/text-search-languages/#text-search-languages).", "Message_Attachments": "Melding Vedlegg", + "Message_with_attachment": "Melding med vedlegg", "Report_sent": "Rapport sendt", "Message_Audio": "Lydmelding", "Message_Audio_bitRate": "Lydmelding Bitrate", @@ -1873,11 +2819,18 @@ "Message_GlobalSearch": "Global søk", "Message_GroupingPeriod": "Grupperingstid (i sekunder)", "Message_GroupingPeriodDescription": "Meldinger vil bli gruppert med forrige melding hvis begge er fra samme bruker og den forløpte tiden var mindre enn informert tid i sekunder.", + "Message_has_been_edited": "Meldingen er redigert", + "Message_has_been_edited_at": "Meldingen ble redigert {{date}}", + "Message_has_been_edited_by": "Meldingen ble redigert av {{username}}", + "Message_has_been_edited_by_at": "Meldingen ble redigert av {{username}} den {{date}}", + "Message_has_been_forwarded": "Meldingen er videresendt", "Message_HideType_au": "Skjul \"User Added\" meldinger", "Message_HideType_mute_unmute": "Skjul \"User Muted / Unmuted\" meldinger", "Message_HideType_ru": "Skjul \"Bruker fjernet\" meldinger", "Message_HideType_uj": "Skjul \"User Join\" meldinger", "Message_HideType_ul": "Skjul \"User Leave\" meldinger", + "Message_HideType_wm": "Skjul velkomstmeldinger", + "Message_Id": "Meldings-ID", "Message_Ignored": "Denne meldingen ble ignorert", "Message_info": "Melding info", "Message_KeepHistory": "Behold beskjed om redigering av meldinger", @@ -1889,19 +2842,24 @@ "Message_Read_Receipt_Store_Users": "Detaljert Les kvitteringer", "Message_Read_Receipt_Store_Users_Description": "Viser hver brukers leserkvitteringer", "Message_removed": "Melding fjernet", + "Message_is_removed": "melding fjernet", "Message_sent_by_email": "Melding sendt via e-post", "Message_ShowDeletedStatus": "Vis slettet status", "Message_starring": "Melding med hovedrollen", + "Message_Time": "Meldingstid", "Message_TimeAndDateFormat": "Tid og datoformat", "Message_TimeAndDateFormat_Description": "Se også: [Moment.js](http://momentjs.com/docs/#/displaying/format/)", "Message_TimeFormat": "Tidsformat", "Message_TimeFormat_Description": "Se også: [Moment.js](http://momentjs.com/docs/#/displaying/format/)", "Message_too_long": "Melding for lenge", + "Message_UserId": "Bruker-ID", "Message_view_mode_info": "Dette endrer mengden plassmeldinger som tas opp på skjermen.", "Message_VideoRecorderEnabled": "Videoopptaker aktivert", "Message_VideoRecorderEnabledDescription": "Krever at video / webm-filer skal være en akseptert medietype i \"Filopplastings\" -innstillinger.", "messages": "meldinger", "Messages": "meldinger", + "Messages_selected": "Meldinger er valgt", + "Messages_sent": "Meldinger sendt", "Messages_that_are_sent_to_the_Incoming_WebHook_will_be_posted_here": "Meldinger som sendes til Incoming WebHook vil bli lagt ut her.", "Meta": "Meta", "Meta_custom": "Egendefinerte Meta Tags", @@ -1910,16 +2868,54 @@ "Meta_language": "Språk", "Meta_msvalidate01": "MSValidate.01", "Meta_robots": "roboter", + "meteor_status_connected": "Tilkoblet", + "meteor_status_connecting": "Kobler til...", + "meteor_status_failed": "Servertilkoblingen mislyktes", + "meteor_status_offline": "Frakoblet modus.", + "meteor_status_reconnect_in_other": "prøver igjen om {{count}} sekunder...", + "meteor_status_reconnect_in_one": "prøver igjen om {{count}} sekunder...", + "meteor_status_try_now_offline": "Koble til igjen", + "meteor_status_try_now_waiting": "Prøv nå", + "meteor_status_waiting": "Venter på serverforbindelse,", + "Method": "Metode", + "Mic_on": "Mikrofon på", + "Microphone": "Mikrofon", + "Microphone_access_not_allowed": "Mikrofontilgang var ikke tillatt, sjekk nettleserinnstillingene.", + "Mic_off": "Mikrofon av", "Min_length_is": "Min lengde er%s", + "Minimum": "Minimum", "Minimum_balance": "Minimumsbalanse", + "minute": "minutt", "minutes": "minutter", + "Missing_configuration": "Manglende konfigurasjon", "Mobex_sms_gateway_from_number": "Fra", "Mobex_sms_gateway_password": "Passord", "Mobex_sms_gateway_username": "Brukernavn", "Mobile": "Mobil", "Mobile_Push_Notifications_Default_Alert": "Standardvarsler for mobilvarsler", + "Moderation_Show_reports": "Vis rapporter", + "Moderation_Go_to_message": "Gå til melding", "Moderation_Delete_message": "Slett melding", + "Moderation_Dismiss_and_delete": "Avvis og slett", + "Moderation_Delete_this_message": "Slett meldingen", + "Moderation_Message_context_header": "Rapporterte melding(er)", + "Moderation_Action_View_reports": "Se rapporterte meldinger", + "Moderation_Hide_reports": "Skjul rapporter", + "Moderation_Deactivate_User": "Deaktiver bruker", + "Moderation_User_deactivated": "Bruker deaktivert", + "Moderation_Delete_all_messages": "Slett alle meldinger", + "Moderation_Duplicate_messages": "Dupliserte meldinger", + "Moderation_Report_reports": "Rapporter", + "Moderation_Reported_message": "Rapportert melding", + "Moderation_Message_already_deleted": "Meldingen er allerede slettet", + "Moderation_Reset_user_avatar": "Tilbakestill brukeravatar", + "Moderation_See_messages": "Se meldinger", + "Moderation_Avatar_reset_success": "Avatar tilbakestilt", + "Moderation_User_deleted_warning": "Brukeren som sendte meldingen(e) eksisterer ikke lenger eller er slettet.", "Monday": "mandag", + "Mongo_version": "Mongo versjon", + "MongoDB": "MongoDB", + "MongoDB_Deprecated": "MongoDB avviklet", "Monitor_history_for_changes_on": "Overvåk historikk for endringer på", "Monthly_Active_Users": "Månedlige aktive brukere", "More": "Mer", @@ -1927,18 +2923,25 @@ "More_direct_messages": "Flere direkte meldinger", "More_groups": "Flere private grupper", "More_unreads": "Flere diskusjoner", + "Most_recent_updated": "Sist oppdatert", "Move_beginning_message": "`%s` - Flytt til begynnelsen av meldingen", "Move_end_message": "`%s` - Flytt til slutten av meldingen", + "Move_queue": "Flytt til køen", "Msgs": "meld", "multi": "multi", + "Multi_line": "Flerlinje", + "Mute": "Demp", + "Mute_and_dismiss": "Demp og avvis", "Mute_all_notifications": "Slå av alle varsler", "Mute_Focused_Conversations": "Mute Fokuserte samtaler", "Mute_Group_Mentions": "Mute @all og @here nevner", "Mute_someone_in_room": "Stum på noen i rommet", "Mute_user": "Stopp brukeren", + "Mute_microphone": "Demp mikrofon", "mute-user": "Slå av brukeren", "mute-user_description": "Tillatelse til å dempe andre brukere i samme kanal", "Muted": "dempet", + "My Data": "Mine data", "My_Account": "Min konto", "My_location": "Min posisjon", "n_messages": "%s meldinger", @@ -1948,10 +2951,21 @@ "Name_of_agent": "Navn på agent", "Name_optional": "Navn (valgfritt)", "Name_Placeholder": "Vennligst skriv inn navnet ditt...", + "Navigation": "Navigasjon", + "Navigation_bar": "Navigasjonslinje", "Navigation_History": "Navigasjonshistorikk", + "Next": "Neste", + "Never": "Aldri", + "New": "Ny", "New_Application": "Ny applikasjon", + "New_Call": "Ny samtale", + "New_chat_in_queue": "Ny chat i kø", + "New_contact": "Ny kontakt", "New_Custom_Field": "Nytt tilpasset felt", "New_Department": "Ny avdeling", + "New_discussion": "Ny diskusjon", + "New_discussion_name": "Et meningsfylt navn for diskusjonsrommet", + "New_Email_Inbox": "Ny e-postinnboks", "New_integration": "Ny integrering", "New_line_message_compose_input": "`%s` - Ny linje i meldingen komponerer inngang", "New_logs": "Nye logger", @@ -1959,31 +2973,55 @@ "New_messages": "Nye meldinger", "New_password": "Nytt passord", "New_Password_Placeholder": "Vennligst oppgi nytt passord ...", + "New_SLA_Policy": "Ny SLA-retningslinje ", "New_role": "Ny rolle", "New_Room_Notification": "Nytt romvarsling", "New_Tag": "Ny tagg", "New_Trigger": "Ny utløser", "New_Unit": "Ny enhet", + "New_users": "Nye brukere", + "New_user": "Ny bruker", "New_version_available_(s)": "Ny versjon tilgjengelig (%s)", "New_videocall_request": "Ny videosamtaleforespørsel", "New_visitor_navigation": "Ny navigasjon: {{history}}", + "New_workspace_confirmed": "Nytt arbeidsområde bekreftet", + "New_workspace": "Nytt arbeidsområdet", "Newer_than": "Nyere enn", "Newer_than_may_not_exceed_Older_than": "\"Nyere enn\" kan ikke overstige \"Eldre enn\"", + "Nickname": "Kallenavn", + "Nickname_Placeholder": "Skriv inn kallenavnet ditt...", "No": "Nei", "No_available_agents_to_transfer": "Ingen tilgjengelige agenter for å overføre", "No_channels_yet": "Du er ikke en del av en kanal ennå", + "No_chats_yet": "Ingen chatter ennå", + "No_chats_yet_description": "Alle chattene dine vises her.", + "No_calls_yet": "Ingen anrop enda", + "No_calls_yet_description": "Alle dine anrop vil vises her.", + "No_contacts_yet": "Ingen kontakter enda", + "No_contacts_yet_description": "Alle kontakter vil vises her.", + "No_custom_fields_yet": "Foreløpig ingen egendefinerte felter", + "No_departments_yet": "Enda ingen avdelinger", "No_direct_messages_yet": "Ingen direkte meldinger.", + "No_Discussions_found": "Ingen diskusjoner funnet", "No_discussions_yet": "Ingen diskusjoner enda", + "No_emojis_found": "Ingen emojier funnet", "No_Encryption": "Ingen kryptering", + "No_files_found": "Ingen filer funnet", + "No_files_left_to_download": "Ingen filer igjen å laste ned", "No_groups_yet": "Du har ingen private grupper enda.", + "No_history": "Ingen historikk", "No_integration_found": "Ingen integrasjon funnet av den oppgitte id.", + "No_Limit": "Ingen grense", "No_livechats": "Du har ingen livechats", + "No_members_found": "Ingen medlemmer funnet", "No_mentions_found": "Ingen meldinger funnet", "No_messages_yet": "Ingen meldinger ennå", "No_pages_yet_Try_hitting_Reload_Pages_button": "Ingen sider ennå. Prøv å trykke på \"Last inn sider\" -knappen.", "No_pinned_messages": "Ingen pinnede meldinger", + "No_previous_chat_found": "Ingen tidligere chat funnet", "No_results_found": "Ingen resultater", "No_results_found_for": "Ingen resultater funnet for:", + "No_SLA_policies_yet": "Ingen SLA-retningslinjer enda", "No_snippet_messages": "Ingen utdrag", "No_starred_messages": "Ingen stjernemerkede meldinger", "No_such_command": "Ingen slik kommando: `/ {{command}}`", @@ -1995,7 +3033,11 @@ "Not_authorized": "Ikke autorisert", "Normal": "Normal", "Not_Available": "Ikke tilgjengelig", + "Not_assigned": "Ikke tildelt", "Not_found_or_not_allowed": "Ikke funnet eller ikke tillatt", + "Not_in_channel": "Ikke i kanalen", + "Not_started": "Ikke påbegynt", + "Not_Visible_To_Workspace": "Ikke synlig for arbeidsområdet", "Nothing": "Ingenting", "Nothing_found": "Ingenting funnet", "Notification_Desktop_Default_For": "Vis skrivebordsvarsler for", @@ -2008,10 +3050,17 @@ "Notifications_Sound_Volume": "Meldinger lydvolum", "Notify_active_in_this_room": "Gi beskjed til aktive brukere i dette rommet", "Notify_all_in_this_room": "Gi beskjed om alt i dette rommet", + "Now_Its_Visible_For_Everyone": "Nå er det synlig for alle", + "Default_Server_Timezone": "Server-tidssone", + "Default_Custom_Timezone": "Egendefinert tidssone", + "Default_User_Timezone": "Brukerens nåværende tidssone", "Num_Agents": "# Agenter", + "Number_in_seconds": "Antall i sekunder", + "Number_of_events": "Antall hendelser", "Number_of_messages": "Antall meldinger", "Number_of_most_recent_chats_estimate_wait_time": "Antall nylige chatter for å beregne estimert ventetid", "Number_of_most_recent_chats_estimate_wait_time_description": "Dette tallet definerer antall sist betjente rom som skal brukes til å beregne ventetid for kø.", + "OAuth": "OAuth", "OAuth_Application": "OAuth Application", "Objects": "objekter", "Off": "Av", @@ -2020,6 +3069,7 @@ "Office_Hours": "Kontortid", "Office_hours_enabled": "Kontortimer aktivert", "Office_hours_updated": "Kontortid oppdatert", + "offline": "frakoblet", "Offline": "offline", "Offline_DM_Email": "Direkte e-post-emne", "Offline_Email_Subject_Description": "Du kan bruke følgende plassholdere: \n - `[Site_Name]`, `[Site_URL]`, [User] og [Room] for henholdsvis søknadens navn, URL, brukernavn og romnavn. ", @@ -2031,26 +3081,53 @@ "Offline_message": "Frakoblet melding", "Offline_success_message": "Frakoblet suksessmelding", "Offline_unavailable": "Frakoblet utilgjengelig", + "Ok": "Ok", + "Old Colors": "Gamle farger", "Older_than": "Eldre enn", + "Omnichannel_External_Frame_Encryption_JWK": "Krypteringsnøkkel (JWK)", + "omnichannel_sla_change_history": "SLA-retningslinjene er endret: {{user}} endret SLA-retningslinjene til {{sla}}", + "Omnichannel_enable_department_removal": "Aktiver fjerning av avdeling", + "Omnichannel_enable_department_removal_alert": "Fjernede avdelinger kan ikke gjenopprettes, vi anbefaler å arkivere avdelingen i stedet.", "Omnichannel_Reports_Status_Open": "Åpne", "Omnichannel_Reports_Status_Closed": "Lukket", + "Omnichannel_Reports_Channels_Empty_Subtitle": "Dette diagrammet viser de mest brukte kanalene.", + "Omnichannel_Reports_Departments_Empty_Subtitle": "Dette diagrammet viser avdelingene som mottar flest samtaler.", + "Omnichannel_Reports_Status_Empty_Subtitle": "Dette diagrammet vil oppdateres så snart samtalene starter.", + "Omnichannel_Reports_Tags_Empty_Subtitle": "Dette diagrammet viser de mest brukte taggene.", + "Omnichannel_Reports_Agents_Empty_Subtitle": "Dette diagrammet viser hvilke agenter som mottar det høyeste volumet av samtaler.", "On": "På", + "On_Hold": "På vent", + "On_Hold_Chats": "På vent", + "On_Hold_conversations": "Samtaler på vent", "online": "på nett", "Online": "på nett", "Only_authorized_users_can_write_new_messages": "Kun autoriserte brukere kan skrive nye meldinger", + "Only_authorized_users_can_react_to_messages": "Kun autoriserte brukere kan reagere på meldinger", "Only_from_users": "Bare beskjære innhold fra disse brukerne (la tomt for å beskjære alles innhold)", "Only_On_Desktop": "Skrivebordsmodus (sendes bare med enter på skrivebordet)", "Only_you_can_see_this_message": "Bare du kan se denne meldingen", + "Only_invited_users_can_acess_this_channel": "Bare inviterte brukere har tilgang til denne kanalen", "Oops_page_not_found": "Ups, siden ble ikke funnet", "Oops!": "Oops", + "Person_Or_Channel": "Person eller Channel", "Open": "Åpne", + "Open_call": "Åpen samtale", + "Open_call_in_new_tab": "Åpne samtale i ny fane", "Open_channel_user_search": "`%s` - Åpne kanal / brukeresøk", + "Open_conversations": "Åpne samtaler", + "Open_Days": "Åpne dager", "Open_days_of_the_week": "Åpen dager i uken", + "Open_Dialpad": "Åpne tastaturet", + "Open_directory": "Åpne katalogen", "Open_Livechats": "Åpne Livechats", + "Open_Outlook": "Åpne Outlook", + "Open_settings": "Åpne innstillinger", + "Open_thread": "Åpne tråd", "Open_your_authentication_app_and_enter_the_code": "Åpne autentiseringsprogrammet ditt og skriv inn koden. Du kan også bruke en av sikkerhetskodene dine.", "Opened": "åpnet", "Opened_in_a_new_window": "Åpnet i nytt vindu.", "Opens_a_channel_group_or_direct_message": "Åpner en kanal, gruppe eller direkte melding", + "Optional": "Valgfri", "optional": "valgfri", "Options": "Egenskaper", "or": "eller", @@ -2061,6 +3138,7 @@ "Organization_Name": "Organisasjonsnavn", "Organization_Type": "Organisasjonstype", "Original": "Opprinnelig", + "OS": "OS", "OS_Arch": "OS Arch", "OS_Cpus": "OS CPU Count", "OS_Freemem": "OS Free Memory", @@ -2075,22 +3153,65 @@ "Others": "Andre", "OTR": "OTR", "OTR_is_only_available_when_both_users_are_online": "OTR er bare tilgjengelig når begge brukerne er online", + "outbound-voip-calls": "Utgående VoIP-anrop", + "Outgoing": "Utgående", "Outgoing_WebHook": "Utgående WebHook", "Outgoing_WebHook_Description": "Få data ut av Rocket.Chat i sanntid.", + "Outlook_authentication": "Outlook-autentisering", + "Outlook_authentication_disabled": "Outlook-autentisering er deaktivert", + "Outlook_calendar": "Outlook-kalender", + "Outlook_calendar_settings": "Outlook-kalenderinnstillinger", + "Outlook_Calendar": "Outlook-kalender", "Outlook_Calendar_Enabled": "aktivert", + "Outlook_Calendar_Outlook_Url": "Outlook URL", + "Outlook_Calendar_Outlook_Url_Description": "URL som brukes til å starte Outlook-nettappen.", + "Output_format": "Utgående format", + "Outlook_Sync_Failed": "Kunne ikke laste inn Outlook-hendelser.", + "Outlook_Sync_Success": "Outlook-hendelser synkronisert.", "Override_URL_to_which_files_are_uploaded_This_url_also_used_for_downloads_unless_a_CDN_is_given": "Overstyr URL-adressen til hvilke filer som lastes opp. Denne nettadressen brukes også til nedlastinger med mindre en CDN er gitt", + "Owner": "Eier", + "Page_not_exist_or_not_permission": "Siden eksisterer ikke, eller du har kanskje ikke tilgangstillatelse", + "Page_not_found": "Fant ikke siden", "Page_title": "Side tittel", "Page_URL": "Side URL", + "Pages": "Sider", + "Parent_channel_doesnt_exist": "Channel finnes ikke.", + "Participants": "Deltakere", "Password": "Passord", "Password_Change_Disabled": "Din Rocket.Chat-administrator har deaktivert endring av passord", + "Password_Changed_Description": "Du kan bruke følgende plassholdere:\n - `[passord]` for det midlertidige passordet.\n - `[navn]`, `[fname]`, `[lname]` for henholdsvis brukerens fulle navn, fornavn eller etternavn.\n - `[email]` for brukerens e-post.\n - `[Site_Name]` og `[Site_URL]` for henholdsvis applikasjonsnavn og URL.", + "Password_Changed_Email_Subject": "[Site_Name] - Passord endret", + "Password_changed_section": "Passord endret", "Password_changed_successfully": "Passordet ble endret", + "Password_History": "Passordhistorikk", + "Password_History_Amount": "Lengde på passordhistorikk", + "Password_History_Amount_Description": "Antall sist brukte passord for å hindre brukere i å gjenbruke.", + "Password_must_have": "Passordet må ha:", "Password_Policy": "Passordpolicy", + "Password_Policy_Aria_Description": "Nedenfor er det oppført verifikasjoner av passordkrav", + "Password_must_meet_the_complexity_requirements": "Passordet må oppfylle kompleksitetskravene.", + "Password_to_access": "Passord for tilgang", + "Passwords_do_not_match": "passordene er ikke like", "Past_Chats": "Tidligere Chats", + "Paste_here": "Lim inn her...", + "Paste": "Lim inn", + "Pause": "Pause", + "Paste_error": "Kunne ikke lese fra utklippstavlen", + "Paid_Apps": "Betalte apper", "Payload": "nyttelast", + "PDF": "PDF", "People": "Mennesker", "Permalink": "permalink", "Permissions": "tillatelser", + "Personal_Access_Tokens": "Personlige tilgangstokener", + "Phone": "Telefon", + "Phone_call": "Telefonsamtale", + "Phone_Number": "Telefonnummer", "Thank_you_exclamation_mark": "Takk skal du ha!", + "Thank_You_For_Choosing_RocketChat": "Takk for at du valgte Rocket.Chat!", + "Phone_already_exists": "Telefonen finnes allerede", + "Phone_number": "Telefonnummer", + "PID": "PID", "Pin_Message": "Pin melding", "pin-message": "Pin melding", "pin-message_description": "Tillatelse til å knytte en melding i en kanal", @@ -2108,6 +3229,9 @@ "PiwikAnalytics_url_Description": "Den url hvor Piwik er bosatt, sørg for å inkludere den bakre skråstreken. Eksempel: `https://piwik.rocket.chat/`", "Placeholder_for_email_or_username_login_field": "Plassholder for e-post eller brukernavn påloggingsfelt", "Placeholder_for_password_login_field": "Plassholder for passordloggfelt", + "Platform_Windows": "Windows", + "Platform_Linux": "Linux", + "Platform_Mac": "Mac", "Please_add_a_comment": "Vennligst legg til en kommentar", "Please_add_a_comment_to_close_the_room": "Vennligst legg til en kommentar for å lukke rommet", "Please_answer_survey": "Ta et øyeblikk for å svare på en rask undersøkelse om denne chatten", @@ -2129,6 +3253,7 @@ "Please_wait_while_OTR_is_being_established": "Vent mens OTR etableres", "Please_wait_while_your_account_is_being_deleted": "Vennligst vent mens kontoen din blir slettet ...", "Please_wait_while_your_profile_is_being_saved": "Vennligst vent mens profilen din blir lagret ...", + "Policies": "Retningslinjer", "Port": "Havn", "Post_as": "Legg inn som", "Post_to_Channel": "Legg til i kanal", @@ -2137,20 +3262,42 @@ "post-readonly_description": "Tillatelse til å legge inn en melding i en skrivebeskyttet kanal", "Preferences": "Preferanser", "Preferences_saved": "Innstillinger lagret", + "Preparing_list_of_channels": "Forbereder kanalliste", + "Preparing_list_of_messages": "Forbereder meldingsliste", + "Preparing_list_of_users": "Forbereder brukerliste", + "Presence": "Tilstedeværelse", + "Preview": "Forhåndsvisning", "preview-c-room": "Forhåndsvis offentlig kanal", "preview-c-room_description": "Tillatelse til å vise innholdet i en offentlig kanal før de ble med", + "Previous_month": "Forrige måned", + "Previous_week": "Forrige uke", + "Price": "Pris", + "Priorities": "Prioriteringer", + "Priority": "Prioritering", + "Priority_saved": "Prioritering lagret", + "Priority_removed": "Prioritering fjernet", + "Priorities_restored": "Prioriteringer gjenopprettet", "Privacy": "Privatliv", "Privacy_Policy": "Personvernerklæring", + "Privacy_policy": "Personvernerklæring", + "Privacy_summary": "Personvernsammendrag", "Private": "Privat", + "private": "privat", + "Private_channels": "Private kanaler", + "Private_Apps": "Private apper", "Private_Channel": "Privat kanal", + "Private_Channels": "Private kanaler", + "Private_Chats": "Private chatter", "Private_Group": "Privat gruppe", "Private_Groups": "Private grupper", "Private_Groups_list": "Liste over private grupper", "Private_Team": "Privat team", + "Productivity": "Produktivitet", "Profile": "Profil", "Profile_details": "Profildetaljer", "Profile_picture": "Profilbilde", "Profile_saved_successfully": "Profilen er lagret vellykket", + "Prometheus": "Prometheus", "Prune": "Sviske", "Prune_finished": "Prune ferdig", "Prune_Messages": "Beskjære meldinger", @@ -2162,8 +3309,12 @@ "Pruning_files": "Beskjæring av filer ...", "Pruning_messages": "Beskjæring av meldinger ...", "Public": "Offentlig", + "public": "offentlig", "Public_Channel": "Offentlig kanal", + "Public_Channels": "Offentlige kanaler", "Public_Community": "Offentlig fellesskap", + "Public_URL": "Offentlig URL", + "Purchased": "Anskaffet", "Push": "Trykk", "Push_apn_cert": "APN-sertifisering", "Push_apn_dev_cert": "APN Dev Cert", @@ -2182,16 +3333,24 @@ "Push_test_push": "Test", "Query": "Spørsmål", "Query_description": "Tilleggsbetingelser for å bestemme hvilke brukere som skal sende e-posten til. Uberegnede brukere blir automatisk fjernet fra spørringen. Det må være et gyldig JSON. Eksempel: \"{\" createdAt \": {\" $ gt \": {\" $ date \":\" 2015-01-01T00: 00: 00.000Z \"}}}\"", + "Query_is_not_valid_JSON": "Spørringen er ikke gyldig JSON", "Queue": "Kø", + "Queued": "Satt i kø", + "Queues": "Køer", + "Queue_Time": "Køtid", + "Queue_management": "Køstyring", "quote": "sitat", "Quote": "Sitat", "Random": "Tilfeldig", + "Rate Limiter": "Frekvensbegrensning ", + "Rate Limiter_Description": "Kontroller frekvensen av forespørsler som sendes eller mottas av serveren din for å forhindre cyberangrep og skraping.", "React_when_read_only": "Tillat reaksjon", "React_when_read_only_changed_successfully": "Tillat å reagere når bare lest ble endret", "Reacted_with": "Reagert med", "Reactions": "reaksjoner", "Read_by": "Les av", "Read_only": "Les bare", + "Readability": "Lesbarhet", "Read_only_changed_successfully": "Bare lest endret", "Read_only_channel": "Les kun kanal", "Read_only_group": "Read Only Group", @@ -2200,7 +3359,11 @@ "Reason_To_Join": "Årsak til å bli med", "Receive_alerts": "Motta varsler", "Receive_Group_Mentions": "Motta @all og @here nevner", + "Receive_login_notifications": "Motta påloggingsvarsler", + "Receive_Login_Detection_Emails": "Motta påloggingsdeteksjons-e-poster", + "Receive_Login_Detection_Emails_Description": "Motta en e-post hver gang en ny pålogging oppdages på kontoen din.", "Record": "Ta opp", + "recording": "opptak", "Redirect_URI": "Omdirigere URI", "Refresh_keys": "Oppdater nøkler", "Refresh_oauth_services": "Oppdater OAuth-tjenester", @@ -2223,39 +3386,70 @@ "Registration_Succeeded": "Registrering lyktes", "Registration_via_Admin": "Registrering via Admin", "Regular_Expressions": "Vanlig uttrykk", + "Reject_call": "Avvis anrop", "Release": "Utgivelse", + "Releases": "Utgivelser", "Religious": "Religiøs", "Reload": "Last", + "Reload_page": "Last inn siden på nytt", "Reload_Pages": "Oppdater sidene", "Remove": "Fjerne", "Remove_Admin": "Fjern Admin", "Remove_as_leader": "Fjern som leder", "Remove_as_moderator": "Fjern som moderator", "Remove_as_owner": "Fjern som eier", + "Remove_Channel_Links": "Fjern kanallenker", "Remove_custom_oauth": "Fjern tilpasset oauth", "Remove_from_room": "Fjern fra rommet", + "Remove_from_team": "Fjern fra teamet", "Remove_last_admin": "Fjerner siste admin", "Remove_someone_from_room": "Fjern noen fra rommet", + "remove-team-channel": "Fjern Teamkanal", "remove-user": "Fjern bruker", "remove-user_description": "Tillatelse til å fjerne en bruker fra et rom", "Removed": "fjernet", "Removed_User": "Fjernet bruker", + "Removed__roomName__from_this_team": "fjernet #{{roomName}} fra dette teamet", + "Removed__username__from_team": "fjernet @{{user_removed}} fra dette teamet", + "Removed__roomName__from_the_team": "fjernet #{{roomName}} fra dette teamet", + "Removed__username__from_the_team": "fjernet @{{user_removed}} fra dette teamet", "Reply": "Svare", + "Reply_in_direct_message": "Svar i direktemelding", + "Reply_in_thread": "Svar i tråden", + "Reply_via_Email": "Svar via e-post", "ReplyTo": "Svare på", + "Reports": "Rapporter", "Report_Abuse": "Rapporter misbruk", "Report_exclamation_mark": "Rapportere!", "Report_this_message_question_mark": "Rapporter denne meldingen?", + "Report_User": "Rapporter bruker", "Reporting": "rapportering", + "Request_comment_when_closing_conversation_description": "Hvis aktivert, må agenten angi en kommentar før samtalen avsluttes.", + "requests": "forespørsler", + "Requests": "Forespørsler", + "Requested": "Forespurt", + "Requested_At": "Forespurt klokken", + "Requested_By": "Forespurt av", + "Required": "Påkrevd", + "required": "påkrevd", "Require_all_tokens": "Krev alle tokens", "Require_any_token": "Krev noen token", "Require_password_change": "Krev passordendring", "Resend_verification_email": "Send bekreftelsesmeldingen på nytt", "Reset": "Tilbakestill", + "Reset_priorities": "Tilbakestill prioriteter", "Reset_Connection": "Tilbakestill tilkobling", + "Reset_E2E_Key": "Tilbakestill E2E-nøkkel", "Reset_password": "Tilbakestilling av passord", - "Reset_section_settings": "Tilbakestill seksjonsinnstillinger", + "Reset_section_settings": "Tilbakestill til standardinnstillinger", + "Reset_TOTP": "Tilbakestill TOTP", + "Responding": "Svarer", "Restart": "Omstart", "Restart_the_server": "Start serveren på nytt", + "restart-server": "Start serveren på nytt", + "restart-server_description": "Tillatelse til å starte serveren på nytt", + "Results": "Resultater", + "Resume": "Gjenoppta", "Retail": "Detaljhandel", "Retention_setting_changed_successfully": "Retenspolicyinnstillingen ble endret", "RetentionPolicy": "Retensjonspolitikk", @@ -2284,6 +3478,10 @@ "RetentionPolicyRoom_MaxAge": "Maksimal meldingsalder i dager (standard: {{max}})", "RetentionPolicyRoom_OverrideGlobal": "Overstyr global retensjonspolicy", "RetentionPolicyRoom_ReadTheDocs": "Pass på! Å endre disse innstillingene uten ytterst forsiktighet kan ødelegge all meldingshistorikk. Les dokumentasjonen før du slår på funksjonen herher.", + "Retry": "Prøv på nytt", + "Required_action": "Påkrevd handling", + "Notes": "Notater", + "Unsafe_Url": "Usikker URL", "Role": "rolle", "Role_Editing": "Rollredigering", "Role_Mapping": "Rollekobling", @@ -2295,29 +3493,43 @@ "Room_archivation_state_true": "arkivert", "Room_archived": "Rom arkivert", "room_changed_announcement": "Rommeldingen endret til: {{room_announcement}}av {{user_by}}", + "room_changed_avatar": "Romavatar endret av {{user_by}}", "room_changed_description": "Rombeskrivelsen endret til: {{room_description}}av {{user_by}}", "room_changed_privacy": "Romtype er endret til: {{room_type}}av {{user_by}}", "room_changed_topic": "Romemne endret til: {{room_topic}}av {{user_by}}", + "room_changed_type": "endret rommet til {{room_type}}", + "room_changed_topic_to": "endret rommets emne til {{room_topic}}", "Room_default_change_to_private_will_be_default_no_more": "Dette er en standardkanal, og endring av den til en privat gruppe vil føre til at den ikke lenger er en standardkanal. Vil du fortsette?", "Room_description_changed_successfully": "Rombeskrivelsen ble endret", + "room_disallowed_reactions": "ikke tillatte reaksjoner", "Room_has_been_archived": "Rom har blitt arkivert", + "Room_has_been_converted": "Room er konvertert", + "Room_has_been_created": "Room er opprettet", "Room_has_been_deleted": "Rommet har blitt slettet", + "Room_has_been_removed": "Room er fjernet", "Room_has_been_unarchived": "Rom har blitt arkivert", "Room_Info": "Rominformasjon", "room_is_blocked": "Dette rommet er blokkert", + "room_account_deactivated": "Denne kontoen er deaktivert", "room_is_read_only": "Dette rommet er kun skrivebeskyttet", "room_name": "Romnavn", "Room_name_changed": "Romnavnet endret til: {{room_name}}av {{user_by}}", + "Room_name_changed_to": "endret romnavn til {{room_name}}", "Room_name_changed_successfully": "Romnavnet ble endret", + "Room_not_exist_or_not_permission": "Rommet eksisterer ikke eller du har ikke tilgang", "Room_not_found": "Rom ikke funnet", "Room_password_changed_successfully": "Rompassordet ble endret", + "Room_Status_Open": "Åpen", "Room_topic_changed_successfully": "Romemne endret seg vellykket", "Room_type_changed_successfully": "Romtype er endret", "Room_type_of_default_rooms_cant_be_changed": "Dette er et standardrom, og typen kan ikke endres, vennligst kontakt med administratoren din.", "Room_unarchived": "Rom unarchived", + "Room_updated_successfully": "Rommet ble oppdatert!", "Room_uploaded_file_list": "Filer Liste", "Room_uploaded_file_list_empty": "Ingen filer tilgjengelig.", "Rooms": "Rom", + "Rooms_added_successfully": "Romet ble lagt til", + "Run_only_once_for_each_visitor": "Kjør bare én gang for hver besøkende", "run-import": "Kjør import", "run-import_description": "Tillatelse til å kjøre importørene", "run-migration": "Kjør migrering", @@ -2325,23 +3537,37 @@ "Running_Instances": "Kjører forekomster", "Runtime_Environment": "Runtime Environment", "S_new_messages_since_s": "%s nye meldinger siden%s", + "S_new_messages": "%s nye meldinger", "Same_As_Token_Sent_Via": "Samme som \"Token Sent Via\"", "Same_Style_For_Mentions": "Samme stil for nevner", "SAML": "SAML", + "SAML_Connection": "Tilkobling", + "SAML_General": "Generell", "SAML_Custom_Cert": "Tilpasset sertifikat", + "SAML_Custom_Debug": "Aktiver feilsøking", "SAML_Custom_Entry_point": "Tilpasset oppføringspunkt", "SAML_Custom_Generate_Username": "Generer brukernavn", "SAML_Custom_IDP_SLO_Redirect_URL": "IDP SLO Omadresser URL", + "SAML_Custom_Immutable_Property_EMail": "E-post", "SAML_Custom_Immutable_Property_Username": "Brukernavn", "SAML_Custom_Issuer": "Tilpasset utsteder", "SAML_Custom_Logout_Behaviour": "Logout Behavior", - "SAML_Custom_Logout_Behaviour_End_Only_RocketChat": "Bare logg ut fra Rocket.Chat", + "SAML_Custom_Logout_Behaviour_End_Only_RocketChat": "Kun logg ut fra Rocket.Chat", "SAML_Custom_Logout_Behaviour_Terminate_SAML_Session": "Avslutt SAML-økten", "SAML_Custom_Private_Key": "Privat nøkkelinnhold", "SAML_Custom_Provider": "Tilpasset leverandør", "SAML_Custom_Public_Cert": "Offentlig sertifisering", "SAML_Custom_user_data_fieldmap": "Brukerdatafeltkart", + "SAML_Custom_Username_Field": "Feltnavn for brukernavn", + "SAML_Custom_Username_Normalize": "Normaliser brukernavn", + "SAML_Custom_Username_Normalize_Lowercase": "Til små bokstaver", + "SAML_Custom_Username_Normalize_None": "Ingen normalisering", + "SAML_Role_Attribute_Sync": "Synkroniser brukerroller", "SAML_Section_1_User_Interface": "Brukergrensesnitt", + "SAML_Section_2_Certificate": "Sertifikat", + "SAML_Section_3_Behavior": "Oppførsel", + "SAML_Section_4_Roles": "Roller", + "SAML_Section_6_Advanced": "Avansert", "Saturday": "lørdag", "Save": "Lagre", "Save_changes": "Lagre endringer", @@ -2355,33 +3581,59 @@ "Scan_QR_code_alternative_s": "Hvis du ikke kan skanne QR-koden, kan du skrive inn kode manuelt i stedet:", "Scope": "omfang", "Screen_Share": "Skjermdel", + "Script": "Script", "Script_Enabled": "Skript aktivert", "Search": "Søk", + "Searchable": "Søkbar", + "Search_Apps": "Søk i apper", + "Search_Installed_Apps": "Søk i installerte apper", + "Search_Private_apps": "Søk i private apper", + "Search_Premium_Apps": "Søk i Premium-apper", "Search_by_file_name": "Søk etter filnavn", "Search_by_username": "Søk etter brukernavn", + "Search_by_category": "Søk på kategori", "Search_Channels": "Søk kanaler", + "Search_Chat_History": "Søk chathistorikk", "Search_current_provider_not_active": "Gjeldende søkeleverandør er ikke aktiv", + "Search_Devices_Users": "Søk etter enheter eller brukere", + "Search_Files": "Søk etter filer", "Search_message_search_failed": "Søkeforespørsel mislyktes", "Search_Messages": "Søk meldinger", "Search_Page_Size": "Sidestørrelse", "Search_Private_Groups": "Søk i private grupper", "Search_Provider": "Søk leverandør", + "Search_rooms": "Søk etter rom", + "Search_Rooms": "Søk etter rom", "Search_Users": "Søk brukere", + "used_limit_infinite": "{{brukt, tall}} / ∞", "seconds": "sekunder", "Secret_token": "Hemmelig Token", "Security": "Sikkerhet", + "See_all_themes": "Se alle temaene", + "See_documentation": "Se dokumentasjon", + "See_Pricing": "Se Priser", + "See_full_profile": "Se hele profilen", + "See_history": "Se historikk", + "Select": "Velg", "Select_a_department": "Velg en avdeling", + "Select_a_room": "Velg et rom", "Select_a_user": "Velg en bruker", "Select_an_avatar": "Velg en avatar", "Select_an_option": "Velg et alternativ", + "Select_at_least_one_user": "Velg minst én bruker", + "Select_at_least_two_users": "Velg minst to brukere", "Select_department": "Velg en avdeling", "Select_file": "Velg Fil", "Select_role": "Velg en rolle", "Select_service_to_login": "Velg en tjeneste for å logge inn for å laste inn bildet eller laste det opp direkte fra datamaskinen", + "Select_the_channels_you_want_the_user_to_be_removed_from": "Velg kanalene du vil at brukeren skal fjernes fra", + "Select_atleast_one_channel_to_forward_the_messsage_to": "Velg minst én kanal å videresende meldingen til", "Select_user": "Velg bruker", "Select_users": "Velg brukere", + "Select_period": "Velg periode", "Selected_agents": "Utvalgte agenter", "Selected_departments": "Valgte avdelinger", + "Selecting_users": "Velger brukere", "Send": "Sende", "Send_a_message": "Send en melding", "Send_a_test_mail_to_my_user": "Send en testmelding til brukeren min", @@ -2389,30 +3641,49 @@ "Send_confirmation_email": "Send bekreftelses-e-post", "Send_data_into_RocketChat_in_realtime": "Send data til Rocket.Chat i sanntid.", "Send_email": "Send e-post", + "Send_Email_SMTP_Warning": "For å sende denne e-posten må du konfigurere SMTP-e-postserveren", "Send_invitation_email": "Send invitasjons-e-post", "Send_invitation_email_error": "Du har ikke oppgitt noen gyldig e-postadresse.", "Send_invitation_email_info": "Du kan sende flere e-post invitasjoner samtidig.", "Send_invitation_email_success": "Du har sendt en invitasjons-e-post til følgende adresser:", + "Send_it_as_attachment_instead_question": "Sende det som vedlegg i stedet?", "Send_request_on_agent_message": "Send forespørsel om agentmeldinger", "Send_request_on_chat_close": "Send forespørsel om chat Lukk", "Send_request_on_lead_capture": "Send forespørsel om blyopptak", "Send_request_on_offline_messages": "Send forespørsel om offline meldinger", "Send_request_on_visitor_message": "Send forespørsel om besøksmeldinger", "Send_Test": "Send test", + "Export_as_PDF": "Eksporter som PDF", "Send_Visitor_navigation_history_as_a_message": "Send besøksnavigasjonsloggen som en melding", "Send_visitor_navigation_history_on_request": "Send besøksnavigasjonshistorikk på forespørsel", "Send_welcome_email": "Send velkomstmelding", "Send_your_JSON_payloads_to_this_URL": "Send JSON nyttelastene til denne nettadressen.", + "send-mail_description": "Tillatelse til å sende e-poster", + "send-many-messages": "Send mange meldinger", + "Sender_Info": "Avsender info", "Sending": "Sender ...", + "Sending_Invitations": "Sender invitasjoner", + "Sending_your_mail_to_s": "Sender e-posten din til %s", "Sent_an_attachment": "Sender et vedlegg", + "Sent_from": "Sendt fra", + "Separate_multiple_words_with_commas": "Skill flere ord med komma", "Served_By": "Servert av", + "Server": "Server", + "Server_already_added": "Server allerede lagt til", + "Server_doesnt_exist": "Serveren eksisterer ikke", + "Servers": "Servere", + "Server_Configuration": "Serverkonfigurasjon", "Server_Info": "Serverinfo", + "Server_name": "Server navn", "Server_Type": "Server Type", "Service": "Tjeneste", "Service_account_key": "Tjenesten konto nøkkel", + "Set_as_favorite": "Sett som favoritt", "Set_as_leader": "Sett som leder", "Set_as_moderator": "Sett som moderator", "Set_as_owner": "Sett som eier", + "Upload_app": "Last opp app", + "Set_random_password_and_send_by_email": "Angi tilfeldig passord og send via e-post", "set-moderator": "Sett moderator", "set-moderator_description": "Tillatelse til å sette andre brukere som moderator på en kanal", "set-owner": "Sett eier", @@ -2423,17 +3694,22 @@ "set-readonly_description": "Tillatelse til å angi en kanal for å lese kun kanal", "Settings": "Innstillinger", "Settings_updated": "innstillingene er oppdatert", + "Setup_SMTP": "Sett opp SMTP", "Setup_Wizard": "Setup Wizard", + "Setup_Wizard_Description": "Grunnleggende informasjon om arbeidsområdet ditt, for eksempel organisasjonsnavn og land.", "Setup_Wizard_Info": "Vi veileder deg gjennom å sette opp din første admin bruker, konfigurere organisasjonen din og registrere serveren din for å motta gratis push notifications og mer.", + "Share": "Dele", "Share_Location_Title": "Del lokasjon?", "Shared_Location": "Felles plassering", "Shortcut": "Snarvei", + "shortcut_name": "snarveisnavn", "Should_be_a_URL_of_an_image": "Skal være en URL til et bilde.", "Should_exists_a_user_with_this_username": "Brukeren må allerede eksistere.", "Show_agent_email": "Vis agent-e-post", "Show_all": "Vis alt", "Show_Avatars": "Vis avatars", "Show_counter": "Vis teller", + "Show_default_content": "Vis standardinnhold", "Show_email_field": "Vis e-postfelt", "Show_more": "Vis mer", "Show_name_field": "Vis navnefelt", @@ -2445,19 +3721,42 @@ "Show_room_counter_on_sidebar": "Vis romteller på sidebar", "Show_Setup_Wizard": "Vis oppsettveiviseren", "Show_the_keyboard_shortcut_list": "Vis hurtigtastlisten for tastaturet", + "Show_To_Workspace": "Vis til arbeidsområdet", + "Show_video": "Vis video", "Showing_archived_results": "

Viser %s arkiverte resultater

", + "Showing_current_of_total": "Viser {{current}} av {{total}}", "Showing_online_users": "Viser: {{total_showing}}, Online: {{online}}, Totalt: {{total}} brukere", "Showing_results": "

Viser %s resultater

", + "Showing_results_of": "Viser resultater %s - %s av %s", + "Show_usernames": "Vis brukernavn", + "Show_roles": "Vis roller", + "Show_or_hide_the_user_roles_of_message_authors": "Vis eller skjul brukerrollene til meldingsforfattere.", + "Show_or_hide_the_username_of_message_authors": "Vis eller skjul brukernavnet til meldingsforfatterne.", "Sidebar": "sidebar", "Sidebar_list_mode": "Sidebar Kanallistemodus", "Sign_in_to_start_talking": "Logg inn for å begynne å snakke", + "Sign_in_with__provider__": "Logg på med {{provider}}", "since_creation": "siden%s", "Site_Name": "Side navn", "Site_Url": "Nettstedets nettadresse", "Site_Url_Description": "Eksempel: `https://chat.domain.com/`", "Size": "Størrelse", + "Skin_tone": "Hudfarge", "Skip": "Hopp", + "Skip_to_main_content": "Gå til hovedinnhold", + "SLA_Policy": "SLA-retningslinje", + "SLA_Policies": "SLA-retningslinjer", + "SLA_removed": "SLA fjernet", + "Slack": "Slack", "Slack_Users": "Slack's Brukere CSV", + "SlackBridge_APIToken": "API-tokens (Legacy)", + "SlackBridge_UseLegacy": "Bruk Legacy API-tokens", + "SlackBridge_APIToken_Description": "Du kan konfigurere flere Slack-servere ved å legge til én API-token per linje.", + "SlackBridge_BotToken": "Bot-tokens", + "SlackBridge_BotToken_Description": "Du kan konfigurere flere Slack servere ved å legge til en Bot Token per linje.", + "SlackBridge_AppToken": "App-tokens", + "SlackBridge_AppToken_Description": "Du kan konfigurere flere Slack servere ved å legge til en apptoken per linje.", + "SlackBridge_SigningSecret_Description": "Du kan konfigurere flere Slack-servere ved å legge til én innloggingshemmelighet per linje.", "SlackBridge_error": "SlackBridge fikk en feil mens du importerte meldingene dine på%s:%s", "SlackBridge_finish": "SlackBridge er ferdig med å importere meldingene på%s. Vennligst last inn for å se alle meldinger.", "SlackBridge_Out_All": "SlackBridge Out All", @@ -2466,10 +3765,13 @@ "SlackBridge_Out_Channels_Description": "Velg hvilke kanaler som vil sende meldinger tilbake til Slack", "SlackBridge_Out_Enabled": "SlackBridge ut aktivert", "SlackBridge_Out_Enabled_Description": "Velg om SlackBridge også skal sende meldingene dine tilbake til Slack", + "SlackBridge_Remove_Channel_Links_Description": "Fjern den interne koblingen mellom Rocket.Chat-kanaler og Slack-kanaler. Koblingene vil bli gjenskapt basert på kanalnavnene.", "SlackBridge_start": "@%s har startet en SlackBridge-import på `#%s`. Vi forteller deg når den er ferdig.", "Slash_Gimme_Description": "Viser (つ ◕ ◕) つ før meldingen din", "Slash_LennyFace_Description": "Viser (͡ ° ͜ʖ ͡ °) etter meldingen", "Slash_Shrug_Description": "Viser ¯ \\ _ (ツ) _ / ¯ etter meldingen", + "Slash_Status_Description": "Angi statusmeldingen din", + "Slash_Status_Params": "Statusmelding", "Slash_Tableflip_Description": "Viser (╯ ° □ °) ╯ (┻━┻", "Slash_TableUnflip_Description": "Viser ── ノ (゜ - ゜ ノ)", "Slash_Topic_Description": "Angi emne", @@ -2483,33 +3785,49 @@ "Smarsh_MissingEmail_Email": "Manglende e-post", "Smarsh_MissingEmail_Email_Description": "E-posten som skal vises for en brukerkonto når e-postadressen mangler, skjer vanligvis med botkontoer.", "Smileys_and_People": "Smileys & People", + "SMS": "SMS", "SMS_Enabled": "SMS aktivert", "SMTP": "SMTP", "SMTP_Host": "SMTP-verten", "SMTP_Password": "SMTP-passord", "SMTP_Port": "SMTP-port", + "SMTP_Server_Not_Setup_Title": "SMTP-serveren er ikke konfigurert enda", "SMTP_Test_Button": "Test SMTP-innstillinger", "SMTP_Username": "SMTP Brukernavn", "Snippet_Added": "Opprettet på%s", "Snippet_name": "Kuttnavn", "Snippeted_a_message": "Lagde et utdrag {{snippetLink}}", "Social_Network": "Sosialt nettverk", + "Something_went_wrong": "Noe gikk galt", + "Something_went_wrong_try_again_later": "Noe gikk galt. Prøv igjen senere.", "Sorry_page_you_requested_does_not_exist_or_was_deleted": "Beklager, siden du ba om, finnes ikke eller ble slettet!", "Sort": "Sortere", + "Sort_By": "Sorter etter", + "Sorting_mechanism": "Sorteringsmekanisme", "Sort_by_activity": "Sorter etter aktivitet", "Sound": "Lyd", + "Sounds": "Lyder", "Sound_File_mp3": "Lydfil (mp3)", + "Sound File": "Lydfil", + "Source": "Kilde", + "Speakers": "Høyttalere", "SSL": "SSL", "Star_Message": "Stjernemelding", "Starred_Messages": "Stjernemerkede meldinger", "Start": "Start", + "Start_a_call": "Start en samtale", + "Start_a_call_with": "Start en samtale med", + "Start_a_free_trial": "Start en gratis prøveperiode", "Start_audio_call": "Start lydanrop", + "Start_call": "Start samtale", "Start_Chat": "Start Chat", + "Start_free_trial": "Start gratis prøveperiode", "Start_of_conversation": "Start samtalen", "Start_OTR": "Start OTR", "Start_video_call": "Start videosamtale", "Start_video_conference": "Start videokonferanse?", "Start_with_s_for_user_or_s_for_channel_Eg_s_or_s": "Start med %sfor bruker eller %sfor kanal. Eksempel: %seller %s", + "Started": "Startet", "Started_a_video_call": "Startet et videoanrop", "Started_At": "Startet på", "Statistics": "Statistikk", @@ -2523,19 +3841,29 @@ "Stats_Non_Active_Users": "Inaktive brukere", "Stats_Offline_Users": "Offline brukere", "Stats_Online_Users": "Online brukere", + "Stats_Total_Active_Apps": "Totalt aktive apper", "Stats_Total_Channels": "Totalt antall kanaler", - "Stats_Total_Direct_Messages": "Totalt direkte meldingsrom", + "Stats_Total_Connected_Users": "Totalt antall tilkoblede brukere", + "Stats_Total_Direct_Messages": "Direktemeldinger", + "Stats_Total_Installed_Apps": "Totalt antall installerte apper", "Stats_Total_Livechat_Rooms": "Totalt Livechat-rom", "Stats_Total_Messages": "Totalt antall meldinger", "Stats_Total_Messages_Channel": "Totalt antall meldinger i kanaler", "Stats_Total_Messages_Direct": "Totalt antall meldinger i direkte meldinger", "Stats_Total_Messages_Livechat": "Totalt antall meldinger i Livechats", "Stats_Total_Messages_PrivateGroup": "Totalt antall meldinger i private grupper", + "Stats_Total_Messages_Discussions": "I diskusjoner", "Stats_Total_Private_Groups": "Totalt Private Grupper", "Stats_Total_Rooms": "Totalt rom", + "Stats_Total_Uploads": "Totalt antall opplastinger", "Stats_Total_Users": "Totalt antall brukere", "Status": "Status", + "StatusMessage": "Statusmelding", + "StatusMessage_Changed_Successfully": "Statusmeldingen ble endret.", + "StatusMessage_Placeholder": "Hva gjør du akkurat nå?", + "StatusMessage_Too_Long": "Statusmeldingen må være kortere enn 120 tegn.", "Step": "Trinn", + "Stop_call": "Stopp samtale", "Stop_Recording": "Stopp innspilling", "Store_Last_Message": "Lagre siste melding", "Store_Last_Message_Sent_per_Room": "Lagre siste melding sendt på hvert rom.", @@ -2544,44 +3872,110 @@ "Stream_Cast_Address_Description": "IP eller vert av Rocket.Chat sentral Stream Cast. F.eks `192.168.1.1: 3000` eller` localhost: 4000`", "Subject": "Emne", "Submit": "Sende inn", + "Subscribe": "Abonner", "Success": "Suksess", "Success_message": "Suksessmelding", + "Suggestion_from_recent_messages": "Forslag fra siste meldinger", "Sunday": "søndag", "Support": "Support", "Survey": "undersøkelse", "Survey_instructions": "Vurder hvert spørsmål i henhold til din tilfredshet, 1 som betyr at du er helt utilfreds og 5 betyr at du er helt fornøyd.", "Symbols": "Symboler", + "Sync": "Synkroniser", + "Sync / Import": "Synkroniser / importer", "Sync_in_progress": "Synkronisering pågår", + "Sync_Interval": "Synkroniseringsintervall", "Sync_success": "Synk suksess", "Sync_Users": "Synkronisere brukere", "System_messages": "Systemmeldinger", "Tag": "stikkord", "Tag_removed": "Tagg fjernet", "Take_it": "Ta det!", + "Talk_Time": "Samtaletid ", + "Talk_to_an_expert": "Snakk med en ekspert", + "Talk_to_sales": "Snakk med salg", + "Talk_to_your_workspace_administrator_about_enabling_video_conferencing": "Snakk med arbeidsområdeadministratoren din om å aktivere videokonferanser", + "Talk_to_your_workspace_admin_to_address_this_issue": "Snakk med arbeidsområdeadministratoren din for å løse dette problemet.", + "Target user not allowed to receive messages": "Valgt bruker har ikke tillatelse til å motta meldinger", "TargetRoom": "Målrommet", "TargetRoom_Description": "Rommet der meldinger vil bli sendt som er et resultat av at denne hendelsen blir sparket. Bare ett målrom er tillatt og det må eksistere.", "Team": "Team", + "Team_Add_existing_channels": "Legg til eksisterende kanaler", + "Team_Add_existing": "Legg til eksisterende", + "Team_Channels": "Team-Channel", + "Team_Delete_Channel_modal_content_danger": "Dette kan ikke angres.", + "Team_Delete_Channel_modal_content": "Vil du slette denne Channel?", + "Team_has_been_created": "Teamet er opprettet", + "Team_has_been_deleted": "Teamet er slettet", + "Team_Info": "Teaminformasjon", + "Team_Mapping": "Teamkartlegging", + "Team_Name": "Teamnavn", + "Team_Remove_from_team_modal_content": "Vil du fjerne denne kanalen fra {{teamName}}? Kanalen flyttes tilbake til arbeidsområdet.", + "Team_Remove_from_team": "Fjern fra team", + "Teams": "Team", + "Teams_channels_didnt_leave": "Du valgte ikke følgende kanaler, så du forlater dem ikke:", + "Teams_channels_last_owner_delete_channel_warning": "Du er den siste eieren av denne kanalen. Når du konverterer teamet til en kanal, vil kanalen bli flyttet til arbeidsområdet.", + "Teams_channels_last_owner_leave_channel_warning": "Du er den siste eieren av denne kanalen. Når du forlater teamet, vil kanalen bli holdt inne i teamet, men du vil administrere den utenfra.", + "Teams_leaving_team": "Du forlater dette teamet.", + "Teams_channels": "Teamets kanaler", + "Teams_convert_channel_to_team": "Konverter til Team", + "Teams_delete_team_choose_channels": "Velg kanalene du vil slette. De du bestemmer deg for å beholde, vil være tilgjengelige på arbeidsområdet ditt.", + "Teams_delete_team_public_notice": "Vær oppmerksom på at offentlige Channel fortsatt vil være offentlige og synlige for alle.", + "Teams_delete_team_Warning": "Når du sletter et team, vil alt chatinnhold og konfigurasjon bli slettet.", + "Teams_delete_team": "Du er i ferd med å slette dette teamet.", + "Teams_deleted_channels": "Følgende Channel vil bli slettet:", + "Teams_Errors_Already_exists": "Teamet `{{name}}` eksisterer allerede.", + "Teams_Errors_team_name": "Du kan ikke bruke \"{{name}}\" som et teamnavn.", + "Teams_move_channel_to_team": "Flytt til Team", + "Teams_New_Title": "Opprett team", "Teams_New_Name_Label": "Navn", + "Teams_Info": "Teaminformasjon", + "Teams_leave": "Forlat teamet", + "Teams_left_team_successfully": "Du forlot teamet", + "Teams_members": "Teamets medlemmer", + "Teams_New_Add_members_Label": "Legg til medlemmer", "Teams_New_Broadcast_Description": "Kun autoriserte brukere kan skrive nye meldinger, men de andre brukerne vil kunne svare", "Teams_New_Description_Label": "Emne", + "Teams_New_Encrypted_Label": "Kryptert", "Teams_New_Private_Label": "Privat", + "Teams_New_Read_only_Description": "Alle brukere i dette teamet kan skrive meldinger", + "Teams_Public_Team": "Offentlig team", "Teams_Private_Team": "Privat team", + "Teams_removing_member": "Fjerner medlem", + "Teams_removing__username__from_team": "Du fjerner {{username}} fra dette teamet", + "Teams_removing__username__from_team_and_channels": "Du fjerner {{username}} fra dette teamet og alle dets Channel.", + "Teams_Select_a_team": "Velg et team", + "Teams_Search_teams": "Søk etter team", "Teams_New_Read_only_Label": "Les bare", "Technology_Services": "Teknologi Tjenester", + "Terms": "Vilkår", + "Terms_of_use": "Bruksvilkår", "Test_Connection": "Testforbindelse", + "Upgrade_tab_trial_guide": "Prøveveiledning", "Test_Desktop_Notifications": "Test skrivebordsbeskjeder", + "test-push-notifications": "Test push-varsler", + "test-push-notifications_description": "Tillatelse til å teste push-varsler", + "Texts": "Tekster", "Thank_you_for_your_feedback": "Takk for din tilbakemelding", "The_application_name_is_required": "Programnavnet kreves", + "The_application_will_be_able_to": "<1>{{appName}} vil kunne:", "The_channel_name_is_required": "Kanalnavnet er påkrevd", "The_emails_are_being_sent": "E-postene blir sendt.", "The_field_is_required": "Feltet%s er påkrevd.", "The_image_resize_will_not_work_because_we_can_not_detect_ImageMagick_or_GraphicsMagick_installed_in_your_server": "Bildestørrelsen fungerer ikke fordi vi ikke kan oppdage ImageMagick eller GraphicsMagick installert på serveren din.", + "The_message_is_a_discussion_you_will_not_be_able_to_recover": "Meldingen er en diskusjon, du vil ikke kunne gjenopprette meldingene!", + "The_necessary_browser_permissions_for_location_sharing_are_not_granted": "De nødvendige nettlesertillatelsene for posisjonsdeling ble ikke gitt", "The_redirectUri_is_required": "RedirectUri er påkrevd", + "The_selected_user_is_not_an_agent": "Den valgte brukeren er ikke en agent", "The_server_will_restart_in_s_seconds": "Serveren starter på nytt i%s sekunder", "The_setting_s_is_configured_to_s_and_you_are_accessing_from_s": "Innstillingen %s er konfigurert til %s og du får tilgang fra %s!", + "The_user_s_will_be_removed_from_role_s": "Brukeren %s vil bli fjernet fra rollen %s", "The_user_will_be_removed_from_s": "Brukeren blir fjernet fra%s", "The_user_wont_be_able_to_type_in_s": "Brukeren kan ikke skrive inn%s", + "The_workspace_has_exceeded_the_monthly_limit_of_active_contacts": "Arbeidsområdet har overskredet den månedlige grensen for aktive kontakter.", "Theme": "Tema", + "Themes": "Temaer", + "theme-color-attention-color": "Oppmerksomhetsfarge", "theme-color-component-color": "Komponentfarge", "theme-color-content-background-color": "Innhold Bakgrunnsfarge", "theme-color-custom-scrollbar-color": "Tilpasset rullegardinfarge", @@ -2594,6 +3988,8 @@ "theme-color-primary-font-color": "Primær skrifttype farge", "theme-color-rc-color-alert": "Varsling", "theme-color-rc-color-alert-light": "Alert Light", + "theme-color-rc-color-alert-message-primary": "Varslingsmelding Primær", + "theme-color-rc-color-alert-message-primary-background": "Varslingsmelding Primær bakgrunn", "theme-color-rc-color-button-primary": "Knapp Primær", "theme-color-rc-color-button-primary-light": "Knapp Primærlys", "theme-color-rc-color-content": "Innhold", @@ -2629,27 +4025,40 @@ "There_are_no_departments_added_to_this_unit_yet": "Ingen avdelinger er lagt til denne enheten enda", "There_are_no_departments_available": "Det er ingen tilgjengelige avdelinger", "There_are_no_integrations": "Det er ingen integrasjoner", + "There_are_no_rooms_for_the_given_search_criteria": "Det er ingen rom for de oppgitte søkekriteriene", "There_are_no_users_in_this_role": "Det er ingen brukere i denne rollen.", + "These_notes_will_be_available_in_the_call_summary": "Disse notatene vil være tilgjengelige i samtalesammendraget", + "This_agent_was_already_selected": "Denne agenten er allerede valgt", + "This_cant_be_undone": "Dette kan ikke angres.", "This_conversation_is_already_closed": "Denne samtalen er allerede stengt.", "This_email_has_already_been_used_and_has_not_been_verified__Please_change_your_password": "Denne e-posten er allerede brukt og har ikke blitt verifisert. Vennligst endre passordet ditt.", + "This_feature_is_currently_in_alpha": "Denne funksjonen er for øyeblikket i alpha!", "This_is_a_desktop_notification": "Dette er et stasjonært varsel", + "Input": "Input ", "This_is_a_push_test_messsage": "Dette er en push-testmelding", "This_room_has_been_archived_by__username_": "Dette rommet er arkivert av {{username}}", "This_room_has_been_unarchived_by__username_": "Dette rommet er blitt arkivert av {{username}}", "Threads": "Tråder", "Thursday": "Torsdag", "Time_in_seconds": "Tid i sekunder", + "Timezone": "Tidssone", "Title": "Tittel", "Title_bar_color": "Tittel bar farge", "Title_bar_color_offline": "Tittellinjefarge offline", "Title_offline": "Tittel offline", + "To": "Til", "To_additional_emails": "Til flere e-poster", "To_install_RocketChat_Livechat_in_your_website_copy_paste_this_code_above_the_last_body_tag_on_your_site": "For å installere Rocket.Chat Livechat på nettstedet ditt, kopier & lim inn denne koden over det siste < / body > ta på nettstedet ditt.", + "To_prevent_seeing_this_message_again_allow_popups_from_workspace_URL": "For å unngå å se denne meldingen igjen, sørg for at nettleserinnstillingene tillater åpning av popup-vinduer fra arbeidsområdets URL:", "to_see_more_details_on_how_to_integrate": "for å se flere detaljer om hvordan å integrere.", "To_users": "Til brukere", + "Today": "I dag", "Toggle_original_translated": "Bytt original / oversatt", + "toggle-room-e2e-encryption_description": "Tillatelse til å veksle e2e-krypteringsrom", + "Token": "Token", "Token_Access": "Token Access", "Token_Controlled_Access": "Token kontrollert tilgang", + "Token_has_been_removed": "Token er fjernet", "Token_required": "Token kreves", "Tokens_Minimum_Needed_Balance": "Minst nødvendig tokenbalanse", "Tokens_Minimum_Needed_Balance_Description": "Angi laveste nødvendige balanse for hvert token. Blank eller \"0\" for ikke grense.", @@ -2659,29 +4068,46 @@ "Tokens_Required_Input_Error": "Ugyldige skrevet tokens.", "Tokens_Required_Input_Placeholder": "Tokens aktiva navn", "Topic": "Emne", + "Total_abandoned_chats": "Totalt antall forlatte chatter", + "Total_conversations": "Totalt antall samtaler", "Total_Discussions": "Totalt antall diskusjoner", "Total_messages": "Totalt antall meldinger", + "Total_rooms": "Totalt antall rom", "Total_Threads": "Totalt antall tråder", + "Total_visitors": "Totalt antall besøkende", + "TOTP Invalid [totp-invalid]": "Kode eller passord er ugyldig", "Transcript_Enabled": "Spør besøkende hvis de vil ha en transkripsjon etter at Chat er lukket", "Transcript_message": "Melding til å vise når du spør om transkripsjon", "Transcript_of_your_livechat_conversation": "Transkripsjon av livechat-samtalen.", + "Translate": "Oversett", "Translated": "oversatt", + "Translate_to": "Oversett til", "Translations": "Oversettelser", "Travel_and_Places": "Reise og steder", "Trigger_removed": "Trigger fjernet", "Trigger_Words": "Trigger Ord", "Triggers": "Triggers", + "Troubleshoot": "Feilsøk", "Troubleshoot_Disable_Notifications": "Deaktiver varslinger", "True": "Ja", + "Try_now": "Prøv nå", "Tuesday": "tirsdag", "Turn_OFF": "Skru av", "Turn_ON": "Slå på", + "Turn_on_video": "Slå på video", + "Turn_on_microphone": "Slå på mikrofon", + "Turn_off_microphone": "Slå av mikrofon", + "Turn_off_video": "Slå av video", + "Two Factor Authentication": "Tofaktorautentisering", "Two-factor_authentication": "Tofaktorautentisering", "Two-factor_authentication_disabled": "Tofaktorautentisering deaktivert", + "Two-factor_authentication_email": "Tofaktorautentisering via e-post", + "Two-factor_authentication_email_is_currently_disabled": "Tofaktorautentisering via e-post er deaktivert for øyeblikket ", "Two-factor_authentication_enabled": "Tofaktorautentisering aktivert", "Two-factor_authentication_is_currently_disabled": "Tofaktorautentisering er for øyeblikket deaktivert", "Two-factor_authentication_native_mobile_app_warning": "ADVARSEL: Når du har aktivert dette, vil du ikke kunne logge på de innkommende mobilappene (Rocket.Chat +) ved hjelp av passordet ditt før de implementerer 2FA.", "Type": "Type", + "Types": "Typer", "Type_your_email": "Skriv inn din e-postadresse", "Type_your_job_title": "Skriv inn jobbtittel", "Type_your_message": "Skriv inn meldingen din", @@ -2697,18 +4123,31 @@ "UI_Unread_Counter_Style": "Ulest counter stil", "UI_Use_Name_Avatar": "Bruk Full Name Initials til å generere Standard Avatar", "UI_Use_Real_Name": "Bruk Real Name", + "unable-to-get-file": "Kan ikke hente filen", "Unarchive": "Opphev arkivering", "unarchive-room": "Unarchive Room", "unarchive-room_description": "Tillatelse til å unarchive kanaler", + "Unassigned": "Ikke tildelt", + "unauthorized": "Ikke autorisert", + "Unavailable": "Utilgjengelig", + "Unblock": "Fjern blokkering", "Unblock_User": "Fjern blokkering av bruker", + "Uncheck_All": "Fjern avmerking for alle", + "Undefined": "Ikke definert", "Unfavorite": "Fjern fra favoritt", + "Unfollow_message": "Slutt å følge melding", "Unignore": "Ikke ignorer", "Uninstall": "Avinstaller", "Unit_removed": "Enhet fjernet", + "Unique_ID_change_detected_learn_more_link": "Les mer", + "Unique_ID_change_detected": "Oppdaget endring av unik ID", + "Unknown_User": "Ukjent bruker", + "Unlimited": "Ubegrenset", "Unmute_someone_in_room": "Slå på noen på rommet", "Unmute_user": "Slå av brukeren", "Unnamed": "unnamed", "Unpin_Message": "Unpin Message", + "Unprioritized": "Uprioritert", "Unread": "ulest", "Unread_Count": "Ulest antall", "Unread_Count_DM": "Ulest antall for direkte meldinger", @@ -2717,17 +4156,24 @@ "Unread_Rooms": "Uleste rom", "Unread_Rooms_Mode": "Uleste rommodus", "Unread_Tray_Icon_Alert": "Uread Tray Icon Alert", - "Unstar_Message": "Fjern Star", + "Unstar_Message": "Fjern stjerne", + "Update": "Oppdater", + "Update_to_version": "Oppdater til {{version}}", "Update_your_RocketChat": "Oppdater Rocket.Chat", "Updated_at": "Oppdatert på", + "Upload": "Last opp", + "Upload_private_app": "Last opp privat app", "Upload_file_description": "Filbeskrivelse", "Upload_file_name": "Filnavn", "Upload_file_question": "Last opp fil?", "Upload_Folder_Path": "Last opp mappebane", + "Upload_From": "Last opp fra {{name}}", "Upload_user_avatar": "Last opp avatar", "Uploading_file": "Laster opp fil ...", "Uptime": "oppetid", "URL": "URL", + "URLs": "URL-er", + "Use": "Bruk", "Use_account_preference": "Bruk kontoinnstillinger", "Use_Emojis": "Bruk Emojis", "Use_Global_Settings": "Bruk Globale innstillinger", @@ -2740,14 +4186,18 @@ "Use_url_for_avatar": "Bruk URL for avatar", "Use_User_Preferences_or_Global_Settings": "Bruk brukerinnstillinger eller globale innstillinger", "User": "Bruker", + "User_menu": "Brukermeny", "User__username__is_now_a_leader_of__room_name_": "Bruker {{username}} er nå leder av {{room_name}}", "User__username__is_now_a_moderator_of__room_name_": "Bruker {{username}} er nå en moderator av {{room_name}}", "User__username__is_now_an_owner_of__room_name_": "Bruker {{username}} er nå eier av {{room_name}}", + "User__username__muted_in_room__roomName__": "Bruker {{username}} er dempet i rom {{roomName}}", "User__username__removed_from__room_name__leaders": "Bruker {{username}} fjernet fra {{room_name}} ledere", "User__username__removed_from__room_name__moderators": "Bruker {{username}} fjernet fra {{room_name}} moderatorer", "User__username__removed_from__room_name__owners": "Bruker {{username}} fjernet fra {{room_name}} eiere", + "User__username__unmuted_in_room__roomName__": "Bruker {{username}}, er ikke lengre dempet i rommet {{roomName}}", "User_added": "Bruker lagt til", "User_added_by": "Bruker {{user_added}}lagt til av {{user_by}}.", + "User_added_to": "la til {{user_added}}", "User_added_successfully": "Bruker lagt til", "User_and_group_mentions_only": "Bruker og gruppe nevner bare", "User_default": "Brukerstandard", @@ -2758,6 +4208,7 @@ "User_has_been_ignored": "Brukeren er ignorert", "User_has_been_muted_in_s": "Brukeren har blitt dempet i%s", "User_has_been_removed_from_s": "Brukeren er fjernet fra%s", + "User_has_been_removed_from_team": "Brukeren er fjernet fra teamet", "User_has_been_unignored": "Brukeren ignoreres ikke lenger", "User_Info": "brukerinformasjon", "User_Interface": "Brukergrensesnitt", @@ -2766,23 +4217,31 @@ "User_is_now_an_admin": "Brukeren er nå en administrator", "User_is_unblocked": "Brukeren er ulåst", "User_joined_channel": "Har sluttet seg til kanalen.", + "User_joined_the_channel": "ble med i kanalen", + "User_joined_the_conversation": "ble med i samtalen", "User_left": "Har forlatt kanalen.", + "User_left_this_channel": "forlot kanalen", + "User_left_this_team": "forlot laget", "User_logged_out": "Brukeren er logget ut", "User_management": "brukeradministrasjon", "User_mentions_only": "Bruker nevner bare", "User_muted": "Bruker Muted", "User_muted_by": "Bruker {{user_muted}}dempet av {{user_by}}.", + "User_has_been_muted": "dempet {{user_muted}}", "User_not_found": "Bruker ikke funnet", "User_not_found_or_incorrect_password": "Bruker ikke funnet eller feil passord", "User_or_channel_name": "Bruker- eller kanalnavn", "User_Presence": "Brukerens tilstedeværelse", "User_removed": "Brukeren er fjernet", "User_removed_by": "Bruker {{user_removed}}fjernet av {{user_by}}.", + "User_has_been_removed": "fjernet {{user_removed}}", "User_sent_a_message_on_channel": "{{username}} sendte en melding på {{channel}}", "User_sent_a_message_to_you": "{{username}} sendte deg en melding", "user_sent_an_attachment": "{{user}} sendte et vedlegg", "User_Settings": "Brukerinstillinger", + "User_started_a_new_conversation": "{{username}} startet en ny samtale", "User_unmuted_by": "Bruker {{user_unmuted}}unmuted av {{user_by}}.", + "User_has_been_unmuted": "dempet {{user_unmuted}}", "User_unmuted_in_room": "Bruker uutløst i rommet", "User_updated_successfully": "Brukeren er oppdatert vellykket", "User_uploaded_a_file_on_channel": "{{username}} lastet opp en fil på {{channel}}", @@ -2814,43 +4273,68 @@ "Username_is_already_in_here": "`@%s` er allerede her inne.", "Username_Placeholder": "Vennligst skriv inn brukernavn ...", "Username_title": "Registrer brukernavn", + "Username_has_been_updated": "Brukernavnet ble oppdatert", "Username_wants_to_start_otr_Do_you_want_to_accept": "{{username}} vil starte OTR. Ønsker du å godta?", + "Username_name_email": "Brukernavn, navn eller e-post", "Users": "brukere", + "Users must use Two Factor Authentication": "Brukere må bruke tofaktorautentisering", "Users_added": "Brukerne har blitt lagt til", "Users_in_role": "Brukere i rollen", + "UTC_Timezone": "UTC-tidssone", "UTF8_Names_Slugify": "UTF8 Navn Slugify", "Videocall_enabled": "Videoanrop aktivert", "Validate_email_address": "Bekreft e-postadresse", + "Validation": "Validering", + "Value_messages": "{{value}} meldinger", + "Value_users": "{{value}} brukere", "Verification": "Bekreftelse", "Verification_Description": "Du kan bruke følgende plassholdere: \n - `[Verification_Url]` for verifikasjonsadressen. \n - [navn], [fname], [lname] for brukerens fulle navn, fornavn eller etternavn. \n - `[email]` for brukerens e-postadresse. \n - `[Site_Name]` og `[Site_URL]` for henholdsvis programnavnet og nettadressen. ", "Verification_Email": "Klikk herfor å bekrefte kontoen din.", + "Verification_email_body": "Vennligst klikk på knappen nedenfor for å bekrefte e-postadressen din.", "Verification_email_sent": "Verifikasjons e-post sendt", "Verification_Email_Subject": "[Site_Name] - Bekreft kontoen din", "Verified": "Verified", "Verify": "Bekreft", + "Verify_your_email": "Bekreft e-posten din", + "Verify_your_email_with_the_code_we_sent": "Bekreft e-posten din med koden vi har sendt", "Version": "Versjon", + "Version_version": "Versjon {{version}}", "Video_Chat_Window": "Video Chat", "Video_Conference": "Video konferanse", + "Video_Conference_Info": "Møteinformasjon", + "Video_Conference_Url": "Møte-URL", "Video_message": "Videomelding", "Videocall_declined": "Videoanrop avslått.", + "video_conference_started": "_Startet en samtale._", + "video_conference_ended": "_Samtalen har sluttet._", + "video_livechat_started": "_Startet en videosamtale._", + "video_direct_calling": "_Ringer._", "View_mode": "Visningsmodus", "View_All": "Se alle medlemmer", + "View_channels": "Se kanaler", "View_Logs": "Se logger", + "View_the_Logs_for": "Se loggene for: \"{{name}}\"", + "view-all-teams": "Se alle team", + "view-all-teams_description": "Tillatelse til å se alle team", + "view-all-team-channels": "Se alle teamkanaler", "view-broadcast-member-list": "Se Medlemsliste i Broadcast Room", "view-c-room": "Se offentlig kanal", "view-c-room_description": "Tillatelse til å vise offentlige kanaler", "view-d-room": "Se direkte meldinger", "view-d-room_description": "Tillatelse til å vise direkte meldinger", + "view-device-management": "Se enhetsstyring", "view-full-other-user-info": "Se full annen brukerinformasjon", "view-full-other-user-info_description": "Tillatelse til å se hele profilen til andre brukere, inkludert kontoopprettelsesdato, siste innlogging, etc.", "view-history": "Se historikk", "view-history_description": "Tillatelse til å se kanalhistorikken", + "onboarding.component.form.action.registerNow": "Registrer deg nå", "view-join-code": "Vis Bli medlem", "view-join-code_description": "Tillatelse til å vise kanalen bli med koden", "view-joined-room": "Se tilknyttet rom", "view-joined-room_description": "Tillatelse til å vise de tilkoblede kanalene", "view-l-room": "Se Livechat-rom", "view-l-room_description": "Tillatelse til å vise livechat-kanaler", + "onboarding.page.awaitingConfirmation.subtitle": "Vi har sendt deg en e-post til {{emailAddress}} med en bekreftelseslenke. Vennligst bekreft at sikkerhetskoden nedenfor samsvarer med den i e-posten.", "view-livechat-manager": "Se Livechat Manager", "view-livechat-manager_description": "Tillatelse til å vise andre livechat-ledere", "view-livechat-rooms": "Se Livechat-rom", @@ -2873,11 +4357,15 @@ "Viewing_room_administration": "Vise rom administrasjon", "Visibility": "Synlighet", "Visible": "Synlig", + "Visible_To_Workspace": "Synlig for arbeidsområdet", "Visitor": "Besøkende", "Visitor_Info": "Visitor Info", + "Visitor_not_found": "Besøkende ikke funnet", "Visitor_Navigation": "Visitor Navigasjon", "Visitor_page_URL": "URL for besøkende siden", "Visitor_time_on_site": "Besøkende tid på stedet", + "Voip_call_duration": "Samtalen varte i {{duration}}", + "Voip_call_ended_unexpectedly": "Samtalen ble avbrutt uventet: {{reason}}", "Wait_activation_warning": "Før du kan logge inn, må kontoen din aktiveres manuelt av en administrator.", "Waiting_queue": "Kø", "Waiting_queue_message": "Melding for kø", @@ -2891,6 +4379,7 @@ "Webdav_Server_URL": "WebDAV Server Access URL", "Webdav_Username": "WebDAV Brukernavn", "Webhook_URL": "Webhook URL", + "Webhook_URL_not_set": "Webhook-URL er ikke angitt", "Webhooks": "Webhooks", "WebRTC_direct_audio_call_from_%s": "Direkte lydanrop fra%s", "WebRTC_direct_video_call_from_%s": "Direkte videosamtale fra%s", @@ -2902,27 +4391,39 @@ "WebRTC_monitor_call_from_%s": "Overvåk anrop fra%s", "WebRTC_Servers": "STUN / TURN servere", "WebRTC_Servers_Description": "En liste over STUN- og TURN-servere adskilt med komma. \n Brukernavn, passord og port er tillatt i formatet «brukernavn:passord@stun:vert:port` eller `brukernavn:passord@tur:vert:port`.", + "WebRTC_call_ended_message": " Samtalen ble avsluttet {{endTime}} – varte {{callDuration}}", "Website": "nettsted", "Wednesday": "onsdag", "Weekly_Active_Users": "Ukentlige aktive brukere", "Welcome": "Velkommen %s.", + "Welcome_to": "Velkommen til [Site_Name]", + "Welcome_to_workspace": "Velkommen til {{Site_Name}}", "Welcome_to_the": "Velkommen til", + "Why_did_you_chose__score__": "Hvorfor valgte du {{score}}?", "Why_do_you_want_to_report_question_mark": "Hvorfor vil du rapportere?", "will_be_able_to": "vil kunne", + "Without_SLA": "Uten SLA", "Worldwide": "Verdensomspennende", "Would_you_like_to_return_the_inquiry": "Vil du returnere forespørselen?", "Yes": "Ja", "Yes_archive_it": "Ja, arkiver det!", "Yes_clear_all": "Ja, fjern alt!", + "Yes_continue": "Ja, fortsett!", "Yes_delete_it": "Ja, slett det!", "Yes_hide_it": "Ja, skjul det!", "Yes_leave_it": "Ja, la det være!", "Yes_mute_user": "Ja, stum bruker!", "Yes_prune_them": "Ja, beskjære dem!", + "Yes_pin_message": "Ja, fest melding", "Yes_remove_user": "Ja, fjern bruker!", "Yes_unarchive_it": "Ja, unarchive det!", "yesterday": "i går", "You": "Du", + "You_reacted_with": "Du reagerte med {{emoji}}", + "Users_reacted_with": "{{users}} reagerte med {{emoji}}", + "Users_and_more_reacted_with": "{{users}} og {{counter}} andre reagerte med {{emoji}}", + "You_and_users_Reacted_with": "Du og {{users}} reagerte med {{emoji}}", + "You_users_and_more_Reacted_with": "Du, {{users}} og {{counter}} andre reagerte med {{emoji}}", "you_are_in_preview_mode_of": "Du er i forhåndsvisningsmodus av kanal # {{room_name}}", "You_are_logged_in_as": "Du er innlogget som", "You_are_not_authorized_to_view_this_page": "Du er ikke autorisert til å vise denne siden.", @@ -2932,10 +4433,14 @@ "You_can_use_webhooks_to_easily_integrate_livechat_with_your_CRM": "Du kan bruke webhooks for enkelt å integrere livechat med CRM.", "You_cant_leave_a_livechat_room_Please_use_the_close_button": "Du kan ikke forlate et livechat-rom. Vennligst bruk lukkeknappen.", "You_have_been_muted": "Du har blitt dempet og kan ikke snakke i dette rommet", + "You_have_been_removed_from__roomName_": "Du har blitt fjernet fra rommet {{roomName}}", "You_have_n_codes_remaining": "Du har {{number}} koder igjen.", "You_have_not_verified_your_email": "Du har ikke bekreftet e-posten din.", "You_have_successfully_unsubscribed": "Du har sluttet å abonnere fra vår mailliste.", "You_must_join_to_view_messages_in_this_channel": "Du må bli med for å vise meldinger i denne kanalen", + "You_mentioned___mentions__but_theyre_not_in_this_room": "Du nevnte {{mentions}}, men de er ikke i dette rommet.", + "You_mentioned___mentions__but_theyre_not_in_this_room_You_can_ask_a_room_admin_to_add_them": "Du nevnte {{mentions}}, men de er ikke i dette rommet. Du kan be en romadministrator om å legge dem til.", + "You_mentioned___mentions__but_theyre_not_in_this_room_You_let_them_know_via_dm": "Du nevnte {{mentions}}, men de er ikke i dette rommet. Du gir dem beskjed via DM.", "You_need_confirm_email": "Du må bekrefte din e-post for å logge inn!", "You_need_install_an_extension_to_allow_screen_sharing": "Du må installere en utvidelse for å tillate skjermdeling", "You_need_to_change_your_password": "Du må endre passordet ditt", @@ -2951,30 +4456,112 @@ "Your_email_has_been_queued_for_sending": "E-posten din har vært i kø for å sende", "Your_entry_has_been_deleted": "Oppføringen din er slettet.", "Your_file_has_been_deleted": "Filen din er slettet.", + "Your_invite_link_will_expire_after__usesLeft__uses": "Invitasjonslenken din utløper etter {{usesLeft}} anvendelser.", + "Your_invite_link_will_expire_on__date__": "Invitasjonslenken din utløper {{date}}.", + "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "Invitasjonskoblingen din utløper {{date}} eller etter {{usesLeft}} anvendelser.", "your_message": "din beskjed", "your_message_optional": "meldingen din (valgfritt)", "Your_password_is_wrong": "Ditt passord er feil!", "Your_push_was_sent_to_s_devices": "Din push ble sendt til%s-enheter", + "Your_request_to_join__roomName__has_been_made_it_could_take_up_to_15_minutes_to_be_processed": "Din forespørsel om å bli med i {{roomName}} er opprettet. Det kan ta opptil 15 minutter å behandle den. Du vil bli varslet når den er klar til bruk.", "Your_server_link": "Din serverkobling", "Your_workspace_is_ready": "Ditt arbeidsområde er klar til bruk 🎉", + "Youre_not_a_part_of__channel__and_I_mentioned_you_there": "Du er ikke en del av {{channel}} og jeg nevnte deg der", + "registration.page.login.errors.wrongCredentials": "Bruker finnes ikke eller så er passordet feil", + "registration.page.login.errors.invalidEmail": "Ugyldig e-post", "registration.page.registration.waitActivationWarning": "Før du kan logge inn, må kontoen din aktiveres manuelt av en administrator.", + "registration.page.login.register": "Ny her? <1>Opprett en konto", "registration.page.resetPassword.sent": "Hvis denne e-posten er registrert, sender vi instruksjoner om hvordan du tilbakestiller passordet ditt. Hvis du ikke mottar en epost, vennligst kom tilbake og prøv igjen.", + "registration.page.resetPassword.sendInstructions": "Send instruksjoner", + "registration.page.resetPassword.errors.invalidEmail": "Ugyldig epost", + "registration.page.guest.chooseHowToJoin": "Velg hvordan du vil bli med", + "registration.page.guest.loginWithRocketChat": "Logg inn med Rocket.Chat", + "registration.page.guest.continueAsGuest": "Fortsett som gjest", + "registration.component.welcome": "Velkommen til <1>Rocket.Chat arbeidsområdet", "registration.component.login": "Logg inn", "registration.component.login.userNotFound": "Bruker ikke funnet", + "registration.component.login.incorrectPassword": "feil passord", + "registration.component.switchLanguage": "Bytt til <1>{{name}}", "registration.component.resetPassword": "Tilbakestilling av passord", "registration.component.form.username": "Brukernavn", "registration.component.form.name": "Navn", + "registration.component.form.nameOptional": "Valgfritt navn", + "registration.component.form.createAnAccount": "Opprett en konto", + "registration.component.form.userAlreadyExist": "Brukernavn finnes allerede. Vennligst prøv et annet brukernavn.", "registration.component.form.emailAlreadyExists": "E-post finnes allerede", "registration.component.form.usernameAlreadyExists": "Brukernavn finnes allerede. Vennligst prøv et nytt brukernavn.", "registration.component.form.invalidEmail": "E-posten som er oppgitt, er ugyldig", "registration.component.form.email": "E-post", + "registration.component.form.emailPlaceholder": "eksempel@eksempel.no", "registration.component.form.password": "Passord", "registration.component.form.divider": "eller", "registration.component.form.submit": "Sende inn", + "registration.component.form.joinYourTeam": "Bli med teamet ditt", "registration.component.form.reasonToJoin": "Årsak til å bli med", "registration.component.form.invalidConfirmPass": "Passordbekreftelsen stemmer ikke overens med passordet", "registration.component.form.confirmPassword": "Bekreft passordet ditt", "registration.component.form.sendConfirmationEmail": "Send bekreftelses-e-post", - "Enterprise": "Bedriften", - "UpgradeToGetMore_engagement-dashboard_Title": "Analytics" + "onboarding.component.form.action.registerWorkspace": "Registrer arbeidsområde", + "onboarding.component.form.action.registerOffline": "Registrer deg offline", + "onboarding.component.form.action.completeRegistration": "Fullfør registrering", + "onboarding.component.emailCodeFallback": "Ikke mottatt e-post? <1>Send på nytt eller <3>Endre e-post.", + "onboarding.form.awaitConfirmationForm.content.securityCode": "Sikkerhetskode", + "onboarding.form.organizationInfoForm.subtitle": "Vi trenger å vite hvem du er.", + "onboarding.form.registeredServerForm.title": "Registrer arbeidsområdet ditt", + "subscription.callout.guestUsers": "gjester", + "subscription.callout.roomsPerGuest": "maks gjest per rom", + "subscription.callout.privateApps": "installerte private apper", + "subscription.callout.monthlyActiveContacts": "månedlige aktive kontakter", + "Upload_anyway": "Last opp allikevel", + "App_limit_reached": "Appgrensen er nådd", + "App_limit_exceeded": "Appgrensen er overskredet", + "Private_apps_limit_reached": "Grensen for private apper er nådd", + "Private_apps_limit_exceeded": "Grensen for private apper er overskredet", + "Community_Private_apps_limit_exceeded": "Grensen for Community-apper er overskredet.", + "Theme_high_contrast": "Høy kontrast", + "Highlighted_chosen_word": "Uthevet valgt ord", + "Create_a_password": "Opprett et passord", + "Create_an_account": "Opprett en konto", + "Get_all_apps": "Få alle appene teamet ditt trenger", + "No_private_apps_installed": "Ingen private apper installert", + "Contact_email": "Kontakt-epost ", + "Customer": "Kunde", + "Time": "Tid", + "Undo_request": "Angre forespørsel", + "No_permission": "Ingen tillatelse", + "User_Status": "Brukerstatus", + "New_custom_status": "Ny egendefinert status", + "Service_disabled": "Tjenesten er nå deaktivert", + "User_status_disabled_learn_more": "Brukerstatus er deaktivert", + "Go_to_workspace_settings": "Gå til arbeidsområdeinnstillinger", + "User_status_temporarily_disabled": "Brukerstatus er midlertidig deaktivert", + "Use_token": "Bruk token", + "Disconnected": "Frakoblet", + "Registration_Token": "Registreringstoken", + "RegisterWorkspace_Button": "Registrer arbeidsområde", + "RegisterWorkspace_Features_Marketplace_Disconnect": "Det vil ikke lenger være mulig å installere apper.", + "RegisterWorkspace_Setup_Steps": "Steg {{step}} av {{numberOfSteps}}", + "RegisterWorkspace_Setup_Have_Account_Title": "Har en konto?", + "RegisterWorkspace_Setup_No_Account_Title": "Har du ikke en konto?", + "RegisterWorkspace_Syncing_Complete": "Synkronisering fullført", + "Uninstall_grandfathered_app": "Vil du avinstallere {{appName}}?", + "All_rooms": "Alle rom", + "All_visible": "Alle synlige", + "Filter_by_room": "Filtrer etter romtype", + "Filter_by_visibility": "Filtrer etter synlighet", + "Premium": "Premium", + "Enterprise": "Premium", + "Solve_issues": "Løs problemer", + "Outdated": "Utdatert", + "Latest": "Siste", + "New_version_available": "Ny versjon tilgjengelig", + "trial": "prøve", + "Subscription": "Abonnement", + "Private_apps": "Privatapper", + "n_days_left": "{{n}} dager igjen", + "Contact_sales": "Kontakt salg", + "Finish_purchase": "Fullfør kjøpet", + "free_per_month_user": "$0 per måned per bruker", + "UpgradeToGetMore_engagement-dashboard_Title": "Analytics", + "Buy_more": "Kjøp mer" } \ No newline at end of file diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/pl.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/pl.i18n.json index 3c315fcd5b99..80c503041a17 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/pl.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/pl.i18n.json @@ -2,10 +2,14 @@ "500": "Wewnętrzny błąd serwera", "__count__empty_rooms_will_be_removed_automatically": "Liczba pokojów do automatycznego usunięcia: {{count}}.", "__count__empty_rooms_will_be_removed_automatically__rooms__": "Puste pokoje ({{count}}) zostaną automatycznie usunięte:
{{rooms}}.", - "__count__message_pruned": "{{count}} wiadomość(i) usuniętych", - "__count__message_pruned_plural": "{{count}} wiadomość(i) usuniętych", - "__usersCount__member_joined": "+ {{usersCount}} członek dołączył", - "__usersCount__member_joined_plural": "+ {{usersCount}} członków dołączyło", + "__count__message_pruned_few": "{{count}} wiadomość(i) usuniętych", + "__count__message_pruned_one": "{{count}} wiadomość(i) usuniętych", + "__count__message_pruned_many": "{{count}} wiadomość(i) usuniętych", + "__count__message_pruned_other": "{{count}} wiadomość(i) usuniętych", + "__usersCount__member_joined_few": "+ {{count}} członków dołączyło", + "__usersCount__member_joined_one": "+ {{count}} członek dołączył", + "__usersCount__member_joined_other": "+ {{count}} członków dołączyło", + "__usersCount__member_joined_many": "+ {{count}} członków dołączyło", "__usersCount__people_will_be_invited": "{{usersCount}} ludzi zostanie zostanie zaproszonych", "__username__is_no_longer__role__defined_by__user_by_": "Użytkownik {{username}} nie ma już roli {{role}}; zmienił to użytkownik {{user_by}}", "__username__was_set__role__by__user_by_": "Użytkownik {{username}} otrzymał rolę {{role}} od użytkownika {{user_by}}", @@ -632,13 +636,16 @@ "Auth_Token": "Token uwierzytelniający", "Authentication": "Uwierzytelnianie", "Author": "Autor", + "Calls_in_queue_few": "{{count}} połączeń w kolejce", "Author_Information": "Informacje o autorze", + "Calls_in_queue_many": "{{count}} połączeń w kolejce", "Author_Site": "Strona autora", "Authorization_URL": "Adres URL uwierzytelniania", "Authorize": "Autoryzuj", "Auto_Load_Images": "Automatycznie ładuj obrazy", "Auto_Selection": "Automatyczny wybór", "Auto_Translate": "Tłumacz automatycznie", + "Calls_in_queue": "Połączeń w kolejce: {{calls}}", "auto-translate": "Tłumacz automatycznie", "auto-translate_description": "Uprawnienie do używania narzędzia do tłumaczenia automatycznego", "Automatic_Translation": "Tłumaczenie automatyczne", @@ -769,9 +776,9 @@ "Call_Center_Description": "Konfiguracja Rocket.Chat call center.", "Call_ended": "Połączenie zakończone", "Calls": "Połączenia", - "Calls_in_queue": "Połączeń w kolejce: {{calls}}", - "Calls_in_queue_plural": "{{calls}} połączeń w kolejce", - "Calls_in_queue_empty": "Kolejka jest pusta", + "Calls_in_queue_zero": "Kolejka jest pusta", + "Calls_in_queue_one": "Połączeń w kolejce: {{count}}", + "Calls_in_queue_other": "{{count}} połączeń w kolejce", "Call_declined": "Połączenie odrzucone!", "Call_Information": "Informacje o połączeniu", "Call_provider": "Dostawca połączenia", @@ -1007,7 +1014,6 @@ "Connection_Closed": "Połączenie zamknięte", "Connection_Reset": "Reset połączenia", "Connection_error": "Błąd połączenia", - "Connection_success": "Nawiązano połączenie z LDAP", "Connection_failed": "Nie można nawiązać połączenia z LDAP", "Connectivity_Services": "Usługi łączności", "Consulting": "Doradztwo", @@ -2562,7 +2568,9 @@ "Language_Not_set": "Brak konkretów", "Language_Polish": "Polski", "Language_Portuguese": "Portugalski", + "message_counter_few": "{{count}} wiadomości", "Language_Romanian": "Rumuński", + "message_counter_many": "{{count}} wiadomości", "Language_Russian": "Rosyjski", "Language_Slovak": "Słowacki", "Language_Slovenian": "Słoweński", @@ -2602,6 +2610,7 @@ "LDAP_Connection": "Połączenie", "LDAP_Connection_Authentication": "Autentykacja", "LDAP_Connection_Encryption": "Szyforwanie", + "LDAP_Connection_successful": "Nawiązano połączenie z LDAP", "LDAP_Connection_Timeouts": "Timeout'y", "LDAP_UserSearch": "Wyszukiwanie użytkowników", "LDAP_UserSearch_Filter": "Filtr wyszukiwania", @@ -2637,6 +2646,8 @@ "LDAP_Background_Sync_Import_New_Users": "Synchronizacja tła Importuj nowych użytkowników", "LDAP_Background_Sync_Import_New_Users_Description": "Zaimportuje wszystkich użytkowników (w oparciu o kryteria filtru), które istnieją w LDAP i nie istnieje w Rocket.Chat", "LDAP_Background_Sync_Interval": "Interwał synchronizacji tła", + "meteor_status_reconnect_in_few": "spróbuj jeszcze raz za {{count}} sekund...", + "meteor_status_reconnect_in_many": "spróbuj jeszcze raz za {{count}} sekund...", "LDAP_Background_Sync_Interval_Description": "Odstęp między synchronizacjami. Przykład \"co 24 godziny\" lub \"pierwszego dnia tygodnia\", więcej przykładów w [Cron Text Parser] (http://bunkat.github.io/later/parsers.html#text)", "LDAP_Background_Sync_Keep_Existant_Users_Updated": "Aktualizacja synchronizacji w tle Istniejących użytkowników", "LDAP_Background_Sync_Keep_Existant_Users_Updated_Description": "Zsynchronizuje awatar, pola, nazwę użytkownika itp. (W zależności od konfiguracji) wszystkich użytkowników już zaimportowanych z LDAP na każdy ** Interwał synchronizacji **", @@ -2761,7 +2772,7 @@ "leave-c_description": "Zezwolenie na opuszczenie kanałów", "leave-p": "Opuść grupy prywatne", "leave-p_description": "Zezwolenie na opuszczenie grup prywatnych", - "Lets_get_you_new_one": "Zróbmy ci nową!", + "Lets_get_you_new_one_": "Zróbmy ci nową!", "License": "Licencja", "Link_Preview": "Podgląd linków", "List_of_Channels": "Lista kanałów", @@ -3067,8 +3078,8 @@ "Message_Characther_Limit": "Limit znaków wiadomości", "Message_Code_highlight": "Lista języków podświetlania kodu", "Message_Code_highlight_Description": "Comma separated list of languages (all supported languages at [highlight.js](https://github.com/highlightjs/highlight.js/tree/11.6.0#supported-languages)) that will be used to highlight code blocks", - "message_counter": "{{counter}} wiadomość", - "message_counter_plural": "{{counter}} wiadomości", + "message_counter_one": "{{count}} wiadomość", + "message_counter_other": "{{count}} wiadomości", "Message_DateFormat": "Format daty", "Message_DateFormat_Description": "Zobacz także: [Moment.js](http://momentjs.com/docs/#/displaying/format/)", "Message_deleting_blocked": "Ta wiadomość nie może być już usunięta", @@ -3168,8 +3179,8 @@ "meteor_status_connecting": "Łączenie...", "meteor_status_failed": "Serwer nie mógł się połączyć", "meteor_status_offline": "Tryb offline.", - "meteor_status_reconnect_in": "spróbuj jeszcze raz za chwilę...", - "meteor_status_reconnect_in_plural": "spróbuj jeszcze raz za {{count}} sekund...", + "meteor_status_reconnect_in_one": "spróbuj jeszcze raz za chwilę...", + "meteor_status_reconnect_in_other": "spróbuj jeszcze raz za {{count}} sekund...", "meteor_status_try_now_offline": "Połącz ponownie", "meteor_status_try_now_waiting": "Spróbuj teraz", "meteor_status_waiting": "Poczekaj na połączenie serwera", @@ -3789,8 +3800,6 @@ "Replied_on": "Odpowiedzi udzielono na", "Replies": "Odpowiedzi", "Reply": "Odpowiedź", - "reply_counter": "{{counter}} odpowiedź", - "reply_counter_plural": "{{counter}} odpowiedzi", "Reply_in_direct_message": "Odpowiedz w bezpośredniej wiadomości", "Reply_in_thread": "Odpowiedz w wątku", "Reply_via_Email": "Odpowiedz przez e-mail", @@ -3952,6 +3961,7 @@ "Running_Instances": "Ilość uruchomionych instancji", "Runtime_Environment": "Środowisko uruchomieniowe", "S_new_messages_since_s": "%s nowych wiadomości od %s", + "S_new_messages": "%s nowych wiadomości", "Same_As_Token_Sent_Via": "Taki sam jak \"Token wysłany przez\"", "Same_Style_For_Mentions": "Ten sam styl do wzmianek", "SAML": "SAML", @@ -5163,9 +5173,9 @@ "You": "ty", "You_reacted_with": "Zareagowałeś z {{emoji}}", "Users_reacted_with": "{{users}} zareagowali z {{emoji}}", - "Users_and_more_reacted_with": "__users__ i __count__ więcej zareagowali z __emoji__", + "Users_and_more_reacted_with": "{{users}} i {{count}} więcej zareagowali z {{emoji}}", "You_and_users_Reacted_with": "Ty i {{users}} zareagowali z {{emoji}}", - "You_users_and_more_Reacted_with": "Ty, __users__ i __count__ więcej zareagowali z __emoji__", + "You_users_and_more_Reacted_with": "Ty, {{users}} i {{count}} więcej zareagowali z {{emoji}}", "You_are_converting_team_to_channel": "Przekształcasz ten zespół w kanał.", "you_are_in_preview_mode_of": "Jesteś w trybie podglądu kanału # {{room_name}}", "you_are_in_preview_mode_of_incoming_livechat": "Jesteś w trybie podglądu wiadomości przychodzącej livechat", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 90ed0d6db384..08b85291398a 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -3,12 +3,15 @@ "__agents__agents_and__count__conversations__period__": "{{agents}} agentes e {{count}} conversas, {{period}}", "__count__empty_rooms_will_be_removed_automatically": "{{count}} salas vazias serão removidas automaticamente.", "__count__empty_rooms_will_be_removed_automatically__rooms__": "{{count}} salas vazias serão removidas automaticamente:
{{rooms}}.", - "__count__message_pruned": "{{count}} mensagem apagada", - "__count__message_pruned_plural": "{{count}} mensagens apagadas", + "__count__message_pruned_one": "{{count}} mensagem apagada", + "__count__message_pruned_many": "{{count}} mensagens apagadas", + "__count__message_pruned_other": "{{count}} mensagens apagadas", "__count__conversations__period__": "{{count}} conversas, {{period}}", "__count__tags__and__count__conversations__period__": "{{count}} tags e {{conversations}} conversas, {{period}}", "__departments__departments_and__count__conversations__period__": "{{departments}} departmentos e {{count}} conversas, {{period}}", - "__usersCount__member_joined_plural": "+ {{usersCount}} membros entraram", + "__usersCount__member_joined_one": "+ um membro entrou", + "__usersCount__member_joined_other": "+ {{count}} membros entraram", + "__usersCount__member_joined_many": "+ {{count}} membros entraram", "__usersCount__people_will_be_invited": "{{usersCount}} usuários vão ser convidados", "__username__is_no_longer__role__defined_by__user_by_": "{{username}} não pertence mais a {{role}}, por {{user_by}}", "__username__was_set__role__by__user_by_": "{{username}} foi definido como {{role}} por {{user_by}}", @@ -618,6 +621,7 @@ "Authentication": "Autenticação", "Author": "Autor", "Author_Information": "Informação sobre o autor", + "Calls_in_queue_many": "{{count}} chamadas na fila", "Author_Site": "Página do autor", "Authorization_URL": "URL de autorização", "Authorize": "Autorizar", @@ -626,6 +630,7 @@ "Auto_Load_Images": "Carregar imagens automaticamente", "Auto_Selection": "Seleção automática", "Auto_Translate": "Traduzir automaticamente", + "Calls_in_queue": "{{calls}} chamadas na fila", "auto-translate": "Traduzir automaticamente", "auto-translate_description": "Permissão para usar a ferramenta de tradução automática", "Automatic_Translation": "Tradução automática", @@ -747,9 +752,9 @@ "Calling": "Chamando", "Call_Center": "Canal de Voz", "Call_Center_Description": "Configure o canal de voz no Rocket.Chat", - "Calls_in_queue": "{{calls}} chamadas na fila", - "Calls_in_queue_plural": "{{calls}} Chamadas na Fila", - "Calls_in_queue_empty": "A fila está Vazia", + "Calls_in_queue_zero": "A fila está Vazia", + "Calls_in_queue_one": "{{count}} chamadas na fila", + "Calls_in_queue_other": "{{count}} chamadas na fila", "Call_declined": "Chamada recusada!", "Call_Information": "Informação da chamada", "Call_provider": "Provedor de chamada", @@ -965,7 +970,6 @@ "Connection_Closed": "Conexão fechada", "Connection_Reset": "Redefinição de conexão", "Connection_error": "Erro de conexão", - "Connection_success": "Conexão com LDAP bem-sucedida", "Connection_failed": "Falha na conexão com o LDAP", "Connectivity_Services": "Serviços de conectividade", "Consulting": "Consultoria", @@ -2421,6 +2425,7 @@ "Language_Polish": "Polonês", "Language_Portuguese": "Português", "Language_Romanian": "Romeno", + "message_counter_many": "{{count}} mensagens", "Language_Russian": "Russo", "Language_Slovak": "Eslovaco", "Language_Slovenian": "Esloveno", @@ -2461,6 +2466,7 @@ "LDAP_Connection": "Conexão", "LDAP_Connection_Authentication": "Autenticação", "LDAP_Connection_Encryption": "Criptografia", + "LDAP_Connection_successful": "Conexão com LDAP bem-sucedida", "LDAP_Connection_Timeouts": "Tempos limite", "LDAP_UserSearch": "Pesquisa de usuários", "LDAP_UserSearch_Filter": "Filtro de pesquisa", @@ -2496,6 +2502,7 @@ "LDAP_Background_Sync_Import_New_Users": "Sincronização de fundo da importação de novos usuários", "LDAP_Background_Sync_Import_New_Users_Description": "Importará todos os usuários (com base em seus critérios de filtro) que existem no LDAP e não existe em Rocket.Chat", "LDAP_Background_Sync_Interval": "Intervalo de sincronização de fundo", + "meteor_status_reconnect_in_many": "tentando novamente em {{count}} segundos...", "LDAP_Background_Sync_Interval_Description": "O intervalo entre as sincronizações. Exemplo de \"a cada 24 horas\" ou \"no primeiro dia da semana\", mais exemplos em [Cron Text Parser] (http://bunkat.github.io/later/parsers.html#text)", "LDAP_Background_Sync_Keep_Existant_Users_Updated": "Atualização da sincronização de plano de fundo de usuários existentes", "LDAP_Background_Sync_Keep_Existant_Users_Updated_Description": "Vai sincronizar o avatar, os campos, o nome de usuário, etc. (com base na sua configuração) de todos os usuários já importados do LDAP em cada ** intervalo de sincronização **", @@ -2618,7 +2625,7 @@ "leave-c_description": "Permissão para deixar canais", "leave-p": "Deixar grupos privados", "leave-p_description": "Permissão para deixar grupos privados", - "Lets_get_you_new_one": "Vamos pegar outro!", + "Lets_get_you_new_one_": "Vamos pegar outro!", "List_of_Channels": "Lista de Canais", "List_of_departments_for_forward": "Lista de departamentos permitidos para encaminhamento (opcional).", "List_of_departments_for_forward_description": "Permite definir uma lista restrita de departamentos que podem receber conversas deste departamento.", @@ -2896,8 +2903,8 @@ "Message_Characther_Limit": "Limite de caracteres da mensagem", "Message_Code_highlight": "Lista de idiomas com destaque de código", "Message_Code_highlight_Description": "Lista de idiomas separados por vírgulas (todos os idiomas suportados em [highlight.js](https://github.com/highlightjs/highlight.js/tree/11.6.0#supported-languages)), que serão usados para destacar os blocos de código", - "message_counter": "{{counter}} mensagem", - "message_counter_plural": "{{counter}} mensagens", + "message_counter_one": "{{count}} mensagem", + "message_counter_other": "{{count}} mensagens", "Message_DateFormat": "Formato de data", "Message_DateFormat_Description": "Veja também: [Moment.js](http://momentjs.com/docs/#/displaying/format/)", "Message_deleting_blocked": "Esta mensagem não pode ser mais apagada", @@ -2989,8 +2996,8 @@ "meteor_status_connecting": "Conectando...", "meteor_status_failed": "A conexão com o servidor falhou", "meteor_status_offline": "Modo offline.", - "meteor_status_reconnect_in": "tentando novamente em um segundo...", - "meteor_status_reconnect_in_plural": "tentando novamente em {{count}} segundos...", + "meteor_status_reconnect_in_one": "tentando novamente em um segundo...", + "meteor_status_reconnect_in_other": "tentando novamente em {{count}} segundos...", "meteor_status_try_now_offline": "Conectar novamente", "meteor_status_try_now_waiting": "Tentar agora", "meteor_status_waiting": "Aguardando pela conexão com o servidor,", @@ -3552,8 +3559,6 @@ "Replied_on": "Respondido em", "Replies": "Respostas", "Reply": "Responder", - "reply_counter": "{{counter}} resposta", - "reply_counter_plural": "{{counter}} respostas", "Reply_in_direct_message": "Responder por mensagem direta", "Reply_in_thread": "Responder na conversa", "Reply_via_Email": "Responder por e-mail", @@ -3705,6 +3710,7 @@ "Running_Instances": "Instâncias em execução", "Runtime_Environment": "Ambiente de execução", "S_new_messages_since_s": "%s novas mensagens desde %s", + "S_new_messages": "%s novas mensagens", "Same_As_Token_Sent_Via": "O mesmo que \"Token enviado via\"", "Same_Style_For_Mentions": "O mesmo estilo para menções", "SAML": "SAML", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/pt.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/pt.i18n.json index 367c9c2cd97b..aabf52554792 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/pt.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/pt.i18n.json @@ -1803,7 +1803,7 @@ "Leave_the_current_channel": "Sai deste canal", "leave-c": "Sair dos canais", "leave-p": "Sair dos grupos privados", - "Lets_get_you_new_one": "Vamos pegar uma nova!", + "Lets_get_you_new_one_": "Vamos pegar uma nova!", "List_of_Channels": "Lista de Canais", "List_of_Direct_Messages": "Lista de Mensagens Diretas", "Livechat": "Livechat", @@ -2460,6 +2460,7 @@ "Running_Instances": "Instâncias em execução", "Runtime_Environment": "Ambiente de execução", "S_new_messages_since_s": "%s novas mensagens desde %s", + "S_new_messages": "%s novas mensagens", "Same_As_Token_Sent_Via": "O mesmo que \"Token Sent Via\"", "Same_Style_For_Mentions": "O mesmo estilo para menções", "SAML": "SAML", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/ro.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/ro.i18n.json index 8a966903515c..8bbe25f79d04 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/ro.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/ro.i18n.json @@ -2119,6 +2119,7 @@ "Running_Instances": "alergare Instanțe", "Runtime_Environment": "Mediu de rulare", "S_new_messages_since_s": "%s mesaje noi de la %s", + "S_new_messages": "%s noi mesaje", "Same_As_Token_Sent_Via": "La fel ca \"Token Trimise Via\"", "Same_Style_For_Mentions": "Același stil pentru mențiuni", "SAML": "SAML", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/ru.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/ru.i18n.json index e1699812a3d9..0b2fe35714b1 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/ru.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/ru.i18n.json @@ -2,10 +2,14 @@ "500": "Внутренняя ошибка сервера", "__count__empty_rooms_will_be_removed_automatically": "{{count}}пустые комнаты будут удалены автоматически.", "__count__empty_rooms_will_be_removed_automatically__rooms__": "{{count}} пустых чатов будет удалено автоматически:
{{rooms}}.", - "__count__message_pruned": "{{count}} сообщение удалено", - "__count__message_pruned_plural": "{{count}} сообщений удалено", - "__usersCount__member_joined": "+ {{usersCount}} участников присоединилось", - "__usersCount__member_joined_plural": "+ {{usersCount}} участников присоединилось", + "__count__message_pruned_few": "{{count}} сообщений удалено", + "__count__message_pruned_one": "{{count}} сообщение удалено", + "__count__message_pruned_many": "{{count}} сообщений удалено", + "__count__message_pruned_other": "{{count}} сообщений удалено", + "__usersCount__member_joined_few": "+ {{count}} участников присоединилось", + "__usersCount__member_joined_one": "+ {{count}} участников присоединилось", + "__usersCount__member_joined_other": "+ {{count}} участников присоединилось", + "__usersCount__member_joined_many": "+ {{count}} участников присоединилось", "__usersCount__people_will_be_invited": "{{usersCount}} человек будет приглашено", "__username__is_no_longer__role__defined_by__user_by_": "{{username}} больше не {{role}} по решению {{user_by}}", "__username__was_set__role__by__user_by_": "{{username}} был установлен {{role}} по решению {{user_by}}", @@ -473,9 +477,13 @@ "App_Information": "Информация о приложении", "Apps_context_enterprise": "Организация", "App_Installation": "Установка приложения", + "Apps_Count_Enabled_few": "{{count}} приложений(-я) включено", + "Apps_Count_Enabled_many": "{{count}} приложений(-я) включено", "App_not_enabled": "Приложение не включено", + "Private_Apps_Count_Enabled_few": "{{count}} приватных приложений включено", "App_not_found": "Приложение не найдено", "App_status_auto_enabled": "Включено", + "Private_Apps_Count_Enabled_many": "{{count}} приватных приложений включено", "App_status_constructed": "построенный", "App_status_disabled": "Отключено", "App_status_error_disabled": "Отключено: неизвестная ошибка", @@ -502,10 +510,10 @@ "Apps_context_installed": "Установлен", "Apps_context_requested": "Запрошено", "Apps_context_private": "Приватные приложения", - "Apps_Count_Enabled": "{{count}} приложение включено", - "Apps_Count_Enabled_plural": "{{count}} приложений(-я) включено", - "Private_Apps_Count_Enabled": "{{count}} приватное приложение включено", - "Private_Apps_Count_Enabled_plural": "{{count}} приватных приложений включено", + "Apps_Count_Enabled_one": "{{count}} приложение включено", + "Apps_Count_Enabled_other": "{{count}} приложений(-я) включено", + "Private_Apps_Count_Enabled_one": "{{count}} приватное приложение включено", + "Private_Apps_Count_Enabled_other": "{{count}} приватных приложений включено", "Apps_Count_Enabled_tooltip": "В рабочих пространствах Community Edition можно использовать до {{number}} {{context}} приложений", "Apps_Engine_Version": "Версия движка приложений", "Apps_Essential_Alert": "Это приложение необходимо для следующих событий:", @@ -658,13 +666,16 @@ "Auth_Token": "Токен авторизации", "Authentication": "Аутентификация", "Author": "Автор", + "Calls_in_queue_few": "{{count}} Звонков в очереди", "Author_Information": "Информация об авторе", + "Calls_in_queue_many": "{{count}} Звонков в очереди", "Author_Site": "Автор", "Authorization_URL": "Авторизация URL-адреса", "Authorize": "Авторизовать", "Auto_Load_Images": "Автозагрузка изображений", "Auto_Selection": "Автоматический выбор", "Auto_Translate": "Авто-перевод", + "Calls_in_queue": "Вызовов в очереди: {{calls}}", "auto-translate": "Автоматический перевод", "auto-translate_description": "Разрешение пользоваться автоматическим переводом", "Automatic_Translation": "Автоматический перевод", @@ -796,9 +807,9 @@ "Call_Center_Description": "Настройка кол-центра Rocket.Chat", "Call_ended": "Звонок завершен", "Calls": "Звонки", - "Calls_in_queue": "Вызовов в очереди: {{calls}}", - "Calls_in_queue_plural": "{{calls}} Звонков в очереди", - "Calls_in_queue_empty": "Очередь пуста", + "Calls_in_queue_zero": "Очередь пуста", + "Calls_in_queue_one": "Вызовов в очереди: {{count}}", + "Calls_in_queue_other": "{{count}} Звонков в очереди", "Call_declined": "Вызов отклонен!", "Call_Information": "Информация о вызове", "Call_provider": "Поставщик вызовов", @@ -1028,7 +1039,6 @@ "Connection_Closed": "Соединение закрыто", "Connection_Reset": "Сброс соединения", "Connection_error": "Ошибка подключения", - "Connection_success": "Подключение к LDAP успешное", "Connection_failed": "Сбой подключения LDAP", "Connectivity_Services": "Connectivity Services", "Consulting": "Консалтинг", @@ -2515,7 +2525,9 @@ "Language_Not_set": "Нет конкретных", "Language_Polish": "Польский", "Language_Portuguese": "Португальский", + "message_counter_few": "{{count}} сообщения", "Language_Romanian": "Румынский", + "message_counter_many": "{{count}} сообщения", "Language_Russian": "Русский", "Language_Slovak": "Словацкий", "Language_Slovenian": "Словенский", @@ -2552,6 +2564,7 @@ "LDAP_Connection": "Подключение", "LDAP_Connection_Authentication": "Аутентификация", "LDAP_Connection_Encryption": "Шифрование", + "LDAP_Connection_successful": "Подключение к LDAP успешное", "LDAP_Connection_Timeouts": "Время ожидания", "LDAP_UserSearch": "Поиск пользователя", "LDAP_UserSearch_Filter": "Фильтр поиска", @@ -2587,6 +2600,8 @@ "LDAP_Background_Sync_Import_New_Users": "Фоновая синхронизация импортирует новых пользователей", "LDAP_Background_Sync_Import_New_Users_Description": "Импортирует всех пользователей (на основе критериев вашего фильтра), которые существуют в LDAP, и не существует в Rocket.Chat", "LDAP_Background_Sync_Interval": "Интервал фоновой синхронизации", + "meteor_status_reconnect_in_few": "пытается снова через {{count}} секунд ...", + "meteor_status_reconnect_in_many": "пытается снова через {{count}} секунд ...", "LDAP_Background_Sync_Interval_Description": "Интервал между синхронизациями. Пример: `каждые 24 часа` или `в первый день недели`, больше примеров в [Cron Text Parser] (http://bunkat.github.io/later/parsers.html#text)", "LDAP_Background_Sync_Keep_Existant_Users_Updated": "Фоновая синхронизация обновляет сущестующих пользователей", "LDAP_Background_Sync_Keep_Existant_Users_Updated_Description": "Будут синхронизироваться аватар, поля, логин итд (на основе вашей конфигурации) всех пользователей уже импортированных из LDAP каждый **Интервал синхронизации**", @@ -2710,7 +2725,7 @@ "leave-c_description": "Разрешение покидать каналы", "leave-p": "Оставить личные группы", "leave-p_description": "Разрешение покидать приватные группы", - "Lets_get_you_new_one": "Давайте получим новый!", + "Lets_get_you_new_one_": "Давайте получим новый!", "License": "Лицензия", "List_of_Channels": "Список чатов", "List_of_departments_for_forward": "Список департаментов, разрешенных к перенаправлению (необязательно)", @@ -2985,8 +3000,8 @@ "Message_Characther_Limit": "Максимальный размер сообщения", "Message_Code_highlight": "Список языков, используемых для выделения кода", "Message_Code_highlight_Description": "Список языков с запятыми-разделителями (все поддерживаемые языки представлены на странице [highlight.js](https://github.com/highlightjs/highlight.js/tree/11.6.0#supported-languages)), которые будут использоваться для выделения блоков кода", - "message_counter": "{{counter}} сообщение", - "message_counter_plural": "{{counter}} сообщения", + "message_counter_one": "{{count}} сообщение", + "message_counter_other": "{{count}} сообщения", "Message_DateFormat": "Формат даты", "Message_DateFormat_Description": "Смотрите также: [Moment.js](http://momentjs.com/docs/#/displaying/format/)", "Message_deleting_blocked": "Это сообщение уже не может быть удалено", @@ -3079,8 +3094,8 @@ "meteor_status_connecting": "Подключение ...", "meteor_status_failed": "Соединение с сервером не удалось", "meteor_status_offline": "Автономный режим.", - "meteor_status_reconnect_in": "пытается снова в одну секунду ...", - "meteor_status_reconnect_in_plural": "пытается снова через {{count}} секунд ...", + "meteor_status_reconnect_in_one": "пытается снова в одну секунду ...", + "meteor_status_reconnect_in_other": "пытается снова через {{count}} секунд ...", "meteor_status_try_now_offline": "Подключите снова", "meteor_status_try_now_waiting": "Попробуйте сейчас", "meteor_status_waiting": "В ожидании соединения с сервером", @@ -3640,8 +3655,6 @@ "Replied_on": "Ответил на", "Replies": "Ответы", "Reply": "Ответить", - "reply_counter": "{{counter}} ответ", - "reply_counter_plural": "{{counter}} ответы", "Reply_in_direct_message": "Ответить личным сообщением", "Reply_in_thread": "Ответить в треде", "Reply_via_Email": "Ответить по электронной почте", @@ -3787,6 +3800,7 @@ "Running_Instances": "Запущенные виртуальные машины", "Runtime_Environment": "Среда выполнения", "S_new_messages_since_s": "%s новых сообщений с %s", + "S_new_messages": "%s новых сообщений", "Same_As_Token_Sent_Via": "То же, что и «Token Sent Via»", "Same_Style_For_Mentions": "Такой же стиль для упоминаний", "SAML": "SAML разметка", @@ -3841,8 +3855,8 @@ "SAML_LogoutRequest_Template": "Шаблон запроса на выход из системы", "SAML_LogoutRequest_Template_Description": "Доступны следующие переменные: \n- **\\_\\_\\_newId\\_\\_**: Случайно сгенерированная строка идентификатора \n- **\\_\\_\\_\\_стоянная\\_\\_**: Текущая метка времени \n- **\\_\\_idpSLORedirectURL\\_\\_**: URL IDP Single LogOut для перенаправления. \n- **\\_\\_\\_\\_issuer\\_\\_**: Значение параметра {{Custom Issuer}}. \n- **\\_\\_identifierFormat\\_\\_**: Значение параметра __Формат идентификатора__. \n- **\\_\\_\\_\\_nameID\\_\\_\\_**: Идентификатор имени, полученный от IdP, когда пользователь вошел в систему. \n- **\\_\\_sessionIndex\\_\\_**: Индекс сессии, полученный от IdP, когда пользователь вошел в систему.", "SAML_LogoutResponse_Template": "Шаблон выхода из системы", - "SAML_LogoutResponse_Template_Description": "Доступны следующие переменные: \n- **__newId__**: Случайно сгенерированная идентификационная строка \n- **__inResponseToId__**: Идентификатор запроса на выход из системы, полученный от IdP \n- **instant_**: Текущая метка времени \n- **__idpSLORedirectURL__**: URL одиночного входа в систему IDP для переадресации. \n- **issuer_**: Значение параметра {{Custom Issuer}}. \n- **{{identifierFormat}}**: Значение параметра {{Identifier Format}}. \n- **__nameID___**: Идентификатор имени, полученный из запроса на выход из системы IdP. \n- **__sessionIndex__**: СессияИндекс, полученный из запроса на выход из системы IdP.", - "SAML_Metadata_Certificate_Template_Description": "Доступны следующие переменные: \n- **__certificate__**: Частный сертификат для шифрования утверждения.", + "SAML_LogoutResponse_Template_Description": "Доступны следующие переменные: \n- **\\_\\_newId\\_\\_**: Случайно сгенерированная идентификационная строка \n- **\\_\\_inResponseToId\\_\\_**: Идентификатор запроса на выход из системы, полученный от IdP \n- **\\_\\_instant\\_\\_**: Текущая метка времени \n- **\\_\\_idpSLORedirectURL\\_\\_**: URL одиночного входа в систему IDP для переадресации. \n- **\\_\\_issuer\\_\\_**: Значение параметра {{Custom Issuer}}. \n- **\\_\\_identifierFormat\\_\\_**: Значение параметра {{Identifier Format}}. \n- **\\_\\_nameID\\_\\_**: Идентификатор имени, полученный из запроса на выход из системы IdP. \n- **\\_\\_sessionIndex\\_\\_**: СессияИндекс, полученный из запроса на выход из системы IdP.", + "SAML_Metadata_Certificate_Template_Description": "Доступны следующие переменные: \n- **\\_\\_certificate\\_\\_**: Частный сертификат для шифрования утверждения.", "SAML_Metadata_Template": "Шаблон метаданных", "SAML_Metadata_Template_Description": "Доступны следующие переменные: \n- **\\_\\_sloLocation\\_\\_**:URL одиночного входа в систему Rocket.Chat. \n- **\\__\\issuer\\__\\_**: Значение параметра {{Custom Issuer}}. \n- **\\_\\_identifierFormat\\_\\_**: Значение параметра {{Identifier Format}}. \n- **\\__\\certificateTag\\__\\_**: Если настроен личный сертификат, он будет включать {{Metadata Certificate Template}}, в противном случае он будет проигнорирован. \n- **\\__\\callbackUrl\\__\\_**: URL обратного вызова Rocket.Chat.", "SAML_MetadataCertificate_Template": "Шаблон сертификата метаданных", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/sk-SK.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/sk-SK.i18n.json index b3062bd2787a..83f2dead338d 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/sk-SK.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/sk-SK.i18n.json @@ -2129,6 +2129,7 @@ "Running_Instances": "Spúšťanie inštancií", "Runtime_Environment": "Runtime Environment", "S_new_messages_since_s": "%s nové správy od%s", + "S_new_messages": "%s nové správy", "Same_As_Token_Sent_Via": "Rovnaké ako \"Token poslaný cez\"", "Same_Style_For_Mentions": "Rovnaký štýl pre zmienky", "SAML": "SAML", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/sl-SI.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/sl-SI.i18n.json index d36f64dee4a8..6fe22e641474 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/sl-SI.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/sl-SI.i18n.json @@ -2109,6 +2109,7 @@ "Running_Instances": "Vodenje primerkov", "Runtime_Environment": "Izvajalno okolje", "S_new_messages_since_s": "%s novo sporočilo od %s", + "S_new_messages": "%s nova sporočila", "Same_As_Token_Sent_Via": "Enako kot \"Žeton poslan preko\"", "Same_Style_For_Mentions": "Isti stil za omembe", "SAML": "SAML", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/sq.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/sq.i18n.json index 8754bcdf3ea4..0b6f0817bff8 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/sq.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/sq.i18n.json @@ -2119,6 +2119,7 @@ "Running_Instances": "drejtimin raste", "Runtime_Environment": "Runtime Environment", "S_new_messages_since_s": "%s mesazhe të reja nga %s", + "S_new_messages": "%s mesazhe të reja", "Same_As_Token_Sent_Via": "Njësoj si \"Token Sent Via\"", "Same_Style_For_Mentions": "E njëjta stil për të përmendur", "SAML": "SAML", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/sr.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/sr.i18n.json index 629b20286646..55f30cb75c6e 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/sr.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/sr.i18n.json @@ -1945,6 +1945,7 @@ "Running_Instances": "Покретање инстанци", "Runtime_Environment": "Рунтиме Енвиронмент", "S_new_messages_since_s": "%s нових порука од %s", + "S_new_messages": "%s нових порука", "Same_As_Token_Sent_Via": "Исто као \"Токен послати преко\"", "Same_Style_For_Mentions": "Исти стил за помињања", "SAML": "САМЛ", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/sv.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/sv.i18n.json index 01ef2e16e50c..75e03aa72f77 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/sv.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/sv.i18n.json @@ -3,10 +3,10 @@ "__agents__agents_and__count__conversations__period__": "{{agents}} agenter och {{count}} konversationer, {{period}}", "__count__empty_rooms_will_be_removed_automatically": "{{count}} tomma rum tas bort automatiskt.", "__count__empty_rooms_will_be_removed_automatically__rooms__": "{{count}} tomma rum tas bort automatiskt:
{{rooms}}.", - "__count__message_pruned": "{{count}} meddelande rensat", - "__count__message_pruned_plural": "{{count}} meddelanden rensade", - "__usersCount__member_joined": "+ {{usersCount}} medlem har anslutit", - "__usersCount__member_joined_plural": "+ {{usersCount}} medlemmar har anslutit", + "__count__message_pruned_one": "{{count}} meddelande rensat", + "__count__message_pruned_other": "{{count}} meddelanden rensade", + "__usersCount__member_joined_one": "+ {{count}} medlem har anslutit", + "__usersCount__member_joined_other": "+ {{count}} medlemmar har anslutit", "__usersCount__people_will_be_invited": "{{usersCount}} personer bjuds in", "__username__is_no_longer__role__defined_by__user_by_": "{{username}} är inte längre {{role}}, av {{user_by}}", "__username__was_set__role__by__user_by_": "{{username}} sattes {{role}} av {{user_by}}", @@ -504,10 +504,10 @@ "Apps_context_installed": "Installerad", "Apps_context_requested": "Förfrågningar", "Apps_context_private": "Privata appar", - "Apps_Count_Enabled": "{{count}} app aktiverad", - "Apps_Count_Enabled_plural": "{{count}} appar aktiverade", - "Private_Apps_Count_Enabled": "{{count}} privat app aktiverad", - "Private_Apps_Count_Enabled_plural": "{{count}} privata appar aktiverade", + "Apps_Count_Enabled_one": "{{count}} app aktiverad", + "Apps_Count_Enabled_other": "{{count}} appar aktiverade", + "Private_Apps_Count_Enabled_one": "{{count}} privat app aktiverad", + "Private_Apps_Count_Enabled_other": "{{count}} privata appar aktiverade", "Apps_Count_Enabled_tooltip": "Arbetsytorna i Community Edition kan aktivera upp till {{number}} {{context}} appar", "Apps_disabled_when_Premium_trial_ended_description": "Arbetsytorna i Community Edition kan ha upp till 5 marknadsplatsappar och 3 privata appar aktiverade. Be din arbetsrumsadministratör att återaktivera appar.", "Apps_disabled_when_Premium_trial_ended_description_admin": "Arbetsytorna i Community Edition kan ha upp till 5 marknadsplatsappar och 3 privata appar aktiverade. Återaktivera de appar du behöver.", @@ -678,6 +678,7 @@ "Auto_Load_Images": "Hämta bilder automatiskt", "Auto_Selection": "Automatiskt val", "Auto_Translate": "Automatisk översättning", + "Calls_in_queue": "{{calls}} samtal i kö", "auto-translate": "Översätt automatiskt", "auto-translate_description": "Tillstånd att använda det automatiska översättningsverktyget", "Automatic_Translation": "Automatisk översättning", @@ -814,9 +815,9 @@ "Call_Center_Description": "Konfigurera röstkanalerna i Rocket.Chat", "Call_ended": "Samtalet avslutades", "Calls": "Samtal", - "Calls_in_queue": "{{calls}} samtal i kö", - "Calls_in_queue_plural": "{{calls}} samtal i kö", - "Calls_in_queue_empty": "Kön är tom", + "Calls_in_queue_zero": "Kön är tom", + "Calls_in_queue_one": "{{count}} samtal i kö", + "Calls_in_queue_other": "{{count}} samtal i kö", "Call_declined": "Samtalet avvisades.", "Call_history_provides_a_record_of_when_calls_took_place_and_who_joined": "I samtalshistoriken samlas information om när samtal ägt rum och vilka som deltog.", "Call_Information": "Samtalsinformation", @@ -1064,7 +1065,6 @@ "Connection_Closed": "Anslutningen är stängd", "Connection_Reset": "Anslutning återställd", "Connection_error": "Anslutningsfel", - "Connection_success": "LDAP-anslutningen har upprättats", "Connection_failed": "LDAP-anslutningen kunde inte upprättas", "Connectivity_Services": "Anslutningstjänster", "Consulting": "Consulting", @@ -2748,6 +2748,7 @@ "LDAP_Connection": "Anslutning", "LDAP_Connection_Authentication": "Autentisering", "LDAP_Connection_Encryption": "Kryptering", + "LDAP_Connection_successful": "LDAP-anslutningen har upprättats", "LDAP_Connection_Timeouts": "Tidsgränser", "LDAP_UserSearch": "Användarsökning", "LDAP_UserSearch_Filter": "Sökfilter", @@ -2909,7 +2910,7 @@ "leave-c_description": "Behörighet att lämna kanaler", "leave-p": "Lämna privata grupper", "leave-p_description": "Tillstånd att lämna privata grupper", - "Lets_get_you_new_one": "Vi ordnar en ny.", + "Lets_get_you_new_one_": "Vi ordnar en ny.", "License": "Licens", "Line": "Linje", "Link": "Länk", @@ -3246,8 +3247,8 @@ "Message_Code_highlight_Description": "Kommaavgränsad lista med språk (alla språk som stöds på [highlight.js](https://github.com/highlightjs/highlight.js/tree/11.6.0#supported-languages)) som används för att markera kodblock", "Message_CustomDomain_AutoLink": "Anpassad vitlista för domäner - Automatiska Länkar", "Message_CustomDomain_AutoLink_Description": "Om du vill autolänka interna länkar som t.ex. `https://internt-verktyg.intranet` eller `internt-vertyg.intranet`, behöver du alltid lägga till `intranet` i slutet. Flera domäner separeras med ett komma (,).", - "message_counter": "{{counter}} meddelande", - "message_counter_plural": "{{counter}} meddelanden", + "message_counter_one": "{{count}} meddelande", + "message_counter_other": "{{count}} meddelanden", "Message_DateFormat": "Datumformat", "Message_DateFormat_Description": "Se även: [Moment.js](http://momentjs.com/docs/#/displaying/format/)", "Message_deleting_blocked": "Detta meddelande kan inte tas bort längre", @@ -3353,8 +3354,8 @@ "meteor_status_connecting": "Ansluter...", "meteor_status_failed": "Det gick inte att ansluta till servern", "meteor_status_offline": "Offlineläge.", - "meteor_status_reconnect_in": "försöker igen om en sekund...", - "meteor_status_reconnect_in_plural": "försöker igen om {{count}} sekunder...", + "meteor_status_reconnect_in_one": "försöker igen om en sekund...", + "meteor_status_reconnect_in_other": "försöker igen om {{count}} sekunder...", "meteor_status_try_now_offline": "Anslut igen", "meteor_status_try_now_waiting": "Försök nu", "meteor_status_waiting": "Väntar på anslutning till servern", @@ -4010,8 +4011,6 @@ "Replied_on": "Svarade", "Replies": "Svar", "Reply": "Svara", - "reply_counter": "{{counter}} svar", - "reply_counter_plural": "{{counter}} svar", "Reply_in_direct_message": "Svara i ett direktmeddelande", "Reply_in_thread": "Svara i tråd", "Reply_via_Email": "Svara via e-post", @@ -4183,6 +4182,7 @@ "Running_Instances": "Antal instanser som körs", "Runtime_Environment": "Körtidsmiljö", "S_new_messages_since_s": "%s nya meddelanden sedan %s", + "S_new_messages": "%s nya meddelanden", "Same_As_Token_Sent_Via": "Samma som \"Token Send Via\"", "Same_Style_For_Mentions": "Samma stil för omnämnanden", "SAML": "SAML", @@ -5732,7 +5732,7 @@ "RegisterWorkspace_Token_Step_Two": "Kopiera ditt token och klistra in det nedan.", "RegisterWorkspace_with_email": "Registrera arbetsytan med e-post", "RegisterWorkspace_Setup_Subtitle": "För att registrera arbetsytan måste det associeras med ett Rocket.Chat Cloud-konto.", - "RegisterWorkspace_Setup_Steps": "Steg __steg__ av __numberOfSteps__", + "RegisterWorkspace_Setup_Steps": "Steg {{step}} av {{numberOfSteps}}", "RegisterWorkspace_Setup_Label": "E-postadress för molnkonto", "RegisterWorkspace_Setup_Have_Account_Title": "Har du ett konto?", "RegisterWorkspace_Setup_Have_Account_Subtitle": "Ange din e-postadress till Cloud-kontot för att koppla arbetsytan till ditt konto.", @@ -5747,7 +5747,7 @@ "cloud.RegisterWorkspace_Setup_Terms_Privacy": "Jag godkänner <1>villkoren och <3>integritetspolicyn", "Larger_amounts_of_active_connections": "För större mängder aktiva anslutningar kan du överväga vår", "Uninstall_grandfathered_app": "Avinstallera {{appName}}?", - "App_will_lose_grandfathered_status": "**Denna {{context}}-app kommer att förlora sin status som gammal app.** \n \nArbetsytorna i Community Edition kan ha upp till {{limit}} __kontext__-appar aktiverade. Gamla appar inkluderas i gränsen, men gränsen tillämpas inte på dem.", + "App_will_lose_grandfathered_status": "**Denna {{context}}-app kommer att förlora sin status som gammal app.** \n \nArbetsytorna i Community Edition kan ha upp till {{limit}} {{context}}-appar aktiverade. Gamla appar inkluderas i gränsen, men gränsen tillämpas inte på dem.", "Theme_Appearence": "Utseende för tema", "Enterprise": "Enterprise", "UpgradeToGetMore_engagement-dashboard_Title": "Analytics" diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/ta-IN.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/ta-IN.i18n.json index 33ed4e6d83df..4b5ab7ac74c0 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/ta-IN.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/ta-IN.i18n.json @@ -2121,6 +2121,7 @@ "Running_Instances": "நிகழ்வுகளை இயக்க", "Runtime_Environment": "இயக்க சூழல்", "S_new_messages_since_s": "% கள் என்பதால்% கள் புதிய செய்திகள்", + "S_new_messages": "% கள் புதிய செய்திகள்", "Same_As_Token_Sent_Via": "அதே போல \"டோக்கன் அனுப்பிய வழி\"", "Same_Style_For_Mentions": "குறிப்பிடுவதற்கான அதே பாணி", "SAML": "SAML", @@ -2524,7 +2525,7 @@ "Use_url_for_avatar": "சின்னம் URL ஐ பயன்படுத்த", "Use_User_Preferences_or_Global_Settings": "பயனர் விருப்பங்கள் அல்லது உலகளாவிய அமைப்புகள் பயன்படுத்தவும்", "User": "பயனர்", - "User__username__is_now_a_leader_of__room_name_": "பயனர் __இயக்குநர்__ இப்போது __room_name__ இன் தலைவர்", + "User__username__is_now_a_leader_of__room_name_": "பயனர் {{username}} இப்போது {{room_name}} இன் தலைவர்", "User__username__is_now_a_moderator_of__room_name_": "பயனர் {{username}} இப்போது {{room_name}} ஒரு மதிப்பீட்டாளர்", "User__username__is_now_an_owner_of__room_name_": "பயனர் {{username}} இப்போது {{room_name}} ஒரு உரிமையாளர் ஆவார்", "User__username__removed_from__room_name__leaders": "{{room_name}} தலைவர்களிடமிருந்து பயனர் {{username}} நீக்கப்பட்டது", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/th-TH.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/th-TH.i18n.json index e97d28e2c30b..e520bf899109 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/th-TH.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/th-TH.i18n.json @@ -2113,6 +2113,7 @@ "Running_Instances": "การใช้งานอินสแตนซ์", "Runtime_Environment": "สภาพแวดล้อมรันไทม์", "S_new_messages_since_s": "%s ข้อความใหม่นับตั้งแต่%s", + "S_new_messages": "%s ข้อความใหม่", "Same_As_Token_Sent_Via": "เหมือนกับ \"Token Sent Via\"", "Same_Style_For_Mentions": "สไตล์เดียวกันสำหรับการพูดถึง", "SAML": "SAML", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/tr.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/tr.i18n.json index 3485f01cd190..c81016871d75 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/tr.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/tr.i18n.json @@ -1833,7 +1833,7 @@ "Leave_the_current_channel": "Geçerli kanalı bırak", "leave-c": "Kanallardan Çık", "leave-p": "Özel Grupları Bırak", - "Lets_get_you_new_one": "Size yeni bir tane verelim!", + "Lets_get_you_new_one_": "Size yeni bir tane verelim!", "List_of_Channels": "Kanal Listesi", "List_of_Direct_Messages": "Doğrudan İletiler Listesi", "Livechat": "Canlı Görüşme", @@ -1990,8 +1990,8 @@ "Message_AudioRecorderEnabled_Description": "'Dosya Yükleme' ayarlarında kabul edilen bir medya türü olması için 'ses / mp3' dosyalarını gerektirir.", "Message_BadWordsFilterList": "kara listeye kötü kelime ekleme", "Message_BadWordsFilterListDescription": "filtrelemek için kötü kelimelerin virgülle ayrılmış liste listesi ekle", - "message_counter": "{{counter}} ileti", - "message_counter_plural": "{{counter}} ileti", + "message_counter_one": "{{count}} ileti", + "message_counter_other": "{{count}} ileti", "Message_DateFormat": "Tarih formatı", "Message_DateFormat_Description": "Ayrıca bkz: [Moment.js](http://momentjs.com/docs/#/displaying/format/)", "Message_deleting_blocked": "Bu ileti artık silinemez", @@ -2048,8 +2048,8 @@ "meteor_status_connecting": "Bağlanıyor...", "meteor_status_failed": "Sunucu ile bağlantı başarısız", "meteor_status_offline": "Çevrimdışı mod.", - "meteor_status_reconnect_in": "tekrar deneniyor...", - "meteor_status_reconnect_in_plural": "{{count}} saniye içinde tekrar denenecek...", + "meteor_status_reconnect_in_one": "tekrar deneniyor...", + "meteor_status_reconnect_in_other": "{{count}} saniye içinde tekrar denenecek...", "meteor_status_try_now_offline": "Tekrar bağlan!", "meteor_status_try_now_waiting": "Şimdi tekrar dene!", "meteor_status_waiting": "Sunucu bağlantısı bekleniyor,", @@ -2427,8 +2427,6 @@ "Replied_on": "Yanıt tarihi", "Replies": "Yanıtlar", "Reply": "Yanıtla", - "reply_counter": "{{counter}} yanıt", - "reply_counter_plural": "{{counter}} yanıt", "Reply_in_direct_message": "Doğrudan İletiyle Yanıtla", "Reply_in_thread": "Konu açarak Yanıtla", "ReplyTo": "Yanıt Adresi", @@ -2524,6 +2522,7 @@ "Running_Instances": "Örneklerini Çalıştırma", "Runtime_Environment": "Çalışma Zamanı Ortamı", "S_new_messages_since_s": "%s yeni ileti (%s'den beri)", + "S_new_messages": "%s yeni ileti", "Same_As_Token_Sent_Via": "\"Token Sent Via\" ile aynı", "Same_Style_For_Mentions": "Bahisler için aynı tarz", "SAML": "SAML", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/ug.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/ug.i18n.json index 57a828f92ffb..737d8c8e4b9a 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/ug.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/ug.i18n.json @@ -912,6 +912,7 @@ "Rooms": "ئۆي", "Running_Instances": "ھازىر يۈرگۈزۈلىۋاتقان مىسال", "S_new_messages_since_s": "دىن كەلگەن%s يېڭى ئۇچۇر %s", + "S_new_messages": "تال يېڭى ئۇچۇر%s", "SAML": "SAML", "SAML_Custom_Cert": "ئۆزلۈكىدىن بېكىتىش ئىسپاتى", "SAML_Custom_Entry_point": "ئۆزلۈكىدىن بېكىتىش ئېغىزى", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/uk.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/uk.i18n.json index bc31ae328e6a..f30d69e0cf8c 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/uk.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/uk.i18n.json @@ -1891,6 +1891,8 @@ "Language_Not_set": "Немає специфіки", "Language_Polish": "Польська", "Language_Portuguese": "Португальська", + "message_counter_few": "{{count}} повідомлень", + "message_counter_many": "{{count}} повідомлень", "Language_Russian": "Російська", "Language_Spanish": "Іспанська", "Language_Version": "Українська версія", @@ -2163,8 +2165,8 @@ "Message_auditing_log": "Журнал аудиту повідомлень", "Message_BadWordsFilterList": "Додати погані слова в чорний список", "Message_BadWordsFilterListDescription": "Додати список розділених комами список поганих слів, щоб фільтрувати", - "message_counter": "{{counter}} повідомлення", - "message_counter_plural": "{{counter}} повідомлень", + "message_counter_one": "{{count}} повідомлення", + "message_counter_other": "{{count}} повідомлень", "Message_DateFormat": "Формат дати", "Message_DateFormat_Description": "Дивіться також: [Moment.js](http://momentjs.com/docs/#/displaying/format/)", "Message_deleting_blocked": "Це повідомлення не може бути видалено більше", @@ -2567,7 +2569,6 @@ "Removed": "вилучені", "Removed_User": "Видалений користувач", "Reply": "Відповідь", - "reply_counter": "{{counter}} відповідь", "Reply_in_direct_message": "Відповісти в особистому повідомленні", "Reply_in_thread": "Відповісти у темі", "ReplyTo": "Відповідати на", @@ -2655,6 +2656,7 @@ "Running_Instances": "Запущено екземплярів", "Runtime_Environment": "Runtime Environment", "S_new_messages_since_s": "%s нових повідомлень з моменту %s", + "S_new_messages": "%s нових повідомлень", "Same_As_Token_Sent_Via": "Той же, що й \"Token Sent Via\"", "Same_Style_For_Mentions": "Той самий стиль для згадування", "SAML": "SAML", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/vi-VN.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/vi-VN.i18n.json index 88bcf754c10d..4b9936642086 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/vi-VN.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/vi-VN.i18n.json @@ -1810,8 +1810,7 @@ "meteor_status_connecting": "Đang kết nối...", "meteor_status_failed": "Kết nối tới máy chủ thất bại", "meteor_status_offline": "Chế độ ngoại tuyến.", - "meteor_status_reconnect_in": "Thử kết nối lại trong một giây...", - "meteor_status_reconnect_in_plural": "Thử kết nối lại trong {{count}} giây...", + "meteor_status_reconnect_in_other": "Thử kết nối lại trong {{count}} giây...", "meteor_status_try_now_offline": "Kết nối lại lần nữa", "meteor_status_try_now_waiting": "Thử ngay bây giờ", "meteor_status_waiting": "Đang đợi kết nối tới máy chủ,", @@ -2219,6 +2218,7 @@ "Running_Instances": "Chạy Các Trường hợp", "Runtime_Environment": "Môi trường thực thi", "S_new_messages_since_s": "%s tin nhắn mới kể từ%s", + "S_new_messages": "%s tin nhắn mới", "Same_As_Token_Sent_Via": "Tương tự như \"Token Sent Via\"", "Same_Style_For_Mentions": "Cùng một phong cách để đề cập đến", "SAML": "SAML", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/zh-HK.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/zh-HK.i18n.json index afeecab7fb7c..d3a3295c5956 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/zh-HK.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/zh-HK.i18n.json @@ -2141,6 +2141,7 @@ "Running_Instances": "运行实例", "Runtime_Environment": "运行环境", "S_new_messages_since_s": "%s 新消息,自从 %s", + "S_new_messages": "%s条新消息", "Same_As_Token_Sent_Via": "与“通过发送的令牌”相同", "Same_Style_For_Mentions": "同样的风格提及", "SAML": "SAML", 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 c1a4796794db..29b4d7522504 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/zh-TW.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/zh-TW.i18n.json @@ -587,6 +587,7 @@ "Auto_Load_Images": "自動載入圖片", "Auto_Selection": "自動選擇", "Auto_Translate": "自動翻譯", + "Calls_in_queue": "{{count}} 個通話在佇列中", "auto-translate": "自動翻譯", "auto-translate_description": "有權限使用自動翻譯工具", "Automatic_Translation": "自動翻譯", @@ -700,7 +701,6 @@ "Call": "呼叫", "Calling": "正在通話", "Call_Center": "通話中心", - "Calls_in_queue": "{{calls}} 個通話在佇列中", "Call_declined": "通話已拒絕!", "Call_Information": "通話資訊", "Call_provider": "通話提供者", @@ -914,7 +914,6 @@ "Connection_Closed": "連接關閉", "Connection_Reset": "連線重置", "Connection_error": "連線錯誤", - "Connection_success": "LDAP 連接成功", "Connection_failed": "LDAP 連線失敗", "Connectivity_Services": "連線的服務", "Consulting": "諮詢", @@ -2362,6 +2361,7 @@ "LDAP_Connection": "連線", "LDAP_Connection_Authentication": "驗證", "LDAP_Connection_Encryption": "加密", + "LDAP_Connection_successful": "LDAP 連接成功", "LDAP_Connection_Timeouts": "逾時", "LDAP_UserSearch": "使用者搜尋", "LDAP_UserSearch_Filter": "搜尋過濾", @@ -2517,7 +2517,7 @@ "Leave_the_description_field_blank_if_you_dont_want_to_show_the_role": "如果不想顯示角色,請將描述欄位保持空白", "leave-c": "保留 Channel", "leave-p": "離開私人群組", - "Lets_get_you_new_one": "來取得新的!", + "Lets_get_you_new_one_": "來取得新的!", "List_of_Channels": "Channel 列表", "List_of_departments_for_forward": "允許轉送的部門列表(可選)", "List_of_departments_for_forward_description": "允許設定可以接收從此部門聊天記錄部門的受限列表", @@ -2769,8 +2769,7 @@ "Message_Characther_Limit": "訊息字元限制", "Message_Code_highlight": "代碼醒目語言列表", "Message_Code_highlight_Description": "逗號分隔的語言列表 (所有支援的語言在 [highlight.js](https://github.com/highlightjs/highlight.js/tree/11.6.0#supported-languages)),用於醒目顯示代碼區塊", - "message_counter": "{{counter}} 訊息", - "message_counter_plural": "{{counter}} 訊息", + "message_counter_other": "{{count}} 訊息", "Message_DateFormat": "日期格式", "Message_DateFormat_Description": "參考: [Moment.js](http://momentjs.com/docs/#/displaying/format/)", "Message_deleting_blocked": "該訊息不能再刪除", @@ -2859,8 +2858,7 @@ "meteor_status_connecting": "連線中...", "meteor_status_failed": "無法與伺服器連線", "meteor_status_offline": "離線模式。", - "meteor_status_reconnect_in": "一秒鐘後再嘗試...", - "meteor_status_reconnect_in_plural": "在{{count}}秒鍾後再嘗試...", + "meteor_status_reconnect_in_other": "在{{count}}秒鍾後再嘗試...", "meteor_status_try_now_offline": "重新連線", "meteor_status_try_now_waiting": "現在再試", "meteor_status_waiting": "等待與伺服器連線,", @@ -3377,8 +3375,6 @@ "Replied_on": "回覆在", "Replies": "回覆", "Reply": "回覆", - "reply_counter": "{{counter}} 回覆", - "reply_counter_plural": "{{counter}} 回覆", "Reply_in_direct_message": "回覆到私訊", "Reply_in_thread": "回覆討論", "ReplyTo": "回覆", @@ -3506,6 +3502,7 @@ "Running_Instances": "正在執行的實例", "Runtime_Environment": "執行環境", "S_new_messages_since_s": "%s 新訊息,來自 %s", + "S_new_messages": "% 新訊息", "Same_As_Token_Sent_Via": "與“通過發送的 Token”相同", "Same_Style_For_Mentions": "同樣的風格提及", "SAML": "SAML", diff --git a/apps/meteor/packages/rocketchat-i18n/i18n/zh.i18n.json b/apps/meteor/packages/rocketchat-i18n/i18n/zh.i18n.json index 1a698984199d..e14fd08dd8d5 100644 --- a/apps/meteor/packages/rocketchat-i18n/i18n/zh.i18n.json +++ b/apps/meteor/packages/rocketchat-i18n/i18n/zh.i18n.json @@ -809,7 +809,6 @@ "Connect_SSL_TLS": "使用 SSL/TLS 连接", "Connection_Closed": "连接关闭", "Connection_Reset": "连接重置", - "Connection_success": "LDAP 连接成功", "Connectivity_Services": "连接性服务", "Consulting": "咨询", "Contact": "联系人", @@ -2088,7 +2087,7 @@ "Jump_to_first_unread": "跳转到第一条未读消息", "Jump_to_message": "跳转到消息", "Jump_to_recent_messages": "跳转到最近的消息", - "Just_invited_people_can_access_this_channel": "刚刚被邀请的人可以访问这个频道。", + "Just_invited_people_can_access_this_channel": "只有被邀请的人可以访问这个频道。", "Katex_Dollar_Syntax": "允许美元符号语法", "Katex_Dollar_Syntax_Description": "允许使用 $$katex 段落$$ 和 $内嵌 katex$ 语法", "Katex_Enabled": "Katex 已启用 ", @@ -2151,6 +2150,7 @@ "Layout_Terms_of_Service": "服务条款", "LDAP": "LDAP", "LDAP_Description": "LDAP(轻量目录访问协议)是一种层次数据库,常被企业用于提供单点登录机制——该机制允许用户使用同一套帐号密码登录多个网站或服务。想要了解LDAP认证的设置及示例,可参考我们的wiki页: https://rocket.chat/docs/administrator-guides/authentication/ldap/", + "LDAP_Connection_successful": "LDAP 连接成功", "LDAP_Advanced_Sync": "高级同步", "LDAP_Authentication": "启用", "LDAP_Authentication_Password": "密码", @@ -2265,7 +2265,7 @@ "Leave_the_description_field_blank_if_you_dont_want_to_show_the_role": "如果不想显示对应角色,请将描述字段留空", "leave-c": "保留频道", "leave-p": "离开私人组", - "Lets_get_you_new_one": "新版本即将到来", + "Lets_get_you_new_one_": "新版本即将到来", "List_of_Channels": "频道列表", "List_of_departments_for_forward": "允许转发的部门列表(可选)", "List_of_departments_for_forward_description": "允许设置一个列表来限制可从此部门接收聊天的部门", @@ -2496,8 +2496,7 @@ "Message_BadWordsWhitelist": "将黑名单中移除词语", "Message_BadWordsWhitelistDescription": "添加逗号分隔的列表从过滤器中移除词语", "Message_Characther_Limit": "消息字符数限制", - "message_counter": "{{counter}} 消息", - "message_counter_plural": "{{counter}} 消息", + "message_counter_other": "{{count}} 消息", "Message_DateFormat": "日期格式", "Message_DateFormat_Description": "参见:[Moment.js](http://momentjs.com/docs/#/displaying/format/)", "Message_deleting_blocked": "已不能删除该消息", @@ -2573,8 +2572,7 @@ "meteor_status_connecting": "连接中...", "meteor_status_failed": "服务器连接失败", "meteor_status_offline": "离线模式。", - "meteor_status_reconnect_in": "一分钟后重试...", - "meteor_status_reconnect_in_plural": "在 {{count}} 秒钟后重试...", + "meteor_status_reconnect_in_other": "在 {{count}} 秒钟后重试...", "meteor_status_try_now_offline": "重新连接", "meteor_status_try_now_waiting": "立即尝试", "meteor_status_waiting": "等待服务器连接,", @@ -3052,8 +3050,6 @@ "Replied_on": "回复于", "Replies": "回复", "Reply": "回复", - "reply_counter": "{{counter}} 回复", - "reply_counter_plural": "{{counter}} 回复", "Reply_in_direct_message": "在私聊中回复", "Reply_in_thread": "在讨论串中回复", "Reply_via_Email": "通过电子邮件回复", @@ -3169,6 +3165,7 @@ "Running_Instances": "正在运行的实例", "Runtime_Environment": "运行环境", "S_new_messages_since_s": "%s 新消息,自从 %s", + "S_new_messages": "%s 条新消息", "Same_As_Token_Sent_Via": "与“通过发送的令牌”相同", "Same_Style_For_Mentions": "同样的风格提及", "SAML": "SAML", diff --git a/apps/meteor/packages/rocketchat-mongo-config/server/index.js b/apps/meteor/packages/rocketchat-mongo-config/server/index.js index 65464a31095c..684620d09054 100644 --- a/apps/meteor/packages/rocketchat-mongo-config/server/index.js +++ b/apps/meteor/packages/rocketchat-mongo-config/server/index.js @@ -4,8 +4,8 @@ import { PassThrough } from 'stream'; import { Email } from 'meteor/email'; import { Mongo } from 'meteor/mongo'; -const shouldDisableOplog = ['yes', 'true'].includes(String(process.env.USE_NATIVE_OPLOG).toLowerCase()); -if (!shouldDisableOplog) { +const shouldUseNativeOplog = ['yes', 'true'].includes(String(process.env.USE_NATIVE_OPLOG).toLowerCase()); +if (!shouldUseNativeOplog) { Package['disable-oplog'] = {}; } diff --git a/apps/meteor/server/configuration/accounts_meld.js b/apps/meteor/server/configuration/accounts_meld.js index fd915de86f1c..64d8a4eeb109 100644 --- a/apps/meteor/server/configuration/accounts_meld.js +++ b/apps/meteor/server/configuration/accounts_meld.js @@ -2,51 +2,53 @@ import { Users } from '@rocket.chat/models'; import { Accounts } from 'meteor/accounts-base'; import _ from 'underscore'; -const orig_updateOrCreateUserFromExternalService = Accounts.updateOrCreateUserFromExternalService; +export async function configureAccounts() { + const orig_updateOrCreateUserFromExternalService = Accounts.updateOrCreateUserFromExternalService; -const updateOrCreateUserFromExternalServiceAsync = async function (serviceName, serviceData = {}, ...args /* , options*/) { - const services = ['facebook', 'github', 'gitlab', 'google', 'meteor-developer', 'linkedin', 'twitter', 'apple']; + const updateOrCreateUserFromExternalServiceAsync = async function (serviceName, serviceData = {}, ...args /* , options*/) { + const services = ['facebook', 'github', 'gitlab', 'google', 'meteor-developer', 'linkedin', 'twitter', 'apple']; - if (services.includes(serviceName) === false && serviceData._OAuthCustom !== true) { - return orig_updateOrCreateUserFromExternalService.apply(this, [serviceName, serviceData, ...args]); - } - - if (serviceName === 'meteor-developer') { - if (Array.isArray(serviceData.emails)) { - const primaryEmail = serviceData.emails.sort((a) => a.primary !== true).filter((item) => item.verified === true)[0]; - serviceData.email = primaryEmail && primaryEmail.address; + if (services.includes(serviceName) === false && serviceData._OAuthCustom !== true) { + return orig_updateOrCreateUserFromExternalService.apply(this, [serviceName, serviceData, ...args]); } - } - - if (serviceName === 'linkedin') { - serviceData.email = serviceData.emailAddress; - } - - if (serviceData.email) { - const user = await Users.findOneByEmailAddress(serviceData.email); - if (user != null) { - const findQuery = { - address: serviceData.email, - verified: true, - }; - - if (user.services?.password && !_.findWhere(user.emails, findQuery)) { - await Users.resetPasswordAndSetRequirePasswordChange( - user._id, - true, - 'This_email_has_already_been_used_and_has_not_been_verified__Please_change_your_password', - ); + + if (serviceName === 'meteor-developer') { + if (Array.isArray(serviceData.emails)) { + const primaryEmail = serviceData.emails.sort((a) => a.primary !== true).filter((item) => item.verified === true)[0]; + serviceData.email = primaryEmail && primaryEmail.address; } + } - await Users.setServiceId(user._id, serviceName, serviceData.id); - await Users.setEmailVerified(user._id, serviceData.email); + if (serviceName === 'linkedin') { + serviceData.email = serviceData.emailAddress; } - } - return orig_updateOrCreateUserFromExternalService.apply(this, [serviceName, serviceData, ...args]); -}; + if (serviceData.email) { + const user = await Users.findOneByEmailAddress(serviceData.email); + if (user != null && user.services?.[serviceName]?.id !== serviceData.id) { + const findQuery = { + address: serviceData.email, + verified: true, + }; + + if (user.services?.password && !_.findWhere(user.emails, findQuery)) { + await Users.resetPasswordAndSetRequirePasswordChange( + user._id, + true, + 'This_email_has_already_been_used_and_has_not_been_verified__Please_change_your_password', + ); + } + + await Users.setServiceId(user._id, serviceName, serviceData.id); + await Users.setEmailVerified(user._id, serviceData.email); + } + } + + return orig_updateOrCreateUserFromExternalService.apply(this, [serviceName, serviceData, ...args]); + }; -Accounts.updateOrCreateUserFromExternalService = function (...args) { - // Depends on meteor support for Async - return Promise.await(updateOrCreateUserFromExternalServiceAsync.call(this, ...args)); -}; + Accounts.updateOrCreateUserFromExternalService = function (...args) { + // Depends on meteor support for Async + return Promise.await(updateOrCreateUserFromExternalServiceAsync.call(this, ...args)); + }; +} diff --git a/apps/meteor/server/configuration/cas.ts b/apps/meteor/server/configuration/cas.ts new file mode 100644 index 000000000000..300320dda3fa --- /dev/null +++ b/apps/meteor/server/configuration/cas.ts @@ -0,0 +1,37 @@ +import type { Awaited } from '@rocket.chat/core-typings'; +import debounce from 'lodash.debounce'; +import { RoutePolicy } from 'meteor/routepolicy'; +import { WebApp } from 'meteor/webapp'; + +import { settings } from '../../app/settings/server/cached'; +import { loginHandlerCAS } from '../lib/cas/loginHandler'; +import { middlewareCAS } from '../lib/cas/middleware'; +import { updateCasServices } from '../lib/cas/updateCasService'; + +export async function configureCAS() { + const _updateCasServices = debounce(updateCasServices, 2000); + + settings.watchByRegex(/^CAS_.+/, async () => { + await _updateCasServices(); + }); + + RoutePolicy.declare('/_cas/', 'network'); + + // Listen to incoming OAuth http requests + WebApp.connectHandlers.use((req, res, next) => { + middlewareCAS(req, res, next); + }); + + /* + * Register a server-side login handler. + * It is called after Accounts.callLoginMethod() is called from client. + * + */ + Accounts.registerLoginHandler('cas', (options) => { + const promise = loginHandlerCAS(options); + + // Pretend the promise has been awaited so the types will match - + // #TODO: Fix registerLoginHandler's type definitions (it accepts promises) + return promise as unknown as Awaited; + }); +} diff --git a/apps/meteor/server/configuration/index.ts b/apps/meteor/server/configuration/index.ts new file mode 100644 index 000000000000..e81d1a64eda1 --- /dev/null +++ b/apps/meteor/server/configuration/index.ts @@ -0,0 +1,11 @@ +import { configureAccounts } from './accounts_meld'; +import { configureCAS } from './cas'; +import { configureLDAP } from './ldap'; +import { configureOAuth } from './oauth'; + +export async function configureLoginServices() { + await configureAccounts(); + await configureCAS(); + await configureLDAP(); + await configureOAuth(); +} diff --git a/apps/meteor/server/configuration/ldap.ts b/apps/meteor/server/configuration/ldap.ts index 1724a4fa1986..f7d8ee9c64c4 100644 --- a/apps/meteor/server/configuration/ldap.ts +++ b/apps/meteor/server/configuration/ldap.ts @@ -4,47 +4,49 @@ import { Accounts } from 'meteor/accounts-base'; import { settings } from '../../app/settings/server'; import { callbacks } from '../../lib/callbacks'; -// Register ldap login handler -Accounts.registerLoginHandler('ldap', async (loginRequest: Record) => { - if (!loginRequest.ldap || !loginRequest.ldapOptions) { - return undefined; - } - - return LDAP.loginRequest(loginRequest.username, loginRequest.ldapPass); -}); - -// Prevent password logins by LDAP users when LDAP is enabled -let ldapEnabled: boolean; -settings.watch('LDAP_Enable', (value) => { - if (ldapEnabled === value) { - return; - } - ldapEnabled = value as boolean; - - if (!value) { - return callbacks.remove('beforeValidateLogin', 'validateLdapLoginFallback'); - } - - callbacks.add( - 'beforeValidateLogin', - (login: Record) => { - if (!login.allowed) { - return login; - } +export async function configureLDAP() { + // Register ldap login handler + Accounts.registerLoginHandler('ldap', async (loginRequest: Record) => { + if (!loginRequest.ldap || !loginRequest.ldapOptions) { + return undefined; + } + + return LDAP.loginRequest(loginRequest.username, loginRequest.ldapPass); + }); + + // Prevent password logins by LDAP users when LDAP is enabled + let ldapEnabled: boolean; + settings.watch('LDAP_Enable', (value) => { + if (ldapEnabled === value) { + return; + } + ldapEnabled = value as boolean; + + if (!value) { + return callbacks.remove('beforeValidateLogin', 'validateLdapLoginFallback'); + } + + callbacks.add( + 'beforeValidateLogin', + (login: Record) => { + if (!login.allowed) { + return login; + } + + // The fallback setting should only block password logins, so users that have other login services can continue using them + if (login.type !== 'password') { + return login; + } + + // LDAP users can still login locally when login fallback is enabled + if (login.user.services?.ldap?.id) { + login.allowed = settings.get('LDAP_Login_Fallback') ?? false; + } - // The fallback setting should only block password logins, so users that have other login services can continue using them - if (login.type !== 'password') { return login; - } - - // LDAP users can still login locally when login fallback is enabled - if (login.user.services?.ldap?.id) { - login.allowed = settings.get('LDAP_Login_Fallback') ?? false; - } - - return login; - }, - callbacks.priority.MEDIUM, - 'validateLdapLoginFallback', - ); -}); + }, + callbacks.priority.MEDIUM, + 'validateLdapLoginFallback', + ); + }); +} diff --git a/apps/meteor/server/configuration/oauth.ts b/apps/meteor/server/configuration/oauth.ts new file mode 100644 index 000000000000..d79705171a7c --- /dev/null +++ b/apps/meteor/server/configuration/oauth.ts @@ -0,0 +1,21 @@ +import debounce from 'lodash.debounce'; + +import { settings } from '../../app/settings/server/cached'; +import { initCustomOAuthServices } from '../lib/oauth/initCustomOAuthServices'; +import { removeOAuthService } from '../lib/oauth/removeOAuthService'; +import { updateOAuthServices } from '../lib/oauth/updateOAuthServices'; + +export async function configureOAuth() { + const _updateOAuthServices = debounce(updateOAuthServices, 2000); + settings.watchByRegex(/^Accounts_OAuth_.+/, () => { + return _updateOAuthServices(); + }); + + settings.watchByRegex(/^Accounts_OAuth_Custom-[a-z0-9_]+/, (key, value) => { + if (!value) { + return removeOAuthService(key); + } + }); + + await initCustomOAuthServices(); +} diff --git a/apps/meteor/server/configureLogLevel.ts b/apps/meteor/server/configureLogLevel.ts index b328d79a023a..7320eea97ded 100644 --- a/apps/meteor/server/configureLogLevel.ts +++ b/apps/meteor/server/configureLogLevel.ts @@ -2,7 +2,9 @@ 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); -} +export const configureLogLevel = async (): Promise => { + const LogLevel = await Settings.getValueById('Log_Level'); + if (LogLevel) { + logLevel.emit('changed', LogLevel as LogLevelSetting); + } +}; diff --git a/apps/meteor/server/cron/oembed.ts b/apps/meteor/server/cron/oembed.ts index 2d5cd2c41d5c..4f8655f6eff9 100644 --- a/apps/meteor/server/cron/oembed.ts +++ b/apps/meteor/server/cron/oembed.ts @@ -1,6 +1,7 @@ import { cronJobs } from '@rocket.chat/cron'; -import { Meteor } from 'meteor/meteor'; + +import { executeClearOEmbedCache } from '../methods/OEmbedCacheCleanup'; export async function oembedCron(): Promise { - await cronJobs.add('Cleanup OEmbed cache', '24 2 * * *', async () => Meteor.callAsync('OEmbedCacheCleanup')); + await cronJobs.add('Cleanup OEmbed cache', '24 2 * * *', async () => executeClearOEmbedCache()); } diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index 9b1ba98e0f3d..d974aa9c91be 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -1,4 +1,3 @@ -import { api } from '@rocket.chat/core-services'; import type { ILivechatVisitor, IOmnichannelRoom, @@ -17,7 +16,7 @@ import { Livechat as LivechatTyped } from '../../../app/livechat/server/lib/Live import { QueueManager } from '../../../app/livechat/server/lib/QueueManager'; import { settings } from '../../../app/settings/server'; import { i18n } from '../../lib/i18n'; -import { broadcastMessageSentEvent } from '../../modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../modules/watchers/lib/messages'; import { logger } from './logger'; type FileAttachment = VideoAttachmentProps & ImageAttachmentProps & AudioAttachmentProps; @@ -238,9 +237,8 @@ export async function onEmailReceived(email: ParsedMail, inbox: string, departme }, ); room && (await LivechatRooms.updateEmailThreadByRoomId(room._id, thread)); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: msgId, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); }) .catch((err) => { diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts index b66610f326c1..708d00422b5d 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts @@ -1,4 +1,3 @@ -import { api } from '@rocket.chat/core-services'; import { isIMessageInbox } from '@rocket.chat/core-typings'; import type { IEmailInbox, IUser, IMessage, IOmnichannelRoom, SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { Messages, Uploads, LivechatRooms, Rooms, Users } from '@rocket.chat/models'; @@ -11,7 +10,7 @@ import { settings } from '../../../app/settings/server'; import { slashCommands } from '../../../app/utils/server/slashCommand'; import { callbacks } from '../../../lib/callbacks'; import { i18n } from '../../lib/i18n'; -import { broadcastMessageSentEvent } from '../../modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../modules/watchers/lib/messages'; import { inboxes } from './EmailInbox'; import type { Inbox } from './EmailInbox'; import { logger } from './logger'; @@ -172,9 +171,8 @@ slashCommands.add({ }, }, ); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: message._id, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); return sendSuccessReplyMessage({ diff --git a/apps/meteor/server/importPackages.ts b/apps/meteor/server/importPackages.ts index d92e02f35038..2b4e3106ed45 100644 --- a/apps/meteor/server/importPackages.ts +++ b/apps/meteor/server/importPackages.ts @@ -6,7 +6,6 @@ import '../app/assets/server'; import '../app/authorization/server'; import '../app/autotranslate/server'; import '../app/bot-helpers/server'; -import '../app/cas/server'; import '../app/channel-settings/server'; import '../app/cloud/server'; import '../app/crowd/server'; diff --git a/apps/meteor/server/lib/cas/createNewUser.ts b/apps/meteor/server/lib/cas/createNewUser.ts new file mode 100644 index 000000000000..d04fa2d22497 --- /dev/null +++ b/apps/meteor/server/lib/cas/createNewUser.ts @@ -0,0 +1,63 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Rooms, Users } from '@rocket.chat/models'; +import { pick } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; + +import { createRoom } from '../../../app/lib/server/functions/createRoom'; +import { logger } from './logger'; + +type CASUserOptions = { + attributes: Record; + casVersion: number; + flagEmailAsVerified: boolean; +}; + +export const createNewUser = async (username: string, { attributes, casVersion, flagEmailAsVerified }: CASUserOptions): Promise => { + // Define new user + const newUser = { + username: attributes.username || username, + active: true, + globalRoles: ['user'], + emails: [attributes.email] + .filter((e) => e) + .map((address) => ({ + address, + verified: flagEmailAsVerified, + })), + services: { + cas: { + external_id: username, + version: casVersion, + attrs: attributes, + }, + }, + ...pick(attributes, 'name'), + }; + + // Create the user + logger.debug(`User "${username}" does not exist yet, creating it`); + const userId = Accounts.insertUserDoc({}, newUser); + + // Fetch and use it + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('Unexpected error: Unable to find user after its creation.'); + } + + logger.debug(`Created new user for '${username}' with id: ${user._id}`); + + logger.debug(`Joining user to attribute channels: ${attributes.rooms}`); + if (attributes.rooms) { + const roomNames = attributes.rooms.split(','); + for await (const roomName of roomNames) { + if (roomName) { + let room = await Rooms.findOneByNameAndType(roomName, 'c'); + if (!room) { + room = await createRoom('c', roomName, user); + } + } + } + } + + return user; +}; diff --git a/apps/meteor/server/lib/cas/findExistingCASUser.ts b/apps/meteor/server/lib/cas/findExistingCASUser.ts new file mode 100644 index 000000000000..60b52965ee68 --- /dev/null +++ b/apps/meteor/server/lib/cas/findExistingCASUser.ts @@ -0,0 +1,27 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { settings } from '../../../app/settings/server'; + +export const findExistingCASUser = async (username: string): Promise => { + const casUser = await Users.findOne({ 'services.cas.external_id': username }); + if (casUser) { + return casUser; + } + + if (!settings.get('CAS_trust_username')) { + return; + } + + // If that user was not found, check if there's any Rocket.Chat user with that username + // With this, CAS login will continue to work if the user is renamed on both sides and also if the user is renamed only on Rocket.Chat. + // It'll also allow non-CAS users to switch to CAS based login + // #TODO: Remove regex based search + const regex = new RegExp(`^${username}$`, 'i'); + const user = await Users.findOne({ regex }); + if (user) { + // Update the user's external_id to reflect this new username. + await Users.updateOne({ _id: user._id }, { $set: { 'services.cas.external_id': username } }); + return user; + } +}; diff --git a/apps/meteor/server/lib/cas/logger.ts b/apps/meteor/server/lib/cas/logger.ts new file mode 100644 index 000000000000..c2b4abe7a802 --- /dev/null +++ b/apps/meteor/server/lib/cas/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('CAS'); diff --git a/apps/meteor/server/lib/cas/loginHandler.ts b/apps/meteor/server/lib/cas/loginHandler.ts new file mode 100644 index 000000000000..80ce91350de8 --- /dev/null +++ b/apps/meteor/server/lib/cas/loginHandler.ts @@ -0,0 +1,121 @@ +import { CredentialTokens, Users } from '@rocket.chat/models'; +import { getObjectKeys, wrapExceptions } from '@rocket.chat/tools'; +import { Accounts } from 'meteor/accounts-base'; + +import { _setRealName } from '../../../app/lib/server/functions/setRealName'; +import { settings } from '../../../app/settings/server'; +import { createNewUser } from './createNewUser'; +import { findExistingCASUser } from './findExistingCASUser'; +import { logger } from './logger'; + +export const loginHandlerCAS = async (options: any): Promise => { + if (!options.cas) { + return undefined; + } + + // TODO: Sync wrapper due to the chain conversion to async models + const credentials = await CredentialTokens.findOneNotExpiredById(options.cas.credentialToken); + if (credentials === undefined || credentials === null) { + throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching login attempt found'); + } + + const result = credentials.userInfo; + const syncUserDataFieldMap = settings.get('CAS_Sync_User_Data_FieldMap').trim(); + const casVersion = parseFloat(settings.get('CAS_version') ?? '1.0'); + const syncEnabled = settings.get('CAS_Sync_User_Data_Enabled'); + const flagEmailAsVerified = settings.get('Accounts_Verify_Email_For_External_Accounts'); + const userCreationEnabled = settings.get('CAS_Creation_User_Enabled'); + + const { username, attributes: credentialsAttributes } = result as { username: string; attributes: Record }; + + // We have these + const externalAttributes: Record = { + username, + }; + + // We need these + const internalAttributes: Record = { + email: undefined, + name: undefined, + username: undefined, + rooms: undefined, + }; + + // Import response attributes + if (casVersion >= 2.0) { + // Clean & import external attributes + for await (const [externalName, value] of Object.entries(credentialsAttributes)) { + if (value) { + externalAttributes[externalName] = value[0]; + } + } + } + + // Source internal attributes + if (syncUserDataFieldMap) { + // Our mapping table: key(int_attr) -> value(ext_attr) + // Spoken: Source this internal attribute from these external attributes + const attributeMap = wrapExceptions(() => JSON.parse(syncUserDataFieldMap) as Record).catch((err) => { + logger.error({ msg: 'Invalid JSON for attribute mapping', err }); + throw err; + }); + + for await (const [internalName, source] of Object.entries(attributeMap)) { + if (!source || typeof source.valueOf() !== 'string') { + continue; + } + + let replacedValue = source as string; + for await (const externalName of getObjectKeys(externalAttributes)) { + replacedValue = replacedValue.replace(`%${externalName}%`, externalAttributes[externalName]); + } + + if (source !== replacedValue) { + internalAttributes[internalName] = replacedValue; + logger.debug(`Sourced internal attribute: ${internalName} = ${replacedValue}`); + } else { + logger.debug(`Sourced internal attribute: ${internalName} skipped.`); + } + } + } + + // Search existing user by its external service id + logger.debug(`Looking up user by id: ${username}`); + // First, look for a user that has logged in from CAS with this username before + const user = await findExistingCASUser(username); + + if (user) { + logger.debug(`Using existing user for '${username}' with id: ${user._id}`); + if (syncEnabled) { + logger.debug('Syncing user attributes'); + // Update name + if (internalAttributes.name) { + await _setRealName(user._id, internalAttributes.name); + } + + // Update email + if (internalAttributes.email) { + await Users.updateOne( + { _id: user._id }, + { $set: { emails: [{ address: internalAttributes.email, verified: flagEmailAsVerified }] } }, + ); + } + } + + return { userId: user._id }; + } + + if (!userCreationEnabled) { + // Should fail as no user exist and can't be created + logger.debug(`User "${username}" does not exist yet, will fail as no user creation is enabled`); + throw new Meteor.Error(Accounts.LoginCancelledError.numericError, 'no matching user account found'); + } + + const newUser = await createNewUser(username, { + attributes: internalAttributes, + casVersion, + flagEmailAsVerified, + }); + + return { userId: newUser._id }; +}; diff --git a/apps/meteor/server/lib/cas/middleware.ts b/apps/meteor/server/lib/cas/middleware.ts new file mode 100644 index 000000000000..074177838f9a --- /dev/null +++ b/apps/meteor/server/lib/cas/middleware.ts @@ -0,0 +1,97 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import url from 'url'; + +import { validate } from '@rocket.chat/cas-validate'; +import type { ICredentialToken } from '@rocket.chat/core-typings'; +import { CredentialTokens } from '@rocket.chat/models'; +import _ from 'underscore'; + +import { settings } from '../../../app/settings/server'; +import { logger } from './logger'; + +const closePopup = function (res: ServerResponse): void { + res.writeHead(200, { 'Content-Type': 'text/html' }); + const content = ''; + res.end(content, 'utf-8'); +}; + +type IncomingMessageWithUrl = IncomingMessage & Required>; + +const casTicket = function (req: IncomingMessageWithUrl, token: string, callback: () => void): void { + // get configuration + if (!settings.get('CAS_enabled')) { + logger.error('Got ticket validation request, but CAS is not enabled'); + callback(); + } + + // get ticket and validate. + const parsedUrl = url.parse(req.url, true); + const ticketId = parsedUrl.query.ticket as string; + const baseUrl = settings.get('CAS_base_url'); + const version = parseFloat(settings.get('CAS_version') ?? '1.0') as 1.0 | 2.0; + const appUrl = Meteor.absoluteUrl().replace(/\/$/, '') + __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; + logger.debug(`Using CAS_base_url: ${baseUrl}`); + + validate( + { + base_url: baseUrl, + version, + service: `${appUrl}/_cas/${token}`, + }, + ticketId, + async (err, status, username, details) => { + if (err) { + logger.error(`error when trying to validate: ${err.message}`); + } else if (status) { + logger.info(`Validated user: ${username}`); + const userInfo: Partial = { username: username as string }; + + // CAS 2.0 attributes handling + if (details?.attributes) { + _.extend(userInfo, { attributes: details.attributes }); + } + await CredentialTokens.create(token, userInfo); + } else { + logger.error(`Unable to validate ticket: ${ticketId}`); + } + // logger.debug("Received response: " + JSON.stringify(details, null , 4)); + + callback(); + }, + ); +}; + +export const middlewareCAS = function (req: IncomingMessage, res: ServerResponse, next: (err?: any) => void) { + // Make sure to catch any exceptions because otherwise we'd crash + // the runner + try { + if (!req.url) { + throw new Error('Invalid request url'); + } + + const barePath = req.url.substring(0, req.url.indexOf('?')); + const splitPath = barePath.split('/'); + + // Any non-cas request will continue down the default + // middlewares. + if (splitPath[1] !== '_cas') { + next(); + return; + } + + // get auth token + const credentialToken = splitPath[2]; + if (!credentialToken) { + closePopup(res); + return; + } + + // validate ticket + casTicket(req as IncomingMessageWithUrl, credentialToken, () => { + closePopup(res); + }); + } catch (err) { + logger.error({ msg: 'Unexpected error', err }); + closePopup(res); + } +}; diff --git a/apps/meteor/server/lib/cas/updateCasService.ts b/apps/meteor/server/lib/cas/updateCasService.ts new file mode 100644 index 000000000000..5583eda22f83 --- /dev/null +++ b/apps/meteor/server/lib/cas/updateCasService.ts @@ -0,0 +1,30 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { settings } from '../../../app/settings/server/cached'; +import { logger } from './logger'; + +export async function updateCasServices(): Promise { + const data: Partial = { + // These will pe passed to 'node-cas' as options + enabled: settings.get('CAS_enabled'), + base_url: settings.get('CAS_base_url'), + login_url: settings.get('CAS_login_url'), + // Rocketchat Visuals + buttonLabelText: settings.get('CAS_button_label_text'), + buttonLabelColor: settings.get('CAS_button_label_color'), + buttonColor: settings.get('CAS_button_color'), + width: settings.get('CAS_popup_width'), + height: settings.get('CAS_popup_height'), + autoclose: settings.get('CAS_autoclose'), + }; + + // Either register or deregister the CAS login service based upon its configuration + if (data.enabled) { + logger.info('Enabling CAS login service'); + await ServiceConfiguration.configurations.upsertAsync({ service: 'cas' }, { $set: data }); + } else { + logger.info('Disabling CAS login service'); + await ServiceConfiguration.configurations.removeAsync({ service: 'cas' }); + } +} diff --git a/apps/meteor/server/lib/i18n.ts b/apps/meteor/server/lib/i18n.ts index 265305ef71d6..bc3ed6184937 100644 --- a/apps/meteor/server/lib/i18n.ts +++ b/apps/meteor/server/lib/i18n.ts @@ -2,13 +2,20 @@ import type { RocketchatI18nKeys } from '@rocket.chat/i18n'; import i18nDict from '@rocket.chat/i18n'; import type { TOptions } from 'i18next'; -import { i18n } from '../../app/utils/lib/i18n'; +import { availableTranslationNamespaces, defaultTranslationNamespace, extractTranslationNamespaces, i18n } from '../../app/utils/lib/i18n'; void i18n.init({ lng: 'en', - defaultNS: 'core', - resources: Object.fromEntries(Object.entries(i18nDict).map(([key, value]) => [key, { core: value }])), - initImmediate: true, + defaultNS: defaultTranslationNamespace, + ns: availableTranslationNamespaces, + nsSeparator: '.', + resources: Object.fromEntries( + Object.entries(i18nDict).map(([language, source]) => [ + language, + extractTranslationNamespaces(source as unknown as Record), + ]), + ), + initImmediate: false, }); declare module 'i18next' { diff --git a/apps/meteor/server/lib/ldap/Connection.ts b/apps/meteor/server/lib/ldap/Connection.ts index 2ab6ba9c73cf..167f1b36e508 100644 --- a/apps/meteor/server/lib/ldap/Connection.ts +++ b/apps/meteor/server/lib/ldap/Connection.ts @@ -465,9 +465,9 @@ export class LDAPConnection { searchLogger.debug({ msg: 'Group filter LDAP:', filter: searchOptions.filter }); - const result = await this.searchRaw(this.options.baseDN, searchOptions); + const result = await this.searchAndCount(this.options.baseDN, searchOptions); - if (!Array.isArray(result) || result.length === 0) { + if (result === 0) { return false; } return true; diff --git a/apps/meteor/server/lib/ldap/Manager.ts b/apps/meteor/server/lib/ldap/Manager.ts index 99fe356d53c1..4a5cdf2df8d6 100644 --- a/apps/meteor/server/lib/ldap/Manager.ts +++ b/apps/meteor/server/lib/ldap/Manager.ts @@ -200,6 +200,10 @@ export class LDAPManager { } const [ldapUser] = users; + if (!(await ldap.isUserAcceptedByGroupFilter(escapedUsername, ldapUser.dn))) { + throw new Error('User not found'); + } + if (!(await ldap.authenticate(ldapUser.dn, password))) { logger.debug(`Wrong password for ${escapedUsername}`); throw new Error('Invalid user or wrong password'); @@ -212,11 +216,6 @@ export class LDAPManager { authLogger.debug(`Bind successful but user ${ldapUser.dn} was not found via search`); } } - - if (!(await ldap.isUserAcceptedByGroupFilter(escapedUsername, ldapUser.dn))) { - throw new Error('User not in a valid group'); - } - return ldapUser; } catch (error) { logger.error(error); diff --git a/apps/meteor/app/lib/server/functions/addOAuthService.ts b/apps/meteor/server/lib/oauth/addOAuthService.ts similarity index 99% rename from apps/meteor/app/lib/server/functions/addOAuthService.ts rename to apps/meteor/server/lib/oauth/addOAuthService.ts index eb28c5a7e3eb..2a49a23a1f4e 100644 --- a/apps/meteor/app/lib/server/functions/addOAuthService.ts +++ b/apps/meteor/server/lib/oauth/addOAuthService.ts @@ -2,7 +2,7 @@ /* eslint comma-spacing: 0 */ import { capitalize } from '@rocket.chat/string-helpers'; -import { settingsRegistry } from '../../../settings/server'; +import { settingsRegistry } from '../../../app/settings/server'; export async function addOAuthService(name: string, values: { [k: string]: string | boolean | undefined } = {}): Promise { name = name.toLowerCase().replace(/[^a-z0-9_]/g, ''); diff --git a/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts new file mode 100644 index 000000000000..3c909f6bc1f1 --- /dev/null +++ b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts @@ -0,0 +1,56 @@ +import { addOAuthService } from './addOAuthService'; + +export async function initCustomOAuthServices(): Promise { + // Add settings for custom OAuth providers to the settings so they get + // automatically added when they are defined in ENV variables + for await (const key of Object.keys(process.env)) { + if (/Accounts_OAuth_Custom_[a-zA-Z0-9_-]+$/.test(key)) { + // Most all shells actually prohibit the usage of - in environment variables + // So this will allow replacing - with _ and translate it back to the setting name + let name = key.replace('Accounts_OAuth_Custom_', ''); + + if (name.indexOf('_') > -1) { + name = name.replace(name.substr(name.indexOf('_')), ''); + } + + const serviceKey = `Accounts_OAuth_Custom_${name}`; + + if (key === serviceKey) { + const values = { + enabled: process.env[`${serviceKey}`] === 'true', + clientId: process.env[`${serviceKey}_id`], + clientSecret: process.env[`${serviceKey}_secret`], + serverURL: process.env[`${serviceKey}_url`], + tokenPath: process.env[`${serviceKey}_token_path`], + identityPath: process.env[`${serviceKey}_identity_path`], + authorizePath: process.env[`${serviceKey}_authorize_path`], + scope: process.env[`${serviceKey}_scope`], + accessTokenParam: process.env[`${serviceKey}_access_token_param`], + buttonLabelText: process.env[`${serviceKey}_button_label_text`], + buttonLabelColor: process.env[`${serviceKey}_button_label_color`], + loginStyle: process.env[`${serviceKey}_login_style`], + buttonColor: process.env[`${serviceKey}_button_color`], + tokenSentVia: process.env[`${serviceKey}_token_sent_via`], + identityTokenSentVia: process.env[`${serviceKey}_identity_token_sent_via`], + keyField: process.env[`${serviceKey}_key_field`], + usernameField: process.env[`${serviceKey}_username_field`], + nameField: process.env[`${serviceKey}_name_field`], + emailField: process.env[`${serviceKey}_email_field`], + rolesClaim: process.env[`${serviceKey}_roles_claim`], + groupsClaim: process.env[`${serviceKey}_groups_claim`], + channelsMap: process.env[`${serviceKey}_groups_channel_map`], + channelsAdmin: process.env[`${serviceKey}_channels_admin`], + mergeUsers: process.env[`${serviceKey}_merge_users`] === 'true', + mergeUsersDistinctServices: process.env[`${serviceKey}_merge_users_distinct_services`] === 'true', + mapChannels: process.env[`${serviceKey}_map_channels`], + mergeRoles: process.env[`${serviceKey}_merge_roles`] === 'true', + rolesToSync: process.env[`${serviceKey}_roles_to_sync`], + showButton: process.env[`${serviceKey}_show_button`] === 'true', + avatarField: process.env[`${serviceKey}_avatar_field`], + }; + + await addOAuthService(name, values); + } + } + } +} diff --git a/apps/meteor/server/lib/oauth/logger.ts b/apps/meteor/server/lib/oauth/logger.ts new file mode 100644 index 000000000000..e1f0fc2a8aeb --- /dev/null +++ b/apps/meteor/server/lib/oauth/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('rocketchat:lib'); diff --git a/apps/meteor/server/lib/oauth/removeOAuthService.ts b/apps/meteor/server/lib/oauth/removeOAuthService.ts new file mode 100644 index 000000000000..383a5acffd92 --- /dev/null +++ b/apps/meteor/server/lib/oauth/removeOAuthService.ts @@ -0,0 +1,9 @@ +import { ServiceConfiguration } from 'meteor/service-configuration'; + +export async function removeOAuthService(mainSettingId: string): Promise { + const serviceName = mainSettingId.replace('Accounts_OAuth_Custom-', ''); + + await ServiceConfiguration.configurations.removeAsync({ + service: serviceName.toLowerCase(), + }); +} diff --git a/apps/meteor/server/lib/oauth/updateOAuthServices.ts b/apps/meteor/server/lib/oauth/updateOAuthServices.ts new file mode 100644 index 000000000000..ed0ae5977d0d --- /dev/null +++ b/apps/meteor/server/lib/oauth/updateOAuthServices.ts @@ -0,0 +1,120 @@ +import type { + FacebookOAuthConfiguration, + ILoginServiceConfiguration, + LinkedinOAuthConfiguration, + OAuthConfiguration, + TwitterOAuthConfiguration, +} from '@rocket.chat/core-typings'; +import { LoginServiceConfiguration } from '@rocket.chat/models'; + +import { CustomOAuth } from '../../../app/custom-oauth/server/custom_oauth_server'; +import { settings } from '../../../app/settings/server/cached'; +import { logger } from './logger'; + +export async function updateOAuthServices(): Promise { + const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); + const filteredServices = services.filter(([, value]) => typeof value === 'boolean'); + for await (const [key, value] of filteredServices) { + logger.debug({ oauth_updated: key }); + let serviceName = key.replace('Accounts_OAuth_', ''); + if (serviceName === 'Meteor') { + serviceName = 'meteor-developer'; + } + if (/Accounts_OAuth_Custom-/.test(key)) { + serviceName = key.replace('Accounts_OAuth_Custom-', ''); + } + + const serviceKey = serviceName.toLowerCase(); + + if (value === true) { + const data: Partial> = { + clientId: settings.get(`${key}_id`), + secret: settings.get(`${key}_secret`), + }; + + if (/Accounts_OAuth_Custom-/.test(key)) { + data.custom = true; + data.clientId = settings.get(`${key}-id`); + data.secret = settings.get(`${key}-secret`); + data.serverURL = settings.get(`${key}-url`); + data.tokenPath = settings.get(`${key}-token_path`); + data.identityPath = settings.get(`${key}-identity_path`); + data.authorizePath = settings.get(`${key}-authorize_path`); + data.scope = settings.get(`${key}-scope`); + data.accessTokenParam = settings.get(`${key}-access_token_param`); + data.buttonLabelText = settings.get(`${key}-button_label_text`); + data.buttonLabelColor = settings.get(`${key}-button_label_color`); + data.loginStyle = settings.get(`${key}-login_style`); + data.buttonColor = settings.get(`${key}-button_color`); + data.tokenSentVia = settings.get(`${key}-token_sent_via`); + data.identityTokenSentVia = settings.get(`${key}-identity_token_sent_via`); + data.keyField = settings.get(`${key}-key_field`); + data.usernameField = settings.get(`${key}-username_field`); + data.emailField = settings.get(`${key}-email_field`); + data.nameField = settings.get(`${key}-name_field`); + data.avatarField = settings.get(`${key}-avatar_field`); + data.rolesClaim = settings.get(`${key}-roles_claim`); + data.groupsClaim = settings.get(`${key}-groups_claim`); + data.channelsMap = settings.get(`${key}-groups_channel_map`); + data.channelsAdmin = settings.get(`${key}-channels_admin`); + data.mergeUsers = settings.get(`${key}-merge_users`); + data.mergeUsersDistinctServices = settings.get(`${key}-merge_users_distinct_services`); + data.mapChannels = settings.get(`${key}-map_channels`); + data.mergeRoles = settings.get(`${key}-merge_roles`); + data.rolesToSync = settings.get(`${key}-roles_to_sync`); + data.showButton = settings.get(`${key}-show_button`); + + new CustomOAuth(serviceKey, { + serverURL: data.serverURL, + tokenPath: data.tokenPath, + identityPath: data.identityPath, + authorizePath: data.authorizePath, + scope: data.scope, + loginStyle: data.loginStyle, + tokenSentVia: data.tokenSentVia, + identityTokenSentVia: data.identityTokenSentVia, + keyField: data.keyField, + usernameField: data.usernameField, + emailField: data.emailField, + nameField: data.nameField, + avatarField: data.avatarField, + rolesClaim: data.rolesClaim, + groupsClaim: data.groupsClaim, + mapChannels: data.mapChannels, + channelsMap: data.channelsMap, + channelsAdmin: data.channelsAdmin, + mergeUsers: data.mergeUsers, + mergeUsersDistinctServices: data.mergeUsersDistinctServices, + mergeRoles: data.mergeRoles, + rolesToSync: data.rolesToSync, + accessTokenParam: data.accessTokenParam, + showButton: data.showButton, + }); + } + if (serviceName === 'Facebook') { + (data as FacebookOAuthConfiguration).appId = data.clientId as string; + delete data.clientId; + } + if (serviceName === 'Twitter') { + (data as TwitterOAuthConfiguration).consumerKey = data.clientId as string; + delete data.clientId; + } + + if (serviceName === 'Linkedin') { + (data as LinkedinOAuthConfiguration).clientConfig = { + requestPermissions: ['openid', 'email', 'profile'], + }; + } + + if (serviceName === 'Nextcloud') { + data.buttonLabelText = settings.get('Accounts_OAuth_Nextcloud_button_label_text'); + data.buttonLabelColor = settings.get('Accounts_OAuth_Nextcloud_button_label_color'); + data.buttonColor = settings.get('Accounts_OAuth_Nextcloud_button_color'); + } + + await LoginServiceConfiguration.createOrUpdateService(serviceKey, data); + } else { + await LoginServiceConfiguration.removeService(serviceKey); + } + } +} diff --git a/apps/meteor/server/lib/pushConfig.ts b/apps/meteor/server/lib/pushConfig.ts index 8bd1b49a4a5f..2cf944bef737 100644 --- a/apps/meteor/server/lib/pushConfig.ts +++ b/apps/meteor/server/lib/pushConfig.ts @@ -1,3 +1,4 @@ +import type { IUser } from '@rocket.chat/core-typings'; import { AppsTokens } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; @@ -9,6 +10,26 @@ import { Push } from '../../app/push/server'; import { settings } from '../../app/settings/server'; import { i18n } from './i18n'; +export const executePushTest = async (userId: IUser['_id'], username: IUser['username']): Promise => { + const tokens = await AppsTokens.countTokensByUserId(userId); + + if (tokens === 0) { + throw new Meteor.Error('error-no-tokens-for-this-user', 'There are no tokens for this user', { + method: 'push_test', + }); + } + + await Push.send({ + from: 'push', + title: `@${username}`, + text: i18n.t('This_is_a_push_test_messsage'), + sound: 'default', + userId, + }); + + return tokens; +}; + declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -38,47 +59,10 @@ Meteor.methods({ }); } - const query = { - $and: [ - { - userId: user._id, - }, - { - $or: [ - { - 'token.apn': { - $exists: true, - }, - }, - { - 'token.gcm': { - $exists: true, - }, - }, - ], - }, - ], - }; - - const tokens = await AppsTokens.col.countDocuments(query); - - if (tokens === 0) { - throw new Meteor.Error('error-no-tokens-for-this-user', 'There are no tokens for this user', { - method: 'push_test', - }); - } - - await Push.send({ - from: 'push', - title: `@${user.username}`, - text: i18n.t('This_is_a_push_test_messsage'), - sound: 'default', - userId: user._id, - }); - + const tokensCount = await executePushTest(user._id, user.username); return { message: 'Your_push_was_sent_to_s_devices', - params: [tokens], + params: [tokensCount], }; }, }); diff --git a/apps/meteor/server/lib/refreshLoginServices.ts b/apps/meteor/server/lib/refreshLoginServices.ts new file mode 100644 index 000000000000..41f3b647f05e --- /dev/null +++ b/apps/meteor/server/lib/refreshLoginServices.ts @@ -0,0 +1,11 @@ +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { loadSamlServiceProviders } from '../../app/meteor-accounts-saml/server/lib/settings'; +import { updateCasServices } from './cas/updateCasService'; +import { updateOAuthServices } from './oauth/updateOAuthServices'; + +export async function refreshLoginServices(): Promise { + await ServiceConfiguration.configurations.removeAsync({}); + + await Promise.allSettled([updateOAuthServices(), loadSamlServiceProviders(), updateCasServices()]); +} diff --git a/apps/meteor/server/main.ts b/apps/meteor/server/main.ts index b9418fe43830..888b84a7807c 100644 --- a/apps/meteor/server/main.ts +++ b/apps/meteor/server/main.ts @@ -1,85 +1,36 @@ import './models/startup'; -import './configureLogLevel'; -import './settings/index'; -import '../ee/server/models/startup'; -import './services/startup'; -import '../app/settings/server'; -import '../lib/oauthRedirectUriServer'; -import './lib/logger/startup'; -import './importPackages'; -import '../imports/startup/server'; + +/** + * ./settings uses top level await, in theory the settings creation + * and the startup should be done in parallel + */ +import './settings'; import '../app/lib/server/startup'; -import '../ee/server/startup'; -import './startup'; -import '../ee/server'; -import './lib/pushConfig'; -import './configuration/accounts_meld'; -import './configuration/ldap'; -import './methods/OEmbedCacheCleanup'; -import './methods/addAllUserToRoom'; -import './methods/addRoomLeader'; -import './methods/addRoomModerator'; -import './methods/addRoomOwner'; -import './methods/afterVerifyEmail'; -import './methods/browseChannels'; -import './methods/canAccessRoom'; -import './methods/channelsList'; -import './methods/createDirectMessage'; -import './methods/deleteFileMessage'; -import './methods/deleteUser'; -import './methods/eraseRoom'; -import './methods/getAvatarSuggestion'; -import './methods/getPasswordPolicy'; -import './methods/getRoomById'; -import './methods/getRoomIdByNameOrId'; -import './methods/getRoomNameById'; -import './methods/getSetupWizardParameters'; -import './methods/getTotalChannels'; -import './methods/getUsersOfRoom'; -import './methods/hideRoom'; -import './methods/ignoreUser'; -import './methods/loadHistory'; -import './methods/loadLocale'; -import './methods/loadMissedMessages'; -import './methods/loadNextMessages'; -import './methods/loadSurroundingMessages'; -import './methods/logoutCleanUp'; -import './methods/messageSearch'; -import './methods/muteUserInRoom'; -import './methods/openRoom'; -import './methods/readMessages'; -import './methods/readThreads'; -import './methods/registerUser'; -import './methods/removeRoomLeader'; -import './methods/removeRoomModerator'; -import './methods/removeRoomOwner'; -import './methods/removeUserFromRoom'; -import './methods/reportMessage'; -import './methods/requestDataDownload'; -import './methods/resetAvatar'; -import './methods/roomNameExists'; -import './methods/saveUserPreferences'; -import './methods/saveUserProfile'; -import './methods/sendConfirmationEmail'; -import './methods/sendForgotPasswordEmail'; -import './methods/setAvatarFromService'; -import './methods/setUserActiveStatus'; -import './methods/setUserPassword'; -import './methods/toggleFavorite'; -import './methods/unmuteUserInRoom'; -import './methods/userPresence'; -import './methods/userSetUtcOffset'; -import './publications/messages'; -import './publications/room'; -import './publications/settings'; -import './publications/spotlight'; -import './publications/subscription'; -import './routes/avatar'; -import './routes/health'; -import './routes/i18n'; -import './routes/timesync'; -import './routes/userDataDownload'; -import './stream/stdout'; +import { startLicense } from '../ee/app/license/server/startup'; +import { registerEEBroker } from '../ee/server'; +import { configureLoginServices } from './configuration'; +import { configureLogLevel } from './configureLogLevel'; +import { registerServices } from './services/startup'; +import { startup } from './startup'; +import './importPackages'; +import './methods'; +import './publications'; +import './routes'; + +await import('./lib/logger/startup'); + +await import('../lib/oauthRedirectUriServer'); + +await import('./lib/pushConfig'); + +await import('./stream/stdout'); +await import('./features/EmailInbox/index'); -import './features/EmailInbox/index'; +await configureLogLevel(); +await registerServices(); +await import('../app/settings/server'); +await configureLoginServices(); +await registerEEBroker(); +await startup(); +await startLicense(); diff --git a/apps/meteor/server/methods/OEmbedCacheCleanup.ts b/apps/meteor/server/methods/OEmbedCacheCleanup.ts index 00d937bb913e..158964125502 100644 --- a/apps/meteor/server/methods/OEmbedCacheCleanup.ts +++ b/apps/meteor/server/methods/OEmbedCacheCleanup.ts @@ -12,6 +12,13 @@ declare module '@rocket.chat/ui-contexts' { } } +export const executeClearOEmbedCache = async () => { + const date = new Date(); + const expirationDays = settings.get('API_EmbedCacheExpirationDays'); + date.setDate(date.getDate() - expirationDays); + return OEmbedCache.removeBeforeDate(date); +}; + Meteor.methods({ async OEmbedCacheCleanup() { const uid = Meteor.userId(); @@ -21,10 +28,7 @@ Meteor.methods({ }); } - const date = new Date(); - const expirationDays = settings.get('API_EmbedCacheExpirationDays'); - date.setDate(date.getDate() - expirationDays); - await OEmbedCache.removeAfterDate(date); + await executeClearOEmbedCache(); return { message: 'cache_cleared', }; diff --git a/apps/meteor/server/methods/getAvatarSuggestion.ts b/apps/meteor/server/methods/getAvatarSuggestion.ts index 755b9053c569..5cf8cdd2e4b1 100644 --- a/apps/meteor/server/methods/getAvatarSuggestion.ts +++ b/apps/meteor/server/methods/getAvatarSuggestion.ts @@ -8,16 +8,14 @@ import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWar declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { - getAvatarSuggestion(): Promise< - Record< - string, - { - blob: string; - contentType: string; - service: string; - url: string; - } - > + getAvatarSuggestion(): Record< + string, + { + blob: string; + contentType: string; + service: string; + url: string; + } >; } } diff --git a/apps/meteor/server/methods/index.ts b/apps/meteor/server/methods/index.ts new file mode 100644 index 000000000000..27c345964637 --- /dev/null +++ b/apps/meteor/server/methods/index.ts @@ -0,0 +1,56 @@ +import '../../imports/personal-access-tokens/server/api/methods'; + +import './OEmbedCacheCleanup'; +import './addAllUserToRoom'; +import './addRoomLeader'; +import './addRoomModerator'; +import './addRoomOwner'; +import './afterVerifyEmail'; +import './browseChannels'; +import './canAccessRoom'; +import './channelsList'; +import './createDirectMessage'; +import './deleteFileMessage'; +import './deleteUser'; +import './eraseRoom'; +import './getAvatarSuggestion'; +import './getPasswordPolicy'; +import './getRoomById'; +import './getRoomIdByNameOrId'; +import './getRoomNameById'; +import './getSetupWizardParameters'; +import './getTotalChannels'; +import './getUsersOfRoom'; +import './hideRoom'; +import './ignoreUser'; +import './loadHistory'; +import './loadLocale'; +import './loadMissedMessages'; +import './loadNextMessages'; +import './loadSurroundingMessages'; +import './logoutCleanUp'; +import './messageSearch'; +import './muteUserInRoom'; +import './openRoom'; +import './readMessages'; +import './readThreads'; +import './registerUser'; +import './removeRoomLeader'; +import './removeRoomModerator'; +import './removeRoomOwner'; +import './removeUserFromRoom'; +import './reportMessage'; +import './requestDataDownload'; +import './resetAvatar'; +import './roomNameExists'; +import './saveUserPreferences'; +import './saveUserProfile'; +import './sendConfirmationEmail'; +import './sendForgotPasswordEmail'; +import './setAvatarFromService'; +import './setUserActiveStatus'; +import './setUserPassword'; +import './toggleFavorite'; +import './unmuteUserInRoom'; +import './userPresence'; +import './userSetUtcOffset'; diff --git a/apps/meteor/server/methods/saveUserPreferences.ts b/apps/meteor/server/methods/saveUserPreferences.ts index 814627a745bc..c23f466cf8a5 100644 --- a/apps/meteor/server/methods/saveUserPreferences.ts +++ b/apps/meteor/server/methods/saveUserPreferences.ts @@ -5,6 +5,8 @@ import type { ThemePreference } from '@rocket.chat/ui-theming/src/types/themes'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { settings as rcSettings } from '../../app/settings/server'; + type UserPreferences = { language: string; newRoomNotification: string; @@ -102,6 +104,7 @@ export const saveUserPreferences = async (settings: Partial, us desktopNotifications: oldDesktopNotifications, pushNotifications: oldMobileNotifications, emailNotificationMode: oldEmailNotifications, + language: oldLanguage, } = user.settings?.preferences || {}; if (user.settings == null) { @@ -169,6 +172,10 @@ export const saveUserPreferences = async (settings: Partial, us if (Array.isArray(settings.highlights)) { await Subscriptions.updateUserHighlights(user._id, settings.highlights); } + + if (settings.language && oldLanguage !== settings.language && rcSettings.get('AutoTranslate_AutoEnableOnJoinRoom')) { + await Subscriptions.updateAllAutoTranslateLanguagesByUserId(user._id, settings.language); + } }); }; diff --git a/apps/meteor/server/models/raw/AppsTokens.ts b/apps/meteor/server/models/raw/AppsTokens.ts index bddc13a52792..43c085f30226 100644 --- a/apps/meteor/server/models/raw/AppsTokens.ts +++ b/apps/meteor/server/models/raw/AppsTokens.ts @@ -1,4 +1,4 @@ -import type { IAppsTokens } from '@rocket.chat/core-typings'; +import type { IAppsTokens, IUser } from '@rocket.chat/core-typings'; import type { IAppsTokensModel } from '@rocket.chat/model-typings'; import type { Db } from 'mongodb'; @@ -8,4 +8,29 @@ export class AppsTokens extends BaseRaw implements IAppsTokensModel constructor(db: Db) { super(db, '_raix_push_app_tokens', undefined, { collectionNameResolver: (name) => name }); } + + countApnTokens() { + const query = { + 'token.apn': { $exists: true }, + }; + + return this.countDocuments(query); + } + + countGcmTokens() { + const query = { + 'token.gcm': { $exists: true }, + }; + + return this.countDocuments(query); + } + + countTokensByUserId(userId: IUser['_id']) { + const query = { + userId, + $or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }], + }; + + return this.countDocuments(query); + } } diff --git a/apps/meteor/server/models/raw/BaseRaw.ts b/apps/meteor/server/models/raw/BaseRaw.ts index e43bac39b339..5ab3b9802105 100644 --- a/apps/meteor/server/models/raw/BaseRaw.ts +++ b/apps/meteor/server/models/raw/BaseRaw.ts @@ -78,6 +78,8 @@ export abstract class BaseRaw< this.preventSetUpdatedAt = options?.preventSetUpdatedAt ?? false; } + private pendingIndexes: Promise | undefined; + public async createIndexes() { const indexes = this.modelIndexes(); if (this.options?._updatedAtIndexOptions) { @@ -85,7 +87,17 @@ export abstract class BaseRaw< } if (indexes?.length) { - return this.col.createIndexes(indexes); + if (this.pendingIndexes) { + await this.pendingIndexes; + } + + this.pendingIndexes = this.col.createIndexes(indexes) as unknown as Promise; + + void this.pendingIndexes.finally(() => { + this.pendingIndexes = undefined; + }); + + return this.pendingIndexes; } } diff --git a/apps/meteor/server/models/raw/LivechatDepartment.ts b/apps/meteor/server/models/raw/LivechatDepartment.ts index 96a0dc5c9e0e..a54b03876dd9 100644 --- a/apps/meteor/server/models/raw/LivechatDepartment.ts +++ b/apps/meteor/server/models/raw/LivechatDepartment.ts @@ -13,6 +13,7 @@ import type { IndexDescription, DeleteResult, UpdateFilter, + AggregationCursor, } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -446,4 +447,8 @@ export class LivechatDepartmentRaw extends BaseRaw implemen findByParentId(_parentId: string, _options?: FindOptions | undefined): FindCursor { throw new Error('Method not implemented in CE'); } + + findAgentsByBusinessHourId(_businessHourId: string): AggregationCursor<{ agentIds: string[] }> { + throw new Error('Method not implemented in CE'); + } } diff --git a/apps/meteor/server/models/raw/LivechatDepartmentAgents.ts b/apps/meteor/server/models/raw/LivechatDepartmentAgents.ts index 91f3f4e22e34..082a3e6aa2e6 100644 --- a/apps/meteor/server/models/raw/LivechatDepartmentAgents.ts +++ b/apps/meteor/server/models/raw/LivechatDepartmentAgents.ts @@ -78,6 +78,10 @@ export class LivechatDepartmentAgentsRaw extends BaseRaw): FindCursor { + return this.find({ agentId: { $in: agentIds } }, options); + } + findByAgentId(agentId: string, options?: FindOptions): FindCursor { return this.find({ agentId }, options); } diff --git a/apps/meteor/server/models/raw/LoginServiceConfiguration.ts b/apps/meteor/server/models/raw/LoginServiceConfiguration.ts index a0db761ee5b8..98b14d2c1947 100644 --- a/apps/meteor/server/models/raw/LoginServiceConfiguration.ts +++ b/apps/meteor/server/models/raw/LoginServiceConfiguration.ts @@ -1,11 +1,11 @@ -import type { ILoginServiceConfiguration, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { LoginServiceConfiguration, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { ILoginServiceConfigurationModel } from '@rocket.chat/model-typings'; -import type { Collection, Db } from 'mongodb'; +import type { Collection, Db, DeleteResult } from 'mongodb'; import { BaseRaw } from './BaseRaw'; -export class LoginServiceConfigurationRaw extends BaseRaw implements ILoginServiceConfigurationModel { - constructor(db: Db, trash?: Collection>) { +export class LoginServiceConfigurationRaw extends BaseRaw implements ILoginServiceConfigurationModel { + constructor(db: Db, trash?: Collection>) { super(db, 'meteor_accounts_loginServiceConfiguration', trash, { preventSetUpdatedAt: true, collectionNameResolver(name) { @@ -13,4 +13,40 @@ export class LoginServiceConfigurationRaw extends BaseRaw, + ): Promise { + const service = serviceName.toLowerCase(); + + const existing = await this.findOne({ service }); + if (!existing) { + const insertResult = await this.insertOne({ + service, + ...serviceData, + }); + + return insertResult.insertedId; + } + + if (Object.keys(serviceData).length > 0) { + await this.updateOne( + { + _id: existing._id, + }, + { + $set: serviceData, + }, + ); + } + + return existing._id; + } + + async removeService(serviceName: string): Promise { + const service = serviceName.toLowerCase(); + + return this.deleteOne({ service }); + } } diff --git a/apps/meteor/server/models/raw/ModerationReports.ts b/apps/meteor/server/models/raw/ModerationReports.ts index b91258745e25..065f6ec18070 100644 --- a/apps/meteor/server/models/raw/ModerationReports.ts +++ b/apps/meteor/server/models/raw/ModerationReports.ts @@ -9,6 +9,7 @@ import type { import type { FindPaginated, IModerationReportsModel, PaginationParams } from '@rocket.chat/model-typings'; import type { AggregationCursor, Collection, Db, Document, FindCursor, FindOptions, IndexDescription, UpdateResult } from 'mongodb'; +import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; import { BaseRaw } from './BaseRaw'; export class ModerationReportsRaw extends BaseRaw implements IModerationReportsModel { @@ -18,12 +19,17 @@ export class ModerationReportsRaw extends BaseRaw implements modelIndexes(): IndexDescription[] | undefined { return [ - { key: { 'ts': 1, 'reports.ts': 1 } }, - { key: { 'message.u._id': 1, 'ts': 1 } }, - { key: { 'reportedUser._id': 1, 'ts': 1 } }, - { key: { 'message.rid': 1, 'ts': 1 } }, - { key: { userId: 1, ts: 1 } }, - { key: { 'message._id': 1, 'ts': 1 } }, + // TODO deprecated. remove within a migration in v7.0 + // { key: { 'ts': 1, 'reports.ts': 1 } }, + // { key: { 'message.u._id': 1, 'ts': 1 } }, + // { key: { 'reportedUser._id': 1, 'ts': 1 } }, + // { key: { 'message.rid': 1, 'ts': 1 } }, + // { key: { 'message._id': 1, 'ts': 1 } }, + // { key: { userId: 1, ts: 1 } }, + { key: { _hidden: 1, ts: 1 } }, + { key: { 'message._id': 1, '_hidden': 1, 'ts': 1 } }, + { key: { 'message.u._id': 1, '_hidden': 1, 'ts': 1 } }, + { key: { 'reportedUser._id': 1, '_hidden': 1, 'ts': 1 } }, ]; } @@ -132,6 +138,82 @@ export class ModerationReportsRaw extends BaseRaw implements return this.col.aggregate(params, { allowDiskUse: true }); } + findUserReports( + latest: Date, + oldest: Date, + selector: string, + pagination: PaginationParams, + ): AggregationCursor & { count: number }> { + const query = { + _hidden: { + $ne: true, + }, + ts: { + $lt: latest, + $gt: oldest, + }, + ...this.getSearchQueryForSelectorUsers(selector), + }; + + const { sort, offset, count } = pagination; + + const pipeline = [ + { $match: query }, + { + $sort: { + ts: -1, + }, + }, + { + $group: { + _id: '$reportedUser._id', + count: { $sum: 1 }, + reports: { $first: '$$ROOT' }, + }, + }, + { + $sort: sort || { + 'reports.ts': -1, + }, + }, + { + $skip: offset, + }, + { + $limit: count, + }, + { + $project: { + _id: 0, + reportedUser: '$reports.reportedUser', + ts: '$reports.ts', + count: 1, + }, + }, + ]; + + return this.col.aggregate(pipeline, { allowDiskUse: true, readPreference: readSecondaryPreferred() }); + } + + async getTotalUniqueReportedUsers(latest: Date, oldest: Date, selector: string, isMessageReports?: boolean): Promise { + const query = { + _hidden: { + $ne: true, + }, + ts: { + $lt: latest, + $gt: oldest, + }, + ...(isMessageReports ? this.getSearchQueryForSelector(selector) : this.getSearchQueryForSelectorUsers(selector)), + }; + + const field = isMessageReports ? 'message.u._id' : 'reportedUser._id'; + const pipeline = [{ $match: query }, { $group: { _id: `$${field}` } }, { $group: { _id: null, count: { $sum: 1 } } }]; + + const result = await this.col.aggregate(pipeline).toArray(); + return result[0]?.count || 0; + } + countMessageReportsInRange(latest: Date, oldest: Date, selector: string): Promise { return this.col.countDocuments({ _hidden: { $ne: true }, @@ -182,6 +264,41 @@ export class ModerationReportsRaw extends BaseRaw implements return this.findPaginated({ ...query, ...fuzzyQuery }, params); } + findUserReportsByReportedUserId( + userId: string, + selector: string, + pagination: PaginationParams, + options: FindOptions = {}, + ): FindPaginated>> { + const query = { + '_hidden': { + $ne: true, + }, + 'reportedUser._id': userId, + ...this.getSearchQueryForSelectorUsers(selector), + }; + + const { count, offset, sort } = pagination; + + const opts = { + sort: sort || { + ts: -1, + }, + skip: offset, + limit: count, + projection: { + _id: 1, + description: 1, + ts: 1, + reportedBy: 1, + reportedUser: 1, + }, + ...options, + }; + + return this.findPaginated(query, opts); + } + findReportsByMessageId( messageId: string, selector: string, @@ -246,6 +363,21 @@ export class ModerationReportsRaw extends BaseRaw implements return this.updateMany(query, update); } + async hideUserReportsByUserId(userId: string, moderatorId: string, reason: string, action: string): Promise { + const query = { + 'reportedUser._id': userId, + }; + + const update = { + $set: { + _hidden: true, + moderationInfo: { hiddenAt: new Date(), moderatedBy: moderatorId, reason, action }, + }, + }; + + return this.updateMany(query, update); + } + private getSearchQueryForSelector(selector?: string): any { const messageExistsQuery = { message: { $exists: true } }; if (!selector) { @@ -281,4 +413,28 @@ export class ModerationReportsRaw extends BaseRaw implements ], }; } + + private getSearchQueryForSelectorUsers(selector?: string): any { + const messageAbsentQuery = { message: { $exists: false } }; + if (!selector) { + return messageAbsentQuery; + } + return { + ...messageAbsentQuery, + $or: [ + { + 'reportedUser.username': { + $regex: selector, + $options: 'i', + }, + }, + { + 'reportedUser.name': { + $regex: selector, + $options: 'i', + }, + }, + ], + }; + } } diff --git a/apps/meteor/server/models/raw/OEmbedCache.ts b/apps/meteor/server/models/raw/OEmbedCache.ts index 396e2972571a..6bfab4024b4e 100644 --- a/apps/meteor/server/models/raw/OEmbedCache.ts +++ b/apps/meteor/server/models/raw/OEmbedCache.ts @@ -23,7 +23,7 @@ export class OEmbedCacheRaw extends BaseRaw implements IOEmbedCach return record; } - removeAfterDate(date: Date): Promise { + removeBeforeDate(date: Date): Promise { const query = { updatedAt: { $lte: date, diff --git a/apps/meteor/server/models/raw/Rooms.ts b/apps/meteor/server/models/raw/Rooms.ts index 9c1b14dc3f35..06b6d030424b 100644 --- a/apps/meteor/server/models/raw/Rooms.ts +++ b/apps/meteor/server/models/raw/Rooms.ts @@ -562,6 +562,15 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.findOne(query, options); } + findOneByJoinCodeAndId(joinCode: string, rid: IRoom['_id'], options: FindOptions = {}): Promise { + const query: Filter = { + _id: rid, + joinCode, + }; + + return this.findOne(query, options); + } + async findOneByNonValidatedName(name: NonNullable, options: FindOptions = {}) { const room = await this.findOneByNameOrFname(name, options); if (room) { diff --git a/apps/meteor/server/models/raw/Sessions.ts b/apps/meteor/server/models/raw/Sessions.ts index c02fc8b5de99..96f6a596ddb8 100644 --- a/apps/meteor/server/models/raw/Sessions.ts +++ b/apps/meteor/server/models/raw/Sessions.ts @@ -166,6 +166,12 @@ const getProjectionByFullDate = (): { day: string; month: string; year: string } year: '$_id.year', }); +const getProjectionFullDate = (): { day: string; month: string; year: string } => ({ + day: '$day', + month: '$month', + year: '$year', +}); + export const aggregates = { dailySessions( collection: Collection, @@ -209,9 +215,7 @@ export const aggregates = { _id: { userId: '$userId', device: '$device', - day: '$day', - month: '$month', - year: '$year', + ...getProjectionFullDate(), }, mostImportantRole: { $first: '$mostImportantRole' }, time: { $sum: '$time' }, @@ -227,9 +231,7 @@ export const aggregates = { $group: { _id: { userId: '$_id.userId', - day: '$_id.day', - month: '$_id.month', - year: '$_id.year', + ...getProjectionByFullDate(), }, mostImportantRole: { $first: '$mostImportantRole' }, time: { $sum: '$time' }, @@ -253,9 +255,7 @@ export const aggregates = { _id: 0, type: { $literal: 'user_daily' }, _computedAt: { $literal: new Date() }, - day: '$_id.day', - month: '$_id.month', - year: '$_id.year', + ...getProjectionByFullDate(), userId: '$_id.userId', mostImportantRole: 1, time: 1, @@ -292,9 +292,7 @@ export const aggregates = { { $group: { _id: { - day: '$day', - month: '$month', - year: '$year', + ...getProjectionFullDate(), mostImportantRole: '$mostImportantRole', }, count: { @@ -311,9 +309,7 @@ export const aggregates = { { $group: { _id: { - day: '$day', - month: '$month', - year: '$year', + ...getProjectionFullDate(), }, roles: { $push: { @@ -875,6 +871,10 @@ export class SessionsRaw extends BaseRaw implements ISessionsModel { $exists: true, $ne: '', }, + sessionId: { + $exists: true, + $ne: '', + }, }, { logoutAt: { @@ -1069,9 +1069,7 @@ export class SessionsRaw extends BaseRaw implements ISessionsModel { { $group: { _id: { - day: '$day', - month: '$month', - year: '$year', + ...getProjectionFullDate(), userId: '$userId', }, }, @@ -1079,9 +1077,7 @@ export class SessionsRaw extends BaseRaw implements ISessionsModel { { $group: { _id: { - day: '$_id.day', - month: '$_id.month', - year: '$_id.year', + ...getProjectionByFullDate(), }, usersList: { $addToSet: '$_id.userId', @@ -1174,7 +1170,7 @@ export class SessionsRaw extends BaseRaw implements ISessionsModel { }, { $group: { - _id: { year: '$year', month: '$month', day: '$day' }, + _id: { ...getProjectionFullDate() }, users: { $sum: 1 }, }, }, @@ -1431,11 +1427,15 @@ export class SessionsRaw extends BaseRaw implements ISessionsModel { }; } + private isValidData(data: Omit): boolean { + return Boolean(data.year && data.month && data.day && data.sessionId && data.instanceId); + } + async createOrUpdate(data: Omit): Promise { // TODO: check if we should create a session when there is no loginToken or not const { year, month, day, sessionId, instanceId } = data; - if (!year || !month || !day || !sessionId || !instanceId) { + if (!this.isValidData(data)) { return; } @@ -1588,16 +1588,17 @@ export class SessionsRaw extends BaseRaw implements ISessionsModel { sessions.forEach((doc) => { const { year, month, day, sessionId, instanceId } = doc; delete doc._id; - - ops.push({ - updateOne: { - filter: { year, month, day, sessionId, instanceId }, - update: { - $set: doc, + if (this.isValidData(doc)) { + ops.push({ + updateOne: { + filter: { year, month, day, sessionId, instanceId }, + update: { + $set: doc, + }, + upsert: true, }, - upsert: true, - }, - }); + }); + } }); return this.col.bulkWrite(ops, { ordered: false }); diff --git a/apps/meteor/server/models/raw/Settings.ts b/apps/meteor/server/models/raw/Settings.ts index 1154b7dfe630..16ccdc788fdf 100644 --- a/apps/meteor/server/models/raw/Settings.ts +++ b/apps/meteor/server/models/raw/Settings.ts @@ -1,6 +1,6 @@ import type { ISetting, ISettingColor, ISettingSelectOption, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { ISettingsModel } from '@rocket.chat/model-typings'; -import type { Collection, FindCursor, Db, Filter, UpdateFilter, UpdateResult, Document } from 'mongodb'; +import type { Collection, FindCursor, Db, Filter, UpdateFilter, UpdateResult, Document, FindOptions } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -36,7 +36,7 @@ export class SettingsRaw extends BaseRaw implements ISettingsModel { return this.findOne(query); } - findByIds(_id: string[] | string = []): FindCursor { + findByIds(_id: string[] | string = [], options?: FindOptions): FindCursor { if (typeof _id === 'string') { _id = [_id]; } @@ -47,7 +47,7 @@ export class SettingsRaw extends BaseRaw implements ISettingsModel { }, }; - return this.find(query); + return this.find(query, options); } updateValueById( diff --git a/apps/meteor/server/models/raw/Subscriptions.ts b/apps/meteor/server/models/raw/Subscriptions.ts index 72a91e6ab17c..6b3c184a0e24 100644 --- a/apps/meteor/server/models/raw/Subscriptions.ts +++ b/apps/meteor/server/models/raw/Subscriptions.ts @@ -61,6 +61,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri { key: { prid: 1 } }, { key: { 'u._id': 1, 'open': 1, 'department': 1 } }, { key: { rid: 1, ls: 1 } }, + { key: { 'u._id': 1, 'autotranslate': 1 } }, ]; } @@ -603,6 +604,21 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateOne(query, update); } + updateAllAutoTranslateLanguagesByUserId(userId: IUser['_id'], language: string): Promise { + const query = { + 'u._id': userId, + 'autoTranslate': true, + }; + + const update: UpdateFilter = { + $set: { + autoTranslateLanguage: language, + }, + }; + + return this.updateMany(query, update); + } + disableAutoTranslateByRoomId(roomId: IRoom['_id']): Promise { const query = { rid: roomId, diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index ae78f78b0e39..49a3017af81c 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -1,8 +1,8 @@ import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import type { ISetting as AppsSetting } from '@rocket.chat/apps-engine/definition/settings'; import type { IServiceClass } from '@rocket.chat/core-services'; -import { EnterpriseSettings, listenToMessageSentEvent } from '@rocket.chat/core-services'; -import { UserStatus, isSettingColor, isSettingEnterprise } from '@rocket.chat/core-typings'; +import { EnterpriseSettings } from '@rocket.chat/core-services'; +import { isSettingColor, isSettingEnterprise } from '@rocket.chat/core-typings'; import type { IUser, IRoom, VideoConference, ISetting, IOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { parse } from '@rocket.chat/message-parser'; @@ -12,13 +12,6 @@ import type { NotificationsModule } from '../notifications/notifications.module' const isMessageParserDisabled = process.env.DISABLE_MESSAGE_PARSER === 'true'; -const STATUS_MAP = { - [UserStatus.OFFLINE]: 0, - [UserStatus.ONLINE]: 1, - [UserStatus.AWAY]: 2, - [UserStatus.BUSY]: 3, -} as const; - const minimongoChangeMap: Record = { inserted: 'added', updated: 'changed', @@ -152,12 +145,10 @@ export class ListenersModule { return; } - const statusChanged = (STATUS_MAP as any)[status] as 0 | 1 | 2 | 3; - - notifications.notifyLoggedInThisInstance('user-status', [_id, username, statusChanged, statusText, name, roles]); + notifications.notifyLoggedInThisInstance('user-status', [_id, username, status, statusText, name, roles]); if (_id) { - notifications.sendPresence(_id, username, statusChanged, statusText); + notifications.sendPresence(_id, username, status, statusText); } }); @@ -167,7 +158,7 @@ export class ListenersModule { }); }); - listenToMessageSentEvent(service, async (message) => { + service.onEvent('watch.messages', async ({ message }) => { if (!message.rid) { return; } diff --git a/apps/meteor/server/modules/notifications/notifications.module.ts b/apps/meteor/server/modules/notifications/notifications.module.ts index 979603c92650..73cede2cd36c 100644 --- a/apps/meteor/server/modules/notifications/notifications.module.ts +++ b/apps/meteor/server/modules/notifications/notifications.module.ts @@ -1,5 +1,5 @@ import { Authorization, VideoConf } from '@rocket.chat/core-services'; -import type { ISubscription, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; +import type { ISubscription, IOmnichannelRoom, IUser, UserStatus } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users, Settings } from '@rocket.chat/models'; import type { StreamerCallbackArgs, StreamKeys, StreamNames } from '@rocket.chat/ui-contexts'; import type { IStreamer, IStreamerConstructor, IPublication } from 'meteor/rocketchat:streamer'; @@ -531,7 +531,7 @@ export class NotificationsModule { return this.streamUser.emitWithoutBroadcast(`${userId}/${eventName}`, ...args); } - sendPresence(uid: string, ...args: [username: string, statusChanged: 0 | 1 | 2 | 3, statusText: string | undefined]): void { + sendPresence(uid: string, ...args: [username: string, status?: UserStatus, statusText?: string]): void { emit(uid, [args]); return this.streamPresence.emitWithoutBroadcast(uid, args); } diff --git a/apps/meteor/server/modules/watchers/lib/messages.ts b/apps/meteor/server/modules/watchers/lib/messages.ts index ded1c2389e17..576f27f83b96 100644 --- a/apps/meteor/server/modules/watchers/lib/messages.ts +++ b/apps/meteor/server/modules/watchers/lib/messages.ts @@ -1,3 +1,4 @@ +import { api, dbWatchersDisabled } from '@rocket.chat/core-services'; import type { IMessage, SettingValue, IUser } from '@rocket.chat/core-typings'; import { Messages, Settings, Users } from '@rocket.chat/models'; import mem from 'mem'; @@ -12,41 +13,48 @@ const getUserNameCached = mem( { maxAge: 10000 }, ); -export const broadcastMessageSentEvent = async ({ - id, - data, - broadcastCallback, -}: { - id: IMessage['_id']; - broadcastCallback: (message: IMessage) => Promise; - data?: IMessage; -}): Promise => { +export async function getMessageToBroadcast({ id, data }: { id: IMessage['_id']; data?: IMessage }): Promise { const message = data ?? (await Messages.findOneById(id)); if (!message) { return; } - if (message._hidden !== true && message.imported == null) { - const UseRealName = (await getSettingCached('UI_Use_Real_Name')) === true; + if (message._hidden || message.imported != null) { + return; + } - if (UseRealName) { - if (message.u?._id) { - const name = await getUserNameCached(message.u._id); - if (name) { - message.u.name = name; - } + const useRealName = (await getSettingCached('UI_Use_Real_Name')) === true; + if (useRealName) { + if (message.u?._id) { + const name = await getUserNameCached(message.u._id); + if (name) { + message.u.name = name; } + } - if (message.mentions?.length) { - for await (const mention of message.mentions) { - const name = await getUserNameCached(mention._id); - if (name) { - mention.name = name; - } + if (message.mentions?.length) { + for await (const mention of message.mentions) { + const name = await getUserNameCached(mention._id); + if (name) { + mention.name = name; } } } + } + + return message; +} - void broadcastCallback(message); +// TODO once the broadcast from file apps/meteor/server/modules/watchers/watchers.module.ts is removed +// this function can be renamed to broadcastMessage +export async function broadcastMessageFromData({ id, data }: { id: IMessage['_id']; data?: IMessage }): Promise { + // if db watchers are active, the event will be triggered automatically so we don't need to broadcast it here. + if (!dbWatchersDisabled) { + return; + } + const message = await getMessageToBroadcast({ id, data }); + if (!message) { + return; } -}; + void api.broadcast('watch.messages', { message }); +} diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 88e465edc018..08d6d3c21a15 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -39,7 +39,7 @@ import { import { subscriptionFields, roomFields } from '../../../lib/publishFields'; import type { DatabaseWatcher } from '../../database/DatabaseWatcher'; -import { broadcastMessageSentEvent } from './lib/messages'; +import { getMessageToBroadcast } from './lib/messages'; type BroadcastCallback = (event: T, ...args: Parameters) => Promise; @@ -64,25 +64,21 @@ export function isWatcherRunning(): boolean { return watcherStarted; } -const messageWatcher = (watcher: DatabaseWatcher, broadcast: BroadcastCallback): void => { - watcher.on(Messages.getCollectionName(), async ({ clientAction, id, data }) => { - switch (clientAction) { - case 'inserted': - case 'updated': - void broadcastMessageSentEvent({ - id, - data, - broadcastCallback: (message) => broadcast('watch.messages', { clientAction, message }), - }); - break; - } - }); -}; - export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallback): void { - const dbWatchersEnabled = !dbWatchersDisabled; - if (dbWatchersEnabled) { - messageWatcher(watcher, broadcast); + // watch for changes on the database and broadcast them to the other instances + if (!dbWatchersDisabled) { + watcher.on(Messages.getCollectionName(), async ({ clientAction, id, data }) => { + switch (clientAction) { + case 'inserted': + case 'updated': + const message = await getMessageToBroadcast({ id, data }); + if (!message) { + return; + } + void broadcast('watch.messages', { message }); + break; + } + }); } watcher.on(Subscriptions.getCollectionName(), async ({ clientAction, id, data, diff }) => { @@ -339,7 +335,13 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb }); watcher.on(LoginServiceConfiguration.getCollectionName(), async ({ clientAction, id }) => { + if (clientAction === 'removed') { + void broadcast('watch.loginServiceConfiguration', { clientAction, id }); + return; + } + const data = await LoginServiceConfiguration.findOne>(id, { projection: { secret: 0 } }); + if (!data) { return; } diff --git a/apps/meteor/server/publications/index.ts b/apps/meteor/server/publications/index.ts new file mode 100644 index 000000000000..b880933c2e30 --- /dev/null +++ b/apps/meteor/server/publications/index.ts @@ -0,0 +1,5 @@ +import './messages'; +import './room'; +import './settings'; +import './spotlight'; +import './subscription'; diff --git a/apps/meteor/server/routes/index.ts b/apps/meteor/server/routes/index.ts new file mode 100644 index 000000000000..e60f0ceb3f24 --- /dev/null +++ b/apps/meteor/server/routes/index.ts @@ -0,0 +1,5 @@ +import './avatar'; +import './health'; +import './i18n'; +import './timesync'; +import './userDataDownload'; diff --git a/apps/meteor/server/services/authorization/service.ts b/apps/meteor/server/services/authorization/service.ts index 6918d40af871..075fe569bfff 100644 --- a/apps/meteor/server/services/authorization/service.ts +++ b/apps/meteor/server/services/authorization/service.ts @@ -39,16 +39,20 @@ export class Authorization extends ServiceClass implements IAuthorization { } async started(): Promise { - if (!(await License.hasValidLicense())) { - return; - } + try { + if (!(await License.hasValidLicense())) { + return; + } - const permissions = await License.getGuestPermissions(); - if (!permissions) { - return; - } + const permissions = await License.getGuestPermissions(); + if (!permissions) { + return; + } - AuthorizationUtils.addRolePermissionWhiteList('guest', permissions); + AuthorizationUtils.addRolePermissionWhiteList('guest', permissions); + } catch (error) { + console.error('Authorization Service did not start correctly', error); + } } async hasAllPermission(userId: string, permissions: string[], scope?: string): Promise { diff --git a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/File.ts b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/File.ts index eb4c6e08fcc6..c44cbf83466b 100644 --- a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/File.ts +++ b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/File.ts @@ -16,18 +16,12 @@ export class RocketChatFileAdapter { internalUser: IUser, fileRecord: Partial, ): Promise<{ files: IMessage['files']; attachments: IMessage['attachments'] }> { - return new Promise<{ files: IMessage['files']; attachments: IMessage['attachments'] }>(async (resolve, reject) => { - const fileStore = FileUpload.getStore('Uploads'); + const fileStore = FileUpload.getStore('Uploads'); - const uploadedFile = await fileStore.insert(fileRecord, readableStream); - try { - const { files, attachments } = await parseFileIntoMessageAttachments(uploadedFile, internalRoomId, internalUser); + const uploadedFile = await fileStore.insert(fileRecord, readableStream); + const { files, attachments } = await parseFileIntoMessageAttachments(uploadedFile, internalRoomId, internalUser); - resolve({ files, attachments }); - } catch (error) { - reject(error); - } - }); + return { files, attachments }; } public async getBufferFromFileRecord(fileRecord: IUpload): Promise { diff --git a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Settings.ts b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Settings.ts index 4032c4503aad..92278549b6fc 100644 --- a/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Settings.ts +++ b/apps/meteor/server/services/federation/infrastructure/rocket-chat/adapters/Settings.ts @@ -174,80 +174,100 @@ export class RocketChatSettingsAdapter { private async addFederationSettings(): Promise { const preExistingConfiguration = this.getRegistrationFileFromHomeserver(); - await settingsRegistry.addGroup('Federation', async function () { - await this.section('Matrix Bridge', async function () { - await this.add('Federation_Matrix_enabled', Boolean(preExistingConfiguration), { - readonly: false, - type: 'boolean', - i18nLabel: 'Federation_Matrix_enabled', - i18nDescription: 'Federation_Matrix_enabled_desc', - alert: 'Federation_Matrix_Enabled_Alert', - public: true, - }); - - const uniqueId = settings.get('uniqueID') || uuidv4().slice(0, 15).replace(new RegExp('-', 'g'), '_'); - const homeserverToken = crypto.createHash('sha256').update(`hs_${uniqueId}`).digest('hex'); - const applicationServiceToken = crypto.createHash('sha256').update(`as_${uniqueId}`).digest('hex'); - - await this.add('Federation_Matrix_id', preExistingConfiguration?.id || `rocketchat_${uniqueId}`, { - readonly: true, - type: 'string', - i18nLabel: 'Federation_Matrix_id', - i18nDescription: 'Federation_Matrix_id_desc', - }); - - await this.add('Federation_Matrix_hs_token', preExistingConfiguration?.homeserverToken || homeserverToken, { - readonly: true, - type: 'string', - i18nLabel: 'Federation_Matrix_hs_token', - i18nDescription: 'Federation_Matrix_hs_token_desc', - }); - - await this.add('Federation_Matrix_as_token', preExistingConfiguration?.applicationServiceToken || applicationServiceToken, { - readonly: true, - type: 'string', - i18nLabel: 'Federation_Matrix_as_token', - i18nDescription: 'Federation_Matrix_as_token_desc', - }); - - await this.add('Federation_Matrix_homeserver_url', preExistingConfiguration?.rocketchat?.homeServerUrl || 'http://localhost:8008', { - type: 'string', - i18nLabel: 'Federation_Matrix_homeserver_url', - i18nDescription: 'Federation_Matrix_homeserver_url_desc', - alert: 'Federation_Matrix_homeserver_url_alert', - }); - - await this.add('Federation_Matrix_homeserver_domain', preExistingConfiguration?.rocketchat?.domainName || 'local.rocket.chat', { - type: 'string', - i18nLabel: 'Federation_Matrix_homeserver_domain', - i18nDescription: 'Federation_Matrix_homeserver_domain_desc', - alert: 'Federation_Matrix_homeserver_domain_alert', - }); - - await this.add('Federation_Matrix_bridge_url', preExistingConfiguration?.bridgeUrl || 'http://host.docker.internal:3300', { - type: 'string', - i18nLabel: 'Federation_Matrix_bridge_url', - i18nDescription: 'Federation_Matrix_bridge_url_desc', - }); - - await this.add('Federation_Matrix_bridge_localpart', preExistingConfiguration?.botName || 'rocket.cat', { - type: 'string', - i18nLabel: 'Federation_Matrix_bridge_localpart', - i18nDescription: 'Federation_Matrix_bridge_localpart_desc', - }); - - await this.add('Federation_Matrix_registration_file', '', { - readonly: true, - hidden: Boolean(preExistingConfiguration), - type: 'code', - i18nLabel: 'Federation_Matrix_registration_file', - i18nDescription: 'Federation_Matrix_registration_file_desc', - alert: 'Federation_Matrix_registration_file_Alert', - }); - }); + await settingsRegistry.add('Federation_Matrix_enabled', Boolean(preExistingConfiguration), { + readonly: false, + type: 'boolean', + i18nLabel: 'Federation_Matrix_enabled', + i18nDescription: 'Federation_Matrix_enabled_desc', + alert: 'Federation_Matrix_Enabled_Alert', + public: true, + group: 'Federation', + section: 'Matrix Bridge', + }); + + const uniqueId = settings.get('uniqueID') || uuidv4().slice(0, 15).replace(new RegExp('-', 'g'), '_'); + const homeserverToken = crypto.createHash('sha256').update(`hs_${uniqueId}`).digest('hex'); + const applicationServiceToken = crypto.createHash('sha256').update(`as_${uniqueId}`).digest('hex'); + + await settingsRegistry.add('Federation_Matrix_id', preExistingConfiguration?.id || `rocketchat_${uniqueId}`, { + readonly: true, + type: 'string', + i18nLabel: 'Federation_Matrix_id', + i18nDescription: 'Federation_Matrix_id_desc', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_hs_token', preExistingConfiguration?.homeserverToken || homeserverToken, { + readonly: true, + type: 'string', + i18nLabel: 'Federation_Matrix_hs_token', + i18nDescription: 'Federation_Matrix_hs_token_desc', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_as_token', preExistingConfiguration?.applicationServiceToken || applicationServiceToken, { + readonly: true, + type: 'string', + i18nLabel: 'Federation_Matrix_as_token', + i18nDescription: 'Federation_Matrix_as_token_desc', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add( + 'Federation_Matrix_homeserver_url', + preExistingConfiguration?.rocketchat?.homeServerUrl || 'http://localhost:8008', + { + type: 'string', + i18nLabel: 'Federation_Matrix_homeserver_url', + i18nDescription: 'Federation_Matrix_homeserver_url_desc', + alert: 'Federation_Matrix_homeserver_url_alert', + group: 'Federation', + section: 'Matrix Bridge', + }, + ); + + await settingsRegistry.add( + 'Federation_Matrix_homeserver_domain', + preExistingConfiguration?.rocketchat?.domainName || 'local.rocket.chat', + { + type: 'string', + i18nLabel: 'Federation_Matrix_homeserver_domain', + i18nDescription: 'Federation_Matrix_homeserver_domain_desc', + alert: 'Federation_Matrix_homeserver_domain_alert', + }, + ); + + await settingsRegistry.add('Federation_Matrix_bridge_url', preExistingConfiguration?.bridgeUrl || 'http://host.docker.internal:3300', { + type: 'string', + i18nLabel: 'Federation_Matrix_bridge_url', + i18nDescription: 'Federation_Matrix_bridge_url_desc', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_bridge_localpart', preExistingConfiguration?.botName || 'rocket.cat', { + type: 'string', + i18nLabel: 'Federation_Matrix_bridge_localpart', + i18nDescription: 'Federation_Matrix_bridge_localpart_desc', + group: 'Federation', + section: 'Matrix Bridge', + }); + + await settingsRegistry.add('Federation_Matrix_registration_file', '', { + readonly: true, + hidden: Boolean(preExistingConfiguration), + type: 'code', + i18nLabel: 'Federation_Matrix_registration_file', + i18nDescription: 'Federation_Matrix_registration_file_desc', + alert: 'Federation_Matrix_registration_file_Alert', + group: 'Federation', + section: 'Matrix Bridge', }); - void settingsRegistry.add('Federation_Matrix_max_size_of_public_rooms_users', 100, { + await settingsRegistry.add('Federation_Matrix_max_size_of_public_rooms_users', 100, { readonly: false, type: 'int', i18nLabel: 'Federation_Matrix_max_size_of_public_rooms_users', diff --git a/apps/meteor/server/services/messages/service.ts b/apps/meteor/server/services/messages/service.ts index f20c545f6abe..f0c30c359110 100644 --- a/apps/meteor/server/services/messages/service.ts +++ b/apps/meteor/server/services/messages/service.ts @@ -11,7 +11,7 @@ import { executeSetReaction } from '../../../app/reactions/server/setReaction'; import { settings } from '../../../app/settings/server'; import { getUserAvatarURL } from '../../../app/utils/server/getUserAvatarURL'; import { BeforeSaveCannedResponse } from '../../../ee/server/hooks/messages/BeforeSaveCannedResponse'; -import { broadcastMessageSentEvent } from '../../modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../modules/watchers/lib/messages'; import { BeforeSaveBadWords } from './hooks/BeforeSaveBadWords'; import { BeforeSaveCheckMAC } from './hooks/BeforeSaveCheckMAC'; import { BeforeSaveJumpToMessage } from './hooks/BeforeSaveJumpToMessage'; @@ -121,9 +121,8 @@ export class MessageService extends ServiceClassInternal implements IMessageServ Rooms.incMsgCountById(rid, 1), ]); - void broadcastMessageSentEvent({ + void broadcastMessageFromData({ id: result.insertedId, - broadcastCallback: async (message) => this.api?.broadcast('message.sent', message), }); return result.insertedId; diff --git a/apps/meteor/server/services/meteor/service.ts b/apps/meteor/server/services/meteor/service.ts index 8b9462740c6c..2ed97981c842 100644 --- a/apps/meteor/server/services/meteor/service.ts +++ b/apps/meteor/server/services/meteor/service.ts @@ -1,10 +1,9 @@ -import { api, ServiceClassInternal, listenToMessageSentEvent } from '@rocket.chat/core-services'; +import { api, ServiceClassInternal } from '@rocket.chat/core-services'; import type { AutoUpdateRecord, IMeteor } from '@rocket.chat/core-services'; -import type { ILivechatAgent, UserStatus } from '@rocket.chat/core-typings'; -import { Users } from '@rocket.chat/models'; +import type { ILivechatAgent, LoginServiceConfiguration, UserStatus } from '@rocket.chat/core-typings'; +import { LoginServiceConfiguration as LoginServiceConfigurationModel, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { MongoInternals } from 'meteor/mongo'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { triggerHandler } from '../../../app/integrations/server/lib/triggerHandler'; import { Livechat } from '../../../app/livechat/server/lib/LivechatTyped'; @@ -152,9 +151,11 @@ export class MeteorService extends ServiceClassInternal implements IMeteor { return; } - serviceConfigCallbacks.forEach((callbacks) => { - callbacks[clientAction === 'inserted' ? 'added' : 'changed']?.(id, data); - }); + if (data) { + serviceConfigCallbacks.forEach((callbacks) => { + callbacks[clientAction === 'inserted' ? 'added' : 'changed']?.(id, data); + }); + } }); } @@ -221,7 +222,7 @@ export class MeteorService extends ServiceClassInternal implements IMeteor { }); if (!disableMsgRoundtripTracking) { - listenToMessageSentEvent(this, async (message) => { + this.onEvent('watch.messages', async ({ message }) => { if (message?._updatedAt instanceof Date) { metrics.messageRoundtripTime.observe(Date.now() - message._updatedAt.getTime()); } @@ -256,8 +257,8 @@ export class MeteorService extends ServiceClassInternal implements IMeteor { return Object.fromEntries(clientVersionsStore); } - async getLoginServiceConfiguration(): Promise { - return ServiceConfiguration.configurations.find({}, { fields: { secret: 0 } }).fetchAsync(); + async getLoginServiceConfiguration(): Promise { + return LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray(); } async callMethodWithToken(userId: string, token: string, method: string, args: any[]): Promise { diff --git a/apps/meteor/server/services/omnichannel-analytics/ChartData.ts b/apps/meteor/server/services/omnichannel-analytics/ChartData.ts index 044dca954c6d..f2b1f21b5b9a 100644 --- a/apps/meteor/server/services/omnichannel-analytics/ChartData.ts +++ b/apps/meteor/server/services/omnichannel-analytics/ChartData.ts @@ -8,7 +8,9 @@ type ChartDataValidActions = | 'Avg_chat_duration' | 'Total_messages' | 'Avg_first_response_time' - | 'Avg_reaction_time'; + | 'Avg_reaction_time' + | 'Best_first_response_time' + | 'Avg_response_time'; type DateParam = { gte: Date; @@ -22,7 +24,15 @@ export class ChartData { if (!action) { return false; } - return ['Total_conversations', 'Avg_chat_duration', 'Total_messages', 'Avg_first_response_time', 'Avg_reaction_time'].includes(action); + return [ + 'Total_conversations', + 'Avg_chat_duration', + 'Total_messages', + 'Avg_first_response_time', + 'Avg_reaction_time', + 'Best_first_response_time', + 'Avg_response_time', + ].includes(action); } callAction(action: T, ...args: [DateParam, string?, Filter?]) { @@ -37,6 +47,10 @@ export class ChartData { return this.Avg_first_response_time(...args); case 'Avg_reaction_time': return this.Avg_reaction_time(...args); + case 'Best_first_response_time': + return this.Best_first_response_time(...args); + case 'Avg_response_time': + return this.Avg_response_time(...args); default: throw new Error('Invalid action'); } diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 7b9b85cecbd0..dcfb4276bb0c 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -1,7 +1,7 @@ import { ServiceClassInternal, Authorization, MeteorError } from '@rocket.chat/core-services'; import type { ICreateRoomParams, IRoomService } from '@rocket.chat/core-services'; import { type AtLeast, type IRoom, type IUser, isRoomWithJoinCode } from '@rocket.chat/core-typings'; -import { Users } from '@rocket.chat/models'; +import { Rooms, Users } from '@rocket.chat/models'; import { saveRoomTopic } from '../../../app/channel-settings/server/functions/saveRoomTopic'; import { addUserToRoom } from '../../../app/lib/server/functions/addUserToRoom'; @@ -106,14 +106,18 @@ export class RoomService extends ServiceClassInternal implements IRoomService { throw new MeteorError('error-not-allowed', 'Not allowed', { method: 'joinRoom' }); } - if ( - isRoomWithJoinCode(room) && - (!joinCode || joinCode !== room.joinCode) && - !(await Authorization.hasPermission(user._id, 'join-without-join-code')) - ) { - throw new MeteorError('error-code-invalid', 'Invalid Room Password', { - method: 'joinRoom', - }); + if (isRoomWithJoinCode(room) && !(await Authorization.hasPermission(user._id, 'join-without-join-code'))) { + if (!joinCode) { + throw new MeteorError('error-code-required', 'Code required', { method: 'joinRoom' }); + } + + const isCorrectJoinCode = !!(await Rooms.findOneByJoinCodeAndId(joinCode, room._id, { + projection: { _id: 1 }, + })); + + if (!isCorrectJoinCode) { + throw new MeteorError('error-code-invalid', 'Invalid code', { method: 'joinRoom' }); + } } return addUserToRoom(room._id, user); diff --git a/apps/meteor/server/services/startup.ts b/apps/meteor/server/services/startup.ts index 3b13ff75497b..c7b2fa547a42 100644 --- a/apps/meteor/server/services/startup.ts +++ b/apps/meteor/server/services/startup.ts @@ -31,38 +31,38 @@ import { UploadService } from './upload/service'; import { VideoConfService } from './video-conference/service'; import { VoipService } from './voip/service'; -const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; +export const registerServices = async (): Promise => { + const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; -api.registerService(new AppsEngineService()); -api.registerService(new AnalyticsService()); -api.registerService(new AuthorizationLivechat()); -api.registerService(new BannerService()); -api.registerService(new CalendarService()); -api.registerService(new LDAPService()); -api.registerService(new MediaService()); -api.registerService(new MeteorService()); -api.registerService(new NPSService()); -api.registerService(new RoomService()); -api.registerService(new SAUMonitorService()); -api.registerService(new VoipService(db)); -api.registerService(new OmnichannelService()); -api.registerService(new OmnichannelVoipService()); -api.registerService(new TeamService()); -api.registerService(new UiKitCoreAppService()); -api.registerService(new PushService()); -api.registerService(new DeviceManagementService()); -api.registerService(new VideoConfService()); -api.registerService(new UploadService()); -api.registerService(new MessageService()); -api.registerService(new TranslationService()); -api.registerService(new SettingsService()); -api.registerService(new OmnichannelIntegrationService()); -api.registerService(new ImportService()); -api.registerService(new OmnichannelAnalyticsService()); + api.registerService(new AppsEngineService()); + api.registerService(new AnalyticsService()); + api.registerService(new AuthorizationLivechat()); + api.registerService(new BannerService()); + api.registerService(new CalendarService()); + api.registerService(new LDAPService()); + api.registerService(new MediaService()); + api.registerService(new MeteorService()); + api.registerService(new NPSService()); + api.registerService(new RoomService()); + api.registerService(new SAUMonitorService()); + api.registerService(new VoipService(db)); + api.registerService(new OmnichannelService()); + api.registerService(new OmnichannelVoipService()); + api.registerService(new TeamService()); + api.registerService(new UiKitCoreAppService()); + api.registerService(new PushService()); + api.registerService(new DeviceManagementService()); + api.registerService(new VideoConfService()); + api.registerService(new UploadService()); + api.registerService(new MessageService()); + api.registerService(new TranslationService()); + api.registerService(new SettingsService()); + api.registerService(new OmnichannelIntegrationService()); + api.registerService(new ImportService()); + api.registerService(new OmnichannelAnalyticsService()); -// if the process is running in micro services mode we don't need to register services that will run separately -if (!isRunningMs()) { - void (async (): Promise => { + // if the process is running in micro services mode we don't need to register services that will run separately + if (!isRunningMs()) { const { Presence } = await import('@rocket.chat/presence'); const { Authorization } = await import('./authorization/service'); @@ -75,5 +75,5 @@ if (!isRunningMs()) { // Always register the service and manage licensing inside the service (tbd) api.registerService(new QueueWorker(db, Logger)); api.registerService(new OmnichannelTranscript(Logger)); - })(); -} + } +}; diff --git a/apps/meteor/server/services/upload/service.ts b/apps/meteor/server/services/upload/service.ts index 2aabdfc983fb..7333918786e9 100644 --- a/apps/meteor/server/services/upload/service.ts +++ b/apps/meteor/server/services/upload/service.ts @@ -1,9 +1,9 @@ import { ServiceClassInternal } from '@rocket.chat/core-services'; import type { ISendFileLivechatMessageParams, ISendFileMessageParams, IUploadFileParams, IUploadService } from '@rocket.chat/core-services'; -import type { IUpload } from '@rocket.chat/core-typings'; +import type { IUpload, IUser, FilesAndAttachments } from '@rocket.chat/core-typings'; import { FileUpload } from '../../../app/file-upload/server'; -import { sendFileMessage } from '../../../app/file-upload/server/methods/sendFileMessage'; +import { parseFileIntoMessageAttachments, sendFileMessage } from '../../../app/file-upload/server/methods/sendFileMessage'; import { sendFileLivechatMessage } from '../../../app/livechat/server/methods/sendFileLivechatMessage'; export class UploadService extends ServiceClassInternal implements IUploadService { @@ -22,7 +22,7 @@ export class UploadService extends ServiceClassInternal implements IUploadServic return sendFileLivechatMessage({ roomId, visitorToken, file, msgData: message }); } - async getFileBuffer({ file }: { userId: string; file: IUpload }): Promise { + async getFileBuffer({ file }: { file: IUpload }): Promise { const buffer = await FileUpload.getBuffer(file); if (!(buffer instanceof Buffer)) { @@ -30,4 +30,12 @@ export class UploadService extends ServiceClassInternal implements IUploadServic } return buffer; } + + async extractMetadata(file: IUpload): Promise<{ height?: number; width?: number; format?: string }> { + return FileUpload.extractMetadata(file); + } + + async parseFileIntoMessageAttachments(file: Partial, roomId: string, user: IUser): Promise { + return parseFileIntoMessageAttachments(file, roomId, user); + } } diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index f1cf01e6538d..90a7a3302427 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -49,7 +49,7 @@ import { i18n } from '../../lib/i18n'; import { isRoomCompatibleWithVideoConfRinging } from '../../lib/isRoomCompatibleWithVideoConfRinging'; import { videoConfProviders } from '../../lib/videoConfProviders'; import { videoConfTypes } from '../../lib/videoConfTypes'; -import { broadcastMessageSentEvent } from '../../modules/watchers/lib/messages'; +import { broadcastMessageFromData } from '../../modules/watchers/lib/messages'; const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; @@ -328,9 +328,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf const text = i18n.t('video_livechat_missed', { username: name }); await Messages.setBlocksById(call.messages.started, [this.buildMessageBlock(text)]); - await broadcastMessageSentEvent({ + await broadcastMessageFromData({ id: call.messages.started, - broadcastCallback: (message) => api.broadcast('message.sent', message), }); } @@ -618,7 +617,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await Push.send({ from: 'push', badge: 0, - sound: 'default', + sound: 'ringtone.mp3', priority: 10, title: `@${call.createdBy.username}`, text: i18n.t('Video_Conference'), diff --git a/apps/meteor/server/settings/email.ts b/apps/meteor/server/settings/email.ts index 9de6ecf7efc0..fdfd43c7557a 100644 --- a/apps/meteor/server/settings/email.ts +++ b/apps/meteor/server/settings/email.ts @@ -499,7 +499,7 @@ export const createEmailSettings = () => await this.add( 'Forgot_Password_Email', - '

{Forgot_password}

{Lets_get_you_new_one}

{Reset}

{If_you_didnt_ask_for_reset_ignore_this_email}

', + '

{Forgot_password}

{Lets_get_you_new_one_}

{Reset}

{If_you_didnt_ask_for_reset_ignore_this_email}

', { type: 'code', code: 'text/html', diff --git a/apps/meteor/server/settings/index.ts b/apps/meteor/server/settings/index.ts index aaae0b7ba0bd..2a7973eddeec 100644 --- a/apps/meteor/server/settings/index.ts +++ b/apps/meteor/server/settings/index.ts @@ -37,47 +37,43 @@ import { createVConfSettings } from './video-conference'; import { createWebDavSettings } from './webdav'; import { createWebRTCSettings } from './webrtc'; -async function createSettings() { - await Promise.all([ - createAccountSettings(), - createAnalyticsSettings(), - createAssetsSettings(), - createBotsSettings(), - createCallCenterSettings(), - createCasSettings(), - createCrowdSettings(), - createEmojiSettings(), - createSoundsSettings(), - createDiscussionsSettings(), - createEmailSettings(), - createE2ESettings(), - createFederationSettings(), - createFileUploadSettings(), - createGeneralSettings(), - createIRCSettings(), - createLdapSettings(), - createLogSettings(), - createLayoutSettings(), - createMessageSettings(), - createMetaSettings(), - createMiscSettings(), - createMobileSettings(), - createOauthSettings(), - createOmniSettings(), - createOTRSettings(), - createPushSettings(), - createRateLimitSettings(), - createRetentionSettings(), - createSetupWSettings(), - createSlackBridgeSettings(), - createSmarshSettings(), - createThreadSettings(), - createTroubleshootSettings(), - createVConfSettings(), - createUserDataSettings(), - createWebDavSettings(), - createWebRTCSettings(), - ]); -} - -await createSettings(); +await Promise.all([ + createAccountSettings(), + createAnalyticsSettings(), + createAssetsSettings(), + createBotsSettings(), + createCallCenterSettings(), + createCasSettings(), + createCrowdSettings(), + createEmojiSettings(), + createSoundsSettings(), + createDiscussionsSettings(), + createEmailSettings(), + createE2ESettings(), + createFederationSettings(), + createFileUploadSettings(), + createGeneralSettings(), + createIRCSettings(), + createLdapSettings(), + createLogSettings(), + createLayoutSettings(), + createMessageSettings(), + createMetaSettings(), + createMiscSettings(), + createMobileSettings(), + createOauthSettings(), + createOmniSettings(), + createOTRSettings(), + createPushSettings(), + createRateLimitSettings(), + createRetentionSettings(), + createSetupWSettings(), + createSlackBridgeSettings(), + createSmarshSettings(), + createThreadSettings(), + createTroubleshootSettings(), + createVConfSettings(), + createUserDataSettings(), + createWebDavSettings(), + createWebRTCSettings(), +]); diff --git a/apps/meteor/server/startup/index.ts b/apps/meteor/server/startup/index.ts index 17f53ae2d63d..ccbd621025c5 100644 --- a/apps/meteor/server/startup/index.ts +++ b/apps/meteor/server/startup/index.ts @@ -1,4 +1,3 @@ -import './migrations'; import './appcache'; import './callbacks'; import './cron'; @@ -10,9 +9,13 @@ import '../hooks'; import '../lib/rooms/roomTypes'; import '../lib/settingsRegenerator'; import { isRunningMs } from '../lib/isRunningMs'; +import { performMigrationProcedure } from './migrations'; -// only starts network broker if running in micro services mode -if (!isRunningMs()) { - require('./localServices'); - require('./watchDb'); -} +export const startup = async () => { + await performMigrationProcedure(); + // only starts network broker if running in micro services mode + if (!isRunningMs()) { + require('./localServices'); + require('./watchDb'); + } +}; diff --git a/apps/meteor/server/startup/initialData.js b/apps/meteor/server/startup/initialData.js index 58fc3bf91577..01a808a3c103 100644 --- a/apps/meteor/server/startup/initialData.js +++ b/apps/meteor/server/startup/initialData.js @@ -54,34 +54,41 @@ Meteor.startup(async () => { Settings.updateValueById('Initial_Channel_Created', true); } - if (!(await Users.findOneById('rocket.cat'))) { - await Users.create({ - _id: 'rocket.cat', - name: 'Rocket.Cat', - username: 'rocket.cat', - status: 'online', - statusDefault: 'online', - utcOffset: 0, - active: true, - type: 'bot', - }); + try { + if (!(await Users.findOneById('rocket.cat', { projection: { _id: 1 } }))) { + await Users.create({ + _id: 'rocket.cat', + name: 'Rocket.Cat', + username: 'rocket.cat', + status: 'online', + statusDefault: 'online', + utcOffset: 0, + active: true, + type: 'bot', + }); - await addUserRolesAsync('rocket.cat', ['bot']); + await addUserRolesAsync('rocket.cat', ['bot']); - const buffer = Buffer.from(await Assets.getBinaryAsync('avatars/rocketcat.png')); + const buffer = Buffer.from(await Assets.getBinaryAsync('avatars/rocketcat.png')); - const rs = RocketChatFile.bufferToStream(buffer, 'utf8'); - const fileStore = FileUpload.getStore('Avatars'); - await fileStore.deleteByName('rocket.cat'); + const rs = RocketChatFile.bufferToStream(buffer, 'utf8'); + const fileStore = FileUpload.getStore('Avatars'); + await fileStore.deleteByName('rocket.cat'); - const file = { - userId: 'rocket.cat', - type: 'image/png', - size: buffer.length, - }; + const file = { + userId: 'rocket.cat', + type: 'image/png', + size: buffer.length, + }; - const upload = await fileStore.insert(file, rs); - await Users.setAvatarData('rocket.cat', 'local', upload.etag); + const upload = await fileStore.insert(file, rs); + await Users.setAvatarData('rocket.cat', 'local', upload.etag); + } + } catch (error) { + console.log( + 'Error creating default `rocket.cat` user, if you created a user with this username please remove it and restart the server', + ); + throw error; } if (process.env.ADMIN_PASS) { diff --git a/apps/meteor/server/startup/migrations/index.ts b/apps/meteor/server/startup/migrations/index.ts index 8247f9a72bb5..4cda096b151c 100644 --- a/apps/meteor/server/startup/migrations/index.ts +++ b/apps/meteor/server/startup/migrations/index.ts @@ -37,4 +37,5 @@ import './v300'; import './v301'; import './v303'; import './v304'; -import './xrun'; + +export * from './xrun'; diff --git a/apps/meteor/server/startup/migrations/v304.ts b/apps/meteor/server/startup/migrations/v304.ts index db9aa44b4ee0..48cb217643d0 100644 --- a/apps/meteor/server/startup/migrations/v304.ts +++ b/apps/meteor/server/startup/migrations/v304.ts @@ -6,6 +6,16 @@ addMigration({ version: 304, name: 'Drop wrong index from analytics collection', async up() { + const indexes = await Analytics.col.indexes(); + + if ( + indexes.find( + (index) => index.name === 'room._id_1_date_1' && index.partialFilterExpression && index.partialFilterExpression.type === 'rooms', + ) + ) { + return; + } + await Analytics.col.dropIndex('room._id_1_date_1'); await Analytics.createIndexes(); }, diff --git a/apps/meteor/server/startup/migrations/xrun.ts b/apps/meteor/server/startup/migrations/xrun.ts index c560d488187c..61cfaff50231 100644 --- a/apps/meteor/server/startup/migrations/xrun.ts +++ b/apps/meteor/server/startup/migrations/xrun.ts @@ -6,10 +6,11 @@ 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(); -}); +export const performMigrationProcedure = async (): Promise => { + 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/server/ufs/ufs-methods.ts b/apps/meteor/server/ufs/ufs-methods.ts index 05228e059292..23a6048fda45 100644 --- a/apps/meteor/server/ufs/ufs-methods.ts +++ b/apps/meteor/server/ufs/ufs-methods.ts @@ -71,7 +71,7 @@ export async function ufsComplete(fileId: string, storeName: string): Promise { + let res: any; + + if (method === 'get') { + res = await request.get(api(url)).set(credentials).query(data); + } else if (method === 'post') { + res = await request.post(api(url)).set(credentials).send(data); + } + + return res.body; +}; + +export const reportUser = (userId: string, reason: string) => makeModerationApiRequest('moderation.reportUser', 'post', { userId, reason }); + +export const getUsersReports = (userId: string) => makeModerationApiRequest('moderation.user.reportsByUserId', 'get', { userId }); diff --git a/apps/meteor/tests/data/teams.helper.ts b/apps/meteor/tests/data/teams.helper.ts new file mode 100644 index 000000000000..308fc60f445e --- /dev/null +++ b/apps/meteor/tests/data/teams.helper.ts @@ -0,0 +1,17 @@ +import { ITeam, TEAM_TYPE } from "@rocket.chat/core-typings" +import { api, request } from "./api-data" + +export const createTeam = async (credentials: Record, teamName: string, type: TEAM_TYPE): Promise => { + const response = await request.post(api('teams.create')).set(credentials).send({ + name: teamName, + type, + }); + + return response.body.team; +}; + +export const deleteTeam = async (credentials: Record, teamName: string): Promise => { + await request.post(api('teams.delete')).set(credentials).send({ + teamName, + }); +}; \ No newline at end of file diff --git a/apps/meteor/tests/data/uploads.helper.ts b/apps/meteor/tests/data/uploads.helper.ts index 29c7a143484c..8eb1e7931965 100644 --- a/apps/meteor/tests/data/uploads.helper.ts +++ b/apps/meteor/tests/data/uploads.helper.ts @@ -7,18 +7,30 @@ import { password } from './user'; import { createUser, login } from './users.helper'; import { imgURL } from './interactions'; import { updateSetting } from './permissions.helper'; -import { createRoom } from './rooms.helper'; +import { createRoom, deleteRoom } from './rooms.helper'; import { createVisitor } from './livechat/rooms'; -export async function testFileUploads(filesEndpoint: 'channels.files' | 'groups.files' | 'im.files', room: { _id: string; name?: string; t: string;}, invalidRoomError = 'error-room-not-found') { +export async function testFileUploads(filesEndpoint: 'channels.files' | 'groups.files' | 'im.files', roomType: 'c' | 'd' | 'p', invalidRoomError = 'error-room-not-found') { + let testRoom: Record; + const propertyMap = { + 'c': 'channel', + 'p': 'group', + 'd': 'room', + }; + before(async function () { await updateSetting('VoIP_Enabled', true); await updateSetting('Message_KeepHistory', true); + + testRoom = (await createRoom({ type: roomType, ...(roomType === 'd' ? { username: 'rocket.cat' } : { name: `channel-files-${Date.now()}` }) } as any)).body[propertyMap[roomType]]; }); after(async function () { - await updateSetting('VoIP_Enabled', false); - await updateSetting('Message_KeepHistory', false); + await Promise.all([ + deleteRoom({ type: 'c', roomId: testRoom._id }), + updateSetting('VoIP_Enabled', false), + updateSetting('Message_KeepHistory', false), + ]); }); const createVoipRoom = async function () { @@ -34,6 +46,7 @@ export async function testFileUploads(filesEndpoint: 'channels.files' | 'groups. username: null, members: null, }); + return roomResponse.body.room; }; @@ -74,7 +87,7 @@ export async function testFileUploads(filesEndpoint: 'channels.files' | 'groups. .get(api(filesEndpoint)) .set(credentials) .query({ - roomId: room._id, + roomId: testRoom._id, }) .expect('Content-Type', 'application/json') .expect(200) @@ -90,7 +103,7 @@ export async function testFileUploads(filesEndpoint: 'channels.files' | 'groups. .get(api(filesEndpoint)) .set(credentials) .query({ - roomId: room._id, + roomId: testRoom._id, count: 5, offset: 0, }) @@ -104,14 +117,14 @@ export async function testFileUploads(filesEndpoint: 'channels.files' | 'groups. }); it('should succeed when searching by roomName', function (done) { - if (!room.name) { + if (!testRoom.name) { this.skip(); } request .get(api(filesEndpoint)) .set(credentials) .query({ - roomName: room.name, + roomName: testRoom.name, }) .expect('Content-Type', 'application/json') .expect(200) @@ -123,14 +136,14 @@ export async function testFileUploads(filesEndpoint: 'channels.files' | 'groups. }); it('should succeed when searching by roomName even requested with count and offset params', function (done) { - if (!room.name) { + if (!testRoom.name) { this.skip(); } request .get(api(filesEndpoint)) .set(credentials) .query({ - roomName: room.name, + roomName: testRoom.name, count: 5, offset: 0, }) @@ -145,7 +158,7 @@ export async function testFileUploads(filesEndpoint: 'channels.files' | 'groups. it('should not return thumbnails', async function () { await request - .post(api(`rooms.upload/${room._id}`)) + .post(api(`rooms.upload/${testRoom._id}`)) .set(credentials) .attach('file', imgURL) .expect('Content-Type', 'application/json') @@ -158,7 +171,7 @@ export async function testFileUploads(filesEndpoint: 'channels.files' | 'groups. .get(api(filesEndpoint)) .set(credentials) .query({ - roomId: room._id, + roomId: testRoom._id, }) .expect('Content-Type', 'application/json') .expect(200) @@ -179,7 +192,7 @@ export async function testFileUploads(filesEndpoint: 'channels.files' | 'groups. let fileId: string; await request - .post(api(`rooms.upload/${room._id}`)) + .post(api(`rooms.upload/${testRoom._id}`)) .set(credentials) .attach('file', imgURL) .expect('Content-Type', 'application/json') @@ -195,7 +208,7 @@ export async function testFileUploads(filesEndpoint: 'channels.files' | 'groups. .post(api('chat.delete')) .set(credentials) .send({ - roomId: room._id, + roomId: testRoom._id, msgId, }) .expect('Content-Type', 'application/json') @@ -205,7 +218,7 @@ export async function testFileUploads(filesEndpoint: 'channels.files' | 'groups. .get(api(filesEndpoint)) .set(credentials) .query({ - roomId: room._id, + roomId: testRoom._id, }) .expect('Content-Type', 'application/json') .expect(200) diff --git a/apps/meteor/tests/e2e/account-profile.spec.ts b/apps/meteor/tests/e2e/account-profile.spec.ts index 49d96772bf2d..e287a1c694c4 100644 --- a/apps/meteor/tests/e2e/account-profile.spec.ts +++ b/apps/meteor/tests/e2e/account-profile.spec.ts @@ -41,27 +41,31 @@ test.describe.serial('settings-account-profile', () => { await expect(poHomeChannel.tabs.userInfoUsername).toHaveText(newUsername); }) - - test('change avatar', async ({ page }) => { - await test.step('expect change avatar image by upload', async () => { + + test.describe('Avatar', () => { + test('should change avatar image by uploading file', async () => { await poAccountProfile.inputImageFile.setInputFiles('./tests/e2e/fixtures/files/test-image.jpeg'); - await poAccountProfile.btnSubmit.click(); - await expect(page.locator('.rcx-toastbar.rcx-toastbar--success').first()).toBeVisible(); + + await expect(poAccountProfile.userAvatarEditor).toHaveAttribute('src'); }); + + test('should change avatar image from url', async () => { + await poAccountProfile.inputAvatarLink.fill('https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50'); + await poAccountProfile.btnSetAvatarLink.click(); - await test.step('expect to close toastbar', async () => { - await page.locator('.rcx-toastbar.rcx-toastbar--success').first().click(); + await poAccountProfile.btnSubmit.click(); + await expect(poAccountProfile.userAvatarEditor).toHaveAttribute('src'); }); - - await test.step('expect set image from url', async () => { - await poAccountProfile.inputAvatarLink.fill('https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50'); + + test('should display a skeleton if the image url is not valid', async () => { + await poAccountProfile.inputAvatarLink.fill('https://invalidUrl'); await poAccountProfile.btnSetAvatarLink.click(); await poAccountProfile.btnSubmit.click(); - await expect(page.locator('.rcx-toastbar.rcx-toastbar--success').first()).toBeVisible(); + await expect(poAccountProfile.userAvatarEditor).not.toHaveAttribute('src'); }); - }); + }) }); test.describe('Security', () => { diff --git a/apps/meteor/tests/e2e/administration.spec.ts b/apps/meteor/tests/e2e/administration.spec.ts index 2601c2409b61..8c3bd5c23f6b 100644 --- a/apps/meteor/tests/e2e/administration.spec.ts +++ b/apps/meteor/tests/e2e/administration.spec.ts @@ -3,12 +3,14 @@ import { faker } from '@faker-js/faker'; import { IS_EE } from './config/constants'; import { Users } from './fixtures/userStates'; import { Admin } from './page-objects'; +import { createTargetChannel } from './utils'; import { test, expect } from './utils/test'; test.use({ storageState: Users.admin.state }); test.describe.parallel('administration', () => { let poAdmin: Admin; + let targetChannel: string; test.beforeEach(async ({ page }) => { poAdmin = new Admin(page); @@ -56,14 +58,57 @@ test.describe.parallel('administration', () => { }); test.describe('Rooms', () => { + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); test.beforeEach(async ({ page }) => { await page.goto('/admin/rooms'); }); - test('expect find "general" channel', async ({ page }) => { + test('should find "general" channel', async ({ page }) => { await poAdmin.inputSearchRooms.type('general'); await page.waitForSelector('[qa-room-id="GENERAL"]'); }); + + test('should edit target channel', async () => { + await poAdmin.inputSearchRooms.type(targetChannel); + await poAdmin.getRoomRow(targetChannel).click(); + await poAdmin.privateLabel.click(); + await poAdmin.btnSave.click(); + await expect(poAdmin.getRoomRow(targetChannel)).toContainText('Private Channel'); + }); + + test('should archive target channel', async () => { + await poAdmin.inputSearchRooms.type(targetChannel); + await poAdmin.getRoomRow(targetChannel).click(); + await poAdmin.archivedLabel.click(); + await poAdmin.btnSave.click(); + + await poAdmin.getRoomRow(targetChannel).click(); + await expect(poAdmin.archivedInput).toBeChecked(); + }); + + test.describe.serial('Default rooms', () => { + test('expect target channell to be default', async () => { + await poAdmin.inputSearchRooms.type(targetChannel); + await poAdmin.getRoomRow(targetChannel).click(); + await poAdmin.defaultLabel.click(); + await poAdmin.btnSave.click(); + + await poAdmin.getRoomRow(targetChannel).click(); + await expect(poAdmin.defaultInput).toBeChecked(); + }); + + test('should mark target default channel as "favorite by default"', async () => { + await poAdmin.inputSearchRooms.type(targetChannel); + await poAdmin.getRoomRow(targetChannel).click(); + await poAdmin.favoriteLabel.click(); + await poAdmin.btnSave.click(); + + await poAdmin.getRoomRow(targetChannel).click(); + await expect(poAdmin.favoriteInput).toBeChecked(); + }); + }); }); test.describe('Permissions', () => { diff --git a/apps/meteor/tests/e2e/channel-management.spec.ts b/apps/meteor/tests/e2e/channel-management.spec.ts index 7cad1be43cbf..6bf7084ac494 100644 --- a/apps/meteor/tests/e2e/channel-management.spec.ts +++ b/apps/meteor/tests/e2e/channel-management.spec.ts @@ -22,6 +22,29 @@ test.describe.serial('channel-management', () => { await page.goto('/home'); }); + test('should navigate on toolbar using arrow keys', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + await poHomeChannel.content.sendMessage('hello composer'); + await poHomeChannel.roomHeaderFavoriteBtn.focus(); + + await page.keyboard.press('Tab'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + + await expect(poHomeChannel.roomHeaderToolbar.getByRole('button', { name: 'Threads' })).toBeFocused(); + }); + + test('should move the focus away from toolbar using tab key', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + await poHomeChannel.content.sendMessage('hello composer'); + await poHomeChannel.roomHeaderFavoriteBtn.focus(); + + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + await expect(poHomeChannel.roomHeaderToolbar.getByRole('button', { name: 'Call' })).not.toBeFocused(); + }); + test('expect add "user1" to "targetChannel"', async () => { await poHomeChannel.sidenav.openChat(targetChannel); await poHomeChannel.tabs.btnTabMembers.click(); diff --git a/apps/meteor/tests/e2e/config/constants.ts b/apps/meteor/tests/e2e/config/constants.ts index 3e9b693cc7dc..c938b693ff45 100644 --- a/apps/meteor/tests/e2e/config/constants.ts +++ b/apps/meteor/tests/e2e/config/constants.ts @@ -15,3 +15,8 @@ export const ADMIN_CREDENTIALS = { password: 'rocketchat.internal.admin.test', username: 'rocketchat.internal.admin.test', } as const; + +export const DEFAULT_USER_CREDENTIALS = { + password: 'password', + bcrypt: '$2b$10$LNYaqDreDE7tt9EVEeaS9uw.C3hic9hcqFfIocMBPTMxJaDCC6QWW', +} as const; diff --git a/apps/meteor/tests/e2e/containers/saml/Dockerfile b/apps/meteor/tests/e2e/containers/saml/Dockerfile new file mode 100644 index 000000000000..be87fdb742b6 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/Dockerfile @@ -0,0 +1,35 @@ +FROM php:7.1-apache + +# Utilities +RUN apt-get update && \ + apt-get -y install apt-transport-https git curl vim --no-install-recommends && \ + rm -r /var/lib/apt/lists/* + +# SimpleSAMLphp +ARG SIMPLESAMLPHP_VERSION=1.15.2 +RUN curl -s -L -o /tmp/simplesamlphp.tar.gz https://github.com/simplesamlphp/simplesamlphp/releases/download/v$SIMPLESAMLPHP_VERSION/simplesamlphp-$SIMPLESAMLPHP_VERSION.tar.gz && \ + tar xzf /tmp/simplesamlphp.tar.gz -C /tmp && \ + rm -f /tmp/simplesamlphp.tar.gz && \ + mv /tmp/simplesamlphp-* /var/www/simplesamlphp && \ + touch /var/www/simplesamlphp/modules/exampleauth/enable +COPY config/simplesamlphp/config.php /var/www/simplesamlphp/config +COPY config/simplesamlphp/authsources.php /var/www/simplesamlphp/config +COPY config/simplesamlphp/saml20-sp-remote.php /var/www/simplesamlphp/metadata +COPY config/simplesamlphp/server_crt /var/www/simplesamlphp/cert/server.crt +COPY config/simplesamlphp/server_pem /var/www/simplesamlphp/cert/server.pem + +# Apache +COPY config/apache/ports.conf /etc/apache2 +COPY config/apache/simplesamlphp.conf /etc/apache2/sites-available +COPY config/apache/cert_crt /etc/ssl/cert/cert.crt +COPY config/apache/private_key /etc/ssl/private/private.key +RUN echo "ServerName localhost" >> /etc/apache2/apache2.conf && \ + a2enmod ssl && \ + a2dissite 000-default.conf default-ssl.conf && \ + a2ensite simplesamlphp.conf + +# Set work dir +WORKDIR /var/www/simplesamlphp + +# General setup +EXPOSE 8080 8443 \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/apache/cert_crt b/apps/meteor/tests/e2e/containers/saml/config/apache/cert_crt new file mode 100644 index 000000000000..4104eaf80f6e --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/apache/cert_crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJANdMEUvsTntJMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzMjIxWhcNNDgwNjI1MTQzMjIxWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAyXRKD3KzV/hOqwThFRtA+eoitJpIIEmWbugPMC1G+7bFqxHmtxiuQhOw +yHrij35biiD8VpboY69Zep7n1QCfmIodh9uxdaNxFtzxjryRLzfP3MpPFkpBHCdV +HZWDP2TzIvOxWcnlLmikSnBrBM1nvhKSWjaFsjDAXMLXT0mceiDpQ0QQkDA6RAyx +JWWRJILjudBh56ukqvdz4eFWAAViZX5MUwCDxiBxtP3NIXVmODM7kDLqZ9+QcfpM +N5QvfcUjHP9584yrYiJ9N64Fy5vU2OH1RX5EsMHTtdqR4H5K6zNfVlgRSG170Mcj +ksTSbo1kcDCSuzTO82NrVkU+R78W/QIDAQABo1AwTjAdBgNVHQ4EFgQUkYMQMPqY +leTTtBHS1f7yNFmY86QwHwYDVR0jBBgwFoAUkYMQMPqYleTTtBHS1f7yNFmY86Qw +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGZpbEWzYLoa5keg9rQDa +S2cf9rQFMNflwR7hQ6OtSXeP0JsQ6yFhRq3TgpBdbkmdealDJbyG8pWQxqwOBD/j +45jr+NsHap0HvQAg9pfq/QIqjH5osCGAHmNdHfP638FOpi0s6hAX11+nCW6ClHMO +ofn0fYXCBtM18qZvtoeB8swi6MlRXFLvvRL4tWzGugdQj+0f+ukk/GZAEitdpkuj +qFCHqfKwg9FJ4My5M8lIyB/P4+SK/ail/BoCJ/qGBXky2bob0MQuLysd35zrTA62 +j5IvPp1XZj/2KnuPtqMuFhNtE5wCnOEC1WG02ZVIfs4DAxX78z59VSEaFlnstT9k +aQ== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/apache/ports.conf b/apps/meteor/tests/e2e/containers/saml/config/apache/ports.conf new file mode 100644 index 000000000000..286af7fd4ec3 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/apache/ports.conf @@ -0,0 +1,9 @@ +Listen 8080 + + + Listen 8443 + + + + Listen 8443 + \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/apache/private_key b/apps/meteor/tests/e2e/containers/saml/config/apache/private_key new file mode 100644 index 000000000000..f511efcd9b65 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/apache/private_key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDJdEoPcrNX+E6r +BOEVG0D56iK0mkggSZZu6A8wLUb7tsWrEea3GK5CE7DIeuKPfluKIPxWluhjr1l6 +nufVAJ+Yih2H27F1o3EW3PGOvJEvN8/cyk8WSkEcJ1UdlYM/ZPMi87FZyeUuaKRK +cGsEzWe+EpJaNoWyMMBcwtdPSZx6IOlDRBCQMDpEDLElZZEkguO50GHnq6Sq93Ph +4VYABWJlfkxTAIPGIHG0/c0hdWY4MzuQMupn35Bx+kw3lC99xSMc/3nzjKtiIn03 +rgXLm9TY4fVFfkSwwdO12pHgfkrrM19WWBFIbXvQxyOSxNJujWRwMJK7NM7zY2tW +RT5Hvxb9AgMBAAECggEAVT4KtHyxXJDqIL1gzICKvvUOmGMMD/VzXRx+iMEv3wTY +oWliuakM21LfpAUzZspty4XnoHAch0nET/l7WYr4/R+8HSed8IwnJyh4YhByUouI +PgGw81qaMGKIRotkTOfXZbu+GKMwgbGvivwEnLSZqDjNirS1X8/3JYkgeCFKv/X6 +K4Z1SKoebtA4oGDGZtxNpeGJ6TibaSO3PRW+oH3dD5j5ez1k+dlRasMnuKG76jBy +naz052t52UZxAnGkbZUU84VljO5R8x1jip+M6+qILC/PsI1hb45Dx5VRXNUBGP2s +/PRVFptNGmsIlXBYMSdOo9RJAIsZ1kTty34nIqeuYQKBgQD4Nv+QoeRXjG7iAmzj +JabcA+2FiHftvjdaYjahXQ/Ma6ns/vv//tVfxJaNyCxMObjIKhidsrf/AAn03FgT +LkL0Q2tTBNL9jejzHstnCKSSu/pSgqatsABm1cokC9qsuMjweMVy+tGjSsF6YgjI +2K0heQpF0bMBSYmyQU4aTLbytQKBgQDPxdWVn7ReCscDwpchx+zffKh5JOshW44z +GYBfVbugBljj9w6fDjKyQzk0jMGmuG9rvqfZzu9MpQhjBFKxL4aXPBN7bQOY3DKc +FuGSbIbV7ldPmZhOxqLy6NLyiry6eSQGNf1Q8YCF6bsUR/rvdKDtBLrbJObgRVbu +ChT2PXRYKQKBgFSEDZMGvMReqebE4qSZTm592+Nq60MFUL2y0V0yXc3CHxL2Y4Hw +GGFKg+T08rhlsxhc1RLlJqdqMPmyCT9Gsj+PsTyMWPdC2b3mj2We2MKpxPtRR0W+ +tvRM+U46xxOmu6y9wqV65+TM8IImXU1eEd1i5G+Pjn7ytjL+74Qe+PA9AoGAEasM +F5YmG10lQU+Z1HiQzwxlsy+Ngx+q/vNrNDAxLVF8253Vs3bcnsYSpkJV8Vx7tRjY +YzAyrzzVcr4aXhDhjBjCu1sw1B3de+KCOhZafPSwngc8qW5AyxE7Zv6fP+gvRQvw +R6LRwBF5JCde0mADk0Q0s4/2xhl/Y+ydjbb6HskCgYEAysuIUrDslGGsK57loxMO +FVz9SmLTZIJqkSW+l3dDHMG+BvnJFP+yf0Kr2zGbXRzOvNVWAFP2aU39RFDxbUIM +Nz7obLWrVKbQUDaU7fCbP4OBVuo9p4UM6j/PZy+3Cyps+GMTrC9wi4HbbNEWHyCW +xaNL9LNQ3B9hJG77htK1oRw= +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/apache/simplesamlphp.conf b/apps/meteor/tests/e2e/containers/saml/config/apache/simplesamlphp.conf new file mode 100644 index 000000000000..a81c39d85f8b --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/apache/simplesamlphp.conf @@ -0,0 +1,23 @@ + + ServerName localhost + DocumentRoot /var/www/simplesamlphp + Alias /simplesaml /var/www/simplesamlphp/www + + + Require all granted + + + + + ServerName localhost + DocumentRoot /var/www/simplesamlphp + SSLEngine on + SSLCertificateFile /etc/ssl/cert/cert.crt + SSLCertificateKeyFile /etc/ssl/private/private.key + Alias /simplesaml /var/www/simplesamlphp/www + + + Require all granted + + + \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/authsources.php b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/authsources.php new file mode 100644 index 000000000000..867d66049b3a --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/authsources.php @@ -0,0 +1,34 @@ + array( + 'core:AdminPassword', + ), + + 'example-userpass' => array( + 'exampleauth:UserPass', + 'samluser1:password' => array( + 'uid' => array('1'), + 'username' => 'samluser1', + 'cn' => 'Saml User 1', + 'eduPersonAffiliation' => array('group1'), + 'email' => 'samluser1@example.com', + ), + 'samluser2:password' => array( + 'uid' => array('2'), + 'username' => 'samluser2', + 'cn' => 'Saml User 2', + 'eduPersonAffiliation' => array('group2'), + 'email' => 'user_for_saml_merge@email.com', + ), + 'samluser3:password' => array( + 'uid' => array('3'), + 'username' => 'user_for_saml_merge2', + 'cn' => 'Saml User 3', + 'eduPersonAffiliation' => array('group2'), + 'email' => 'samluser3@example.com', + ), + ), + +); \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php new file mode 100644 index 000000000000..79e73e6b58c4 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php @@ -0,0 +1,111 @@ + 'simplesaml/', + 'certdir' => 'cert/', + 'loggingdir' => 'log/', + 'datadir' => 'data/', + 'tempdir' => '/tmp/simplesaml', + 'debug' => true, + 'showerrors' => true, + 'errorreporting' => true, + 'debug.validatexml' => false, + 'auth.adminpassword' => 'secret', + 'admin.protectindexpage' => false, + 'admin.protectmetadata' => false, + 'secretsalt' => 'defaultsecretsalt', + 'technicalcontact_name' => 'Administrator', + 'technicalcontact_email' => 'na@example.org', + 'timezone' => null, + 'logging.level' => SimpleSAML_Logger::DEBUG, + 'logging.handler' => 'errorlog', + //'logging.format' => '%date{%b %d %H:%M:%S} %process %level %stat[%trackid] %msg', + 'logging.facility' => defined('LOG_LOCAL5') ? constant('LOG_LOCAL5') : LOG_USER, + 'logging.processname' => 'simplesamlphp', + 'logging.logfile' => 'simplesamlphp.log', + 'statistics.out' => array( + ), + 'database.dsn' => 'mysql:host=localhost;dbname=saml', + 'database.username' => 'simplesamlphp', + 'database.password' => 'secret', + 'database.prefix' => '', + 'database.persistent' => false, + 'database.slaves' => array( + ), + 'enable.saml20-idp' => true, + 'enable.shib13-idp' => true, + 'enable.adfs-idp' => false, + 'enable.wsfed-sp' => false, + 'enable.authmemcookie' => false, + 'session.duration' => 8 * (60 * 60), // 8 hours. + 'session.datastore.timeout' => (4 * 60 * 60), // 4 hours + 'session.state.timeout' => (60 * 60), // 1 hour + 'session.cookie.name' => 'SimpleSAMLSessionIDIdp', + 'session.cookie.lifetime' => 0, + 'session.cookie.path' => '/', + 'session.cookie.domain' => null, + 'session.cookie.secure' => false, + 'enable.http_post' => false, + 'session.phpsession.cookiename' => 'PHPSESSIDIDP', + 'session.phpsession.savepath' => null, + 'session.phpsession.httponly' => true, + 'session.authtoken.cookiename' => 'SimpleSAMLAuthTokenIdp', + 'session.rememberme.enable' => false, + 'session.rememberme.checked' => false, + 'session.rememberme.lifetime' => (14 * 86400), + 'language.available' => array( + 'en', 'no', 'nn', 'se', 'da', 'de', 'sv', 'fi', 'es', 'fr', 'it', 'nl', 'lb', 'cs', + 'sl', 'lt', 'hr', 'hu', 'pl', 'pt', 'pt-br', 'tr', 'ja', 'zh', 'zh-tw', 'ru', 'et', + 'he', 'id', 'sr', 'lv', 'ro', 'eu' + ), + 'language.rtl' => array('ar', 'dv', 'fa', 'ur', 'he'), + 'language.default' => 'en', + 'language.parameter.name' => 'language', + 'language.parameter.setcookie' => true, + 'language.cookie.name' => 'language', + 'language.cookie.domain' => null, + 'language.cookie.path' => '/', + 'language.cookie.lifetime' => (60 * 60 * 24 * 900), + 'attributes.extradictionary' => null, + 'theme.use' => 'default', + 'default-wsfed-idp' => 'urn:federation:pingfederate:localhost', + 'idpdisco.enableremember' => true, + 'idpdisco.rememberchecked' => true, + 'idpdisco.validate' => true, + 'idpdisco.extDiscoveryStorage' => null, + 'idpdisco.layout' => 'dropdown', + 'shib13.signresponse' => true, + 'authproc.idp' => array( + 30 => 'core:LanguageAdaptor', + 45 => array( + 'class' => 'core:StatisticsWithAttribute', + 'attributename' => 'realm', + 'type' => 'saml20-idp-SSO', + ), + 50 => 'core:AttributeLimit', + 99 => 'core:LanguageAdaptor', + ), + 'authproc.sp' => array( + 90 => 'core:LanguageAdaptor', + ), + 'metadata.sources' => array( + array('type' => 'flatfile'), + ), + 'store.type' => 'phpsession', + 'store.sql.dsn' => 'sqlite:/path/to/sqlitedatabase.sq3', + 'store.sql.username' => null, + 'store.sql.password' => null, + 'store.sql.prefix' => 'SimpleSAMLphp', + 'memcache_store.servers' => array( + array( + array('hostname' => 'localhost'), + ), + ), + 'memcache_store.prefix' => null, + 'memcache_store.expires' => 36 * (60 * 60), // 36 hours. + 'metadata.sign.enable' => false, + 'metadata.sign.privatekey' => null, + 'metadata.sign.privatekey_pass' => null, + 'metadata.sign.certificate' => null, + 'proxy' => null, + 'trusted.url.domains' => array(), +); \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-sp-remote.php b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-sp-remote.php new file mode 100644 index 000000000000..79591af7dfa3 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/saml20-sp-remote.php @@ -0,0 +1,24 @@ + 'http://localhost:3000/_saml/metadata/test-sp', + 'contacts' => array ( + ), + 'metadata-set' => 'saml20-sp-remote', + 'AssertionConsumerService' => array ( + 0 => array ( + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'Location' => 'http://localhost:3000/_saml/validate/test-sp', + 'index' => 1, + 'isDefault' => true, + ), + ), + 'SingleLogoutService' => array ( + 0 => array ( + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + 'Location' => 'http://localhost:3000/_saml/logout/test-sp/', + 'ResponseLocation' => 'http://localhost:3000/_saml/logout/test-sp/', + ), + ), + 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', +); \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_crt b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_crt new file mode 100644 index 000000000000..f0623b9305e0 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+Cgav +Og8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+ +YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc ++TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyix +YFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8 +jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/C +YQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkw +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6b +lEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFs +X1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7 +yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7 +NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG +99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2n +aQ== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_pem b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_pem new file mode 100644 index 000000000000..ba2bac5fe630 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/server_pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDNQIWjOA1vWHUz +SPM1FIKOE4GdH65VtWlpZ9dghH4CFYN0R7mvJj4KBq86Dxt8vJvLMV16GVh0NGCR +50QH8aMbxonDTqXSoXiMM4DDSQTKBYK7aZwftc7FG35gAfdNUdr8e7VbdaPOShuq +qotDyCQpZYzbt86ABnoaJ5okE3pUFIwxw97LcdYsGZz5Ngma/V1to7aMeEqHyl8r +DRbXZUzw/U8g7yC/g+G7+64liJ4FYqLEETLLSUePKLFgUJHXbF2HgIDjur3nxlEa +ecNQYVUTVCGBFpwkI5n1t3m32avwotpUFhMImjkRETyPKZpvl0+p7mop8mwJmKpa +CVuNSj23AgMBAAECggEABn4I/B20xxXcNzASiVZJvua9DdRHtmxTlkLznBj0x2oY +y1/Nbs3d3oFRn5uEuhBZOTcphsgwdRSHDXZsP3gUObew+d2N/zieUIj8hLDVlvJP +rU/s4U/l53Q0LiNByE9ThvL+zJLPCKJtd5uHZjB5fFm69+Q7gu8xg4xHIub+0pP5 +PHanmHCDrbgNN/oqlar4FZ2MXTgekW6Amyc/koE9hIn4Baa2Ke/B/AUGY4pMRLqp +TArt+GTVeWeoFY9QACUpaHpJhGb/Piou6tlU57e42cLoki1f0+SARsBBKyXA7BB1 +1fMH10KQYFA68dTYWlKzQau/K4xaqg4FKmtwF66GQQKBgQD9OpNUS7oRxMHVJaBR +TNWW+V1FXycqojekFpDijPb2X5CWV16oeWgaXp0nOHFdy9EWs3GtGpfZasaRVHsX +SHtPh4Nb8JqHdGE0/CD6t0+4Dns8Bn9cSqtdQB7R3Jn7IMXi9X/U8LDKo+A18/Jq +V8VgUngMny9YjMkQIbK8TRWkYQKBgQDPf4nxO6ju+tOHHORQty3bYDD0+OV3I0+L +0yz0uPreryBVi9nY43KakH52D7UZEwwsBjjGXD+WH8xEsmBWsGNXJu025PvzIJoz +lAEiXvMp/NmYp+tY4rDmO8RhyVocBqWHzh38m0IFOd4ByFD5nLEDrA3pDVo0aNgY +n0GwRysZFwKBgQDkCj3m6ZMUsUWEty+aR0EJhmKyODBDOnY09IVhH2S/FexVFzUN +LtfK9206hp/Awez3Ln2uT4Zzqq5K7fMzUniJdBWdVB004l8voeXpIe9OZuwfcBJ9 +gFi1zypx/uFDv421BzQpBN+QfOdKbvbdQVFjnqCxbSDr80yVlGMrI5fbwQKBgG09 +oRrepO7EIO8GN/GCruLK/ptKGkyhy3Q6xnVEmdb47hX7ncJA5IoZPmrblCVSUNsw +n11XHabksL8OBgg9rt8oQEThQv/aDzTOW9aDlJNragejiBTwq99aYeZ1gjo1CZq4 +2jKubpCfyZC4rGDtrIfZYi1q+S2UcQhtd8DdhwQbAoGAAM4EpDA4yHB5yiek1p/o +CbqRCta/Dx6Eyo0KlNAyPuFPAshupG4NBx7mT2ASfL+2VBHoi6mHSri+BDX5ryYF +fMYvp7URYoq7w7qivRlvvEg5yoYrK13F2+Gj6xJ4jEN9m0KdM/g3mJGq0HBTIQrp +Sm75WXsflOxuTn08LbgGc4s= +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/apps/meteor/tests/e2e/containers/saml/docker-compose.yml b/apps/meteor/tests/e2e/containers/saml/docker-compose.yml new file mode 100644 index 000000000000..6d8a00f8eba9 --- /dev/null +++ b/apps/meteor/tests/e2e/containers/saml/docker-compose.yml @@ -0,0 +1,7 @@ +version: '3' +services: + testsamlidp_idp: + build: . + ports: + - "8080:8080" + - "8443:8443" diff --git a/apps/meteor/tests/e2e/fixtures/collections/users.ts b/apps/meteor/tests/e2e/fixtures/collections/users.ts index cc437597a5e0..5b85974fb57c 100644 --- a/apps/meteor/tests/e2e/fixtures/collections/users.ts +++ b/apps/meteor/tests/e2e/fixtures/collections/users.ts @@ -1,7 +1,8 @@ import { faker } from '@faker-js/faker'; import type { IUser } from '@rocket.chat/core-typings'; -import type { IUserState } from '../userStates'; +import { DEFAULT_USER_CREDENTIALS } from '../../config/constants'; +import { type IUserState } from '../userStates'; type UserFixture = IUser & { username: string; @@ -23,7 +24,7 @@ export function createUserFixture(user: IUserState): UserFixture { utcOffset: -3, username, services: { - password: { bcrypt: '$2b$10$EMxaeQQbSw9JLL.YvOVPaOW8MKta6pgmp2BcN5Op4cC9bJiOqmUS.' }, + password: { bcrypt: DEFAULT_USER_CREDENTIALS.bcrypt }, email2fa: { enabled: true, changedAt: new Date() }, email: { verificationTokens: [ diff --git a/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts b/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts index 11cea78b3f3d..e7e68790cf3d 100644 --- a/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts +++ b/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts @@ -57,6 +57,26 @@ export default async function injectInitialData() { _id: 'API_Enable_Rate_Limiter_Dev', value: false, }, + { + _id: 'SAML_Custom_Default_provider', + value: 'test-sp', + }, + { + _id: 'SAML_Custom_Default_issuer', + value: 'http://localhost:3000/_saml/metadata/test-sp', + }, + { + _id: 'SAML_Custom_Default_entry_point', + value: 'http://localhost:8080/simplesaml/saml2/idp/SSOService.php', + }, + { + _id: 'SAML_Custom_Default_idp_slo_redirect_url', + value: 'http://localhost:8080/simplesaml/saml2/idp/SingleLogoutService.php', + }, + { + _id: 'Accounts_OAuth_Google', + value: false, + }, ].map((setting) => connection .db() diff --git a/apps/meteor/tests/e2e/fixtures/userStates.ts b/apps/meteor/tests/e2e/fixtures/userStates.ts index 7bcab213f8fc..f21405a94f02 100644 --- a/apps/meteor/tests/e2e/fixtures/userStates.ts +++ b/apps/meteor/tests/e2e/fixtures/userStates.ts @@ -86,6 +86,10 @@ export const Users = { user1: generateContext('user1'), user2: generateContext('user2'), user3: generateContext('user3'), + samluser1: generateContext('samluser1'), + samluser2: generateContext('samluser2'), + userForSamlMerge: generateContext('user_for_saml_merge'), + userForSamlMerge2: generateContext('user_for_saml_merge2'), admin: generateContext('rocketchat.internal.admin.test'), }; diff --git a/apps/meteor/tests/e2e/login.spec.ts b/apps/meteor/tests/e2e/login.spec.ts index 958f5120f142..41710fffa203 100644 --- a/apps/meteor/tests/e2e/login.spec.ts +++ b/apps/meteor/tests/e2e/login.spec.ts @@ -1,13 +1,16 @@ import { faker } from '@faker-js/faker'; -import { Registration } from './page-objects'; +import { DEFAULT_USER_CREDENTIALS } from './config/constants'; +import { Utils, Registration } from './page-objects'; import { test, expect } from './utils/test'; test.describe.parallel('Login', () => { let poRegistration: Registration; + let poUtils: Utils; test.beforeEach(async ({ page }) => { poRegistration = new Registration(page); + poUtils = new Utils(page); await page.goto('/home'); }); @@ -27,4 +30,24 @@ test.describe.parallel('Login', () => { await expect(poRegistration.inputPassword).toBeInvalid(); }); }); + + test('Login with valid username and password', async () => { + await test.step('expect successful login', async () => { + await poRegistration.username.type('user1'); + await poRegistration.inputPassword.type(DEFAULT_USER_CREDENTIALS.password); + await poRegistration.btnLogin.click(); + + await expect(poUtils.mainContent).toBeVisible(); + }); + }); + + test('Login with valid email and password', async () => { + await test.step('expect successful login', async () => { + await poRegistration.username.type('user1@email.com'); + await poRegistration.inputPassword.type(DEFAULT_USER_CREDENTIALS.password); + await poRegistration.btnLogin.click(); + + await expect(poUtils.mainContent).toBeVisible(); + }); + }); }); diff --git a/apps/meteor/tests/e2e/message-composer.spec.ts b/apps/meteor/tests/e2e/message-composer.spec.ts new file mode 100644 index 000000000000..9ed3e42b941d --- /dev/null +++ b/apps/meteor/tests/e2e/message-composer.spec.ts @@ -0,0 +1,60 @@ +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { createTargetChannel } from './utils'; +import { expect, test } from './utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe.serial('message-composer', () => { + let poHomeChannel: HomeChannel; + let targetChannel: string; + + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + + await page.goto('/home'); + }); + + test('should have all formatters and the main actions visible on toolbar', async () => { + await poHomeChannel.sidenav.openChat(targetChannel); + await poHomeChannel.content.sendMessage('hello composer'); + + await expect(poHomeChannel.composerToolbarActions).toHaveCount(11); + }); + + test('should have only the main formatter and the main action', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 600 }); + + await poHomeChannel.sidenav.openChat(targetChannel); + await poHomeChannel.content.sendMessage('hello composer'); + + await expect(poHomeChannel.composerToolbarActions).toHaveCount(5); + }); + + test('should navigate on toolbar using arrow keys', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + await poHomeChannel.content.sendMessage('hello composer'); + + await page.keyboard.press('Tab'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await expect(poHomeChannel.composerToolbar.getByRole('button', { name: 'Italic' })).toBeFocused(); + + await page.keyboard.press('ArrowLeft'); + await expect(poHomeChannel.composerToolbar.getByRole('button', { name: 'Bold' })).toBeFocused(); + }); + + test('should move the focus away from toolbar using tab key', async ({ page }) => { + await poHomeChannel.sidenav.openChat(targetChannel); + await poHomeChannel.content.sendMessage('hello composer'); + + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + await expect(poHomeChannel.composerToolbar.getByRole('button', { name: 'Emoji' })).not.toBeFocused(); + }); +}); diff --git a/apps/meteor/tests/e2e/messaging.spec.ts b/apps/meteor/tests/e2e/messaging.spec.ts index 20620b79e093..c5099c52b9f1 100644 --- a/apps/meteor/tests/e2e/messaging.spec.ts +++ b/apps/meteor/tests/e2e/messaging.spec.ts @@ -29,8 +29,10 @@ test.describe.serial('Messaging', () => { await poHomeChannel.content.sendMessage('hello world'); - await expect(auxContext.poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(async () => { + await expect(auxContext.poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + }).toPass(); await auxContext.page.close(); }); @@ -43,8 +45,10 @@ test.describe.serial('Messaging', () => { await poHomeChannel.content.sendMessage('hello world'); - await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); - await expect(auxContext.poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(async () => { + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + await expect(auxContext.poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); + }).toPass(); await auxContext.page.close(); }); diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts new file mode 100644 index 000000000000..8d53fa9503b4 --- /dev/null +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -0,0 +1,30 @@ +import { Registration } from './page-objects'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; + +test.describe('OAuth', () => { + let poRegistration: Registration; + + test.beforeEach(async ({ page }) => { + poRegistration = new Registration(page); + }); + + test('Login Page', async ({ page, api }) => { + await test.step('expect OAuth button to be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', true)).status()).toBe(200); + await page.waitForTimeout(5000); + + await page.goto('/home'); + + await expect(poRegistration.btnLoginWithGoogle).toBeVisible(); + }); + + await test.step('expect OAuth button to not be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); + await page.waitForTimeout(5000); + + await page.goto('/home'); + await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts index 617d5ba268cd..c8810fa4eb77 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-api.spec.ts @@ -71,7 +71,7 @@ test.describe('OC - Livechat API', () => { page = await browser.newPage(); await expect((await api.post('/settings/Enable_CSP', { value: false })).status()).toBe(200); - poLiveChat = new OmnichannelLiveChatEmbedded(page, api); + poLiveChat = new OmnichannelLiveChatEmbedded(page); const { page: pageCtx } = await createAuxContext(browser, Users.user1); poAuxContext = { page: pageCtx, poHomeOmnichannel: new HomeOmnichannel(pageCtx) }; @@ -238,10 +238,10 @@ test.describe('OC - Livechat API', () => { await expect((await api.post('/settings/Livechat_offline_email', { value: 'test@testing.com' })).status()).toBe(200); }); - test.beforeEach(async ({ browser, api }, testInfo) => { + test.beforeEach(async ({ browser }, testInfo) => { page = await browser.newPage(); - poLiveChat = new OmnichannelLiveChatEmbedded(page, api); + poLiveChat = new OmnichannelLiveChatEmbedded(page); const { page: pageCtx } = await createAuxContext(browser, Users.user1); poAuxContext = { page: pageCtx, poHomeOmnichannel: new HomeOmnichannel(pageCtx) }; @@ -421,6 +421,8 @@ test.describe('OC - Livechat API', () => { await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); await poLiveChat.btnSendMessageToOnlineAgent.click(); + await poAuxContext.poHomeOmnichannel.sidenav.openChat(registerGuestVisitor.name); + await test.step('Expect setGuestEmail to change a guest email', async () => { await poLiveChat.page.evaluate( (registerGuestVisitor) => window.RocketChat.livechat.setGuestName(`changed${registerGuestVisitor.name}`), @@ -429,9 +431,7 @@ test.describe('OC - Livechat API', () => { }); await test.step('Expect registered guest to have valid info', async () => { - await poAuxContext.poHomeOmnichannel.sidenav.openChat(registerGuestVisitor.name); - - await expect(poAuxContext.poHomeOmnichannel.content.infoContactName).toContainText(`changed${registerGuestVisitor.name}`); + await expect(poAuxContext.poHomeOmnichannel.content.infoHeaderName).toContainText(`changed${registerGuestVisitor.name}`); }); }); @@ -488,10 +488,10 @@ test.describe('OC - Livechat API', () => { await expect((await api.post('/settings/Livechat_offline_email', { value: 'test@testing.com' })).status()).toBe(200); }); - test.beforeEach(async ({ browser, api }, testInfo) => { + test.beforeEach(async ({ browser }, testInfo) => { page = await browser.newPage(); - poLiveChat = new OmnichannelLiveChatEmbedded(page, api); + poLiveChat = new OmnichannelLiveChatEmbedded(page); const { page: pageCtx } = await createAuxContext(browser, Users.user1); poAuxContext = { page: pageCtx, poHomeOmnichannel: new HomeOmnichannel(pageCtx) }; @@ -559,7 +559,7 @@ test.describe('OC - Livechat API', () => { }), ); - await poLiveChat.openLiveChat(false); + await poLiveChat.openLiveChat(); await poLiveChat.sendMessage(newVisitor, false); await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); await poLiveChat.btnSendMessageToOnlineAgent.click(); @@ -601,7 +601,7 @@ test.describe('OC - Livechat API', () => { }), ); - await poLiveChat.openLiveChat(false); + await poLiveChat.openLiveChat(); await poLiveChat.sendMessage(newVisitor, false); await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); await poLiveChat.btnSendMessageToOnlineAgent.click(); @@ -631,7 +631,7 @@ test.describe('OC - Livechat API', () => { email: faker.internet.email(), }; - await poLiveChat.openLiveChat(false); + await poLiveChat.openLiveChat(); await poLiveChat.sendMessage(newVisitor, false); await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor'); await poLiveChat.btnSendMessageToOnlineAgent.click(); @@ -669,7 +669,7 @@ test.describe('OC - Livechat API', () => { }), ); - await poLiveChat.openLiveChat(true); + await poLiveChat.openLiveChat(); await poLiveChat.sendMessage(newVisitor, true); await watchForTrigger; diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-widget.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-widget.spec.ts index 812a2496c740..37279923dbda 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-widget.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-widget.spec.ts @@ -1,18 +1,17 @@ import type { Page } from '@playwright/test'; +import { OmnichannelLiveChatEmbedded } from '../page-objects'; import { test, expect } from '../utils/test'; test.describe('Omnichannel - Livechat Widget Embedded', () => { test.describe('Widget is working on Embedded View', () => { let page: Page; - let siteName: string; + let poLiveChat: OmnichannelLiveChatEmbedded; test.beforeAll(async ({ browser, api }) => { page = await browser.newPage(); + poLiveChat = new OmnichannelLiveChatEmbedded(page); await expect((await api.post('/settings/Enable_CSP', { value: false })).status()).toBe(200); - const { value } = await(await api.get('/settings/Site_Name')).json(); - siteName = value; - await page.goto('/packages/rocketchat_livechat/assets/demo.html'); }); @@ -24,7 +23,7 @@ test.describe('Omnichannel - Livechat Widget Embedded', () => { test('Open and Close widget', async () => { await test.step('Expect widget to be visible while embedded in an iframe', async () => { - await expect(page.frameLocator('#rocketchat-iframe').locator(`role=button[name="${siteName}"]`)).toBeVisible(); + await expect(poLiveChat.btnOpenLiveChat()).toBeVisible(); }); }); }); diff --git a/apps/meteor/tests/e2e/page-objects/account-profile.ts b/apps/meteor/tests/e2e/page-objects/account-profile.ts index 875eff37770d..0ba8dab72e75 100644 --- a/apps/meteor/tests/e2e/page-objects/account-profile.ts +++ b/apps/meteor/tests/e2e/page-objects/account-profile.ts @@ -36,6 +36,10 @@ export class AccountProfile { return this.page.locator('.avatar-file-input'); } + get userAvatarEditor(): Locator { + return this.page.locator('[data-qa-id="UserAvatarEditor"]'); + } + get emailTextInput(): Locator { return this.page.locator('//label[contains(text(), "Email")]/..//input'); } diff --git a/apps/meteor/tests/e2e/page-objects/admin.ts b/apps/meteor/tests/e2e/page-objects/admin.ts index 112d285a205f..022fd609077f 100644 --- a/apps/meteor/tests/e2e/page-objects/admin.ts +++ b/apps/meteor/tests/e2e/page-objects/admin.ts @@ -16,6 +16,42 @@ export class Admin { return this.page.locator('input[placeholder ="Search rooms"]'); } + getRoomRow(name?: string): Locator { + return this.page.locator('[role="link"]', { hasText: name }); + } + + get btnSave(): Locator { + return this.page.locator('button >> text="Save"'); + } + + get privateLabel(): Locator { + return this.page.locator(`label >> text=Private`); + } + + get archivedLabel(): Locator { + return this.page.locator('label >> text=Archived'); + } + + get archivedInput(): Locator { + return this.page.locator('input[name="archived"]'); + } + + get favoriteLabel(): Locator { + return this.page.locator('label >> text=Favorite'); + } + + get favoriteInput(): Locator { + return this.page.locator('input[name="favorite"]'); + } + + get defaultLabel(): Locator { + return this.page.locator('label >> text=Default'); + } + + get defaultInput(): Locator { + return this.page.locator('input[name="isDefault"]'); + } + get inputSearchUsers(): Locator { return this.page.locator('input[placeholder="Search Users"]'); } diff --git a/apps/meteor/tests/e2e/page-objects/auth.ts b/apps/meteor/tests/e2e/page-objects/auth.ts index 9b47d2e44adc..51290d46f9cc 100644 --- a/apps/meteor/tests/e2e/page-objects/auth.ts +++ b/apps/meteor/tests/e2e/page-objects/auth.ts @@ -15,11 +15,18 @@ export class Registration { return this.page.locator('role=button[name="Reset"]'); } - get btnLogin(): Locator { return this.page.locator('role=button[name="Login"]'); } + get btnLoginWithSaml(): Locator { + return this.page.locator('role=button[name="SAML"]'); + } + + get btnLoginWithGoogle(): Locator { + return this.page.locator('role=button[name="Sign in with Google"]'); + } + get goToRegister(): Locator { return this.page.locator('role=link[name="Create an account"]'); } 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 434fe6ba95eb..79c9617355e9 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -149,7 +149,7 @@ export class HomeContent { } get btnRecordAudio(): Locator { - return this.page.locator('[data-qa-id="audio-record"]'); + return this.page.locator('[data-qa-id="audio-message"]'); } get btnMenuMoreActions() { diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts index 7243d613c11f..4716f6812fd7 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts @@ -78,4 +78,8 @@ export class HomeOmnichannelContent extends HomeContent { get btnOnHoldConfirm(): Locator { return this.modalOnHold.locator('role=button[name="Place chat On-Hold"]'); } + + get infoHeaderName(): Locator { + return this.page.locator('.rcx-room-header').getByRole('heading'); + } } diff --git a/apps/meteor/tests/e2e/page-objects/home-channel.ts b/apps/meteor/tests/e2e/page-objects/home-channel.ts index 24403b22b845..278256b7b0b1 100644 --- a/apps/meteor/tests/e2e/page-objects/home-channel.ts +++ b/apps/meteor/tests/e2e/page-objects/home-channel.ts @@ -40,4 +40,20 @@ export class HomeChannel { await this.toastSuccess.locator('button >> i.rcx-icon--name-cross.rcx-icon').click(); await this.page.mouse.move(0, 0); } + + get composerToolbar(): Locator { + return this.page.locator('[role=toolbar][aria-label="Composer Primary Actions"]'); + } + + get composerToolbarActions(): Locator { + return this.page.locator('[role=toolbar][aria-label="Composer Primary Actions"] button'); + } + + get roomHeaderFavoriteBtn(): Locator { + return this.page.getByRole('button', { name: 'Favorite' }); + } + + get roomHeaderToolbar(): Locator { + return this.page.locator('[role=toolbar][aria-label="Primary Room actions"]'); + } } diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-livechat-embedded.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-livechat-embedded.ts index c5bc515a99e2..8d5fff57412b 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel-livechat-embedded.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-livechat-embedded.ts @@ -1,14 +1,14 @@ -import type { Page, Locator, APIResponse } from '@playwright/test'; +import type { Page, Locator } from '@playwright/test'; export class OmnichannelLiveChatEmbedded { readonly page: Page; - constructor(page: Page, private readonly api: { get(url: string): Promise }) { + constructor(page: Page) { this.page = page; } - btnOpenLiveChat(label: string): Locator { - return this.page.frameLocator('#rocketchat-iframe').locator(`role=button[name="${label}"]`); + btnOpenLiveChat(): Locator { + return this.page.frameLocator('#rocketchat-iframe').locator(`[data-qa-id="chat-button"]`); } btnOpenOfflineLiveChat(): Locator { @@ -49,12 +49,8 @@ export class OmnichannelLiveChatEmbedded { await this.btnCloseChatConfirm.click(); } - async openLiveChat(offline: boolean): Promise { - const { value: siteName } = await (await this.api.get('/settings/Site_Name')).json(); - if (offline) { - return this.btnOpenOfflineLiveChat().click(); - } - await this.btnOpenLiveChat(siteName).click(); + async openLiveChat(): Promise { + await this.btnOpenLiveChat().click(); } unreadMessagesBadge(count: number): Locator { diff --git a/apps/meteor/tests/e2e/saml.spec.ts b/apps/meteor/tests/e2e/saml.spec.ts new file mode 100644 index 000000000000..faa6b25710c2 --- /dev/null +++ b/apps/meteor/tests/e2e/saml.spec.ts @@ -0,0 +1,290 @@ +import child_process from 'child_process'; +import path from 'path'; + +import { Page } from '@playwright/test'; +import { v2 as compose } from 'docker-compose' +import { MongoClient } from 'mongodb'; + +import * as constants from './config/constants'; +import { createUserFixture } from './fixtures/collections/users'; +import { Users } from './fixtures/userStates'; +import { Registration } from './page-objects'; +import { getUserInfo } from './utils/getUserInfo'; +import { setSettingValueById } from './utils/setSettingValueById'; +import { test, expect } from './utils/test'; + +const resetTestData = async (cleanupOnly = false) => { + // Reset saml users' data on mongo in the beforeAll hook to allow re-running the tests within the same playwright session + // This is needed because those tests will modify this data and running them a second time would trigger different code paths + const connection = await MongoClient.connect(constants.URL_MONGODB); + + const usernamesToDelete = [Users.userForSamlMerge, Users.userForSamlMerge2, Users.samluser1, Users.samluser2].map(({ data: { username }}) => username); + await connection + .db() + .collection('users') + .deleteMany({ + username: { + $in: usernamesToDelete, + } + }); + + if (cleanupOnly) { + return; + } + + const usersFixtures = [Users.userForSamlMerge, Users.userForSamlMerge2].map((user) => createUserFixture(user)); + await Promise.all( + usersFixtures.map((user) => + connection.db().collection('users').updateOne({ username: user.username }, { $set: user }, { upsert: true }), + ), + ); + + await Promise.all( + [ + { + _id: 'SAML_Custom_Default_logout_behaviour', + value: 'SAML', + }, + { + _id: 'SAML_Custom_Default_immutable_property', + value: 'EMail', + }, + { + _id: 'SAML_Custom_Default_mail_overwrite', + value: false, + }, + { + _id: 'SAML_Custom_Default', + value: false, + }, + ].map((setting) => + connection + .db() + .collection('rocketchat_settings') + .updateOne({ _id: setting._id }, { $set: { value: setting.value } }), + ), + ); +}; + +test.describe('SAML', () => { + let poRegistration: Registration; + + const containerPath = path.join(__dirname, 'containers', 'saml'); + + test.beforeAll(async ({ api }) => { + await resetTestData(); + + // Only one setting updated through the API to avoid refreshing the service configurations several times + await expect((await setSettingValueById(api, 'SAML_Custom_Default', true)).status()).toBe(200); + + await compose.buildOne('testsamlidp_idp', { + cwd: containerPath, + }); + + await compose.upOne('testsamlidp_idp', { + cwd: containerPath, + }); + }); + + test.afterAll(async () => { + await compose.down({ + cwd: containerPath, + }); + + // the compose CLI doesn't have any way to remove images, so try to remove it with a direct call to the docker cli, but ignore errors if it fails. + try { + child_process.spawn('docker', ['rmi', 'saml-testsamlidp_idp'], { + cwd: containerPath, + }); + } catch { + // ignore errors here + } + + // Remove saml test users so they don't interfere with other tests + await resetTestData(true); + }); + + test.beforeEach(async ({ page }) => { + poRegistration = new Registration(page); + + await page.goto('/home'); + }); + + test('Login', async ({ page, api }) => { + await test.step('expect to have SAML login button available', async () => { + await expect(poRegistration.btnLoginWithSaml).toBeVisible({ timeout: 10000 }); + }); + + await test.step('expect to be redirected to the IdP for login', async () => { + await poRegistration.btnLoginWithSaml.click(); + + await expect(page).toHaveURL(/.*\/simplesaml\/module.php\/core\/loginuserpass.php.*/); + }); + + await test.step('expect to be redirected back on successful login', async () => { + await page.getByLabel('Username').fill('samluser1'); + await page.getByLabel('Password').fill('password'); + await page.locator('role=button[name="Login"]').click(); + + await expect(page).toHaveURL('/home'); + }); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'samluser1'); + + expect(user).toBeDefined(); + expect(user?.username).toBe('samluser1'); + expect(user?.name).toBe('Saml User 1'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samluser1@example.com'); + }); + }); + + const doLoginStep = async (page: Page, username: string) => { + await test.step('expect successful login', async () => { + await poRegistration.btnLoginWithSaml.click(); + // Redirect to Idp + await expect(page).toHaveURL(/.*\/simplesaml\/module.php\/core\/loginuserpass.php.*/); + + // Fill username and password + await page.getByLabel('Username').fill(username); + await page.getByLabel('Password').fill('password'); + await page.locator('role=button[name="Login"]').click(); + + // Redirect back to rocket.chat + await expect(page).toHaveURL('/home'); + + await expect(page.getByLabel('User Menu')).toBeVisible(); + }); + }; + + const doLogoutStep = async (page: Page) => { + await test.step('logout', async () => { + await page.getByLabel('User Menu').click(); + await page.locator('//*[contains(@class, "rcx-option__content") and contains(text(), "Logout")]').click(); + + await expect(page).toHaveURL('/home'); + await expect(page.getByLabel('User Menu')).not.toBeVisible(); + }); + }; + + test('Logout - Rocket.Chat only', async ({ page, api }) => { + await test.step('Configure logout to only logout from Rocket.Chat', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_logout_behaviour', 'Local')).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser1'); + await doLogoutStep(page); + + await test.step('expect IdP to redirect back automatically on new login request', async () => { + await poRegistration.btnLoginWithSaml.click(); + + await expect(page).toHaveURL('/home'); + }); + }); + + test('Logout - Single Sign Out', async ({ page, api }) => { + await test.step('Configure logout to terminate SAML session', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_logout_behaviour', 'SAML')).status()).toBe(200); + }) + + await doLoginStep(page, 'samluser1'); + await doLogoutStep(page); + + await test.step('expect IdP to show login form on new login request', async () => { + await poRegistration.btnLoginWithSaml.click(); + + await expect(page).toHaveURL(/.*\/simplesaml\/module.php\/core\/loginuserpass.php.*/); + await expect(page.getByLabel('Username')).toBeVisible(); + }); + }); + + test('User Merge - By Email', async ({ page, api }) => { + await test.step('Configure SAML to identify users by email', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_immutable_property', 'EMail')).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser2'); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'samluser2'); + + expect(user).toBeDefined(); + expect(user?._id).toBe('user_for_saml_merge'); + expect(user?.username).toBe('samluser2'); + expect(user?.name).toBe('Saml User 2'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('user_for_saml_merge@email.com'); + }); + }); + + test('User Merge - By Username', async ({ page, api }) => { + await test.step('Configure SAML to identify users by username', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_immutable_property', 'Username')).status()).toBe(200); + await expect((await setSettingValueById(api, 'SAML_Custom_Default_mail_overwrite', false)).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser3'); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'user_for_saml_merge2'); + + expect(user).toBeDefined(); + expect(user?._id).toBe('user_for_saml_merge2'); + expect(user?.username).toBe('user_for_saml_merge2'); + expect(user?.name).toBe('Saml User 3'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('user_for_saml_merge2@email.com'); + }); + }); + + test('User Merge - By Username with Email Override', async ({ page, api }) => { + await test.step('Configure SAML to identify users by username', async () => { + await expect((await setSettingValueById(api, 'SAML_Custom_Default_immutable_property', 'Username')).status()).toBe(200); + await expect((await setSettingValueById(api, 'SAML_Custom_Default_mail_overwrite', true)).status()).toBe(200); + }); + + await doLoginStep(page, 'samluser3'); + + await test.step('expect user data to have been mapped to the correct fields', async () => { + const user = await getUserInfo(api, 'user_for_saml_merge2'); + + expect(user).toBeDefined(); + expect(user?._id).toBe('user_for_saml_merge2'); + expect(user?.username).toBe('user_for_saml_merge2'); + expect(user?.name).toBe('Saml User 3'); + expect(user?.emails).toBeDefined(); + expect(user?.emails?.[0].address).toBe('samluser3@example.com'); + }); + }); + + test.fixme('User Merge - By Custom Identifier', async () => { + // Test user merge with a custom identifier configured in the fieldmap + }); + + test.fixme('Signature Validation', async () => { + // Test login with signed responses + }); + + test.fixme('Login - User without username', async () => { + // Test login with a SAML user with no username + // Test different variations of the Immutable Property setting + }); + + test.fixme('Login - User without email', async () => { + // Test login with a SAML user with no email + // Test different variations of the Immutable Property setting + }); + + test.fixme('Login - User without name', async () => { + // Test login with a SAML user with no name + }); + + test.fixme('Login - User with channels attribute', async () => { + // Test login with a SAML user with a "channels" attribute + }); + + test.fixme('Data Sync - Custom Field Map', async () => { + // Test the data sync using a custom fieldmap setting + }); +}); diff --git a/apps/meteor/tests/e2e/utils/getUserInfo.ts b/apps/meteor/tests/e2e/utils/getUserInfo.ts new file mode 100644 index 000000000000..13c592a7244b --- /dev/null +++ b/apps/meteor/tests/e2e/utils/getUserInfo.ts @@ -0,0 +1,15 @@ +import { IUser } from '@rocket.chat/core-typings'; + +import type { BaseTest } from './test'; + +export const getUserInfo = async (api: BaseTest['api'], username: string): Promise => { + const response = await api.get(`/users.info?username=${username}`); + + if (response.status() !== 200) { + throw new Error('Failed to get user info.'); + } + + const data = await response.json(); + + return data.user; +} 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 7695718bd01f..7397df990849 100644 --- a/apps/meteor/tests/end-to-end/api/00-autotranslate.js +++ b/apps/meteor/tests/end-to-end/api/00-autotranslate.js @@ -6,7 +6,20 @@ import { sendSimpleMessage } from '../../data/chat.helper'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { password } from '../../data/user'; -import { createUser, login } from '../../data/users.helper.js'; +import { createUser, deleteUser, login } from '../../data/users.helper.js'; + +const resetAutoTranslateDefaults = async () => { + await Promise.all([ + updateSetting('AutoTranslate_Enabled', false), + updateSetting('AutoTranslate_AutoEnableOnJoinRoom', false), + updateSetting('Language', ''), + updatePermission('auto-translate', ['admin']), + ]); +}; + +const resetE2EDefaults = async () => { + await Promise.all([updateSetting('E2E_Enabled_Default_PrivateRooms', false), updateSetting('E2E_Enable', false)]); +}; describe('AutoTranslate', function () { this.retries(0); @@ -15,22 +28,23 @@ describe('AutoTranslate', function () { describe('[AutoTranslate]', () => { describe('[/autotranslate.getSupportedLanguages', () => { + before(() => resetAutoTranslateDefaults()); + after(() => resetAutoTranslateDefaults()); + it('should throw an error when the "AutoTranslate_Enabled" setting is disabled', (done) => { - updateSetting('AutoTranslate_Enabled', false).then(() => { - request - .get(api('autotranslate.getSupportedLanguages')) - .set(credentials) - .query({ - targetLanguage: 'en', - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.a.property('success', false); - expect(res.body.error).to.be.equal('AutoTranslate is disabled.'); - }) - .end(done); - }); + request + .get(api('autotranslate.getSupportedLanguages')) + .set(credentials) + .query({ + targetLanguage: 'en', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('AutoTranslate is disabled.'); + }) + .end(done); }); it('should throw an error when the user does not have the "auto-translate" permission', (done) => { updateSetting('AutoTranslate_Enabled', true).then(() => { @@ -70,38 +84,48 @@ describe('AutoTranslate', function () { }); }); }); + describe('[/autotranslate.saveSettings', () => { let testGroupId; + let testChannelId; + 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; + await Promise.all([ + resetAutoTranslateDefaults(), + updateSetting('E2E_Enable', true), + updateSetting('E2E_Enabled_Default_PrivateRooms', true), + ]); + + testGroupId = (await createRoom({ type: 'p', name: `e2etest-autotranslate-${Date.now()}` })).body.group._id; + testChannelId = (await createRoom({ type: 'c', name: `test-autotranslate-${Date.now()}` })).body.channel._id; }); + after(async () => { - await updateSetting('E2E_Enabled_Default_PrivateRooms', false); - await updateSetting('E2E_Enable', false); - await deleteRoom({ type: 'p', roomId: testGroupId }); + await Promise.all([ + resetAutoTranslateDefaults(), + resetE2EDefaults(), + deleteRoom({ type: 'p', roomId: testGroupId }), + deleteRoom({ type: 'c', roomId: testChannelId }), + ]); }); + it('should throw an error when the "AutoTranslate_Enabled" setting is disabled', (done) => { - updateSetting('AutoTranslate_Enabled', false).then(() => { - request - .post(api('autotranslate.saveSettings')) - .set(credentials) - .send({ - roomId: 'GENERAL', - field: 'autoTranslate', - defaultLanguage: 'en', - value: true, - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.a.property('success', false); - expect(res.body.error).to.be.equal('AutoTranslate is disabled.'); - }) - .end(done); - }); + request + .post(api('autotranslate.saveSettings')) + .set(credentials) + .send({ + roomId: testChannelId, + field: 'autoTranslate', + defaultLanguage: 'en', + value: true, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('AutoTranslate is disabled.'); + }) + .end(done); }); it('should throw an error when the user does not have the "auto-translate" permission', (done) => { updateSetting('AutoTranslate_Enabled', true).then(() => { @@ -110,7 +134,7 @@ describe('AutoTranslate', function () { .post(api('autotranslate.saveSettings')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannelId, defaultLanguage: 'en', field: 'autoTranslateLanguage', value: 'en', @@ -145,7 +169,7 @@ describe('AutoTranslate', function () { .post(api('autotranslate.saveSettings')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannelId, }) .expect('Content-Type', 'application/json') .expect(400) @@ -159,7 +183,7 @@ describe('AutoTranslate', function () { .post(api('autotranslate.saveSettings')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannelId, field: 'autoTranslate', }) .expect('Content-Type', 'application/json') @@ -174,7 +198,7 @@ describe('AutoTranslate', function () { .post(api('autotranslate.saveSettings')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannelId, field: 'autoTranslate', value: 'test', }) @@ -190,7 +214,7 @@ describe('AutoTranslate', function () { .post(api('autotranslate.saveSettings')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannelId, field: 'autoTranslateLanguage', value: 12, }) @@ -206,7 +230,7 @@ describe('AutoTranslate', function () { .post(api('autotranslate.saveSettings')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannelId, field: 'invalid', value: 12, }) @@ -257,7 +281,7 @@ describe('AutoTranslate', function () { .post(api('autotranslate.saveSettings')) .set(credentials) .send({ - roomId: 'GENERAL', + roomId: testChannelId, field: 'autoTranslateLanguage', value: 'en', }) @@ -269,36 +293,41 @@ describe('AutoTranslate', function () { .end(done); }); }); + describe('[/autotranslate.translateMessage', () => { let messageSent; + let testChannelId; + + before(async () => { + await resetAutoTranslateDefaults(); - before((done) => { - sendSimpleMessage({ - roomId: 'GENERAL', + testChannelId = (await createRoom({ type: 'c', name: `test-autotranslate-message-${Date.now()}` })).body.channel._id; + const res = await sendSimpleMessage({ + roomId: testChannelId, text: 'Isso é um teste', - }).end((err, res) => { - messageSent = res.body.message; - done(); }); + messageSent = res.body.message; + }); + + after(async () => { + await Promise.all([resetAutoTranslateDefaults(), deleteRoom({ type: 'c', roomId: testChannelId })]); }); it('should throw an error when the "AutoTranslate_Enabled" setting is disabled', (done) => { - updateSetting('AutoTranslate_Enabled', false).then(() => { - request - .post(api('autotranslate.translateMessage')) - .set(credentials) - .send({ - messageId: 'test', - targetLanguage: 'en', - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.a.property('success', false); - expect(res.body.error).to.be.equal('AutoTranslate is disabled.'); - }) - .end(done); - }); + request + .post(api('autotranslate.translateMessage')) + .set(credentials) + .send({ + messageId: 'test', + targetLanguage: 'en', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('AutoTranslate is disabled.'); + }) + .end(done); }); it('should throw an error when the bodyParam "messageId" is not provided', (done) => { updateSetting('AutoTranslate_Enabled', true).then(() => { @@ -346,12 +375,14 @@ describe('AutoTranslate', function () { .end(done); }); }); + describe('Autoenable setting', () => { let userA; let userB; let credA; let credB; let channel; + const channelsToRemove = []; const createChannel = async (members, cred) => (await createRoom({ type: 'c', members, name: `channel-test-${Date.now()}`, credentials: cred })).body.channel; @@ -385,28 +416,37 @@ describe('AutoTranslate', function () { ).body.subscription; before(async () => { - await updateSetting('AutoTranslate_Enabled', true); - await updateSetting('AutoTranslate_AutoEnableOnJoinRoom', true); - await updateSetting('Language', 'pt-BR'); + await Promise.all([ + updateSetting('AutoTranslate_Enabled', true), + updateSetting('AutoTranslate_AutoEnableOnJoinRoom', true), + updateSetting('Language', 'pt-BR'), + ]); - channel = await createChannel(); userA = await createUser(); userB = await createUser(); credA = await login(userA.username, password); credB = await login(userB.username, password); + channel = await createChannel(undefined, credA); + await setLanguagePref('en', credB); + channelsToRemove.push(channel); }); after(async () => { - await updateSetting('AutoTranslate_AutoEnableOnJoinRoom', false); - await updateSetting('AutoTranslate_Enabled', false); - await updateSetting('Language', ''); + await Promise.all([ + updateSetting('AutoTranslate_AutoEnableOnJoinRoom', false), + updateSetting('AutoTranslate_Enabled', false), + updateSetting('Language', ''), + deleteUser(userA), + deleteUser(userB), + channelsToRemove.map(() => deleteRoom({ type: 'c', roomId: channel._id })), + ]); }); it("should do nothing if the user hasn't changed his language preference", async () => { - const sub = await getSub(channel._id, credentials); + const sub = await getSub(channel._id, credA); expect(sub).to.not.have.property('autoTranslate'); expect(sub).to.not.have.property('autoTranslateLanguage'); }); @@ -414,61 +454,74 @@ describe('AutoTranslate', function () { it("should do nothing if the user changed his language preference to be the same as the server's", async () => { await setLanguagePref('pt-BR', credA); - const channel = await createChannel(undefined, credA); const sub = await getSub(channel._id, credA); expect(sub).to.not.have.property('autoTranslate'); expect(sub).to.not.have.property('autoTranslateLanguage'); }); + it('should enable autotranslate with the correct language when joining a room', async () => { + await request + .post(api('channels.join')) + .set(credB) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + const sub = await getSub(channel._id, credB); + expect(sub).to.have.property('autoTranslate'); + expect(sub).to.have.property('autoTranslateLanguage').and.to.be.equal('en'); + }); + it('should enable autotranslate with the correct language when creating a new room', async () => { await setLanguagePref('en', credA); - const channel = await createChannel(undefined, credA); - const sub = await getSub(channel._id, credA); + const newChannel = await createChannel(undefined, credA); + const sub = await getSub(newChannel._id, credA); expect(sub).to.have.property('autoTranslate'); expect(sub).to.have.property('autoTranslateLanguage').and.to.be.equal('en'); + channelsToRemove.push(newChannel); }); it('should enable autotranslate for all the members added to the room upon creation', async () => { - const channel = await createChannel([userA.username, userB.username]); - const subA = await getSub(channel._id, credA); + const newChannel = await createChannel([userA.username, userB.username], credA); + const subA = await getSub(newChannel._id, credA); expect(subA).to.have.property('autoTranslate'); expect(subA).to.have.property('autoTranslateLanguage').and.to.be.equal('en'); - const subB = await getSub(channel._id, credB); + const subB = await getSub(newChannel._id, credB); expect(subB).to.have.property('autoTranslate'); expect(subB).to.have.property('autoTranslateLanguage').and.to.be.equal('en'); + channelsToRemove.push(newChannel); }); - it('should enable autotranslate with the correct language when joining a room', async () => { + it('should enable autotranslate with the correct language when added to a room', async () => { + const newChannel = await createChannel(undefined, credA); await request - .post(api('channels.join')) + .post(api('channels.invite')) .set(credA) .send({ - roomId: channel._id, + roomId: newChannel._id, + userId: userB._id, }) .expect('Content-Type', 'application/json') .expect(200); - const sub = await getSub(channel._id, credA); + const sub = await getSub(newChannel._id, credB); expect(sub).to.have.property('autoTranslate'); expect(sub).to.have.property('autoTranslateLanguage').and.to.be.equal('en'); + channelsToRemove.push(newChannel); }); - it('should enable autotranslate with the correct language when added to a room', async () => { - await request - .post(api('channels.invite')) - .set(credentials) - .send({ - roomId: channel._id, - userId: userB._id, - }) - .expect('Content-Type', 'application/json') - .expect(200); + it('should change the auto translate language when the user changes his language preference', async () => { + await setLanguagePref('es', credA); + const newChannel = await createChannel(undefined, credA); + const subscription = await getSub(newChannel._id, credA); - const sub = await getSub(channel._id, credB); - expect(sub).to.have.property('autoTranslate'); - expect(sub).to.have.property('autoTranslateLanguage').and.to.be.equal('en'); + expect(subscription).to.have.property('autoTranslate', true); + expect(subscription).to.have.property('autoTranslateLanguage', 'es'); + channelsToRemove.push(newChannel); }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/00-miscellaneous.js b/apps/meteor/tests/end-to-end/api/00-miscellaneous.js index e9fec42e4b66..d545441c1b7c 100644 --- a/apps/meteor/tests/end-to-end/api/00-miscellaneous.js +++ b/apps/meteor/tests/end-to-end/api/00-miscellaneous.js @@ -1,11 +1,13 @@ +import { TEAM_TYPE } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { after, before, describe, it } from 'mocha'; -import { getCredentials, api, login, request, credentials } from '../../data/api-data.js'; -import { updateSetting } from '../../data/permissions.helper'; -import { createRoom } from '../../data/rooms.helper'; +import { getCredentials, api, request, credentials } from '../../data/api-data.js'; +import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { createTeam, deleteTeam } from '../../data/teams.helper'; import { adminEmail, adminUsername, adminPassword, password } from '../../data/user'; -import { createUser, login as doLogin } from '../../data/users.helper'; +import { createUser, deleteUser, login as doLogin } from '../../data/users.helper'; import { IS_EE } from '../../e2e/config/constants'; describe('miscellaneous', function () { @@ -133,10 +135,13 @@ describe('miscellaneous', function () { .end(done); }); - it('/me', (done) => { - request + it('/me', async () => { + const user = await createUser(); + const userCredentials = await doLogin(user.username, password); + + await request .get(api('me')) - .set(credentials) + .set(userCredentials) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { @@ -181,56 +186,43 @@ describe('miscellaneous', function () { ].filter((p) => Boolean(p)); expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('_id', credentials['X-User-Id']); - expect(res.body).to.have.property('username', login.user); + expect(res.body).to.have.property('_id', user._id); + expect(res.body).to.have.property('username', user.username); expect(res.body).to.have.property('active'); expect(res.body).to.have.property('name'); expect(res.body).to.have.property('roles').and.to.be.an('array'); - expect(res.body).to.have.nested.property('emails[0].address', adminEmail); + expect(res.body).to.have.nested.property('emails[0].address', user.emails[0].address); expect(res.body).to.have.nested.property('settings.preferences').and.to.be.an('object'); expect(res.body.settings.preferences).to.have.all.keys(allUserPreferencesKeys); expect(res.body.services).to.not.have.nested.property('password.bcrypt'); - }) - .end(done); + }); + + await deleteUser(user); }); describe('/directory', () => { let user; let testChannel; - before((done) => { - const username = `user.test.${Date.now()}`; - const email = `${username}@rocket.chat`; - request - .post(api('users.create')) - .set(credentials) - .send({ email, name: username, username, password }) - .end((err, res) => { - user = res.body.user; - done(); - }); - }); - after((done) => { - request - .post(api('users.delete')) - .set(credentials) - .send({ - userId: user._id, - }) - .end(done); - user = undefined; + let normalUserCredentials; + const teamName = `new-team-name-${Date.now()}`; + let teamCreated = {}; + + before(async () => { + await updatePermission('create-team', ['admin', 'user']); + user = await createUser(); + normalUserCredentials = await doLogin(user.username, password); + testChannel = (await createRoom({ name: `channel.test.${Date.now()}`, type: 'c' })).body.channel; + teamCreated = await createTeam(normalUserCredentials, teamName, TEAM_TYPE.PUBLIC); }); - it('create a channel', (done) => { - request - .post(api('channels.create')) - .set(credentials) - .send({ - name: `channel.test.${Date.now()}`, - }) - .end((err, res) => { - testChannel = res.body.channel; - done(); - }); + + after(async () => { + await Promise.all([ + deleteTeam(normalUserCredentials, teamName), + deleteUser(user), + deleteRoom({ type: 'c', roomId: testChannel._id }), + ]); }); + it('should return an array(result) when search by user and execute successfully', (done) => { request .get(api('directory')) @@ -258,32 +250,10 @@ describe('miscellaneous', function () { .end(done); }); - let normalUser; - before((done) => { - request - .post(api('login')) - .send({ - username: user.username, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('status', 'success'); - expect(res.body).to.have.property('data').and.to.be.an('object'); - expect(res.body.data).to.have.property('userId'); - expect(res.body.data).to.have.property('authToken'); - normalUser = res.body.data; - }) - .end(done); - }); it('should not return the emails field for non admins', (done) => { request .get(api('directory')) - .set({ - 'X-Auth-Token': normalUser.authToken, - 'X-User-Id': normalUser.userId, - }) + .set(normalUserCredentials) .query({ query: JSON.stringify({ text: user.username, @@ -398,36 +368,10 @@ describe('miscellaneous', function () { .end(done); }); - const teamName = `new-team-name-${Date.now()}`; - let teamCreated = {}; - before((done) => { - request - .post(api('teams.create')) - .set(credentials) - .send({ - name: teamName, - type: 0, - }) - .expect((res) => { - teamCreated = res.body.team; - }) - .end(done); - }); - - after((done) => { - request - .post(api('teams.delete')) - .set(credentials) - .send({ - teamName, - }) - .end(done); - }); - it('should return an object containing rooms and totalCount from teams', (done) => { request .get(api('directory')) - .set(credentials) + .set(normalUserCredentials) .query({ query: JSON.stringify({ text: '', @@ -442,7 +386,7 @@ describe('miscellaneous', function () { .expect((res) => { expect(res.body).to.have.property('result'); expect(res.body.result).to.be.an(`array`); - expect(res.body).to.have.property('total', 1); + expect(res.body).to.have.property('total'); expect(res.body.total).to.be.an('number'); expect(res.body.result[0]).to.have.property('_id', teamCreated.roomId); expect(res.body.result[0]).to.have.property('fname'); @@ -458,80 +402,33 @@ describe('miscellaneous', function () { .end(done); }); }); + describe('[/spotlight]', () => { let user; - before((done) => { - const username = `user.test.${Date.now()}`; - const email = `${username}@rocket.chat`; - request - .post(api('users.create')) - .set(credentials) - .send({ email, name: username, username, password }) - .end((err, res) => { - user = res.body.user; - done(); - }); - }); - let userCredentials; let testChannel; let testTeam; - before((done) => { - request - .post(api('login')) - .send({ - user: user.username, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - userCredentials = {}; - userCredentials['X-Auth-Token'] = res.body.data.authToken; - userCredentials['X-User-Id'] = res.body.data.userId; - }) - .end(done); - }); let testChannelSpecialChars; - const fnameSpecialCharsRoom = `test ГДΕληνικά`; - before((done) => { - updateSetting('UI_Allow_room_names_with_special_chars', true) - .then(() => { - createRoom({ type: 'c', name: fnameSpecialCharsRoom, credentials: userCredentials }).end((err, res) => { - testChannelSpecialChars = res.body.channel; - }); - }) - .then(done); + const fnameSpecialCharsRoom = `test ГДΕληνικά-${Date.now()}`; + const teamName = `team-test-${Date.now()}`; + + before(async () => { + user = await createUser(); + userCredentials = await doLogin(user.username, password); + await updateSetting('UI_Allow_room_names_with_special_chars', true); + testChannelSpecialChars = (await createRoom({ type: 'c', name: fnameSpecialCharsRoom, credentials: userCredentials })).body.channel; + testChannel = (await createRoom({ type: 'c', name: `channel.test.${Date.now()}`, credentials: userCredentials })).body.channel; + testTeam = await createTeam(userCredentials, teamName, TEAM_TYPE.PUBLIC); }); + after(async () => { - await request.post(api('users.delete')).set(credentials).send({ - userId: user._id, - }); - user = undefined; - await updateSetting('UI_Allow_room_names_with_special_chars', false); - }); - it('create a channel', (done) => { - request - .post(api('channels.create')) - .set(userCredentials) - .send({ - name: `channel.test.${Date.now()}`, - }) - .end((err, res) => { - testChannel = res.body.channel; - done(); - }); - }); - before('create a team', async () => { - const res = await request - .post(api('teams.create')) - .set(userCredentials) - .send({ - name: `team-test-${Date.now()}`, - type: 0, - }); - testTeam = res.body.team; + await Promise.all([ + deleteUser(user), + updateSetting('UI_Allow_room_names_with_special_chars', false), + deleteTeam(userCredentials, teamName), + ]); }); + it('should fail when does not have query param', (done) => { request .get(api('spotlight')) @@ -701,24 +598,26 @@ describe('miscellaneous', function () { }); describe('[/shield.svg]', () => { + before(() => updateSetting('API_Enable_Shields', false)); + + after(() => updateSetting('API_Enable_Shields', true)); + it('should fail if API_Enable_Shields is disabled', (done) => { - updateSetting('API_Enable_Shields', false).then(() => { - request - .get(api('shield.svg')) - .query({ - type: 'online', - icon: true, - channel: 'general', - name: 'Rocket.Chat', - }) - .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-endpoint-disabled'); - }) - .end(done); - }); + request + .get(api('shield.svg')) + .query({ + type: 'online', + icon: true, + channel: 'general', + name: 'Rocket.Chat', + }) + .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-endpoint-disabled'); + }) + .end(done); }); it('should succeed if API_Enable_Shields is enabled', (done) => { diff --git a/apps/meteor/tests/end-to-end/api/02-channels.js b/apps/meteor/tests/end-to-end/api/02-channels.js index 6976caa849bf..5291b3621b43 100644 --- a/apps/meteor/tests/end-to-end/api/02-channels.js +++ b/apps/meteor/tests/end-to-end/api/02-channels.js @@ -1,11 +1,11 @@ import { expect } from 'chai'; import { after, before, describe, it } from 'mocha'; -import { getCredentials, api, request, credentials, apiPublicChannelName, channel, reservedWords } from '../../data/api-data.js'; +import { getCredentials, api, request, credentials, reservedWords } from '../../data/api-data.js'; import { CI_MAX_ROOMS_PER_GUEST as maxRoomsPerGuest } from '../../data/constants'; import { createIntegration, removeIntegration } from '../../data/integration.helper'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; -import { createRoom } from '../../data/rooms.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { testFileUploads } from '../../data/uploads.helper'; import { adminUsername, password } from '../../data/user'; import { createUser, login, deleteUser } from '../../data/users.helper'; @@ -24,32 +24,528 @@ function getRoomInfo(roomId) { }); } +const channel = {}; + describe('[Channels]', function () { + const apiPublicChannelName = `api-channel-test-${Date.now()}`; + this.retries(0); - before((done) => getCredentials(done)); + before((done) => getCredentials(done)); + + before('Creating channel', (done) => { + request + .post(api('channels.create')) + .set(credentials) + .send({ + name: apiPublicChannelName, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', 0); + channel._id = res.body.channel._id; + channel.name = res.body.channel.name; + }) + .end(done); + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: channel._id }); + }); + + it('/channels.invite', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: channel._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('channel._id'); + expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + + it('/channels.addModerator', (done) => { + request + .post(api('channels.addModerator')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('/channels.addModerator should fail with missing room Id', (done) => { + request + .post(api('channels.addModerator')) + .set(credentials) + .send({ + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }) + .end(done); + }); + + it('/channels.addModerator should fail with missing user Id', (done) => { + request + .post(api('channels.addModerator')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }) + .end(done); + }); + + it('/channels.removeModerator', (done) => { + request + .post(api('channels.removeModerator')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('/channels.removeModerator should fail on invalid room id', (done) => { + request + .post(api('channels.removeModerator')) + .set(credentials) + .send({ + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }) + .end(done); + }); + + it('/channels.removeModerator should fail on invalid user id', (done) => { + request + .post(api('channels.removeModerator')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }) + .end(done); + }); + + it('/channels.addOwner', (done) => { + request + .post(api('channels.addOwner')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('/channels.removeOwner', (done) => { + request + .post(api('channels.removeOwner')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('/channels.kick', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.kick')) + .set(credentials) + .send({ + roomId: channel._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('channel._id'); + expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + + it('/channels.invite', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: channel._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('channel._id'); + expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + + it('/channels.addOwner', (done) => { + request + .post(api('channels.addOwner')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('/channels.archive', (done) => { + request + .post(api('channels.archive')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('/channels.unarchive', (done) => { + request + .post(api('channels.unarchive')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('/channels.close', (done) => { + request + .post(api('channels.close')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('/channels.close', (done) => { + request + .post(api('channels.close')) + .set(credentials) + .send({ + roomName: apiPublicChannelName, + }) + .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 channel, ${apiPublicChannelName}, is already closed to the sender`); + }) + .end(done); + }); + + it('/channels.open', (done) => { + request + .post(api('channels.open')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('/channels.list', (done) => { + request + .get(api('channels.list')) + .set(credentials) + .query({ + roomId: channel._id, + }) + .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'); + }) + .end(done); + }); + + it('/channels.list.joined', (done) => { + request + .get(api('channels.list.joined')) + .set(credentials) + .query({ + roomId: channel._id, + }) + .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'); + }) + .end(done); + }); + it('/channels.counters', (done) => { + request + .get(api('channels.counters')) + .set(credentials) + .query({ + roomId: channel._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); + }); - before('Creating channel', (done) => { + it('/channels.rename', async () => { + const roomInfo = await getRoomInfo(channel._id); + + function failRenameChannel(name) { + it(`should not rename a channel to the reserved name ${name}`, (done) => { + request + .post(api('channels.rename')) + .set(credentials) + .send({ + roomId: channel._id, + name, + }) + .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 already in use :( [error-field-unavailable]`); + }) + .end(done); + }); + } + + reservedWords.forEach((name) => { + failRenameChannel(name); + }); + + return request + .post(api('channels.rename')) + .set(credentials) + .send({ + roomId: channel._id, + name: `EDITED${apiPublicChannelName}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + + it('/channels.addAll', (done) => { request - .post(api('channels.create')) + .post(api('channels.addAll')) .set(credentials) .send({ - name: apiPublicChannelName, + roomId: channel._id, }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); + expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); expect(res.body).to.have.nested.property('channel.t', 'c'); - expect(res.body).to.have.nested.property('channel.msgs', 0); - channel._id = res.body.channel._id; - channel.name = res.body.channel.name; }) .end(done); }); + it('/channels.addLeader', (done) => { + request + .post(api('channels.addLeader')) + .set(credentials) + .send({ + roomId: channel._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('/channels.removeLeader', (done) => { + request + .post(api('channels.removeLeader')) + .set(credentials) + .send({ + roomId: channel._id, + userId: 'rocket.cat', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('/channels.setJoinCode', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.setJoinCode')) + .set(credentials) + .send({ + roomId: channel._id, + joinCode: '123', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs); + }); + }); + + it('/channels.setReadOnly', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.setReadOnly')) + .set(credentials) + .send({ + roomId: channel._id, + readOnly: 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('channel._id'); + expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + it('/channels.leave', async () => { + const roomInfo = await getRoomInfo(channel._id); + + return request + .post(api('channels.leave')) + .set(credentials) + .send({ + roomId: channel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id'); + expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); + expect(res.body).to.have.nested.property('channel.t', 'c'); + expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + }); + }); + describe('[/channels.create]', () => { let guestUser; let room; @@ -93,7 +589,7 @@ describe('[Channels]', function () { }), ); } - await Promise.all(promises); + const channelIds = (await Promise.all(promises)).map((r) => r.body.channel).map((channel) => channel._id); request .post(api('channels.create')) @@ -123,17 +619,25 @@ describe('[Channels]', function () { expect(res.body.members).to.have.lengthOf(1); }); }); + + await Promise.all(channelIds.map((id) => deleteRoom({ type: 'c', roomId: id }))); }); }); describe('[/channels.info]', () => { + const testChannelName = `api-channel-test-${Date.now()}`; let testChannel = {}; let channelMessage = {}; + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + it('creating new channel...', (done) => { request .post(api('channels.create')) .set(credentials) .send({ - name: apiPublicChannelName, + name: testChannelName, }) .expect('Content-Type', 'application/json') .expect(200) @@ -147,7 +651,7 @@ describe('[Channels]', function () { .post(api('channels.create')) .set(credentials) .send({ - name: apiPublicChannelName, + name: testChannelName, }) .expect('Content-Type', 'application/json') .expect(400) @@ -169,7 +673,7 @@ describe('[Channels]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); + expect(res.body).to.have.nested.property('channel.name', testChannelName); expect(res.body).to.have.nested.property('channel.t', 'c'); expect(res.body).to.have.nested.property('channel.msgs', 0); }) @@ -301,9 +805,13 @@ describe('[Channels]', function () { }); describe('[/channels.online]', () => { + const createdChannels = []; + const createdUsers = []; + const createUserAndChannel = async () => { const testUser = await createUser(); const testUserCredentials = await login(testUser.username, password); + createdUsers.push(testUser); await request.post(api('users.setStatus')).set(testUserCredentials).send({ message: '', @@ -317,6 +825,7 @@ describe('[Channels]', function () { type: 'c', members: [testUser.username], }); + createdChannels.push(roomResponse.body.channel); return { testUser, @@ -325,6 +834,13 @@ describe('[Channels]', function () { }; }; + after(async () => { + await Promise.all([ + createdUsers.map((user) => deleteUser(user)), + createdChannels.map((channel) => deleteRoom({ type: 'c', roomId: channel._id })), + ]); + }); + it('should return an error if no query', () => request .get(api('channels.online')) @@ -373,92 +889,58 @@ describe('[Channels]', function () { const outsider = await createUser(); const outsiderCredentials = await login(outsider.username, password); - const { testUser, room } = await createUserAndChannel(); - - return request - .get(api('channels.online')) - .set(outsiderCredentials) - .query(`query={"_id": "${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('online'); - - const expected = { - _id: testUser._id, - username: testUser.username, - }; - expect(res.body.online).to.deep.include(expected); - }); - }); - }); - - describe('[/channels.files]', async () => { - await testFileUploads('channels.files', channel); - }); - - describe('[/channels.join]', () => { - let testChannelNoCode; - let testChannelWithCode; - let testUser; - let testUserCredentials; - before('Create test user', (done) => { - const username = `user.test.${Date.now()}`; - const email = `${username}@rocket.chat`; - request - .post(api('users.create')) - .set(credentials) - .send({ email, name: username, username, password }) - .end((err, res) => { - testUser = res.body.user; - done(); - }); - }); - before('Login as test user', (done) => { - request - .post(api('login')) - .send({ - user: testUser.username, - password, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - testUserCredentials = {}; - testUserCredentials['X-Auth-Token'] = res.body.data.authToken; - testUserCredentials['X-User-Id'] = res.body.data.userId; - }) - .end(done); - }); - before('Create no code channel', (done) => { - request - .post(api('channels.create')) - .set(testUserCredentials) - .send({ - name: `${apiPublicChannelName}-nojoincode`, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - testChannelNoCode = res.body.channel; - }) - .end(done); - }); - before('Create code channel', (done) => { - request - .post(api('channels.create')) - .set(testUserCredentials) - .send({ - name: `${apiPublicChannelName}-withjoincode`, - }) + const { testUser, room } = await createUserAndChannel(); + + return request + .get(api('channels.online')) + .set(outsiderCredentials) + .query(`query={"_id": "${room._id}"}`) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { - testChannelWithCode = res.body.channel; - }) - .end(done); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('online'); + + const expected = { + _id: testUser._id, + username: testUser.username, + }; + expect(res.body.online).to.deep.include(expected); + }); + }); + }); + + describe('[/channels.files]', async () => { + await testFileUploads('channels.files', 'c'); + }); + + describe('[/channels.join]', () => { + let testChannelNoCode; + let testChannelWithCode; + let testUser; + let testUserCredentials; + + before('Create test user', async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + testChannelNoCode = (await createRoom({ type: 'c', credentials: testUserCredentials, name: `${apiPublicChannelName}-nojoincode` })) + .body.channel; + testChannelWithCode = ( + await createRoom({ type: 'c', credentials: testUserCredentials, name: `${apiPublicChannelName}-withjoincode` }) + ).body.channel; + await updatePermission('edit-room', ['admin', 'owner', 'moderator']); + }); + + after(async () => { + await Promise.all([ + deleteRoom({ type: 'c', roomId: testChannelNoCode._id }), + deleteRoom({ type: 'c', roomId: testChannelWithCode._id }), + deleteUser(testUser), + updatePermission('edit-room', ['admin', 'owner', 'moderator']), + updatePermission('join-without-join-code', ['admin', 'bot', 'app']), + ]); }); + before('Set code for channel', (done) => { request .post(api('channels.setJoinCode')) @@ -491,292 +973,118 @@ describe('[Channels]', function () { .end(done); }); - it('should succeed when joining code-free channel without join code', (done) => { - request - .post(api('channels.join')) - .set(credentials) - .send({ - roomId: testChannelNoCode._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('channel._id', testChannelNoCode._id); - }) - .end(done); - }); - - it('should fail when joining code-needed channel without join code and no join-without-join-code permission', (done) => { - updatePermission('join-without-join-code', []).then(() => { - request - .post(api('channels.join')) - .set(credentials) - .send({ - roomId: testChannelWithCode._id, - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.nested.property('errorType', 'error-code-invalid'); - }) - .end(done); - }); - }); - - it('should succeed when joining code-needed channel without join code and with join-without-join-code permission', (done) => { - updatePermission('join-without-join-code', ['admin']).then(() => { + describe('code-free channel', () => { + it('should succeed when joining code-free channel without join code', (done) => { request .post(api('channels.join')) .set(credentials) .send({ - roomId: testChannelWithCode._id, + roomId: testChannelNoCode._id, }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('channel._id', testChannelWithCode._id); + expect(res.body).to.have.nested.property('channel._id', testChannelNoCode._id); }) .end(done); }); }); - it('leave channel', (done) => { - request - .post(api('channels.leave')) - .set(credentials) - .send({ - roomId: testChannelWithCode._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - - it('should succeed when joining code-needed channel with join code', (done) => { - request - .post(api('channels.join')) - .set(credentials) - .send({ - roomId: testChannelWithCode._id, - joinCode: '123', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('channel._id', testChannelWithCode._id); - }) - .end(done); - }); - }); - - it('/channels.invite', async () => { - const roomInfo = await getRoomInfo(channel._id); - - return request - .post(api('channels.invite')) - .set(credentials) - .send({ - roomId: channel._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('channel._id'); - expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); - expect(res.body).to.have.nested.property('channel.t', 'c'); - expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); - }); - }); - - it('/channels.addModerator', (done) => { - request - .post(api('channels.addModerator')) - .set(credentials) - .send({ - roomId: channel._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - - it('/channels.addModerator should fail with missing room Id', (done) => { - request - .post(api('channels.addModerator')) - .set(credentials) - .send({ - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - }) - .end(done); - }); - - it('/channels.addModerator should fail with missing user Id', (done) => { - request - .post(api('channels.addModerator')) - .set(credentials) - .send({ - roomId: channel._id, - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - }) - .end(done); - }); - - it('/channels.removeModerator', (done) => { - request - .post(api('channels.removeModerator')) - .set(credentials) - .send({ - roomId: channel._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - - it('/channels.removeModerator should fail on invalid room id', (done) => { - request - .post(api('channels.removeModerator')) - .set(credentials) - .send({ - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - }) - .end(done); - }); - - it('/channels.removeModerator should fail on invalid user id', (done) => { - request - .post(api('channels.removeModerator')) - .set(credentials) - .send({ - roomId: channel._id, - }) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - }) - .end(done); - }); - - it('/channels.addOwner', (done) => { - request - .post(api('channels.addOwner')) - .set(credentials) - .send({ - roomId: channel._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - - it('/channels.removeOwner', (done) => { - request - .post(api('channels.removeOwner')) - .set(credentials) - .send({ - roomId: channel._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - - it('/channels.kick', async () => { - const roomInfo = await getRoomInfo(channel._id); + describe('code-needed channel', () => { + describe('without join-without-join-code permission', () => { + before('set join-without-join-code permission to false', async () => { + await updatePermission('join-without-join-code', []); + }); - return request - .post(api('channels.kick')) - .set(credentials) - .send({ - roomId: channel._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('channel._id'); - expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); - expect(res.body).to.have.nested.property('channel.t', 'c'); - expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); - }); - }); + it('should fail when joining code-needed channel without join code and no join-without-join-code permission', (done) => { + request + .post(api('channels.join')) + .set(credentials) + .send({ + roomId: testChannelWithCode._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.nested.property('errorType', 'error-code-required'); + }) + .end(done); + }); - it('/channels.invite', async () => { - const roomInfo = await getRoomInfo(channel._id); + it('should fail when joining code-needed channel with incorrect join code and no join-without-join-code permission', (done) => { + request + .post(api('channels.join')) + .set(credentials) + .send({ + roomId: testChannelWithCode._id, + joinCode: 'WRONG_CODE', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.nested.property('errorType', 'error-code-invalid'); + }) + .end(done); + }); - return request - .post(api('channels.invite')) - .set(credentials) - .send({ - roomId: channel._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('channel._id'); - expect(res.body).to.have.nested.property('channel.name', apiPublicChannelName); - expect(res.body).to.have.nested.property('channel.t', 'c'); - expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); + it('should succeed when joining code-needed channel with join code', (done) => { + request + .post(api('channels.join')) + .set(credentials) + .send({ + roomId: testChannelWithCode._id, + joinCode: '123', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id', testChannelWithCode._id); + }) + .end(done); + }); }); - }); - it('/channels.addOwner', (done) => { - request - .post(api('channels.addOwner')) - .set(credentials) - .send({ - roomId: channel._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); + describe('with join-without-join-code permission', () => { + before('set join-without-join-code permission to true', async () => { + await updatePermission('join-without-join-code', ['admin']); + }); + + before('leave channel', (done) => { + request + .post(api('channels.leave')) + .set(credentials) + .send({ + roomId: testChannelWithCode._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + + it('should succeed when joining code-needed channel without join code and with join-without-join-code permission', (done) => { + request + .post(api('channels.join')) + .set(credentials) + .send({ + roomId: testChannelWithCode._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('channel._id', testChannelWithCode._id); + }) + .end(done); + }); + }); + }); }); describe('/channels.setDescription', () => { @@ -955,138 +1263,27 @@ describe('[Channels]', function () { }); }); - it('/channels.archive', (done) => { - request - .post(api('channels.archive')) - .set(credentials) - .send({ - roomId: channel._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - - it('/channels.unarchive', (done) => { - request - .post(api('channels.unarchive')) - .set(credentials) - .send({ - roomId: channel._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - - it('/channels.close', (done) => { - request - .post(api('channels.close')) - .set(credentials) - .send({ - roomId: channel._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); - - it('/channels.close', (done) => { - request - .post(api('channels.close')) - .set(credentials) - .send({ - roomName: apiPublicChannelName, - }) - .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 channel, ${apiPublicChannelName}, is already closed to the sender`); - }) - .end(done); - }); - - it('/channels.open', (done) => { - request - .post(api('channels.open')) - .set(credentials) - .send({ - roomId: channel._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); + describe('/channels.members', () => { + let testUser; - it('/channels.list', (done) => { - request - .get(api('channels.list')) - .set(credentials) - .query({ - roomId: channel._id, - }) - .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'); - }) - .end(done); - }); + before(async () => { + testUser = await createUser(); + await updateSetting('Accounts_SearchFields', 'username, name, bio, nickname'); + await request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: channel._id, + userId: testUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + }); - it('/channels.list.joined', (done) => { - request - .get(api('channels.list.joined')) - .set(credentials) - .query({ - roomId: channel._id, - }) - .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'); - }) - .end(done); - }); - it('/channels.counters', (done) => { - request - .get(api('channels.counters')) - .set(credentials) - .query({ - roomId: channel._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); - }); + after(async () => { + await Promise.all([updateSetting('Accounts_SearchFields', 'username, name, bio, nickname'), deleteUser(testUser)]); + }); - describe('/channels.members', () => { it('should return an array of members by channel', (done) => { request .get(api('channels.members')) @@ -1133,75 +1330,34 @@ describe('[Channels]', function () { .set(credentials) .query({ roomId: channel._id, - filter: 'rocket.cat', + filter: testUser.username, }) .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).to.have.property('count'); expect(res.body).to.have.property('count', 1); + expect(res.body.members[0]._id).to.be.equal(testUser._id); + expect(res.body).to.have.property('count'); expect(res.body).to.have.property('total'); - expect(res.body).to.have.property('offset'); - }) - .end(done); - }); - }); - - it('/channels.rename', async () => { - const roomInfo = await getRoomInfo(channel._id); - - function failRenameChannel(name) { - it(`should not rename a channel to the reserved name ${name}`, (done) => { - request - .post(api('channels.rename')) - .set(credentials) - .send({ - roomId: channel._id, - name, - }) - .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 already in use :( [error-field-unavailable]`); - }) - .end(done); - }); - } - - reservedWords.forEach((name) => { - failRenameChannel(name); + expect(res.body).to.have.property('offset'); + }) + .end(done); }); - - return request - .post(api('channels.rename')) - .set(credentials) - .send({ - roomId: channel._id, - name: `EDITED${apiPublicChannelName}`, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); - expect(res.body).to.have.nested.property('channel.t', 'c'); - expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); - }); }); describe('/channels.getIntegrations', () => { let integrationCreatedByAnUser; let userCredentials; let createdChannel; + let user; + before((done) => { createRoom({ name: `test-integration-channel-${Date.now()}`, type: 'c' }).end((err, res) => { createdChannel = res.body.channel; createUser().then((createdUser) => { - const user = createdUser; + user = createdUser; login(user.username, password).then((credentials) => { userCredentials = credentials; updatePermission('manage-incoming-integrations', ['user']).then(() => { @@ -1229,8 +1385,14 @@ describe('[Channels]', function () { }); }); - after((done) => { - removeIntegration(integrationCreatedByAnUser._id, 'incoming').then(done); + after(async () => { + await Promise.all([ + deleteRoom({ type: 'c', roomId: createdChannel._id }), + removeIntegration(integrationCreatedByAnUser._id, 'incoming'), + updatePermission('manage-incoming-integrations', ['admin']), + updatePermission('manage-own-incoming-integrations', ['admin']), + deleteUser(user), + ]); }); it('should return the list of integrations of created channel and it should contain the integration created by user when the admin DOES have the permission', (done) => { @@ -1307,57 +1469,14 @@ describe('[Channels]', function () { }); }); - it('/channels.addAll', (done) => { - request - .post(api('channels.addAll')) - .set(credentials) - .send({ - roomId: channel._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); - expect(res.body).to.have.nested.property('channel.t', 'c'); - }) - .end(done); - }); + describe('/channels.setCustomFields:', () => { + let withCFChannel; + let withoutCFChannel; - it('/channels.addLeader', (done) => { - request - .post(api('channels.addLeader')) - .set(credentials) - .send({ - roomId: channel._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('/channels.removeLeader', (done) => { - request - .post(api('channels.removeLeader')) - .set(credentials) - .send({ - roomId: channel._id, - userId: 'rocket.cat', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }) - .end(done); - }); + after(async () => { + await deleteRoom({ type: 'c', roomId: withCFChannel._id }); + }); - describe('/channels.setCustomFields:', () => { - let cfchannel; it('create channel with customFields', (done) => { const customFields = { field0: 'value0' }; request @@ -1368,7 +1487,7 @@ describe('[Channels]', function () { customFields, }) .end((err, res) => { - cfchannel = res.body.channel; + withCFChannel = res.body.channel; done(); }); }); @@ -1377,7 +1496,7 @@ describe('[Channels]', function () { .get(api('channels.info')) .set(credentials) .query({ - roomId: cfchannel._id, + roomId: withCFChannel._id, }) .expect('Content-Type', 'application/json') .expect(200) @@ -1393,7 +1512,7 @@ describe('[Channels]', function () { .post(api('channels.setCustomFields')) .set(credentials) .send({ - roomId: cfchannel._id, + roomId: withCFChannel._id, customFields, }) .expect('Content-Type', 'application/json') @@ -1401,7 +1520,7 @@ describe('[Channels]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', cfchannel.name); + expect(res.body).to.have.nested.property('channel.name', withCFChannel.name); expect(res.body).to.have.nested.property('channel.t', 'c'); expect(res.body).to.have.nested.property('channel.customFields.field9', 'value9'); expect(res.body).to.have.not.nested.property('channel.customFields.field0', 'value0'); @@ -1412,7 +1531,7 @@ describe('[Channels]', function () { .get(api('channels.info')) .set(credentials) .query({ - roomId: cfchannel._id, + roomId: withCFChannel._id, }) .expect('Content-Type', 'application/json') .expect(200) @@ -1427,7 +1546,7 @@ describe('[Channels]', function () { .post(api('channels.delete')) .set(credentials) .send({ - roomName: cfchannel.name, + roomName: withCFChannel.name, }) .expect('Content-Type', 'application/json') .expect(200) @@ -1444,7 +1563,7 @@ describe('[Channels]', function () { name: `channel.cf.${Date.now()}`, }) .end((err, res) => { - cfchannel = res.body.channel; + withoutCFChannel = res.body.channel; done(); }); }); @@ -1454,7 +1573,7 @@ describe('[Channels]', function () { .post(api('channels.setCustomFields')) .set(credentials) .send({ - roomId: cfchannel._id, + roomId: withoutCFChannel._id, customFields, }) .expect('Content-Type', 'application/json') @@ -1462,7 +1581,7 @@ describe('[Channels]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', cfchannel.name); + expect(res.body).to.have.nested.property('channel.name', withoutCFChannel.name); expect(res.body).to.have.nested.property('channel.t', 'c'); expect(res.body).to.have.nested.property('channel.customFields.field1', 'value1'); }); @@ -1474,7 +1593,7 @@ describe('[Channels]', function () { .post(api('channels.setCustomFields')) .set(credentials) .send({ - roomName: cfchannel.name, + roomName: withoutCFChannel.name, customFields, }) .expect('Content-Type', 'application/json') @@ -1482,7 +1601,7 @@ describe('[Channels]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', cfchannel.name); + expect(res.body).to.have.nested.property('channel.name', withoutCFChannel.name); expect(res.body).to.have.nested.property('channel.t', 'c'); expect(res.body).to.have.nested.property('channel.customFields.field2', 'value2'); expect(res.body).to.have.nested.property('channel.customFields.field3', 'value3'); @@ -1497,7 +1616,7 @@ describe('[Channels]', function () { .post(api('channels.setCustomFields')) .set(credentials) .send({ - roomName: cfchannel.name, + roomName: withoutCFChannel.name, customFields, }) .expect('Content-Type', 'application/json') @@ -1505,7 +1624,7 @@ describe('[Channels]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', cfchannel.name); + expect(res.body).to.have.nested.property('channel.name', withoutCFChannel.name); expect(res.body).to.have.nested.property('channel.t', 'c'); expect(res.body).to.have.not.nested.property('channel.customFields.field2', 'value2'); expect(res.body).to.have.not.nested.property('channel.customFields.field3', 'value3'); @@ -1520,7 +1639,7 @@ describe('[Channels]', function () { .post(api('channels.setCustomFields')) .set(credentials) .send({ - roomName: cfchannel.name, + roomName: withoutCFChannel.name, customFields, }) .expect('Content-Type', 'application/json') @@ -1535,7 +1654,7 @@ describe('[Channels]', function () { .post(api('channels.delete')) .set(credentials) .send({ - roomName: cfchannel.name, + roomName: withoutCFChannel.name, }) .expect('Content-Type', 'application/json') .expect(200) @@ -1546,57 +1665,26 @@ describe('[Channels]', function () { }); }); - it('/channels.setJoinCode', async () => { - const roomInfo = await getRoomInfo(channel._id); - - return request - .post(api('channels.setJoinCode')) - .set(credentials) - .send({ - roomId: channel._id, - joinCode: '123', - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); - expect(res.body).to.have.nested.property('channel.t', 'c'); - expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs); - }); - }); + describe('/channels.setDefault', () => { + let testChannel; + const name = `setDefault-${Date.now()}`; - it('/channels.setReadOnly', async () => { - const roomInfo = await getRoomInfo(channel._id); + before(async () => { + testChannel = (await createRoom({ type: 'c', name })).body.channel; + }); - return request - .post(api('channels.setReadOnly')) - .set(credentials) - .send({ - roomId: channel._id, - readOnly: 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('channel._id'); - expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); - expect(res.body).to.have.nested.property('channel.t', 'c'); - expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); - }); - }); + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); - describe('/channels.setDefault', () => { it('should set channel as default', async () => { - const roomInfo = await getRoomInfo(channel._id); + const roomInfo = await getRoomInfo(testChannel._id); return request .post(api('channels.setDefault')) .set(credentials) .send({ - roomId: channel._id, + roomId: testChannel._id, default: true, }) .expect('Content-Type', 'application/json') @@ -1604,20 +1692,20 @@ describe('[Channels]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); + expect(res.body).to.have.nested.property('channel.name', name); expect(res.body).to.have.nested.property('channel.t', 'c'); expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs); expect(res.body).to.have.nested.property('channel.default', true); }); }); it('should unset channel as default', async () => { - const roomInfo = await getRoomInfo(channel._id); + const roomInfo = await getRoomInfo(testChannel._id); return request .post(api('channels.setDefault')) .set(credentials) .send({ - roomId: channel._id, + roomId: testChannel._id, default: false, }) .expect('Content-Type', 'application/json') @@ -1625,7 +1713,7 @@ describe('[Channels]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); + expect(res.body).to.have.nested.property('channel.name', name); expect(res.body).to.have.nested.property('channel.t', 'c'); expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs); expect(res.body).to.have.nested.property('channel.default', false); @@ -1633,29 +1721,20 @@ describe('[Channels]', function () { }); }); - it('/channels.leave', async () => { - const roomInfo = await getRoomInfo(channel._id); + describe('/channels.setType', () => { + let testChannel; + const name = `setType-${Date.now()}`; - return request - .post(api('channels.leave')) - .set(credentials) - .send({ - roomId: channel._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); - expect(res.body).to.have.nested.property('channel.t', 'c'); - expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); - }); - }); + before(async () => { + testChannel = (await createRoom({ type: 'c', name })).body.channel; + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); - describe('/channels.setType', () => { it('should change the type public channel to private', async () => { - const roomInfo = await getRoomInfo(channel._id); + const roomInfo = await getRoomInfo(testChannel._id); request .post(api('channels.setType')) @@ -1669,7 +1748,7 @@ describe('[Channels]', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.nested.property('channel._id'); - expect(res.body).to.have.nested.property('channel.name', `EDITED${apiPublicChannelName}`); + expect(res.body).to.have.nested.property('channel.name', name); expect(res.body).to.have.nested.property('channel.t', 'p'); expect(res.body).to.have.nested.property('channel.msgs', roomInfo.channel.msgs + 1); }); @@ -1678,18 +1757,15 @@ describe('[Channels]', function () { describe('/channels.delete:', () => { let testChannel; - it('/channels.create', (done) => { - request - .post(api('channels.create')) - .set(credentials) - .send({ - name: `channel.test.${Date.now()}`, - }) - .end((err, res) => { - testChannel = res.body.channel; - done(); - }); + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.${Date.now()}` })).body.channel; }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + }); + it('/channels.delete', (done) => { request .post(api('channels.delete')) @@ -1764,18 +1840,15 @@ describe('[Channels]', function () { describe('/channels.roles', () => { let testChannel; - it('/channels.create', (done) => { - request - .post(api('channels.create')) - .set(credentials) - .send({ - name: `channel.roles.test.${Date.now()}`, - }) - .end((err, res) => { - testChannel = res.body.channel; - done(); - }); + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.roles.test.${Date.now()}` })).body.channel; + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); }); + it('/channels.invite', (done) => { request .post(api('channels.invite')) @@ -1839,18 +1912,15 @@ describe('[Channels]', function () { describe('/channels.moderators', () => { let testChannel; - it('/channels.create', (done) => { - request - .post(api('channels.create')) - .set(credentials) - .send({ - name: `channel.roles.test.${Date.now()}`, - }) - .end((err, res) => { - testChannel = res.body.channel; - done(); - }); + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.moderators.test.${Date.now()}` })).body.channel; + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); }); + it('/channels.invite', (done) => { request .post(api('channels.invite')) @@ -1888,14 +1958,24 @@ describe('[Channels]', function () { .end(done); }); }); + describe('/channels.anonymousread', () => { - after(() => updateSetting('Accounts_AllowAnonymousRead', false)); + let testChannel; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.anonymousread.test.${Date.now()}` })).body.channel; + }); + + after(async () => { + await Promise.all([updateSetting('Accounts_AllowAnonymousRead', false), deleteRoom({ type: 'c', roomId: testChannel._id })]); + }); + it('should return an error when the setting "Accounts_AllowAnonymousRead" is disabled', (done) => { updateSetting('Accounts_AllowAnonymousRead', false).then(() => { request .get(api('channels.anonymousread')) .query({ - roomId: 'GENERAL', + roomId: testChannel._id, }) .expect('Content-Type', 'application/json') .expect(400) @@ -1914,7 +1994,7 @@ describe('[Channels]', function () { request .get(api('channels.anonymousread')) .query({ - roomId: 'GENERAL', + roomId: testChannel._id, }) .expect('Content-Type', 'application/json') .expect(200) @@ -1930,7 +2010,7 @@ describe('[Channels]', function () { request .get(api('channels.anonymousread')) .query({ - roomId: 'GENERAL', + roomId: testChannel._id, count: 5, offset: 0, }) @@ -1946,15 +2026,18 @@ describe('[Channels]', function () { }); describe('/channels.convertToTeam', () => { - before((done) => { - request - .post(api('channels.create')) - .set(credentials) - .send({ name: `channel-${Date.now()}` }) - .then((response) => { - this.newChannel = response.body.channel; - }) - .then(() => done()); + let testChannel; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.convertToTeam.test.${Date.now()}` })).body.channel; + }); + + after(async () => { + await Promise.all([ + updatePermission('create-team', ['admin', 'user']), + updatePermission('edit-room', ['admin', 'owner', 'moderator']), + deleteRoom({ type: 'c', roomId: testChannel._id }), + ]); }); it('should fail to convert channel if lacking edit-room permission', async () => { @@ -1964,7 +2047,7 @@ describe('[Channels]', function () { await request .post(api('channels.convertToTeam')) .set(credentials) - .send({ channelId: this.newChannel._id }) + .send({ channelId: testChannel._id }) .expect(403) .expect((res) => { expect(res.body).to.have.a.property('success', false); @@ -1978,7 +2061,7 @@ describe('[Channels]', function () { await request .post(api('channels.convertToTeam')) .set(credentials) - .send({ channelId: this.newChannel._id }) + .send({ channelId: testChannel._id }) .expect(403) .expect((res) => { expect(res.body).to.have.a.property('success', false); @@ -1990,8 +2073,8 @@ describe('[Channels]', function () { .post(api('channels.convertToTeam')) .set(credentials) .send({ - channelName: this.newChannel.name, - channelId: this.newChannel._id, + channelName: testChannel.name, + channelId: testChannel._id, }) .expect(400) .expect((res) => { @@ -2008,7 +2091,7 @@ describe('[Channels]', function () { await request .post(api('channels.convertToTeam')) .set(credentials) - .send({ channelId: this.newChannel._id }) + .send({ channelId: testChannel._id }) .expect(200) .expect((res) => { expect(res.body).to.have.a.property('success', true); @@ -2019,7 +2102,7 @@ describe('[Channels]', function () { await request .post(api('teams.convertToChannel')) .set(credentials) - .send({ teamName: this.newChannel.name }) + .send({ teamName: testChannel.name }) .expect(200) .expect((res) => { expect(res.body).to.have.a.property('success', true); @@ -2028,7 +2111,7 @@ describe('[Channels]', function () { await request .post(api('channels.convertToTeam')) .set(credentials) - .send({ channelName: this.newChannel.name }) + .send({ channelName: testChannel.name }) .expect(200) .expect((res) => { expect(res.body).to.have.a.property('success', true); @@ -2043,7 +2126,7 @@ describe('[Channels]', function () { request .post(api('channels.convertToTeam')) .set(credentials) - .send({ channelId: this.newChannel._id }) + .send({ channelId: testChannel._id }) .expect(400) .expect((res) => { expect(res.body).to.have.a.property('success', false); @@ -2052,64 +2135,12 @@ describe('[Channels]', function () { }); }); - describe.skip('/channels.setAutojoin', () => { - // let testTeam; + describe("Setting: 'Use Real Name': true", () => { let testChannel; - // let testUser1; - // let testUser2; - before(async () => { - const teamCreateRes = await request - .post(api('teams.create')) - .set(credentials) - .send({ name: `team-${Date.now()}` }); - - const { team } = teamCreateRes.body; - - const user1 = await createUser(); - const user2 = await createUser(); - - const channelCreateRes = await request - .post(api('channels.create')) - .set(credentials) - .send({ - name: `team-channel-${Date.now()}`, - extraData: { - teamId: team._id, - }, - }); - - const { channel } = channelCreateRes.body; - - // testTeam = team; - testChannel = channel; - // testUser1 = user1; - // testUser2 = user2; - - await request - .post(api('teams.addMembers')) - .set(credentials) - .send({ - name: team.name, - members: [{ userId: user1._id }, { userId: user2._id }], - }); - }); - - it('should add all existing team members', async () => { - const resAutojoin = await request - .post(api('channels.setAutojoin')) - .set(credentials) - .send({ roomName: testChannel.name, autojoin: true }) - .expect(200); - expect(resAutojoin.body).to.have.a.property('success', true); - const channelInfoResponse = await request.get(api('channels.info')).set(credentials).query({ roomId: testChannel._id }); - const { channel } = channelInfoResponse.body; - - return expect(channel.usersCount).to.be.equals(3); + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.anonymousread.test.${Date.now()}` })).body.channel; }); - }); - - describe("Setting: 'Use Real Name': true", () => { before(async () => { await updateSetting('UI_Use_Real_Name', true); @@ -2117,13 +2148,13 @@ describe('[Channels]', function () { .post(api('channels.join')) .set(credentials) .send({ - roomId: channel._id, + roomId: testChannel._id, }) .expect('Content-Type', 'application/json') .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('channel._id', channel._id); + expect(res.body).to.have.nested.property('channel._id', testChannel._id); }); await request @@ -2132,7 +2163,7 @@ describe('[Channels]', function () { .send({ message: { text: 'Sample message', - rid: channel._id, + rid: testChannel._id, }, }) .expect('Content-Type', 'application/json') @@ -2141,21 +2172,13 @@ describe('[Channels]', function () { expect(res.body).to.have.property('success', true); }); }); - after(async () => { - await updateSetting('UI_Use_Real_Name', false); - await request - .post(api('channels.leave')) - .set(credentials) - .send({ - roomId: channel._id, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.nested.property('channel._id', channel._id); - }); + after(async () => { + await Promise.all([ + updateSetting('Accounts_AllowAnonymousRead', false), + updateSetting('UI_Use_Real_Name', false), + deleteRoom({ type: 'c', roomId: testChannel._id }), + ]); }); it('/channels.list', (done) => { @@ -2170,7 +2193,7 @@ describe('[Channels]', function () { expect(res.body).to.have.property('total'); expect(res.body).to.have.property('channels').and.to.be.an('array'); - const retChannel = res.body.channels.find(({ _id }) => _id === channel._id); + const retChannel = res.body.channels.find(({ _id }) => _id === testChannel._id); expect(retChannel).to.have.nested.property('lastMessage.u.name', 'RocketChat Internal Admin Test'); }) @@ -2189,7 +2212,7 @@ describe('[Channels]', function () { expect(res.body).to.have.property('total'); expect(res.body).to.have.property('channels').and.to.be.an('array'); - const retChannel = res.body.channels.find(({ _id }) => _id === channel._id); + const retChannel = res.body.channels.find(({ _id }) => _id === testChannel._id); expect(retChannel).to.have.nested.property('lastMessage.u.name', 'RocketChat Internal Admin Test'); }) 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 df736ecbeb86..07b03494900f 100644 --- a/apps/meteor/tests/end-to-end/api/03-groups.js +++ b/apps/meteor/tests/end-to-end/api/03-groups.js @@ -1066,7 +1066,7 @@ describe('[Groups]', function () { }); describe('/groups.files', async () => { - await testFileUploads('groups.files', group); + await testFileUploads('groups.files', 'p'); }); describe('/groups.listAll', () => { diff --git a/apps/meteor/tests/end-to-end/api/04-direct-message.js b/apps/meteor/tests/end-to-end/api/04-direct-message.js index a8ea87e2eddc..be8868ef6b48 100644 --- a/apps/meteor/tests/end-to-end/api/04-direct-message.js +++ b/apps/meteor/tests/end-to-end/api/04-direct-message.js @@ -333,7 +333,7 @@ describe('[Direct Messages]', function () { }); describe('[/im.files]', async () => { - await testFileUploads('im.files', directMessage, 'invalid-channel'); + await testFileUploads('im.files', 'd', 'invalid-channel'); }); describe('/im.messages', () => { diff --git a/apps/meteor/tests/end-to-end/api/05-chat.js b/apps/meteor/tests/end-to-end/api/05-chat.js index a41d78dd7bf6..0fa52cf3392d 100644 --- a/apps/meteor/tests/end-to-end/api/05-chat.js +++ b/apps/meteor/tests/end-to-end/api/05-chat.js @@ -3105,4 +3105,71 @@ describe('Threads', () => { }); }); }); + + describe('[/chat.getURLPreview]', () => { + const url = 'https://www.youtube.com/watch?v=no050HN4ojo'; + it('should return the URL preview with metadata and headers', async () => { + await request + .get(api('chat.getURLPreview')) + .set(credentials) + .query({ + roomId: 'GENERAL', + url, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('urlPreview').and.to.be.an('object').that.is.not.empty; + expect(res.body.urlPreview).to.have.property('url', url); + expect(res.body.urlPreview).to.have.property('headers').and.to.be.an('object').that.is.not.empty; + }); + }); + + describe('when an error occurs', () => { + it('should return statusCode 400 and an error when "roomId" is not provided', async () => { + await request + .get(api('chat.getURLPreview')) + .set(credentials) + .query({ + url, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + }); + it('should return statusCode 400 and an error when "url" is not provided', async () => { + await request + .get(api('chat.getURLPreview')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + }); + it('should return statusCode 400 and an error when "roomId" is provided but user is not in the room', async () => { + await request + .get(api('chat.getURLPreview')) + .set(credentials) + .query({ + roomId: 'undefined', + url, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-not-allowed'); + }); + }); + }); + }); }); diff --git a/apps/meteor/tests/end-to-end/api/08-settings.js b/apps/meteor/tests/end-to-end/api/08-settings.js index de8a21ffac41..d517b60eea9d 100644 --- a/apps/meteor/tests/end-to-end/api/08-settings.js +++ b/apps/meteor/tests/end-to-end/api/08-settings.js @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { before, describe, it } from 'mocha'; import { getCredentials, api, request, credentials } from '../../data/api-data.js'; +import { updateSetting } from '../../data/permissions.helper'; describe('[Settings]', function () { this.retries(0); @@ -84,6 +85,54 @@ describe('[Settings]', function () { }) .end(done); }); + + describe('With OAuth enabled', () => { + before((done) => { + updateSetting('Accounts_OAuth_Google', true).then(done); + }); + + it('should include the OAuth service in the response', (done) => { + // wait 3 seconds before getting the service list so the server has had time to update it + setTimeout(() => { + request + .get(api('service.configurations')) + .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('configurations'); + + expect(res.body.configurations.find(({ service }) => service === 'google')).to.exist; + }) + .end(done); + }, 3000); + }); + }); + + describe('With OAuth disabled', () => { + before((done) => { + updateSetting('Accounts_OAuth_Google', false).then(done); + }); + + it('should not include the OAuth service in the response', (done) => { + // wait 3 seconds before getting the service list so the server has had time to update it + setTimeout(() => { + request + .get(api('service.configurations')) + .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('configurations'); + + expect(res.body.configurations.find(({ service }) => service === 'google')).to.not.exist; + }) + .end(done); + }, 3000); + }); + }); }); describe('/settings.oauth', () => { diff --git a/apps/meteor/tests/end-to-end/api/22-push-token.js b/apps/meteor/tests/end-to-end/api/22-push.ts similarity index 54% rename from apps/meteor/tests/end-to-end/api/22-push-token.js rename to apps/meteor/tests/end-to-end/api/22-push.ts index 439ac6944adf..f32651ea2ccf 100644 --- a/apps/meteor/tests/end-to-end/api/22-push-token.js +++ b/apps/meteor/tests/end-to-end/api/22-push.ts @@ -2,26 +2,26 @@ import { expect } from 'chai'; import { before, describe, it } from 'mocha'; import { getCredentials, api, request, credentials } from '../../data/api-data.js'; +import { updateSetting } from '../../data/permissions.helper'; -describe('push token', function () { +describe('[Push]', function () { this.retries(0); before((done) => getCredentials(done)); describe('POST [/push.token]', () => { - it('should fail if not logged in', (done) => { - request + it('should fail if not logged in', async () => { + await request .post(api('push.token')) .expect(401) .expect((res) => { expect(res.body).to.have.property('status', 'error'); expect(res.body).to.have.property('message'); - }) - .end(done); + }); }); - it('should fail if missing type', (done) => { - request + it('should fail if missing type', async () => { + await request .post(api('push.token')) .set(credentials) .send({ @@ -32,12 +32,11 @@ describe('push token', function () { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'error-type-param-not-valid'); - }) - .end(done); + }); }); - it('should fail if missing value', (done) => { - request + it('should fail if missing value', async () => { + await request .post(api('push.token')) .set(credentials) .send({ @@ -48,12 +47,11 @@ describe('push token', function () { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'error-token-param-not-valid'); - }) - .end(done); + }); }); - it('should fail if missing appName', (done) => { - request + it('should fail if missing appName', async () => { + await request .post(api('push.token')) .set(credentials) .send({ @@ -64,12 +62,11 @@ describe('push token', function () { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'error-appName-param-not-valid'); - }) - .end(done); + }); }); - it('should fail if type param is unknown', (done) => { - request + it('should fail if type param is unknown', async () => { + await request .post(api('push.token')) .set(credentials) .send({ @@ -79,12 +76,11 @@ describe('push token', function () { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'error-type-param-not-valid'); - }) - .end(done); + }); }); - it('should fail if token param is empty', (done) => { - request + it('should fail if token param is empty', async () => { + await request .post(api('push.token')) .set(credentials) .send({ @@ -96,12 +92,11 @@ describe('push token', function () { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'error-token-param-not-valid'); - }) - .end(done); + }); }); - it('should add a token if valid', (done) => { - request + it('should add a token if valid', async () => { + await request .post(api('push.token')) .set(credentials) .send({ @@ -113,14 +108,13 @@ describe('push token', function () { .expect((res) => { expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('result').and.to.be.an('object'); - }) - .end(done); + }); }); }); describe('DELETE [/push.token]', () => { - it('should fail if not logged in', (done) => { - request + it('should fail if not logged in', async () => { + await request .delete(api('push.token')) .send({ token: 'token', @@ -129,12 +123,11 @@ describe('push token', function () { .expect((res) => { expect(res.body).to.have.property('status', 'error'); expect(res.body).to.have.property('message'); - }) - .end(done); + }); }); - it('should fail if missing token key', (done) => { - request + it('should fail if missing token key', async () => { + await request .delete(api('push.token')) .set(credentials) .send({}) @@ -142,12 +135,11 @@ describe('push token', function () { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'error-token-param-not-valid'); - }) - .end(done); + }); }); - it('should fail if token is empty', (done) => { - request + it('should fail if token is empty', async () => { + await request .delete(api('push.token')) .set(credentials) .send({ @@ -157,12 +149,11 @@ describe('push token', function () { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'error-token-param-not-valid'); - }) - .end(done); + }); }); - it('should fail if token is invalid', (done) => { - request + it('should fail if token is invalid', async () => { + await request .delete(api('push.token')) .set(credentials) .send({ @@ -171,12 +162,11 @@ describe('push token', function () { .expect(404) .expect((res) => { expect(res.body).to.have.property('success', false); - }) - .end(done); + }); }); - it('should delete a token if valid', (done) => { - request + it('should delete a token if valid', async () => { + await request .delete(api('push.token')) .set(credentials) .send({ @@ -185,12 +175,11 @@ describe('push token', function () { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - }) - .end(done); + }); }); - it('should fail if token is already deleted', (done) => { - request + it('should fail if token is already deleted', async () => { + await request .delete(api('push.token')) .set(credentials) .send({ @@ -199,8 +188,75 @@ describe('push token', function () { .expect(404) .expect((res) => { expect(res.body).to.have.property('success', false); - }) - .end(done); + }); + }); + }); + + describe('[/push.info]', () => { + before(async () => { + await updateSetting('Push_enable', true); + await updateSetting('Push_enable_gateway', true); + await updateSetting('Push_gateway', 'https://random-gateway.rocket.chat'); + }); + + it('should fail if not logged in', async () => { + await request + .get(api('push.info')) + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + }); + + it('should succesfully retrieve non default push notification info', async () => { + await request + .get(api('push.info')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('pushGatewayEnabled', true); + expect(res.body).to.have.property('defaultPushGateway', false); + }); + }); + + it('should succesfully retrieve default push notification info', async () => { + await updateSetting('Push_gateway', 'https://gateway.rocket.chat'); + await request + .get(api('push.info')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('pushGatewayEnabled', true); + expect(res.body).to.have.property('defaultPushGateway', true); + }); + }); + }); + + describe('[/push.test]', () => { + before(async () => { + await updateSetting('Push_enable', false); + }); + + it('should fail if not logged in', async () => { + await request + .post(api('push.test')) + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }); + }); + + it('should fail if push is disabled', async () => { + await request + .post(api('push.test')) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-push-disabled'); + }); }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/27-moderation.ts b/apps/meteor/tests/end-to-end/api/27-moderation.ts index efdc596ffe09..42845c4181a8 100644 --- a/apps/meteor/tests/end-to-end/api/27-moderation.ts +++ b/apps/meteor/tests/end-to-end/api/27-moderation.ts @@ -4,6 +4,7 @@ import { after, before, describe, it } from 'mocha'; import type { Response } from 'supertest'; import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { getUsersReports, reportUser } from '../../data/moderation.helper'; import { createUser, deleteUser } from '../../data/users.helper.js'; // test for the /moderation.reportsByUsers endpoint @@ -73,6 +74,66 @@ describe('[Moderation]', function () { }); }); + describe('[/moderation.userReports]', () => { + it('should return an array of reports', async () => { + await request + .get(api('moderation.userReports')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + + it('should return an array of reports even requested with count and offset params', async () => { + await request + .get(api('moderation.userReports')) + .set(credentials) + .query({ + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + + it('should return an array of reports even requested with oldest param', async () => { + await request + .get(api('moderation.userReports')) + .set(credentials) + .query({ + oldest: new Date(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + + it('should return an array of reports even requested with latest param', async () => { + await request + .get(api('moderation.userReports')) + .set(credentials) + .query({ + latest: new Date(), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array'); + }); + }); + }); + // test for testing out the moderation.dismissReports endpoint describe('[/moderation.dismissReports]', () => { @@ -98,7 +159,6 @@ describe('[Moderation]', function () { }); }); - // create a reported message by sending a request to chat.reportMessage beforeEach(async () => { await request .post(api('chat.reportMessage')) @@ -191,6 +251,100 @@ describe('[Moderation]', function () { }); }); + describe('[/moderation.user.reportsByUserId]', () => { + let reportedUser: IUser; + + before(async () => { + reportedUser = await createUser(); + await reportUser(reportedUser._id, 'sample report'); + }); + + after(async () => { + await deleteUser(reportedUser); + }); + + it('should return an array of reports', async () => { + await request + .get(api('moderation.user.reportsByUserId')) + .set(credentials) + .query({ + userId: reportedUser._id, + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect(async (res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('reports').and.to.be.an('array').and.to.have.lengthOf(1); + }); + }); + + it('should return an error when the userId is not provided', async () => { + await request + .get(api('moderation.user.reportsByUserId')) + .set(credentials) + .query({ + userId: '', + count: 5, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect(async (res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + }); + + describe('[/moderation.dismissUserReports', () => { + let reportedUser: IUser; + + before(async () => { + reportedUser = await createUser(); + await reportUser(reportedUser._id, 'sample report'); + }); + + after(async () => { + await deleteUser(reportedUser); + }); + + it('should hide reports of a user', async () => { + await request + .post(api('moderation.dismissUserReports')) + .set(credentials) + .send({ + userId: reportedUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await getUsersReports(reportedUser._id).then((res) => { + expect(res.reports).to.have.lengthOf(0); + }); + }); + + it('should return an error when the userId is not provided', async () => { + await request + .post(api('moderation.dismissUserReports')) + .set(credentials) + .send({ + userId: '', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').and.to.be.a('string'); + }); + }); + }); + // test for testing out the moderation.reports endpoint describe('[/moderation.reports]', () => { diff --git a/apps/meteor/tests/end-to-end/api/livechat/02-appearance.ts b/apps/meteor/tests/end-to-end/api/livechat/02-appearance.ts index e949ef706319..9d1d3dc7d525 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/02-appearance.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/02-appearance.ts @@ -116,5 +116,69 @@ describe('LIVECHAT - appearance', function () { expect(body.config.settings.limitTextLength).to.be.equal(100); await updateSetting('Livechat_enable_message_character_limit', false); }); + it('should coerce the value of a setting based on its stored datatype (int)', async () => { + await updateSetting('Livechat_enable_message_character_limit', true); + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_message_character_limit', value: '100' }]) + .expect(200); + + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.limitTextLength).to.be.equal(100); + await updateSetting('Livechat_enable_message_character_limit', false); + }); + it('should coerce the value of a setting based on its stored datatype (boolean)', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_registration_form', value: 'true' }]) + .expect(200); + + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.registrationForm).to.be.true; + }); + it('should coerce an invalid number value to zero', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([ + { _id: 'Livechat_message_character_limit', value: 'xxxx' }, + { _id: 'Livechat_enable_message_character_limit', value: true }, + ]) + .expect(200); + + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + // When setting is 0, we default to Message_MaxAllowedSize value + expect(body.config.settings.limitTextLength).to.be.equal(5000); + }); + it('should coerce a boolean value on an int setting to 0', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([ + { _id: 'Livechat_message_character_limit', value: true }, + { _id: 'Livechat_enable_message_character_limit', value: true }, + ]) + .expect(200); + + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.limitTextLength).to.be.equal(5000); + }); + it('should coerce a non boolean value on a boolean setting to false', async () => { + await request + .post(api('livechat/appearance')) + .set(credentials) + .send([{ _id: 'Livechat_enable_message_character_limit', value: 'xxxx' }]) + .expect(200); + + // Get data from livechat/config + const { body } = await request.get(api('livechat/config')).set(credentials).expect(200); + expect(body.config.settings.limitTextLength).to.be.false; + }); }); }); 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 b7ddd493acab..bfe53d92dade 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 @@ -253,8 +253,8 @@ describe('LIVECHAT - dashboards', function () { 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); + /* const avgWaitingTimeValue = moment.duration(avgWaitingTime.value).asSeconds(); + expect(avgWaitingTimeValue).to.be.closeTo(DELAY_BETWEEN_MESSAGES.max / 1000, 5); */ }); }); diff --git a/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts b/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts index 538ae040fde6..54f8739efee3 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts @@ -259,6 +259,24 @@ import { IS_EE } from '../../../e2e/config/constants'; .expect(400); }); + it('should return an error if fallbackForwardDepartment is referencing a department that does not exist', async () => { + await request + .post(api('livechat/department')) + .set(credentials) + .send({ + department: { + name: 'Test', + enabled: true, + showOnOfflineForm: true, + showOnRegistration: true, + email: 'bla@bla', + fallbackForwardDepartment: 'not a department id', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should create a new department', async () => { const { body } = await request .post(api('livechat/department')) diff --git a/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts b/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts index 5a334d9ecdbd..3fa2b9f6b89e 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/11-livechat.ts @@ -703,4 +703,34 @@ describe('LIVECHAT - Utils', function () { expect(body.messages[0]).to.have.property('t'); }); }); + + (IS_EE ? describe : describe.skip)('[EE] livechat widget', () => { + it('should include additional css when provided via Livechat_WidgetLayoutClasses setting', async () => { + await updateSetting('Livechat_WidgetLayoutClasses', 'http://my.css.com/my.css'); + const x = await request.get('/livechat').expect(200); + + expect(x.text.includes('http://my.css.com/my.css')).to.be.true; + }); + + it('should remove additional css when setting Livechat_WidgetLayoutClasses is empty', async () => { + await updateSetting('Livechat_WidgetLayoutClasses', ''); + const x = await request.get('/livechat').expect(200); + + expect(x.text.includes('http://my.css.com/my.css')).to.be.false; + }); + + it('should include additional js when provided via Livechat_AdditionalWidgetScripts setting', async () => { + await updateSetting('Livechat_AdditionalWidgetScripts', 'http://my.js.com/my.js'); + const x = await request.get('/livechat').expect(200); + + expect(x.text.includes('http://my.js.com/my.js')).to.be.true; + }); + + it('should remove additional js when setting Livechat_AdditionalWidgetScripts is empty', async () => { + await updateSetting('Livechat_AdditionalWidgetScripts', ''); + const x = await request.get('/livechat').expect(200); + + expect(x.text.includes('http://my.js.com/my.js')).to.be.false; + }); + }); }); diff --git a/apps/meteor/tests/end-to-end/api/livechat/12-mailer.ts b/apps/meteor/tests/end-to-end/api/livechat/12-mailer.ts index e49628ce718e..01a47594620d 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/12-mailer.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/12-mailer.ts @@ -13,7 +13,7 @@ describe('Mailer', () => { .post(api('mailer')) .set(credentials) .send({ - from: 'test-email@example.com', + from: 'rocketchat.internal.admin.test@rocket.chat', subject: 'Test email subject', body: 'Test email body [unsubscribe]', dryrun: true, diff --git a/apps/meteor/tsconfig.json b/apps/meteor/tsconfig.json index e07772b269a9..b57502d54633 100644 --- a/apps/meteor/tsconfig.json +++ b/apps/meteor/tsconfig.json @@ -22,10 +22,7 @@ "paths": { /* Support absolute /imports/* with a leading '/' */ "/*": ["*"], - "meteor/*": [ - "../../node_modules/@types/meteor/*", - ".meteor/local/types/packages.d.ts" - ] + "meteor/*": ["./node_modules/@types/meteor/*", ".meteor/local/types/packages.d.ts"] }, "preserveSymlinks": true diff --git a/codecov.yml b/codecov.yml index f9bbd51211c7..ee96be7cf2a1 100644 --- a/codecov.yml +++ b/codecov.yml @@ -12,13 +12,22 @@ coverage: flags: - client flags: + unit: + carryforward: true e2e: paths: - apps/meteor/ + carryforward: true + + e2e-api: + paths: + - apps/meteor/server + carryforward: true client: paths: - apps/meteor/client + carryforward: true comment: layout: 'reach, diff, flags' diff --git a/ee/apps/account-service/CHANGELOG.md b/ee/apps/account-service/CHANGELOG.md index e85436ebfb4f..824d020909e7 100644 --- a/ee/apps/account-service/CHANGELOG.md +++ b/ee/apps/account-service/CHANGELOG.md @@ -1,5 +1,25 @@ # @rocket.chat/account-service +## 0.3.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 +- @rocket.chat/rest-typings@6.5.3 +- @rocket.chat/core-services@0.3.3 +- @rocket.chat/model-typings@0.2.3 +- @rocket.chat/models@0.0.27 + +## 0.3.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 +- @rocket.chat/rest-typings@6.5.2 +- @rocket.chat/core-services@0.3.2 +- @rocket.chat/model-typings@0.2.2 +- @rocket.chat/models@0.0.26 + ## 0.3.1 ### Patch Changes diff --git a/ee/apps/account-service/package.json b/ee/apps/account-service/package.json index c1f2d56bbd91..5ae5f167dc50 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.3.1", + "version": "0.3.3", "description": "Rocket.Chat Account service", "scripts": { "build": "tsc -p tsconfig.json", @@ -31,7 +31,7 @@ "gc-stats": "^1.4.0", "mem": "^8.1.1", "moleculer": "^0.14.31", - "mongodb": "^4.17.1", + "mongodb": "^4.17.2", "nats": "^2.4.0", "pino": "^8.15.0", "polka": "^0.5.2", diff --git a/ee/apps/authorization-service/CHANGELOG.md b/ee/apps/authorization-service/CHANGELOG.md index c66e47fe7af3..bb477adaad66 100644 --- a/ee/apps/authorization-service/CHANGELOG.md +++ b/ee/apps/authorization-service/CHANGELOG.md @@ -1,5 +1,25 @@ # @rocket.chat/authorization-service +## 0.3.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 +- @rocket.chat/rest-typings@6.5.3 +- @rocket.chat/core-services@0.3.3 +- @rocket.chat/model-typings@0.2.3 +- @rocket.chat/models@0.0.27 + +## 0.3.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 +- @rocket.chat/rest-typings@6.5.2 +- @rocket.chat/core-services@0.3.2 +- @rocket.chat/model-typings@0.2.2 +- @rocket.chat/models@0.0.26 + ## 0.3.1 ### Patch Changes diff --git a/ee/apps/authorization-service/package.json b/ee/apps/authorization-service/package.json index 46ca1fc5c4a7..92e61764f49b 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.3.1", + "version": "0.3.3", "description": "Rocket.Chat Authorization service", "scripts": { "build": "tsc -p tsconfig.json", @@ -30,7 +30,7 @@ "gc-stats": "^1.4.0", "mem": "^8.1.1", "moleculer": "^0.14.31", - "mongodb": "^4.17.1", + "mongodb": "^4.17.2", "nats": "^2.4.0", "pino": "^8.15.0", "polka": "^0.5.2" diff --git a/ee/apps/ddp-streamer/CHANGELOG.md b/ee/apps/ddp-streamer/CHANGELOG.md index 0aff47ff570e..171744660af8 100644 --- a/ee/apps/ddp-streamer/CHANGELOG.md +++ b/ee/apps/ddp-streamer/CHANGELOG.md @@ -1,5 +1,29 @@ # @rocket.chat/ddp-streamer +## 0.2.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 +- @rocket.chat/rest-typings@6.5.3 +- @rocket.chat/core-services@0.3.3 +- @rocket.chat/model-typings@0.2.3 +- @rocket.chat/ui-contexts@3.0.3 +- @rocket.chat/models@0.0.27 +- @rocket.chat/instance-status@0.0.27 + +## 0.2.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 +- @rocket.chat/rest-typings@6.5.2 +- @rocket.chat/core-services@0.3.2 +- @rocket.chat/model-typings@0.2.2 +- @rocket.chat/ui-contexts@3.0.2 +- @rocket.chat/models@0.0.26 +- @rocket.chat/instance-status@0.0.26 + ## 0.2.1 ### Patch Changes diff --git a/ee/apps/ddp-streamer/package.json b/ee/apps/ddp-streamer/package.json index ef4bbc695f80..5edb7f4114ad 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.2.1", + "version": "0.2.3", "description": "Rocket.Chat DDP-Streamer service", "scripts": { "build": "tsc -p tsconfig.json", @@ -35,7 +35,7 @@ "jaeger-client": "^3.19.0", "mem": "^8.1.1", "moleculer": "^0.14.31", - "mongodb": "^4.17.1", + "mongodb": "^4.17.2", "nats": "^2.4.0", "pino": "^8.15.0", "polka": "^0.5.2", @@ -48,7 +48,7 @@ "@rocket.chat/eslint-config": "workspace:^", "@types/ejson": "^2.2.1", "@types/gc-stats": "^1.4.2", - "@types/meteor": "^2.9.5", + "@types/meteor": "^2.9.8", "@types/node": "^14.18.63", "@types/polka": "^0.5.6", "@types/sharp": "^0.30.5", diff --git a/ee/apps/ddp-streamer/src/DDPStreamer.ts b/ee/apps/ddp-streamer/src/DDPStreamer.ts index bccb35d2b326..79905fc8206d 100644 --- a/ee/apps/ddp-streamer/src/DDPStreamer.ts +++ b/ee/apps/ddp-streamer/src/DDPStreamer.ts @@ -44,7 +44,9 @@ export class DDPStreamer extends ServiceClass { return; } - events.emit('meteor.loginServiceConfiguration', clientAction === 'inserted' ? 'added' : 'changed', data); + if (data) { + events.emit('meteor.loginServiceConfiguration', clientAction === 'inserted' ? 'added' : 'changed', data); + } }); this.onEvent('meteor.clientVersionUpdated', (versions): void => { @@ -154,44 +156,50 @@ export class DDPStreamer extends ServiceClass { async started(): Promise { // TODO this call creates a dependency to MeteorService, should it be a hard dependency? or can this call fail and be ignored? - const versions = await MeteorService.getAutoUpdateClientVersions(); - - Object.keys(versions).forEach((key) => { - Autoupdate.updateVersion(versions[key]); - }); - - this.app = polka() - .use(proxy()) - .get('/health', async (_req, res) => { - try { - if (!this.api) { - throw new Error('API not available'); + try { + const versions = await MeteorService.getAutoUpdateClientVersions(); + + Object.keys(versions || {}).forEach((key) => { + Autoupdate.updateVersion(versions[key]); + }); + + this.app = polka() + .use(proxy()) + .get('/health', async (_req, res) => { + try { + if (!this.api) { + throw new Error('API not available'); + } + + await this.api.nodeList(); + res.end('ok'); + } catch (err) { + console.error('Service not healthy', err); + + res.writeHead(500); + res.end('not healthy'); } + }) + .get('*', function (_req, res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Content-Type', 'application/json'); - await this.api.nodeList(); - res.end('ok'); - } catch (err) { - console.error('Service not healthy', err); + res.writeHead(200); - res.writeHead(500); - res.end('not healthy'); - } - }) - .get('*', function (_req, res) { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Content-Type', 'application/json'); + res.end( + `{"websocket":true,"origins":["*:*"],"cookie_needed":false,"entropy":${crypto.randomBytes(4).readUInt32LE(0)},"ms":true}`, + ); + }) + .listen(PORT); - res.writeHead(200); + this.wss = new WebSocket.Server({ server: this.app.server }); - res.end(`{"websocket":true,"origins":["*:*"],"cookie_needed":false,"entropy":${crypto.randomBytes(4).readUInt32LE(0)},"ms":true}`); - }) - .listen(PORT); + this.wss.on('connection', (ws, req) => new Client(ws, req.url !== '/websocket', req)); - this.wss = new WebSocket.Server({ server: this.app.server }); - - this.wss.on('connection', (ws, req) => new Client(ws, req.url !== '/websocket', req)); - - InstanceStatus.registerInstance('ddp-streamer', {}); + InstanceStatus.registerInstance('ddp-streamer', {}); + } catch (err) { + console.error('DDPStreamer did not start correctly', err); + } } async stopped(): Promise { diff --git a/ee/apps/ddp-streamer/src/configureServer.ts b/ee/apps/ddp-streamer/src/configureServer.ts index bad70254f3b8..ed187db498cc 100644 --- a/ee/apps/ddp-streamer/src/configureServer.ts +++ b/ee/apps/ddp-streamer/src/configureServer.ts @@ -15,7 +15,9 @@ const loginServiceConfigurationCollection = 'meteor_accounts_loginServiceConfigu const loginServiceConfigurationPublication = 'meteor.loginServiceConfiguration'; const loginServices = new Map(); -MeteorService.getLoginServiceConfiguration().then((records = []) => records.forEach((record) => loginServices.set(record._id, record))); +MeteorService.getLoginServiceConfiguration() + .then((records = []) => records.forEach((record) => loginServices.set(record._id, record))) + .catch((err) => console.error('DDPStreamer not able to retrieve login services configuration', err)); server.publish(loginServiceConfigurationPublication, async function () { loginServices.forEach((record) => this.added(loginServiceConfigurationCollection, record._id, record)); diff --git a/ee/apps/omnichannel-transcript/CHANGELOG.md b/ee/apps/omnichannel-transcript/CHANGELOG.md index 731eb6908a83..0b4f925cbaf5 100644 --- a/ee/apps/omnichannel-transcript/CHANGELOG.md +++ b/ee/apps/omnichannel-transcript/CHANGELOG.md @@ -1,5 +1,27 @@ # @rocket.chat/omnichannel-transcript +## 0.3.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 +- @rocket.chat/omnichannel-services@0.1.3 +- @rocket.chat/pdf-worker@0.0.27 +- @rocket.chat/core-services@0.3.3 +- @rocket.chat/model-typings@0.2.3 +- @rocket.chat/models@0.0.27 + +## 0.3.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 +- @rocket.chat/omnichannel-services@0.1.2 +- @rocket.chat/pdf-worker@0.0.26 +- @rocket.chat/core-services@0.3.2 +- @rocket.chat/model-typings@0.2.2 +- @rocket.chat/models@0.0.26 + ## 0.3.1 ### Patch Changes diff --git a/ee/apps/omnichannel-transcript/package.json b/ee/apps/omnichannel-transcript/package.json index e21e0bb97d87..0f5c5fa63dd7 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.3.1", + "version": "0.3.3", "description": "Rocket.Chat service", "scripts": { "build": "tsc -p tsconfig.json", @@ -36,7 +36,7 @@ "moleculer": "^0.14.31", "moment-timezone": "^0.5.43", "mongo-message-queue": "^1.0.0", - "mongodb": "^4.17.1", + "mongodb": "^4.17.2", "nats": "^2.4.0", "pino": "^8.15.0", "polka": "^0.5.2" diff --git a/ee/apps/presence-service/CHANGELOG.md b/ee/apps/presence-service/CHANGELOG.md index f233462ab0f7..47c58c497c4d 100644 --- a/ee/apps/presence-service/CHANGELOG.md +++ b/ee/apps/presence-service/CHANGELOG.md @@ -1,5 +1,25 @@ # @rocket.chat/presence-service +## 0.3.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 +- @rocket.chat/presence@0.1.3 +- @rocket.chat/core-services@0.3.3 +- @rocket.chat/model-typings@0.2.3 +- @rocket.chat/models@0.0.27 + +## 0.3.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 +- @rocket.chat/presence@0.1.2 +- @rocket.chat/core-services@0.3.2 +- @rocket.chat/model-typings@0.2.2 +- @rocket.chat/models@0.0.26 + ## 0.3.1 ### Patch Changes diff --git a/ee/apps/presence-service/package.json b/ee/apps/presence-service/package.json index 7150bbe3af0f..37b876f8cd50 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.3.1", + "version": "0.3.3", "description": "Rocket.Chat Presence service", "scripts": { "build": "tsc -p tsconfig.json", @@ -30,7 +30,7 @@ "gc-stats": "^1.4.0", "mem": "^8.1.1", "moleculer": "^0.14.31", - "mongodb": "^4.17.1", + "mongodb": "^4.17.2", "nats": "^2.4.0", "pino": "^8.15.0", "polka": "^0.5.2" diff --git a/ee/apps/queue-worker/CHANGELOG.md b/ee/apps/queue-worker/CHANGELOG.md index e54cc9fda23c..1b2e205ec383 100644 --- a/ee/apps/queue-worker/CHANGELOG.md +++ b/ee/apps/queue-worker/CHANGELOG.md @@ -1,5 +1,25 @@ # @rocket.chat/queue-worker +## 0.3.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 +- @rocket.chat/omnichannel-services@0.1.3 +- @rocket.chat/core-services@0.3.3 +- @rocket.chat/model-typings@0.2.3 +- @rocket.chat/models@0.0.27 + +## 0.3.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 +- @rocket.chat/omnichannel-services@0.1.2 +- @rocket.chat/core-services@0.3.2 +- @rocket.chat/model-typings@0.2.2 +- @rocket.chat/models@0.0.26 + ## 0.3.1 ### Patch Changes diff --git a/ee/apps/queue-worker/package.json b/ee/apps/queue-worker/package.json index b35c9bd5a0ac..01c0e8b5ce7c 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.3.1", + "version": "0.3.3", "description": "Rocket.Chat service", "scripts": { "build": "tsc -p tsconfig.json", @@ -34,7 +34,7 @@ "moleculer": "^0.14.31", "moment-timezone": "^0.5.43", "mongo-message-queue": "^1.0.0", - "mongodb": "^4.17.1", + "mongodb": "^4.17.2", "nats": "^2.4.0", "pino": "^8.15.0", "polka": "^0.5.2" diff --git a/ee/apps/stream-hub-service/CHANGELOG.md b/ee/apps/stream-hub-service/CHANGELOG.md index d4e2438d7ec7..4eb61a7aaebe 100644 --- a/ee/apps/stream-hub-service/CHANGELOG.md +++ b/ee/apps/stream-hub-service/CHANGELOG.md @@ -1,5 +1,23 @@ # @rocket.chat/stream-hub-service +## 0.3.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 +- @rocket.chat/core-services@0.3.3 +- @rocket.chat/model-typings@0.2.3 +- @rocket.chat/models@0.0.27 + +## 0.3.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 +- @rocket.chat/core-services@0.3.2 +- @rocket.chat/model-typings@0.2.2 +- @rocket.chat/models@0.0.26 + ## 0.3.1 ### Patch Changes diff --git a/ee/apps/stream-hub-service/package.json b/ee/apps/stream-hub-service/package.json index 3085d492cef1..e36df001de61 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.3.1", + "version": "0.3.3", "description": "Rocket.Chat Stream Hub service", "scripts": { "build": "tsc -p tsconfig.json", @@ -30,7 +30,7 @@ "gc-stats": "^1.4.0", "mem": "^8.1.1", "moleculer": "^0.14.31", - "mongodb": "^4.17.1", + "mongodb": "^4.17.2", "nats": "^2.4.0", "pino": "^8.15.0", "polka": "^0.5.2" diff --git a/ee/packages/api-client/CHANGELOG.md b/ee/packages/api-client/CHANGELOG.md index 3407d68b664a..79126a3f9c11 100644 --- a/ee/packages/api-client/CHANGELOG.md +++ b/ee/packages/api-client/CHANGELOG.md @@ -1,5 +1,19 @@ # @rocket.chat/api-client +## 0.1.21 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 +- @rocket.chat/rest-typings@6.5.3 + +## 0.1.20 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 +- @rocket.chat/rest-typings@6.5.2 + ## 0.1.19 ### Patch Changes diff --git a/ee/packages/api-client/package.json b/ee/packages/api-client/package.json index 3f7a895409a6..2ca0bc59db6e 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.19", + "version": "0.1.21", "devDependencies": { "@swc/core": "^1.3.95", "@swc/jest": "^0.2.29", diff --git a/ee/packages/ddp-client/CHANGELOG.md b/ee/packages/ddp-client/CHANGELOG.md index 0015f31f22f8..8647b8bba324 100644 --- a/ee/packages/ddp-client/CHANGELOG.md +++ b/ee/packages/ddp-client/CHANGELOG.md @@ -1,5 +1,19 @@ # @rocket.chat/ddp-client +## 0.2.12 + +### Patch Changes + +- @rocket.chat/rest-typings@6.5.3 +- @rocket.chat/api-client@0.1.21 + +## 0.2.11 + +### Patch Changes + +- @rocket.chat/rest-typings@6.5.2 +- @rocket.chat/api-client@0.1.20 + ## 0.2.10 ### Patch Changes diff --git a/ee/packages/ddp-client/README.md b/ee/packages/ddp-client/README.md new file mode 100644 index 000000000000..81cfe53fffe8 --- /dev/null +++ b/ee/packages/ddp-client/README.md @@ -0,0 +1,90 @@ +# Getting started +Add `@rocket.chat/ddp-client` and `@rocket.chat/emitter` as dependencies of your project: + +`yarn add @rocket.chat/ddp-client @rocket.chat/emitter` +or: +`npm install @rocket.chat/ddp-client @rocket.chat/emitter` + +> @rocket.chat/emitter is listed as a peer dependency of ddp-client and is strictly necessary to make it work. + +> Tip: The whole project is typed using typescript. For that reason, references to interfaces and objects will be kept to a minimum. + +## Setting up the SDK + >This works out of the box for browsers. if you want to use it on NodeJS, you need to offer a `WebSocket` implementation and a `fetch` implementation. + + First things first, let's import the SDK: + `import { DDPSDK } from '@rocket.chat/ddp-client';` + + Now we need to create a new SDK instance. Fortunately, `DDPSDK` exposes a `create` function that initalizes everything for a quick setup: + `const sdk = DDPSDK.create('http://localhost:3000');` + + We can then try to connect to the Rocket.Chat instance by doing: + `await sdk.connection.connect();` + + You can check the connection status by referencing `sdk.connection.status`. If everything went right, it's value should be `'connected'`. + +> If you're feeling fancy, you can create and connect in a single function call: +> `const sdk = DDPSDK.createAndConnect('http://localhost:3000');` + +## SDK Modules +### Connection + +Responsible for the connection to the server, status and connection states. + +### Account + +Responsible for the account management, login, logout, handle credentials, get user information, etc. + +### ClientStream + +Responsible for the DDP communication, method calls, subscriptions, etc. + +### TimeoutControl + +Responsible for the Reconnection control + +### RestClient + +Responsible for the REST API communication for more info [see here](https://developer.rocket.chat/reference/api/rest-api) + +## Login handling + +The just created `sdk` exposes an `account` interface (`sdk.account`), which should have everything you need. It has 3 methods: + + 1. `sdk.account.loginWithPassword(username, hashedPassword)` + - This method accepts 2 parameters, `username` and `password`. The password must be hashed as `sha-256` for it to work + 2. `sdk.account.loginWithToken('userTokenGoesHere')` + - If you already got the token someway through the API, you can call this method. + 3. `sdk.account.logout()` + - This one is self-explanatory. + +While the `sdk` instance is kept in memory, you can find some user information and credentials by referencing `sdk.account.user` + +## REST API +> TIP: You might have to enable CORS in your Rocket.Chat instance for this to work. + +The sdk exposes a `rest` interface, which accept all rest methods (`get`, `post`, `put`, `delete`). + +Example call: +`await sdk.rest.post('/v1/chat.sendMessage', { message: { rid: id, msg } });` + +> WARNING: if you wrap a rest call in a try catch block, the error will be of type `Response`. By calling `error.json()` you get access to the server error response. + +## Streams + +Rocket.Chat uses websockets as to provide realtime data. You can subscribe to publications in order to listen to data updates. + +Below is an example of subscribing to the room-messages publication, which receives message updates from a room: +```ts +const messages = new Map([]); +const stream = sdk.stream('room-messages', roomId, (args) => { + setMessages((messages) => { + messages.set(args._id, args); + return new Map(messages); +}); + +// Stop the stream when you're done +stream.stop(); +``` + +> TIP: always unsubscribe from publications when you're done with them. This saves bandwidth and server resources diff --git a/ee/packages/ddp-client/package.json b/ee/packages/ddp-client/package.json index 4087ed39b95c..ffe605e8fab8 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.10", + "version": "0.2.12", "devDependencies": { "@swc/core": "^1.3.95", "@swc/jest": "^0.2.29", diff --git a/ee/packages/ddp-client/src/README.md b/ee/packages/ddp-client/src/README.md deleted file mode 100644 index 99e313f0dba5..000000000000 --- a/ee/packages/ddp-client/src/README.md +++ /dev/null @@ -1,97 +0,0 @@ -[rest]: https://rocket.chat/docs/developer-guides/rest-api/ -[api-rest]: ../../api-client/ - - - -# Rocket.Chat Javascript/Typescript SDK - -Library for building Rocket.Chat clients in Javascript/Typescript. - -## Quick Start - -``` -npm install @rocket.chat/sdk --save -``` - -or - -``` -yarn add @rocket.chat/sdk -``` - -This is pretty straightforward, but covers all the basics you need to do! - -```ts -import { DDPSDK } from '@rocket.chat/sdk'; - -const sdk = DDPSDK.create('http://localhost:3000'); - -await sdk.connection.connect(); - -await sdk.accounts.login({ - user: { - username: 'username', - }, - password: 'password', -}); - -await sdk.stream('room-messages', 'GENERAL', (data) => { - console.log('RECEIVED->>', data); -}); - -await sdk.rest.post('chat.postMessage', { - rid: 'GENERAL', - msg: 'Hello World', -}); -``` - -This works out of the box for browsers. if you want to use it on NodeJS, you need to offer a `WebSocket` implementation and a `fetch` implementation. - -We decided to not include any of those dependencies on the SDK, keeping it as lightweight as possible. - -If you are coding in Typescript, which we recommend, you are going to inherit all the types from the Rocket.Chat server, so everything is going to be type safe. - -All types used on the server and original clients are going to be available for you to use. - -if you don't want to use realtime communication, you can use the REST API client directly: `@rocket.chat/api-rest` - -## Overview - -The sdk is implemented based on the following interface definition: - -```ts -export interface SDK { - stream(name: string, params: unknown[], cb: (...data: unknown[]) => void): Publication; - - connection: Connection; - account: Account; - client: ClientStream; - - timeoutControl: TimeoutControl; - rest: RestClient; -} -``` - -Which means that in case of any new feature, bug fix or improvement, you can implement your own SDK variant and use it instead of the default one. - -Each peace contains a set of methods and responsibilities: - -### Connection - -Responsible for the connection to the server, status and connection states. - -### Account - -Responsible for the account management, login, logout, handle credentials, get user information, etc. - -### ClientStream - -Responsible for the DDP communication, method calls, subscriptions, etc. - -### TimeoutControl - -Responsible for the Reconnection control - -### RestClient - -Responsible for the REST API communication for more info [see here][api-rest] diff --git a/ee/packages/ddp-client/src/types/streams.ts b/ee/packages/ddp-client/src/types/streams.ts index da9b913fc6dd..17638c283512 100644 --- a/ee/packages/ddp-client/src/types/streams.ts +++ b/ee/packages/ddp-client/src/types/streams.ts @@ -9,7 +9,6 @@ import type { IEmoji, ICustomSound, INotificationDesktop, - IWebdavAccount, VoipEventDataSignature, IUser, IOmnichannelRoom, @@ -18,12 +17,14 @@ import type { IIntegrationHistory, IUserDataEvent, ICalendarNotification, - IUserStatus, ILivechatInquiryRecord, ILivechatAgent, IImportProgress, IBanner, LicenseLimitKind, + ICustomUserStatus, + UserStatus, + IWebdavAccount, } from '@rocket.chat/core-typings'; import type * as UiKit from '@rocket.chat/ui-kit'; @@ -64,6 +65,7 @@ export interface StreamerEvents { { key: `${string}/videoconf`; args: [id: string] }, { key: `${string}/messagesRead`; args: [{ until: Date; tmid?: string }] }, { key: `${string}/messagesImported`; args: [null] }, + { key: `${string}/webrtc`; args: unknown[] }, /* @deprecated over videoconf*/ // { key: `${string}/${string}`; args: [id: string] }, ]; @@ -197,7 +199,7 @@ export interface StreamerEvents { key: 'updateCustomUserStatus'; args: [ { - userStatusData: IUserStatus; + userStatusData: Omit; }, ]; }, @@ -231,10 +233,22 @@ export interface StreamerEvents { }, ]; }, - { key: 'Users:NameChanged'; args: [Pick] }, + { key: 'Users:NameChanged'; args: [Pick] }, { key: 'private-settings-changed'; args: ['inserted' | 'updated' | 'removed' | 'changed', ISetting] }, - { key: 'deleteCustomUserStatus'; args: [{ userStatusData: unknown }] }, - { key: 'user-status'; args: [[IUser['_id'], IUser['username'], 0 | 1 | 2 | 3, IUser['statusText'], IUser['name'], IUser['roles']]] }, + { key: 'deleteCustomUserStatus'; args: [{ userStatusData: Omit }] }, + { + key: 'user-status'; + args: [ + [ + uid: IUser['_id'], + username: IUser['username'], + status: UserStatus, + statusText: IUser['statusText'], + name: IUser['name'], + roles: IUser['roles'], + ], + ]; + }, { key: 'Users:Deleted'; args: [ @@ -310,7 +324,7 @@ export interface StreamerEvents { }, ]; - 'user-presence': [{ key: string; args: [[username: string, statusChanged?: 0 | 1 | 2 | 3, statusText?: string]] }]; + 'user-presence': [{ key: string; args: [[username: string, statusChanged?: UserStatus, statusText?: string]] }]; // TODO: rename to 'integration-history' 'integrationHistory': [ diff --git a/ee/packages/license/CHANGELOG.md b/ee/packages/license/CHANGELOG.md index cee46b612d9d..7dc95b782296 100644 --- a/ee/packages/license/CHANGELOG.md +++ b/ee/packages/license/CHANGELOG.md @@ -1,5 +1,17 @@ # @rocket.chat/license +## 0.1.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 + +## 0.1.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 + ## 0.1.1 ### Patch Changes diff --git a/ee/packages/license/package.json b/ee/packages/license/package.json index 238d3574f59d..b17708cfa115 100644 --- a/ee/packages/license/package.json +++ b/ee/packages/license/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/license", - "version": "0.1.1", + "version": "0.1.3", "private": true, "devDependencies": { "@swc/core": "^1.3.95", diff --git a/ee/packages/omnichannel-services/CHANGELOG.md b/ee/packages/omnichannel-services/CHANGELOG.md index 75ca235fcdd2..fb81b057f97e 100644 --- a/ee/packages/omnichannel-services/CHANGELOG.md +++ b/ee/packages/omnichannel-services/CHANGELOG.md @@ -1,5 +1,27 @@ # @rocket.chat/omnichannel-services +## 0.1.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 +- @rocket.chat/rest-typings@6.5.3 +- @rocket.chat/pdf-worker@0.0.27 +- @rocket.chat/core-services@0.3.3 +- @rocket.chat/model-typings@0.2.3 +- @rocket.chat/models@0.0.27 + +## 0.1.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 +- @rocket.chat/rest-typings@6.5.2 +- @rocket.chat/pdf-worker@0.0.26 +- @rocket.chat/core-services@0.3.2 +- @rocket.chat/model-typings@0.2.2 +- @rocket.chat/models@0.0.26 + ## 0.1.1 ### Patch Changes diff --git a/ee/packages/omnichannel-services/package.json b/ee/packages/omnichannel-services/package.json index 01e051ed5713..68ba14c8de31 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.1.1", + "version": "0.1.3", "private": true, "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", @@ -29,7 +29,7 @@ "mem": "^8.1.1", "moment-timezone": "^0.5.43", "mongo-message-queue": "^1.0.0", - "mongodb": "^4.17.1", + "mongodb": "^4.17.2", "pino": "^8.15.0" }, "scripts": { diff --git a/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts b/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts index 7f27e70ac8a5..0014a50f1f7f 100644 --- a/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts +++ b/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts @@ -156,7 +156,7 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT return quotes; } - private async getMessagesData(userId: string, messages: IMessage[]): Promise { + private async getMessagesData(messages: IMessage[]): Promise { const messagesData: MessageData[] = []; for await (const message of messages) { if (!message.attachments?.length) { @@ -229,7 +229,7 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT continue; } - const fileBuffer = await uploadService.getFileBuffer({ userId, file: uploadedFile }); + const fileBuffer = await uploadService.getFileBuffer({ file: uploadedFile }); files.push({ name: file.name, buffer: fileBuffer, extension: uploadedFile.extension }); } @@ -284,7 +284,7 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT const agent = room.servedBy && (await Users.findOneAgentById(room.servedBy._id, { projection: { _id: 1, name: 1, username: 1, utcOffset: 1 } })); - const messagesData = await this.getMessagesData(details.userId, messages); + const messagesData = await this.getMessagesData(messages); const [siteName, dateFormat, timeAndDateFormat, timezone, translations] = await Promise.all([ settingsService.get('Site_Name'), diff --git a/ee/packages/pdf-worker/CHANGELOG.md b/ee/packages/pdf-worker/CHANGELOG.md index e2e460560821..8b37edeb7e45 100644 --- a/ee/packages/pdf-worker/CHANGELOG.md +++ b/ee/packages/pdf-worker/CHANGELOG.md @@ -1,5 +1,17 @@ # @rocket.chat/pdf-worker +## 0.0.27 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 + +## 0.0.26 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 + ## 0.0.25 ### Patch Changes diff --git a/ee/packages/pdf-worker/package.json b/ee/packages/pdf-worker/package.json index cd37d8d7b0ae..8c10f32d1b85 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.25", + "version": "0.0.27", "private": true, "devDependencies": { "@storybook/addon-essentials": "~6.5.16", diff --git a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/BoldSpan.tsx b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/BoldSpan.tsx index b54cb63d5ca6..043d05e9ae0f 100644 --- a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/BoldSpan.tsx +++ b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/BoldSpan.tsx @@ -17,7 +17,8 @@ type MessageBlock = | MessageParser.ChannelMention | MessageParser.UserMention | MessageParser.Link - | MessageParser.MarkupExcluding; + | MessageParser.MarkupExcluding + | MessageParser.InlineCode; type BoldSpanProps = { children: MessageBlock[]; diff --git a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/ItalicSpan.tsx b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/ItalicSpan.tsx index 590f5c39d93f..1b7cc2abe9da 100644 --- a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/ItalicSpan.tsx +++ b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/ItalicSpan.tsx @@ -17,7 +17,8 @@ type MessageBlock = | MessageParser.ChannelMention | MessageParser.UserMention | MessageParser.Link - | MessageParser.MarkupExcluding; + | MessageParser.MarkupExcluding + | MessageParser.InlineCode; type ItalicSpanProps = { children: MessageBlock[]; diff --git a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/StrikeSpan.tsx b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/StrikeSpan.tsx index 623ff0aea735..78f87aaf314f 100644 --- a/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/StrikeSpan.tsx +++ b/ee/packages/pdf-worker/src/templates/ChatTranscript/markup/elements/StrikeSpan.tsx @@ -17,7 +17,8 @@ type MessageBlock = | MessageParser.ChannelMention | MessageParser.UserMention | MessageParser.Link - | MessageParser.MarkupExcluding; + | MessageParser.MarkupExcluding + | MessageParser.InlineCode; type StrikeSpanProps = { children: MessageBlock[]; diff --git a/ee/packages/presence/CHANGELOG.md b/ee/packages/presence/CHANGELOG.md index 50d1609e7c96..ee891ea1315e 100644 --- a/ee/packages/presence/CHANGELOG.md +++ b/ee/packages/presence/CHANGELOG.md @@ -1,5 +1,21 @@ # @rocket.chat/presence +## 0.1.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 +- @rocket.chat/core-services@0.3.3 +- @rocket.chat/models@0.0.27 + +## 0.1.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 +- @rocket.chat/core-services@0.3.2 +- @rocket.chat/models@0.0.26 + ## 0.1.1 ### Patch Changes diff --git a/ee/packages/presence/package.json b/ee/packages/presence/package.json index 708b4b55622d..35db429365ea 100644 --- a/ee/packages/presence/package.json +++ b/ee/packages/presence/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/presence", - "version": "0.1.1", + "version": "0.1.3", "private": true, "devDependencies": { "@babel/core": "~7.22.20", @@ -35,6 +35,6 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/models": "workspace:^", - "mongodb": "^4.17.1" + "mongodb": "^4.17.2" } } diff --git a/ee/packages/ui-theming/package.json b/ee/packages/ui-theming/package.json index fe3d1af5c36e..e76611b58bfc 100644 --- a/ee/packages/ui-theming/package.json +++ b/ee/packages/ui-theming/package.json @@ -4,9 +4,9 @@ "private": true, "devDependencies": { "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.41.0", - "@rocket.chat/fuselage-hooks": "~0.32.1", - "@rocket.chat/icons": "~0.32.0", + "@rocket.chat/fuselage": "^0.45.0", + "@rocket.chat/fuselage-hooks": "^0.33.0", + "@rocket.chat/icons": "^0.33.0", "@rocket.chat/ui-contexts": "workspace:~", "@storybook/addon-actions": "~6.5.16", "@storybook/addon-docs": "~6.5.16", diff --git a/package.json b/package.json index c0ffec861cda..22423c45e45a 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@changesets/cli": "^2.26.2", "@types/chart.js": "^2.9.39", "@types/js-yaml": "^4.0.8", - "turbo": "~1.10.16" + "turbo": "~1.11.2" }, "workspaces": [ "apps/*", @@ -48,8 +48,6 @@ "minTag": "0.55.0-rc.0", "updateFiles": [ "package.json", - "apps/meteor/package.json", - "apps/meteor/.docker/Dockerfile.rhel", "apps/meteor/app/utils/rocketchat.info" ] }, diff --git a/packages/agenda/package.json b/packages/agenda/package.json index 5c2c636bf805..d0841f474357 100644 --- a/packages/agenda/package.json +++ b/packages/agenda/package.json @@ -9,7 +9,7 @@ "debug": "~4.1.1", "human-interval": "^2.0.1", "moment-timezone": "~0.5.43", - "mongodb": "^4.17.1" + "mongodb": "^4.17.2" }, "devDependencies": { "@types/debug": "^4.1.10", diff --git a/packages/agenda/src/Agenda.ts b/packages/agenda/src/Agenda.ts index 2a76945f7a71..4cf17fa22cf0 100644 --- a/packages/agenda/src/Agenda.ts +++ b/packages/agenda/src/Agenda.ts @@ -19,6 +19,34 @@ const defaultInterval = 5000; type JobSort = Partial>; +type MongoTopology = { + autoReconnect?: boolean; + connections?(): unknown[]; + isDestroyed?(): boolean; +}; + +type MongoDB = Db & { + s?: { + client?: { + topology?: MongoTopology; + }; + }; + db?: { + s?: { + client?: { + topology?: MongoTopology; + }; + }; + }; + topology?: { + s?: { + options?: { + useUnifiedTopology?: boolean; + }; + }; + }; +}; + type AgendaConfig = { name?: string; processEvery?: string; @@ -31,7 +59,7 @@ type AgendaConfig = { sort?: JobSort; } & ( | { - mongo: Db; + mongo: MongoDB; db?: { collection?: string; }; @@ -46,8 +74,6 @@ type AgendaConfig = { } ); -type AgendaCallback = (error: unknown, result: unknown) => void; - export type RepeatOptions = { timezone?: string; skipImmediate?: boolean }; export class Agenda extends EventEmitter { @@ -63,14 +89,11 @@ export class Agenda extends EventEmitter { private _defaultLockLifetime: number; - // @ts-ignore - private _db: MongoClient; + protected _db: MongoClient | undefined; - // @ts-ignore - private _mdb: Db; + private _mdb: MongoDB | undefined; - // @ts-ignore - private _collection: Collection; + private _collection: Collection | undefined; private _definitions: Record = {}; @@ -98,7 +121,7 @@ export class Agenda extends EventEmitter { private _mongoUseUnifiedTopology: boolean | undefined; - constructor(config: AgendaConfig = {}, cb?: AgendaCallback) { + constructor(config: AgendaConfig = {}) { super(); this._name = config.name; @@ -121,21 +144,18 @@ export class Agenda extends EventEmitter { this._ready = new Promise((resolve) => this.once('ready', resolve)); if (config.mongo) { - this.mongo(config.mongo, config.db ? config.db.collection : undefined, cb); - // @ts-ignore - if (config.mongo.s && config.mongo.topology && config.mongo.topology.s) { - // @ts-ignore - this._mongoUseUnifiedTopology = Boolean(config.mongo?.topology?.s?.options?.useUnifiedTopology); - } + this.mongo(config.mongo, config.db ? config.db.collection : undefined); } else if (config.db) { - this.database(config.db.address, config.db.collection, config.db.options, cb); + this.database(config.db.address, config.db.collection, config.db.options); } } - public mongo(mdb: Db, collection: string | undefined, cb?: AgendaCallback): Agenda { + public mongo(mdb: MongoDB, collection: string | undefined) { this._mdb = mdb; - this.dbInit(collection, cb); - return this; + if (mdb.s && mdb.topology && mdb.topology.s) { + this._mongoUseUnifiedTopology = Boolean(mdb?.topology?.s?.options?.useUnifiedTopology); + } + return this.dbInit(collection); } /** @@ -146,7 +166,7 @@ export class Agenda extends EventEmitter { * or use Agenda.mongo(). If your app already has a MongoDB connection then use that. ie. specify config.mongo in * the constructor or use Agenda.mongo(). */ - public database(url: string, collection: string | undefined, options: MongoClientOptions = {}, cb?: AgendaCallback): Agenda { + public async database(url: string, collection: string | undefined, options: MongoClientOptions = {}) { if (!hasMongoProtocol(url)) { url = `mongodb://${url}`; } @@ -157,43 +177,30 @@ export class Agenda extends EventEmitter { ...options, }; - MongoClient.connect(url, options, (error, client) => { - if (error || !client) { - debug('error connecting to MongoDB using collection: [%s]', collection); - if (cb) { - cb(error, null); - } else { - throw error; - } - return; - } - + try { + const client = await MongoClient.connect(url, options); debug('successful connection to MongoDB using collection: [%s]', collection); this._db = client; this._mdb = client.db(); - this.dbInit(collection, cb); - }); - - return this; + this.dbInit(collection); + } catch (error) { + debug('error connecting to MongoDB using collection: [%s]', collection); + return error; + } } - public dbInit(collection: string | undefined, cb?: AgendaCallback): void { + public async dbInit(collection: string | undefined) { debug('init database collection using name [%s]', collection); - this._collection = this._mdb.collection(collection || 'agendaJobs'); + this._collection = this.getMongoDB().collection(collection || 'agendaJobs'); debug('attempting index creation'); - this._collection.createIndex(this._indexes, { name: 'findAndLockNextJobIndex' }, (err) => { - if (err) { - debug('index creation failed'); - this.emit('error', err); - } else { - debug('index creation success'); - this.emit('ready'); - } - - if (cb) { - cb(err, this._collection); - } - }); + try { + await this._collection.createIndex(this._indexes, { name: 'findAndLockNextJobIndex' }); + debug('index creation success'); + this.emit('ready'); + } catch (err) { + debug('index creation failed'); + this.emit('error', err); + } } public name(name: string): Agenda { @@ -257,8 +264,24 @@ export class Agenda extends EventEmitter { return job; } + private getCollection(): Collection { + if (!this._collection) { + throw new Error('Agenda instance is not ready yet'); + } + + return this._collection; + } + + private getMongoDB(): MongoDB { + if (!this._mdb) { + throw new Error('Agenda instance is not ready yet'); + } + + return this._mdb; + } + public async jobs(query = {}, sort = {}, limit = 0, skip = 0): Promise { - const result = await this._collection.find(query).sort(sort).limit(limit).skip(skip).toArray(); + const result = await this.getCollection().find(query).sort(sort).limit(limit).skip(skip).toArray(); return result.map((job) => createJob(this, job)); } @@ -390,7 +413,7 @@ export class Agenda extends EventEmitter { public async cancel(query: Record): Promise { debug('attempting to cancel all Agenda jobs', query); try { - const { deletedCount } = await this._collection.deleteMany(query); + const { deletedCount } = await this.getCollection().deleteMany(query); debug('%s jobs cancelled', deletedCount || 0); return deletedCount || 0; } catch (error) { @@ -401,7 +424,7 @@ export class Agenda extends EventEmitter { public async has(query: Record): Promise { debug('checking whether Agenda has any jobs matching query', query); - const record = await this._collection.findOne(query, { projection: { _id: 1 } }); + const record = await this.getCollection().findOne(query, { projection: { _id: 1 } }); return record !== null; } @@ -416,7 +439,7 @@ export class Agenda extends EventEmitter { } if ('insertedId' in result) { - return this._collection.findOne({ _id: result.insertedId }); + return this.getCollection().findOne({ _id: result.insertedId }); } return null; @@ -445,7 +468,7 @@ export class Agenda extends EventEmitter { // Update the job and process the resulting data' debug('job already has _id, calling findOneAndUpdate() using _id as query'); - const result = await this._collection.findOneAndUpdate({ _id: id }, update, { returnDocument: 'after' }); + const result = await this.getCollection().findOneAndUpdate({ _id: id }, update, { returnDocument: 'after' }); return this._processDbResult(job, result); } @@ -471,7 +494,7 @@ export class Agenda extends EventEmitter { // Try an upsert debug('calling findOneAndUpdate() with job name and type of "single" as query'); - const result = await this._collection.findOneAndUpdate( + const result = await this.getCollection().findOneAndUpdate( { name: props.name, type: 'single', @@ -498,14 +521,14 @@ export class Agenda extends EventEmitter { // Use the 'unique' query object to find an existing job or create a new one debug('calling findOneAndUpdate() with unique object as query: \n%O', query); - const result = await this._collection.findOneAndUpdate(query, update, { upsert: true, returnDocument: 'after' }); + const result = await this.getCollection().findOneAndUpdate(query, update, { upsert: true, returnDocument: 'after' }); return this._processDbResult(job, result); } private async _saveNewJob(job: Job, props: Record): Promise { // If all else fails, the job does not exist yet so we just insert it into MongoDB debug('using default behavior, inserting new job via insertOne() with props that were set: \n%O', props); - const result = await this._collection.insertOne(props); + const result = await this.getCollection().insertOne(props); return this._processDbResult(job, result); } @@ -562,26 +585,18 @@ export class Agenda extends EventEmitter { process.nextTick(() => this.processJobs()); } - private _unlockJobs(): Promise { - return new Promise((resolve, reject) => { - debug('Agenda._unlockJobs()'); - const jobIds = this._lockedJobs.map((job) => job.attrs._id); + private async _unlockJobs(): Promise { + debug('Agenda._unlockJobs()'); + const jobIds = this._lockedJobs.map((job) => job.attrs._id); - if (jobIds.length === 0) { - debug('no jobs to unlock'); - return resolve(); - } - - debug('about to unlock jobs with ids: %O', jobIds); - this._collection.updateMany({ _id: { $in: jobIds } }, { $set: { lockedAt: null } }, (err) => { - if (err) { - return reject(err); - } + if (jobIds.length === 0) { + debug('no jobs to unlock'); + return; + } - this._lockedJobs = []; - return resolve(); - }); - }); + debug('about to unlock jobs with ids: %O', jobIds); + await this.getCollection().updateMany({ _id: { $in: jobIds } }, { $set: { lockedAt: null } }); + this._lockedJobs = []; } public stop(): Promise { @@ -602,16 +617,15 @@ export class Agenda extends EventEmitter { debug('_findAndLockNextJob(%s, [Function])', jobName); // Don't try and access MongoDB if we've lost connection to it. - // @ts-ignore - const s = this._mdb.s.client || this._mdb.db.s.client; - if (s.topology.connections && s.topology.connections().length === 0 && !this._mongoUseUnifiedTopology) { - if (s.topology.autoReconnect && !s.topology.isDestroyed()) { + const client = (this.getMongoDB() || this.getMongoDB().db)?.s?.client; + if (client?.topology?.connections?.().length === 0 && !this._mongoUseUnifiedTopology) { + if (client.topology.autoReconnect && !client.topology.isDestroyed?.()) { // Continue processing but notify that Agenda has lost the connection debug('Missing MongoDB connection, not attempting to find and lock a job'); this.emit('error', new Error('Lost MongoDB connection')); } else { // No longer recoverable - debug('topology.autoReconnect: %s, topology.isDestroyed(): %s', s.topology.autoReconnect, s.topology.isDestroyed()); + debug('topology.autoReconnect: %s, topology.isDestroyed(): %s', client.topology.autoReconnect, client.topology.isDestroyed?.()); throw new Error('MongoDB connection is not recoverable, application restart required'); } } else { @@ -642,7 +656,7 @@ export class Agenda extends EventEmitter { const JOB_PROCESS_SET_QUERY = { $set: { lockedAt: now } }; // Find ONE and ONLY ONE job and set the 'lockedAt' time so that job begins to be processed - const result = await this._collection.findOneAndUpdate(JOB_PROCESS_WHERE_QUERY, JOB_PROCESS_SET_QUERY, { + const result = await this.getCollection().findOneAndUpdate(JOB_PROCESS_WHERE_QUERY, JOB_PROCESS_SET_QUERY, { returnDocument: 'after', sort: this._sort, }); @@ -734,7 +748,7 @@ export class Agenda extends EventEmitter { const update = { $set: { lockedAt: now } }; // Lock the job in MongoDB! - const resp = await this._collection.findOneAndUpdate(criteria, update, { returnDocument: 'after' }); + const resp = await this.getCollection().findOneAndUpdate(criteria, update, { returnDocument: 'after' }); if (resp.value) { const job = createJob(this, resp.value as unknown as IJob); diff --git a/packages/cas-validate/src/validate.ts b/packages/cas-validate/src/validate.ts index bfb2e73af0b2..cef47a50a230 100644 --- a/packages/cas-validate/src/validate.ts +++ b/packages/cas-validate/src/validate.ts @@ -13,15 +13,15 @@ export type CasOptions = { }; export type CasCallbackExtendedData = { - username?: unknown; - attributes?: unknown; + username?: string; + attributes?: Record; // eslint-disable-next-line @typescript-eslint/naming-convention - PGTIOU?: unknown; - ticket?: unknown; - proxies?: unknown; + PGTIOU?: string; + ticket?: string; + proxies?: string[]; }; -export type CasCallback = (err: any, status?: unknown, username?: unknown, extended?: CasCallbackExtendedData) => void; +export type CasCallback = (err: any, status?: unknown, username?: string, extended?: CasCallbackExtendedData) => void; function parseJasigAttributes(elemAttribute: Cheerio, cheerio: CheerioAPI): Record { // "Jasig Style" Attributes: diff --git a/packages/cas-validate/tsconfig.json b/packages/cas-validate/tsconfig.json index 26aeeb5e5cff..49c73da90c82 100644 --- a/packages/cas-validate/tsconfig.json +++ b/packages/cas-validate/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.base.server.json", "compilerOptions": { "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, "rootDir": "./src", "outDir": "./dist" }, diff --git a/packages/core-services/CHANGELOG.md b/packages/core-services/CHANGELOG.md index 15f5278724e3..8e2af62049de 100644 --- a/packages/core-services/CHANGELOG.md +++ b/packages/core-services/CHANGELOG.md @@ -1,5 +1,21 @@ # @rocket.chat/core-services +## 0.3.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 +- @rocket.chat/rest-typings@6.5.3 +- @rocket.chat/models@0.0.27 + +## 0.3.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 +- @rocket.chat/rest-typings@6.5.2 +- @rocket.chat/models@0.0.26 + ## 0.3.1 ### Patch Changes diff --git a/packages/core-services/package.json b/packages/core-services/package.json index 987d9ebd30ee..0ba76e16e391 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/core-services", - "version": "0.3.1", + "version": "0.3.3", "private": true, "devDependencies": { "@babel/core": "~7.22.20", @@ -13,7 +13,7 @@ "babel-jest": "^29.5.0", "eslint": "~8.45.0", "jest": "~29.6.4", - "mongodb": "^4.17.1", + "mongodb": "^4.17.2", "prettier": "~2.8.8", "typescript": "~5.3.2" }, @@ -36,8 +36,8 @@ "dependencies": { "@rocket.chat/apps-engine": "1.41.0", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/icons": "~0.32.0", - "@rocket.chat/message-parser": "~0.31.27", + "@rocket.chat/icons": "^0.33.0", + "@rocket.chat/message-parser": "~0.31.28", "@rocket.chat/models": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", "@rocket.chat/ui-kit": "workspace:~", diff --git a/packages/core-services/src/LocalBroker.ts b/packages/core-services/src/LocalBroker.ts index cdedd4fa7057..47f4aaba3c80 100644 --- a/packages/core-services/src/LocalBroker.ts +++ b/packages/core-services/src/LocalBroker.ts @@ -8,6 +8,8 @@ import type { IBroker, IBrokerNode } from './types/IBroker'; import type { ServiceClass, IServiceClass } from './types/ServiceClass'; export class LocalBroker implements IBroker { + private started = false; + private methods = new Map any>(); private events = new EventEmitter(); @@ -73,6 +75,9 @@ export class LocalBroker implements IBroker { this.methods.set(`${namespace}.${method}`, i[method].bind(i)); } + if (this.started) { + void instance.started(); + } } onBroadcast(callback: (eventName: string, args: unknown[]) => void): void { @@ -106,5 +111,6 @@ export class LocalBroker implements IBroker { async start(): Promise { await Promise.all([...this.services].map((service) => service.started())); + this.started = true; } } diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index 67327c3ea215..a592a777829c 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -18,9 +18,7 @@ import type { ISocketConnection, ISubscription, IUser, - IUserStatus, IInvite, - IWebdavAccount, ICustomSound, VoipEventDataSignature, UserStatus, @@ -33,6 +31,8 @@ import type { IBanner, ILivechatVisitor, LicenseLimitKind, + ICustomUserStatus, + IWebdavAccount, } from '@rocket.chat/core-typings'; import type * as UiKit from '@rocket.chat/ui-kit'; @@ -40,6 +40,19 @@ import type { AutoUpdateRecord } from '../types/IMeteor'; type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; +type LoginServiceConfigurationEvent = { + id: string; +} & ( + | { + clientAction: 'removed'; + data?: never; + } + | { + clientAction: Omit; + data: Partial; + } +); + export type EventSignatures = { 'room.video-conference': (params: { rid: string; callId: string }) => void; 'shutdown': (params: Record) => void; @@ -115,7 +128,7 @@ export type EventSignatures = { replaceByUser: { _id: IUser['_id']; username: IUser['username']; alias: string }; }, ): void; - 'user.deleteCustomStatus'(userStatus: IUserStatus): void; + 'user.deleteCustomStatus'(userStatus: Omit): void; 'user.nameChanged'(user: Pick): void; 'user.realNameChanged'(user: Partial): void; 'user.roleUpdate'(update: { @@ -124,7 +137,7 @@ export type EventSignatures = { u?: { _id: IUser['_id']; username: IUser['username']; name?: IUser['name'] }; scope?: string; }): void; - 'user.updateCustomStatus'(userStatus: IUserStatus): void; + 'user.updateCustomStatus'(userStatus: Omit): void; 'user.typing'(data: { user: Partial; isTyping: boolean; roomId: string }): void; 'user.video-conference'(data: { userId: IUser['_id']; @@ -139,7 +152,7 @@ export type EventSignatures = { user: Pick; previousStatus: UserStatus | undefined; }): void; - 'watch.messages'(data: { clientAction: ClientAction; message: IMessage }): void; + 'watch.messages'(data: { message: IMessage }): void; 'watch.roles'( data: | { clientAction: Exclude; role: IRole } @@ -235,7 +248,7 @@ export type EventSignatures = { } ), ): void; - 'watch.loginServiceConfiguration'(data: { clientAction: ClientAction; data: Partial; id: string }): void; + 'watch.loginServiceConfiguration'(data: LoginServiceConfigurationEvent): void; 'watch.instanceStatus'(data: { clientAction: ClientAction; data?: undefined | Partial; @@ -287,5 +300,4 @@ export type EventSignatures = { 'command.updated'(command: string): void; 'command.removed'(command: string): void; 'actions.changed'(): void; - 'message.sent'(message: IMessage): void; }; diff --git a/packages/core-services/src/events/listeners.ts b/packages/core-services/src/events/listeners.ts deleted file mode 100644 index ced986cb6f30..000000000000 --- a/packages/core-services/src/events/listeners.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { IMessage } from '@rocket.chat/core-typings'; - -import type { IServiceClass } from '../types/ServiceClass'; - -export const dbWatchersDisabled = ['yes', 'true'].includes(String(process.env.DISABLE_DB_WATCHERS).toLowerCase()); - -export const listenToMessageSentEvent = (service: IServiceClass, action: (message: IMessage) => Promise): void => { - if (dbWatchersDisabled) { - return service.onEvent('message.sent', (message: IMessage) => action(message)); - } - return service.onEvent('watch.messages', ({ message }) => action(message)); -}; diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index e84ecb9da9e1..a82318dc7133 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -52,7 +52,6 @@ export { asyncLocalStorage } from './lib/asyncLocalStorage'; export { MeteorError, isMeteorError } from './MeteorError'; export { api } from './api'; export { EventSignatures } from './events/Events'; -export { listenToMessageSentEvent, dbWatchersDisabled } from './events/listeners'; export { LocalBroker } from './LocalBroker'; export { IBroker, IBrokerNode, BaseMetricOptions, IServiceMetrics } from './types/IBroker'; @@ -137,6 +136,8 @@ export { IOmnichannelAnalyticsService, }; +export const dbWatchersDisabled = ['yes', 'true'].includes(String(process.env.DISABLE_DB_WATCHERS).toLowerCase()); + // TODO think in a way to not have to pass the service name to proxify here as well export const Authorization = proxifyWithWait('authorization'); export const Apps = proxifyWithWait('apps-engine'); diff --git a/packages/core-services/src/types/IMeteor.ts b/packages/core-services/src/types/IMeteor.ts index 6b9ef7fdaffc..f905f7d7cddc 100644 --- a/packages/core-services/src/types/IMeteor.ts +++ b/packages/core-services/src/types/IMeteor.ts @@ -1,3 +1,5 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; + import type { IServiceClass } from './ServiceClass'; export type AutoUpdateRecord = { @@ -14,7 +16,7 @@ export type AutoUpdateRecord = { }; export interface IMeteor extends IServiceClass { getAutoUpdateClientVersions(): Promise>; - getLoginServiceConfiguration(): Promise; + getLoginServiceConfiguration(): Promise; callMethodWithToken(userId: string | undefined, token: string | undefined, method: string, args: any[]): Promise; notifyGuestStatusChanged(token: string, status: string): Promise; getURL(path: string, params?: Record, cloudDeepLinkUrl?: string): Promise; diff --git a/packages/core-services/src/types/IUploadService.ts b/packages/core-services/src/types/IUploadService.ts index 86202b2d5867..9e96791207e1 100644 --- a/packages/core-services/src/types/IUploadService.ts +++ b/packages/core-services/src/types/IUploadService.ts @@ -1,5 +1,5 @@ import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails'; -import type { IMessage, IUpload } from '@rocket.chat/core-typings'; +import type { IMessage, IUpload, IUser, FilesAndAttachments } from '@rocket.chat/core-typings'; export interface IUploadFileParams { userId: string; @@ -24,5 +24,7 @@ export interface IUploadService { uploadFile(params: IUploadFileParams): Promise; sendFileMessage(params: ISendFileMessageParams): Promise; sendFileLivechatMessage(params: ISendFileLivechatMessageParams): Promise; - getFileBuffer({ file }: { userId: string; file: IUpload }): Promise; + getFileBuffer({ file }: { file: IUpload }): Promise; + extractMetadata(file: IUpload): Promise<{ height?: number; width?: number; format?: string }>; + parseFileIntoMessageAttachments(file: Partial, roomId: string, user: IUser): Promise; } diff --git a/packages/core-typings/CHANGELOG.md b/packages/core-typings/CHANGELOG.md index ade8d2311ebe..ff2f3497bce5 100644 --- a/packages/core-typings/CHANGELOG.md +++ b/packages/core-typings/CHANGELOG.md @@ -1,5 +1,9 @@ # @rocket.chat/core-typings +## 6.5.3 + +## 6.5.2 + ## 6.5.1 ### Patch Changes diff --git a/packages/core-typings/package.json b/packages/core-typings/package.json index 7df9d5e0eb69..f6806383ecba 100644 --- a/packages/core-typings/package.json +++ b/packages/core-typings/package.json @@ -5,7 +5,7 @@ "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", "eslint": "~8.45.0", - "mongodb": "^4.17.1", + "mongodb": "^4.17.2", "prettier": "~2.8.8", "typescript": "~5.3.2" }, @@ -23,8 +23,8 @@ ], "dependencies": { "@rocket.chat/apps-engine": "1.41.0", - "@rocket.chat/icons": "~0.32.0", - "@rocket.chat/message-parser": "~0.31.27", + "@rocket.chat/icons": "^0.33.0", + "@rocket.chat/message-parser": "~0.31.28", "@rocket.chat/ui-kit": "workspace:~" }, "volta": { diff --git a/packages/core-typings/src/ICustomOAuthConfig.ts b/packages/core-typings/src/ICustomOAuthConfig.ts index dfbddc5ce2d5..ff695865cf9d 100644 --- a/packages/core-typings/src/ICustomOAuthConfig.ts +++ b/packages/core-typings/src/ICustomOAuthConfig.ts @@ -1,7 +1,7 @@ export type OauthConfig = { serverURL?: string; identityPath?: string; - addAutopublishFields: { + addAutopublishFields?: { forLoggedInUser: string[]; forOtherUsers: string[]; }; @@ -13,4 +13,5 @@ export type OauthConfig = { tokenSentVia?: string; usernameField?: string; mergeUsers?: boolean; + responseType?: string; }; diff --git a/packages/core-typings/src/ICustomUserStatus.ts b/packages/core-typings/src/ICustomUserStatus.ts index d634d53247fe..a1d9e40a2269 100644 --- a/packages/core-typings/src/ICustomUserStatus.ts +++ b/packages/core-typings/src/ICustomUserStatus.ts @@ -1,6 +1,5 @@ -import type { IRocketChatRecord } from './IRocketChatRecord'; +import type { IUserStatus } from './IUserStatus'; -export interface ICustomUserStatus extends IRocketChatRecord { - name: string; - statusType: string; +export interface ICustomUserStatus extends IUserStatus { + _updatedAt?: Date; } diff --git a/packages/core-typings/src/ILoginServiceConfiguration.ts b/packages/core-typings/src/ILoginServiceConfiguration.ts index 0d082b43a324..1874eea5d8bd 100644 --- a/packages/core-typings/src/ILoginServiceConfiguration.ts +++ b/packages/core-typings/src/ILoginServiceConfiguration.ts @@ -1,6 +1,113 @@ export interface ILoginServiceConfiguration { _id: string; service: string; +} + +export type OAuthConfiguration = { + custom: boolean; clientId: string; secret: string; -} + serverURL: string; + tokenPath: string; + identityPath: string; + authorizePath: string; + scope: string; + accessTokenParam: string; + buttonLabelText: string; + buttonLabelColor: string; + loginStyle: '' | 'redirect' | 'popup'; + buttonColor: string; + tokenSentVia: 'header' | 'payload'; + identityTokenSentVia: 'default' | 'header' | 'payload'; + keyField: 'username' | 'email'; + usernameField: string; + emailField: string; + nameField: string; + avatarField: string; + rolesClaim: string; + groupsClaim: string; + channelsMap: string; + channelsAdmin: string; + mergeUsers: boolean; + mergeUsersDistinctServices: boolean; + mapChannels: boolean; + mergeRoles: boolean; + rolesToSync: string; + showButton: boolean; +}; + +export type FacebookOAuthConfiguration = Omit, 'clientId'> & { + appId: OAuthConfiguration['clientId']; +}; + +export type TwitterOAuthConfiguration = Omit, 'clientId'> & { + consumerKey: OAuthConfiguration['clientId']; +}; + +export type LinkedinOAuthConfiguration = Partial & { + clientConfig: { + requestPermissions?: string[]; + }; +}; + +export type CASConfiguration = { + enabled: boolean; + base_url: string; + login_url: string; + buttonLabelText: string; + buttonLabelColor: string; + buttonColor: string; + width: number; + height: number; + autoclose: boolean; +}; + +export type SAMLConfiguration = { + buttonLabelText: string; + buttonLabelColor: string; + buttonColor: string; + clientConfig: { + provider?: string; + }; + entryPoint: string; + idpSLORedirectURL: string; + usernameNormalize: 'None' | 'Lowercase'; + immutableProperty: 'Username' | 'EMail'; + generateUsername: boolean; + debug: boolean; + nameOverwrite: boolean; + mailOverwrite: boolean; + issuer: string; + logoutBehaviour: 'SAML' | 'Local'; + defaultUserRole: string; + secret: { + privateKey: string; + publicCert: string; + cert: string; + }; + signatureValidationType: 'All' | 'Response' | 'Assertion' | 'Either'; + userDataFieldMap: string; + allowedClockDrift: number; + channelsAttributeUpdate: boolean; + includePrivateChannelsInUpdate: boolean; + customAuthnContext: string; + authnContextComparison: 'better' | 'exact' | 'maximum' | 'minimum'; + identifierFormat: string; + nameIDPolicyTemplate: string; + authnContextTemplate: string; + authRequestTemplate: string; + logoutResponseTemplate: string; + logoutRequestTemplate: string; + metadataCertificateTemplate: string; + metadataTemplate: string; +}; + +export type LoginServiceConfiguration = ILoginServiceConfiguration & + ( + | Partial + | Partial + | Partial + | Partial + | Partial + | Partial + ); diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 190446502f02..a47477c244d5 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -13,11 +13,11 @@ import type { IUser } from '../IUser'; import type { FileProp } from './MessageAttachment/Files/FileProp'; import type { MessageAttachment } from './MessageAttachment/MessageAttachment'; -type MessageUrl = { +export type MessageUrl = { url: string; source?: string; meta: Record; - headers?: { contentLength: string } | { contentType: string } | { contentLength: string; contentType: string }; + headers?: { contentLength?: string; contentType?: string }; ignoreParse?: boolean; parsedUrl?: Pick; }; @@ -287,9 +287,6 @@ export interface IMessageReactionsNormalized extends IMessage { }; } -export const isMessageReactionsNormalized = (message: IMessage): message is IMessageReactionsNormalized => - Boolean('reactions' in message && message.reactions && message.reactions[0] && 'names' in message.reactions[0]); - export interface IOmnichannelSystemMessage extends IMessage { navigation?: { page: { diff --git a/packages/core-typings/src/IMessage/MessageAttachment/Files/FileAttachmentProps.ts b/packages/core-typings/src/IMessage/MessageAttachment/Files/FileAttachmentProps.ts index 8ea81fdeb432..568494fd22a1 100644 --- a/packages/core-typings/src/IMessage/MessageAttachment/Files/FileAttachmentProps.ts +++ b/packages/core-typings/src/IMessage/MessageAttachment/Files/FileAttachmentProps.ts @@ -3,9 +3,11 @@ import type { AudioAttachmentProps } from './AudioAttachmentProps'; import type { ImageAttachmentProps } from './ImageAttachmentProps'; import type { VideoAttachmentProps } from './VideoAttachmentProps'; -export type FileAttachmentProps = { - type: 'file'; -} & (VideoAttachmentProps | ImageAttachmentProps | AudioAttachmentProps); +export type FileAttachmentProps = + | ({ type: 'file' } & VideoAttachmentProps) + | ({ type: 'file' } & ImageAttachmentProps) + | ({ type: 'file' } & AudioAttachmentProps) + | ({ type: 'file' } & MessageAttachmentBase); export const isFileAttachment = (attachment: MessageAttachmentBase): attachment is FileAttachmentProps => 'type' in attachment && (attachment as any).type === 'file'; diff --git a/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachment.ts b/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachment.ts index fb16ee94f1ab..8264b31ba640 100644 --- a/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachment.ts +++ b/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachment.ts @@ -1,6 +1,12 @@ +import type { FileProp } from './Files'; import type { FileAttachmentProps } from './Files/FileAttachmentProps'; import type { MessageAttachmentAction } from './MessageAttachmentAction'; import type { MessageAttachmentDefault } from './MessageAttachmentDefault'; import type { MessageQuoteAttachment } from './MessageQuoteAttachment'; export type MessageAttachment = MessageAttachmentAction | MessageAttachmentDefault | FileAttachmentProps | MessageQuoteAttachment; + +export type FilesAndAttachments = { + files: FileProp[]; + attachments: MessageAttachment[]; +}; diff --git a/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentBase.ts b/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentBase.ts index 19c60ac2b75b..167975ae014f 100644 --- a/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentBase.ts +++ b/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentBase.ts @@ -1,18 +1,16 @@ import type { Root } from '@rocket.chat/message-parser'; export type MessageAttachmentBase = { + id?: string; title?: string; - ts?: Date; collapsed?: boolean; description?: string; descriptionMd?: Root; text?: string; md?: Root; - size?: number; format?: string; - title_link?: string; title_link_download?: boolean; }; diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index 98785805714c..29864ae81ed1 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -71,6 +71,7 @@ export interface IUserServices { enabled: boolean; hashedBackup: string[]; secret: string; + tempSecret?: string; }; email2fa?: { enabled: boolean; diff --git a/packages/core-typings/src/IWebdavAccount.ts b/packages/core-typings/src/IWebdavAccount.ts index 8ab86fda573f..53059382fb50 100644 --- a/packages/core-typings/src/IWebdavAccount.ts +++ b/packages/core-typings/src/IWebdavAccount.ts @@ -3,14 +3,14 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; export interface IWebdavAccount extends IRocketChatRecord { userId: string; serverURL: string; - username?: string; + username: string; password?: string; name: string; } export type IWebdavAccountIntegration = Pick; -export type IWebdavAccountPayload = Pick; +export type IWebdavAccountPayload = Pick & Partial>; export type IWebdavNode = { basename: string; diff --git a/packages/core-typings/src/omnichannel/routing.ts b/packages/core-typings/src/omnichannel/routing.ts index 43ca0c08f5d2..af27f221c2b1 100644 --- a/packages/core-typings/src/omnichannel/routing.ts +++ b/packages/core-typings/src/omnichannel/routing.ts @@ -38,6 +38,9 @@ export type TransferData = { clientAction?: boolean; scope?: 'agent' | 'department' | 'queue' | 'autoTransferUnansweredChatsToAgent' | 'autoTransferUnansweredChatsToQueue'; comment?: string; + hops?: number; + usingFallbackDep?: boolean; + originalDepartmentName?: string; }; export type TransferByData = { diff --git a/packages/cron/CHANGELOG.md b/packages/cron/CHANGELOG.md index 190bc292bc48..16668d405333 100644 --- a/packages/cron/CHANGELOG.md +++ b/packages/cron/CHANGELOG.md @@ -1,5 +1,19 @@ # @rocket.chat/cron +## 0.0.23 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 +- @rocket.chat/models@0.0.27 + +## 0.0.22 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 +- @rocket.chat/models@0.0.26 + ## 0.0.21 ### Patch Changes diff --git a/packages/cron/package.json b/packages/cron/package.json index e66d2cc6aa06..956f3b5f7064 100644 --- a/packages/cron/package.json +++ b/packages/cron/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/cron", - "version": "0.0.21", + "version": "0.0.23", "private": true, "devDependencies": { "@types/jest": "~29.5.7", @@ -26,6 +26,6 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/models": "workspace:^", "@rocket.chat/random": "workspace:^", - "mongodb": "^4.17.1" + "mongodb": "^4.17.2" } } diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 43381ad4d188..e913455d2f97 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -31,6 +31,7 @@ "/imports", "/node", "/style", - "/variables" + "/variables", + "/react" ] } diff --git a/packages/fuselage-ui-kit/CHANGELOG.md b/packages/fuselage-ui-kit/CHANGELOG.md index 8dc3c6c9560b..ab92b70e2cbb 100644 --- a/packages/fuselage-ui-kit/CHANGELOG.md +++ b/packages/fuselage-ui-kit/CHANGELOG.md @@ -1,5 +1,21 @@ # Change Log +## 3.0.3 + +### Patch Changes + +- @rocket.chat/gazzodown@3.0.3 +- @rocket.chat/ui-contexts@3.0.3 +- @rocket.chat/ui-video-conf@3.0.3 + +## 3.0.2 + +### Patch Changes + +- @rocket.chat/gazzodown@3.0.2 +- @rocket.chat/ui-contexts@3.0.2 +- @rocket.chat/ui-video-conf@3.0.2 + ## 3.0.1 ### Patch Changes diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json index a21f77dbf20f..cbc80a52ea75 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": "3.0.1", + "version": "3.0.3", "description": "UiKit elements for Rocket.Chat Apps built under Fuselage design system", "homepage": "https://rocketchat.github.io/Rocket.Chat.Fuselage/", "author": { @@ -48,9 +48,9 @@ "@rocket.chat/icons": "*", "@rocket.chat/prettier-config": "*", "@rocket.chat/styled": "*", - "@rocket.chat/ui-contexts": "3.0.1", + "@rocket.chat/ui-contexts": "3.0.3", "@rocket.chat/ui-kit": "*", - "@rocket.chat/ui-video-conf": "3.0.1", + "@rocket.chat/ui-video-conf": "3.0.3", "@tanstack/react-query": "*", "react": "*", "react-dom": "*" @@ -62,10 +62,10 @@ "@babel/preset-typescript": "~7.22.15", "@rocket.chat/apps-engine": "1.41.0", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.41.0", - "@rocket.chat/fuselage-hooks": "~0.32.1", + "@rocket.chat/fuselage": "^0.45.0", + "@rocket.chat/fuselage-hooks": "^0.33.0", "@rocket.chat/fuselage-polyfills": "~0.31.25", - "@rocket.chat/icons": "~0.32.0", + "@rocket.chat/icons": "^0.33.0", "@rocket.chat/prettier-config": "~0.31.25", "@rocket.chat/styled": "~0.31.25", "@rocket.chat/ui-contexts": "workspace:^", diff --git a/packages/fuselage-ui-kit/src/elements/LinearScaleElement.tsx b/packages/fuselage-ui-kit/src/elements/LinearScaleElement.tsx index 0985eacfdb24..8637c57f8612 100644 --- a/packages/fuselage-ui-kit/src/elements/LinearScaleElement.tsx +++ b/packages/fuselage-ui-kit/src/elements/LinearScaleElement.tsx @@ -52,12 +52,7 @@ const LinearScaleElement = ({ )} - + {points.map((point, i) => ( ); -class PopoverMenuWrapper extends Component { - state = {}; - handleRef = (ref) => { +type PopoverMenuWrapperProps = { + children?: ComponentChildren; + dismiss: () => void; + triggerBounds: DOMRect; + overlayBounds: DOMRect; +}; + +type PopoverMenuWrapperState = { + position?: { + left?: number; + right?: number; + top?: number; + bottom?: number; + }; + placement?: string; +}; + +class PopoverMenuWrapper extends Component { + state: PopoverMenuWrapperState = {}; + + menuRef: (Component & { base: Element }) | null = null; + + handleRef = (ref: (Component & { base: Element }) | null) => { this.menuRef = ref; }; - handleClick = ({ target }) => { - if (!target.closest(`.${styles.menu__item}`)) { + handleClick = ({ target }: TargetedEvent) => { + if (!(target as HTMLElement)?.closest(`.${styles.menu__item}`)) { return; } @@ -48,7 +80,7 @@ class PopoverMenuWrapper extends Component { componentDidMount() { const { triggerBounds, overlayBounds } = this.props; - const menuBounds = normalizeDOMRect(this.menuRef.base.getBoundingClientRect()); + const menuBounds = normalizeDOMRect(this.menuRef?.base?.getBoundingClientRect()); const menuWidth = menuBounds.right - menuBounds.left; const menuHeight = menuBounds.bottom - menuBounds.top; @@ -56,11 +88,11 @@ class PopoverMenuWrapper extends Component { const rightSpace = overlayBounds.right - triggerBounds.left; const bottomSpace = overlayBounds.bottom - triggerBounds.bottom; - const left = menuWidth < rightSpace ? triggerBounds.left - overlayBounds.left : null; - const right = menuWidth < rightSpace ? null : overlayBounds.right - triggerBounds.right; + const left = menuWidth < rightSpace ? triggerBounds.left - overlayBounds.left : undefined; + const right = menuWidth < rightSpace ? undefined : overlayBounds.right - triggerBounds.right; - const top = menuHeight < bottomSpace ? triggerBounds.bottom : null; - const bottom = menuHeight < bottomSpace ? null : overlayBounds.bottom - triggerBounds.top; + const top = menuHeight < bottomSpace ? triggerBounds.bottom : undefined; + const bottom = menuHeight < bottomSpace ? undefined : overlayBounds.bottom - triggerBounds.top; const placement = `${menuWidth < rightSpace ? 'right' : 'left'}-${menuHeight < bottomSpace ? 'bottom' : 'top'}`; @@ -71,7 +103,7 @@ class PopoverMenuWrapper extends Component { }); } - render = ({ children }) => ( + render = ({ children }: PopoverMenuWrapperProps) => ( void }) => void, overlayed?: boolean }} PopoverMenuProps */ +type PopoverMenuProps = { + children?: ComponentChildren; + trigger: (contextValue: { pop: () => void }) => void; + overlayed?: boolean; +}; -/** @type {(props: PopoverMenuProps) => JSX.Element} */ -export const PopoverMenu = ({ children = null, trigger, overlayed }) => ( +export const PopoverMenu = ({ children = null, trigger, overlayed }: PopoverMenuProps) => ( ( {({ open }) => children[0]({ pop: open.bind(null, children[1], props) })} ); diff --git a/packages/livechat/src/components/Screen/Footer.stories.tsx b/packages/livechat/src/components/Screen/Footer.stories.tsx index 5038b1faf584..90faa3869a84 100644 --- a/packages/livechat/src/components/Screen/Footer.stories.tsx +++ b/packages/livechat/src/components/Screen/Footer.stories.tsx @@ -4,7 +4,7 @@ import i18next from 'i18next'; import type { ComponentProps } from 'preact'; import { Screen } from '.'; -import { screenDecorator } from '../../helpers.stories'; +import { screenDecorator } from '../../../.storybook/helpers'; import { FooterOptions } from '../Footer'; import Menu from '../Menu'; diff --git a/packages/livechat/src/components/Screen/index.js b/packages/livechat/src/components/Screen/index.js index e59c39bdc023..e4f4aff1ea4c 100644 --- a/packages/livechat/src/components/Screen/index.js +++ b/packages/livechat/src/components/Screen/index.js @@ -31,6 +31,7 @@ const ChatButton = ({ text, minimized, badge, onClick, triggered = false, agent badge={badge} onClick={onClick} className={createClassName(styles, 'screen__chat-button')} + data-qa-id='chat-button' img={triggered && agent && agent.avatar.src} > {text} @@ -101,7 +102,12 @@ export const Screen = ({
{triggered && ( - )} diff --git a/packages/livechat/src/components/Screen/stories.tsx b/packages/livechat/src/components/Screen/stories.tsx index 5113046cc97a..65217c143cae 100644 --- a/packages/livechat/src/components/Screen/stories.tsx +++ b/packages/livechat/src/components/Screen/stories.tsx @@ -3,7 +3,7 @@ import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; import { Screen } from '.'; -import { gazzoAvatar, screenDecorator } from '../../helpers.stories'; +import { gazzoAvatar, screenDecorator } from '../../../.storybook/helpers'; export default { title: 'Components/Screen', diff --git a/packages/livechat/src/components/Sound/stories.tsx b/packages/livechat/src/components/Sound/stories.tsx index 4a5a89b16ab5..6a854dce3995 100644 --- a/packages/livechat/src/components/Sound/stories.tsx +++ b/packages/livechat/src/components/Sound/stories.tsx @@ -3,7 +3,7 @@ import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; import { Sound } from '.'; -import { beepAudio, sampleAudio } from '../../helpers.stories'; +import { beepAudio, sampleAudio } from '../../../.storybook/helpers'; export default { title: 'Components/Sound', diff --git a/packages/livechat/src/components/uiKit/message/ActionsBlock/index.js b/packages/livechat/src/components/uiKit/message/ActionsBlock/index.tsx similarity index 80% rename from packages/livechat/src/components/uiKit/message/ActionsBlock/index.js rename to packages/livechat/src/components/uiKit/message/ActionsBlock/index.tsx index 9791a8df3ed2..d94c6e03a592 100644 --- a/packages/livechat/src/components/uiKit/message/ActionsBlock/index.js +++ b/packages/livechat/src/components/uiKit/message/ActionsBlock/index.tsx @@ -1,13 +1,20 @@ +import type * as uikit from '@rocket.chat/ui-kit'; import { BlockContext } from '@rocket.chat/ui-kit'; import { useState, useMemo, useCallback } from 'preact/compat'; -import { withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import { createClassName } from '../../../../helpers/createClassName'; import { Button } from '../../../Button'; import Block from '../Block'; import styles from './styles.scss'; -const ActionsBlock = ({ appId, blockId, elements, parser, t }) => { +type ActionsBlockProps = uikit.ActionsBlock & { + parser: any; + t: any; +}; + +const ActionsBlock = ({ appId, blockId, elements, parser }: ActionsBlockProps) => { + const { t } = useTranslation(); const [collapsed, setCollapsed] = useState(true); const renderableElements = useMemo(() => (collapsed ? elements.slice(0, 5) : elements), [collapsed, elements]); const hiddenElementsCount = elements.length - renderableElements.length; @@ -43,4 +50,4 @@ const ActionsBlock = ({ appId, blockId, elements, parser, t }) => { ); }; -export default withTranslation()(ActionsBlock); +export default ActionsBlock; diff --git a/packages/livechat/src/components/uiKit/message/Block.js b/packages/livechat/src/components/uiKit/message/Block.tsx similarity index 74% rename from packages/livechat/src/components/uiKit/message/Block.js rename to packages/livechat/src/components/uiKit/message/Block.tsx index b90beac1ef51..a6b8e371de8b 100644 --- a/packages/livechat/src/components/uiKit/message/Block.js +++ b/packages/livechat/src/components/uiKit/message/Block.tsx @@ -1,4 +1,4 @@ -import { createContext } from 'preact'; +import { type ComponentChildren, createContext } from 'preact'; import { memo, useContext, useCallback, useState, useRef, useEffect } from 'preact/compat'; import { useDispatchAction } from './Surface'; @@ -8,7 +8,13 @@ const BlockContext = createContext({ blockId: null, }); -const Block = ({ appId, blockId, children }) => ( +type BlockProps = { + appId?: string; + blockId?: string; + children: ComponentChildren; +}; + +const Block = ({ appId, blockId, children }: BlockProps) => ( ( /> ); -export const usePerformAction = (actionId) => { +export const usePerformAction = (actionId: string) => { const { appId } = useContext(BlockContext); const dispatchAction = useDispatchAction(); @@ -51,7 +57,7 @@ export const usePerformAction = (actionId) => { [actionId, appId, dispatchAction], ); - return [perform, performing]; + return [perform, performing] as const; }; export default memo(Block); diff --git a/packages/livechat/src/components/uiKit/message/ButtonElement/index.js b/packages/livechat/src/components/uiKit/message/ButtonElement/index.tsx similarity index 57% rename from packages/livechat/src/components/uiKit/message/ButtonElement/index.js rename to packages/livechat/src/components/uiKit/message/ButtonElement/index.tsx index 2cc2ea02615c..cab76bddad55 100644 --- a/packages/livechat/src/components/uiKit/message/ButtonElement/index.js +++ b/packages/livechat/src/components/uiKit/message/ButtonElement/index.tsx @@ -1,17 +1,24 @@ -import { BlockContext } from '@rocket.chat/ui-kit'; +import * as uikit from '@rocket.chat/ui-kit'; +import type { ComponentChild } from 'preact'; +import type { TargetedEvent } from 'preact/compat'; import { memo, useCallback } from 'preact/compat'; import { createClassName } from '../../../../helpers/createClassName'; import { usePerformAction } from '../Block'; import styles from './styles.scss'; -const handleMouseUp = ({ target }) => target.blur(); +const handleMouseUp = ({ currentTarget }: TargetedEvent) => currentTarget.blur(); -const ButtonElement = ({ text, actionId, url, value, style, context, confirm, parser }) => { +type ButtonElementProps = uikit.ButtonElement & { + context: uikit.BlockContext; + parser: uikit.SurfaceRenderer; +}; + +const ButtonElement = ({ text, actionId, url, value, style, context, confirm, parser }: ButtonElementProps) => { const [performAction, performingAction] = usePerformAction(actionId); const handleClick = useCallback( - async (event) => { + async (event: TargetedEvent) => { event.preventDefault(); if (confirm) { @@ -20,6 +27,9 @@ const ButtonElement = ({ text, actionId, url, value, style, context, confirm, pa if (url) { const newTab = window.open(); + if (!newTab) { + throw new Error('Failed to open new tab'); + } newTab.opener = null; newTab.location = url; return; @@ -35,8 +45,8 @@ const ButtonElement = ({ text, actionId, url, value, style, context, confirm, pa children={parser.text(text)} className={createClassName(styles, 'uikit-button', { style, - accessory: context === BlockContext.SECTION, - action: context === BlockContext.ACTION, + accessory: context === uikit.BlockContext.SECTION, + action: context === uikit.BlockContext.ACTION, })} disabled={performingAction} type='button' diff --git a/packages/livechat/src/components/uiKit/message/ButtonElement/stories.tsx b/packages/livechat/src/components/uiKit/message/ButtonElement/stories.tsx index adf60b56dc95..36c41c67f13a 100644 --- a/packages/livechat/src/components/uiKit/message/ButtonElement/stories.tsx +++ b/packages/livechat/src/components/uiKit/message/ButtonElement/stories.tsx @@ -14,7 +14,7 @@ export default { actionId: undefined, url: undefined, value: undefined, - style: null, + style: undefined, context: undefined, confirm: undefined, }, diff --git a/packages/livechat/src/components/uiKit/message/ContextBlock.stories.tsx b/packages/livechat/src/components/uiKit/message/ContextBlock.stories.tsx index 1760ca18135d..1f967365b408 100644 --- a/packages/livechat/src/components/uiKit/message/ContextBlock.stories.tsx +++ b/packages/livechat/src/components/uiKit/message/ContextBlock.stories.tsx @@ -1,7 +1,7 @@ import type { Meta } from '@storybook/preact'; import { renderMessageBlocks } from '.'; -import { accessoryImage } from '../../../helpers.stories'; +import { accessoryImage } from '../../../../.storybook/helpers'; export default { title: 'UiKit/Message/Context block', diff --git a/packages/livechat/src/components/uiKit/message/ContextBlock/index.js b/packages/livechat/src/components/uiKit/message/ContextBlock/index.tsx similarity index 60% rename from packages/livechat/src/components/uiKit/message/ContextBlock/index.js rename to packages/livechat/src/components/uiKit/message/ContextBlock/index.tsx index 62d1f05ffb80..79f0902c7849 100644 --- a/packages/livechat/src/components/uiKit/message/ContextBlock/index.js +++ b/packages/livechat/src/components/uiKit/message/ContextBlock/index.tsx @@ -1,16 +1,22 @@ +import type * as uikit from '@rocket.chat/ui-kit'; import { BlockContext } from '@rocket.chat/ui-kit'; +import type { ComponentChild } from 'preact'; import { memo } from 'preact/compat'; import { createClassName } from '../../../../helpers/createClassName'; import Block from '../Block'; import styles from './styles.scss'; -const ContextBlock = ({ appId, blockId, elements, parser }) => ( +type ContextBlockProps = uikit.ContextBlock & { + parser: uikit.SurfaceRenderer; +}; + +const ContextBlock = ({ appId, blockId, elements, parser }: ContextBlockProps) => (
{elements.map((element, key) => (
- {parser.renderContext(element, BlockContext.CONTEXT)} + {parser.renderContext(element, BlockContext.CONTEXT, undefined, key)}
))}
diff --git a/packages/livechat/src/components/uiKit/message/DatePickerElement/index.js b/packages/livechat/src/components/uiKit/message/DatePickerElement/index.tsx similarity index 68% rename from packages/livechat/src/components/uiKit/message/DatePickerElement/index.js rename to packages/livechat/src/components/uiKit/message/DatePickerElement/index.tsx index c5fa758ff60c..724bd8de9fbd 100644 --- a/packages/livechat/src/components/uiKit/message/DatePickerElement/index.js +++ b/packages/livechat/src/components/uiKit/message/DatePickerElement/index.tsx @@ -1,13 +1,17 @@ +import type * as uikit from '@rocket.chat/ui-kit'; +import type { ChangeEvent } from 'preact/compat'; import { memo, useCallback } from 'preact/compat'; import DateInput from '../../../Form/DateInput'; import { usePerformAction } from '../Block'; -const DatePickerElement = ({ actionId, confirm /* , placeholder */, initialDate /* , parser */ }) => { +type DatePickerElementProps = uikit.DatePickerElement; + +const DatePickerElement = ({ actionId, confirm /* , placeholder */, initialDate /* , parser */ }: DatePickerElementProps) => { const [performAction, performingAction] = usePerformAction(actionId); const handleChange = useCallback( - async (event) => { + async (event: ChangeEvent) => { event.preventDefault(); if (confirm) { @@ -16,7 +20,7 @@ const DatePickerElement = ({ actionId, confirm /* , placeholder */, initialDate await performAction({ initialDate, - selectedDate: event.target.value, + selectedDate: event.currentTarget?.value, }); }, [confirm, initialDate, performAction], diff --git a/packages/livechat/src/components/uiKit/message/DividerBlock/index.js b/packages/livechat/src/components/uiKit/message/DividerBlock/index.tsx similarity index 67% rename from packages/livechat/src/components/uiKit/message/DividerBlock/index.js rename to packages/livechat/src/components/uiKit/message/DividerBlock/index.tsx index 2e8d222d97c1..cdcd95d1bf32 100644 --- a/packages/livechat/src/components/uiKit/message/DividerBlock/index.js +++ b/packages/livechat/src/components/uiKit/message/DividerBlock/index.tsx @@ -1,10 +1,13 @@ +import type * as uikit from '@rocket.chat/ui-kit'; import { memo } from 'preact/compat'; import { createClassName } from '../../../../helpers/createClassName'; import Block from '../Block'; import styles from './styles.scss'; -const DividerBlock = ({ appId, blockId }) => ( +type DividerBlockProps = uikit.DividerBlock; + +const DividerBlock = ({ appId, blockId }: DividerBlockProps) => (
diff --git a/packages/livechat/src/components/uiKit/message/ImageBlock.stories.tsx b/packages/livechat/src/components/uiKit/message/ImageBlock.stories.tsx index 2623e606dbc9..ed24559f7878 100644 --- a/packages/livechat/src/components/uiKit/message/ImageBlock.stories.tsx +++ b/packages/livechat/src/components/uiKit/message/ImageBlock.stories.tsx @@ -1,7 +1,7 @@ import type { Meta } from '@storybook/preact'; import { renderMessageBlocks } from '.'; -import { imageBlock } from '../../../helpers.stories'; +import { imageBlock } from '../../../../.storybook/helpers'; export default { title: 'UiKit/Message/Image block', diff --git a/packages/livechat/src/components/uiKit/message/ImageBlock/index.js b/packages/livechat/src/components/uiKit/message/ImageBlock/index.tsx similarity index 89% rename from packages/livechat/src/components/uiKit/message/ImageBlock/index.js rename to packages/livechat/src/components/uiKit/message/ImageBlock/index.tsx index b07c5c0e6bf8..b9801b13ebe4 100644 --- a/packages/livechat/src/components/uiKit/message/ImageBlock/index.js +++ b/packages/livechat/src/components/uiKit/message/ImageBlock/index.tsx @@ -1,3 +1,5 @@ +import type * as uikit from '@rocket.chat/ui-kit'; +import type { ComponentChild } from 'preact'; import { memo, useEffect, useState, useMemo } from 'preact/compat'; import { createClassName } from '../../../../helpers/createClassName'; @@ -6,7 +8,11 @@ import styles from './styles.scss'; const MAX_SIZE = 360; -const ImageBlock = ({ appId, blockId, title, imageUrl, altText, parser }) => { +type ImageBlockProps = uikit.ImageBlock & { + parser: uikit.SurfaceRenderer; +}; + +const ImageBlock = ({ appId, blockId, title, imageUrl, altText, parser }: ImageBlockProps) => { const [{ loading, naturalWidth, naturalHeight }, updateImageState] = useState(() => ({ loading: true, naturalWidth: MAX_SIZE, diff --git a/packages/livechat/src/components/uiKit/message/ImageElement/index.js b/packages/livechat/src/components/uiKit/message/ImageElement/index.tsx similarity index 53% rename from packages/livechat/src/components/uiKit/message/ImageElement/index.js rename to packages/livechat/src/components/uiKit/message/ImageElement/index.tsx index f09ffbb250d5..1c8f698c2bc8 100644 --- a/packages/livechat/src/components/uiKit/message/ImageElement/index.js +++ b/packages/livechat/src/components/uiKit/message/ImageElement/index.tsx @@ -1,15 +1,19 @@ -import { BlockContext } from '@rocket.chat/ui-kit'; +import * as uikit from '@rocket.chat/ui-kit'; import { memo } from 'preact/compat'; import { createClassName } from '../../../../helpers/createClassName'; import styles from './styles.scss'; -const ImageElement = ({ imageUrl, altText, context }) => ( +type ImageElementProps = uikit.ImageElement & { + context: uikit.BlockContext; +}; + +const ImageElement = ({ imageUrl, altText, context }: ImageElementProps) => (
{ - const handleMouseUp = useCallback(({ target }) => { - target.blur(); +type OverflowTriggerProps = { + loading: boolean; + onClick: () => void; +}; + +const OverflowTrigger = ({ loading, onClick }: OverflowTriggerProps) => { + const handleMouseUp = useCallback(({ currentTarget }: TargetedEvent) => { + currentTarget.blur(); }, []); return ( @@ -26,9 +34,15 @@ const OverflowTrigger = ({ loading, onClick }) => { ); }; -const OverflowOption = ({ confirm, text, value, url, parser, onClick }) => { +type OverflowOptionProps = uikit.Option & { + confirm: boolean; + parser: uikit.SurfaceRenderer; + onClick: (value: string) => void; +}; + +const OverflowOption = ({ confirm, text, value, url, parser, onClick }: OverflowOptionProps) => { const handleClick = useCallback( - async (event) => { + async (event: TargetedEvent) => { event.preventDefault(); if (confirm) { @@ -37,6 +51,9 @@ const OverflowOption = ({ confirm, text, value, url, parser, onClick }) => { if (url) { const newTab = window.open(); + if (!newTab) { + throw new Error('Could not open new tab'); + } newTab.opener = null; newTab.location = url; return; @@ -50,11 +67,15 @@ const OverflowOption = ({ confirm, text, value, url, parser, onClick }) => { return {parser.text(text)}; }; -const OverflowElement = ({ actionId, confirm, options, parser }) => { +type OverflowElementProps = uikit.OverflowElement & { + parser: uikit.SurfaceRenderer; +}; + +const OverflowElement = ({ actionId, confirm, options, parser }: OverflowElementProps) => { const [performAction, performingAction] = usePerformAction(actionId); const handleClick = useCallback( - async (value) => { + async (value: TargetedEvent) => { await performAction({ value }); }, [performAction], diff --git a/packages/livechat/src/components/uiKit/message/PlainText/index.tsx b/packages/livechat/src/components/uiKit/message/PlainText/index.tsx index bbbe5ae7ebb9..e5cb6986359f 100644 --- a/packages/livechat/src/components/uiKit/message/PlainText/index.tsx +++ b/packages/livechat/src/components/uiKit/message/PlainText/index.tsx @@ -1,7 +1,7 @@ import { memo } from 'preact/compat'; import { createClassName } from '../../../../helpers/createClassName'; -import shortnameToUnicode from '../../../Emoji/shortnameToUnicode'; +import shortnameToUnicode from '../../../../lib/emoji/shortnameToUnicode'; import MarkdownBlock from '../../../MarkdownBlock'; import styles from './styles.scss'; diff --git a/packages/livechat/src/components/uiKit/message/SectionBlock.stories.tsx b/packages/livechat/src/components/uiKit/message/SectionBlock.stories.tsx index f24c308da090..3e74f3384e0a 100644 --- a/packages/livechat/src/components/uiKit/message/SectionBlock.stories.tsx +++ b/packages/livechat/src/components/uiKit/message/SectionBlock.stories.tsx @@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/preact'; import { renderMessageBlocks } from '.'; -import { accessoryImage } from '../../../helpers.stories'; +import { accessoryImage } from '../../../../.storybook/helpers'; import { PopoverContainer } from '../../Popover'; import Surface from './Surface'; diff --git a/packages/livechat/src/components/uiKit/message/SectionBlock/index.js b/packages/livechat/src/components/uiKit/message/SectionBlock/index.tsx similarity index 63% rename from packages/livechat/src/components/uiKit/message/SectionBlock/index.js rename to packages/livechat/src/components/uiKit/message/SectionBlock/index.tsx index 0d829788c282..c59b522464f6 100644 --- a/packages/livechat/src/components/uiKit/message/SectionBlock/index.js +++ b/packages/livechat/src/components/uiKit/message/SectionBlock/index.tsx @@ -1,20 +1,27 @@ -import { BlockContext } from '@rocket.chat/ui-kit'; +import * as uikit from '@rocket.chat/ui-kit'; +import type { ComponentChild } from 'preact'; import { memo } from 'preact/compat'; import { createClassName } from '../../../../helpers/createClassName'; import Block from '../Block'; import styles from './styles.scss'; -const SectionBlock = ({ appId, blockId, text, fields, accessory, parser }) => ( +type SectionBlockProps = uikit.SectionBlock & { + parser: uikit.SurfaceRenderer; +}; + +const SectionBlock = ({ appId, blockId, text, fields, accessory, parser }: SectionBlockProps) => (
- {text &&
{parser.text(text, BlockContext.SECTION)}
} + {text && ( +
{parser.text(text, uikit.BlockContext.SECTION)}
+ )} {Array.isArray(fields) && fields.length > 0 && (
{fields.map((field, i) => (
- {parser.text(field, BlockContext.SECTION)} + {parser.text(field, uikit.BlockContext.SECTION)}
))}
@@ -22,7 +29,7 @@ const SectionBlock = ({ appId, blockId, text, fields, accessory, parser }) => (
{accessory && (
- {parser.renderAccessories(accessory, BlockContext.SECTION)} + {parser.renderAccessories(accessory, uikit.BlockContext.SECTION, undefined, 0)}
)}
diff --git a/packages/livechat/src/components/uiKit/message/StaticSelectElement/index.js b/packages/livechat/src/components/uiKit/message/StaticSelectElement/index.tsx similarity index 63% rename from packages/livechat/src/components/uiKit/message/StaticSelectElement/index.js rename to packages/livechat/src/components/uiKit/message/StaticSelectElement/index.tsx index a5fb80552afd..7bf9931bc2d7 100644 --- a/packages/livechat/src/components/uiKit/message/StaticSelectElement/index.js +++ b/packages/livechat/src/components/uiKit/message/StaticSelectElement/index.tsx @@ -1,3 +1,6 @@ +import type * as uikit from '@rocket.chat/ui-kit'; +import type { ComponentChild } from 'preact'; +import type { TargetedEvent } from 'preact/compat'; import { memo, useCallback, useMemo } from 'preact/compat'; import { createClassName } from '../../../../helpers/createClassName'; @@ -5,11 +8,22 @@ import { SelectInput } from '../../../Form/SelectInput'; import { usePerformAction } from '../Block'; import styles from './styles.scss'; -const StaticSelectElement = ({ actionId, confirm, placeholder, options /* , optionGroups */, initialOption, parser }) => { +type StaticSelectElementProps = uikit.StaticSelectElement & { + parser: uikit.SurfaceRenderer; +}; + +const StaticSelectElement = ({ + actionId, + confirm, + placeholder, + options /* , optionGroups */, + initialOption, + parser, +}: StaticSelectElementProps) => { const [performAction, performingAction] = usePerformAction(actionId); const handleChange = useCallback( - async (event) => { + async (event: TargetedEvent) => { event.preventDefault(); if (confirm) { @@ -17,7 +31,7 @@ const StaticSelectElement = ({ actionId, confirm, placeholder, options /* , opti } await performAction({ - value: event.target.value, + value: event.currentTarget?.value, }); }, [confirm, performAction], @@ -39,7 +53,7 @@ const StaticSelectElement = ({ actionId, confirm, placeholder, options /* , opti options={selectOptions} placeholder={placeholder && parser.text(placeholder)} small - value={(initialOption && initialOption.value) || ''} + value={initialOption?.value ?? ''} onChange={handleChange} /> ); diff --git a/packages/livechat/src/components/uiKit/message/Surface.js b/packages/livechat/src/components/uiKit/message/Surface.js deleted file mode 100644 index fdc8d4955cf2..000000000000 --- a/packages/livechat/src/components/uiKit/message/Surface.js +++ /dev/null @@ -1,19 +0,0 @@ -import { createContext } from 'preact'; -import { memo, useContext } from 'preact/compat'; - -const SurfaceContext = createContext({ - dispatchAction: () => undefined, -}); - -const Surface = ({ children, dispatchAction }) => ( - -); - -export const useDispatchAction = () => useContext(SurfaceContext).dispatchAction; - -export default memo(Surface); diff --git a/packages/livechat/src/components/uiKit/message/Surface.tsx b/packages/livechat/src/components/uiKit/message/Surface.tsx new file mode 100644 index 000000000000..2ca22907c502 --- /dev/null +++ b/packages/livechat/src/components/uiKit/message/Surface.tsx @@ -0,0 +1,29 @@ +import type { ComponentChildren } from 'preact'; +import { createContext } from 'preact'; +import { memo, useContext } from 'preact/compat'; + +type SurfaceContextValue = { + dispatchAction: (args: { appId: any; actionId: any; payload: any }) => void; +}; + +const SurfaceContext = createContext({ + dispatchAction: () => undefined, +}); + +type SurfaceProps = { + children: ComponentChildren; + dispatchAction: (action: any) => void; +}; + +const Surface = ({ children, dispatchAction }: SurfaceProps) => ( + +); + +export const useDispatchAction = () => useContext(SurfaceContext).dispatchAction; + +export default memo(Surface); diff --git a/packages/livechat/src/definitions/jpg.d.ts b/packages/livechat/src/definitions/jpg.d.ts new file mode 100644 index 000000000000..51ef84b5f794 --- /dev/null +++ b/packages/livechat/src/definitions/jpg.d.ts @@ -0,0 +1,4 @@ +declare module '*.jpg' { + const path: string; + export = path; +} diff --git a/packages/livechat/src/definitions/mp3.d.ts b/packages/livechat/src/definitions/mp3.d.ts new file mode 100644 index 000000000000..acb2a2495626 --- /dev/null +++ b/packages/livechat/src/definitions/mp3.d.ts @@ -0,0 +1,4 @@ +declare module '*.mp3' { + const path: string; + export = path; +} diff --git a/packages/livechat/src/definitions/mp4.d.ts b/packages/livechat/src/definitions/mp4.d.ts new file mode 100644 index 000000000000..729c2e1232f8 --- /dev/null +++ b/packages/livechat/src/definitions/mp4.d.ts @@ -0,0 +1,4 @@ +declare module '*.mp4' { + const path: string; + export = path; +} diff --git a/packages/livechat/src/definitions/png.d.ts b/packages/livechat/src/definitions/png.d.ts new file mode 100644 index 000000000000..d39b09460e7a --- /dev/null +++ b/packages/livechat/src/definitions/png.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const path: string; + export = path; +} diff --git a/packages/livechat/src/helpers.stories.js b/packages/livechat/src/helpers.stories.js deleted file mode 100644 index 1a597d8a2e82..000000000000 --- a/packages/livechat/src/helpers.stories.js +++ /dev/null @@ -1,49 +0,0 @@ -import { action } from '@storybook/addon-actions'; -import { loremIpsum as originalLoremIpsum } from 'lorem-ipsum'; - -import gazzoAvatar from '../.storybook/assets/gazzo.jpg'; -import martinAvatar from '../.storybook/assets/martin.jpg'; -import tassoAvatar from '../.storybook/assets/tasso.jpg'; - -export const screenDecorator = (storyFn) =>
{storyFn()}
; - -export const screenProps = () => ({ - theme: { - color: '', - fontColor: '', - iconColor: '', - }, - notificationsEnabled: true, - minimized: false, - windowed: false, - onEnableNotifications: action('enableNotifications'), - onDisableNotifications: action('disableNotifications'), - onMinimize: action('minimize'), - onRestore: action('restore'), - onOpenWindow: action('openWindow'), -}); - -export const avatarResolver = (username) => - ({ - 'guilherme.gazzo': gazzoAvatar, - 'martin.schoeler': martinAvatar, - 'tasso.evangelista': tassoAvatar, - }[username]); - -export const attachmentResolver = (url) => url; - -const createRandom = (s) => () => { - s = Math.sin(s) * 10000; - return s - Math.floor(s); -}; -const loremIpsumRandom = createRandom(42); -export const loremIpsum = (options) => originalLoremIpsum({ random: loremIpsumRandom, ...options }); - -export { gazzoAvatar, martinAvatar, tassoAvatar }; - -export { default as sampleAudio } from '../.storybook/assets/sample-audio.mp3'; -export { default as sampleImage } from '../.storybook/assets/sample-image.jpg'; -export { default as sampleVideo } from '../.storybook/assets/sample-video.mp4'; -export { default as accessoryImage } from '../.storybook/assets/accessoryImage.png'; -export { default as imageBlock } from '../.storybook/assets/imageBlock.png'; -export { default as beepAudio } from '../.storybook/assets/beep.mp3'; diff --git a/packages/livechat/src/helpers/normalizeDOMRect.ts b/packages/livechat/src/helpers/normalizeDOMRect.ts index b9cd729e3826..2211f4904d59 100644 --- a/packages/livechat/src/helpers/normalizeDOMRect.ts +++ b/packages/livechat/src/helpers/normalizeDOMRect.ts @@ -1,6 +1,13 @@ -export const normalizeDOMRect = ({ left, top, right, bottom }: DOMRect) => ({ - left, - top, - right, - bottom, -}); +export const normalizeDOMRect = (rect: DOMRect | undefined) => { + if (!rect) { + throw new Error('DOMRect is not defined'); + } + + const { left, top, right, bottom } = rect; + return { + left, + top, + right, + bottom, + }; +}; diff --git a/packages/livechat/src/helpers/normalizeTransferHistoryMessage.ts b/packages/livechat/src/helpers/normalizeTransferHistoryMessage.ts index 83712bdd01ea..2d119bffacb0 100644 --- a/packages/livechat/src/helpers/normalizeTransferHistoryMessage.ts +++ b/packages/livechat/src/helpers/normalizeTransferHistoryMessage.ts @@ -34,7 +34,7 @@ export const normalizeTransferHistoryMessage = ( if (!sender.username) { return t('the_chat_was_moved_back_to_queue'); } - return t('from_returned_the_chat_to_the_queue', { from }); + return t('from_returned_the_chat_to_the_queue', { from, interpolation: { escapeValue: false } }); }, autoTransferUnansweredChatsToAgent: () => t('the_chat_was_transferred_to_another_agent_due_to_unanswered', { duration: comment }), autoTransferUnansweredChatsToQueue: () => t('the_chat_was_moved_back_to_queue_due_to_unanswered', { duration: comment }), diff --git a/packages/livechat/src/i18next.ts b/packages/livechat/src/i18next.ts index 05c0433163ae..74ac49d8e604 100644 --- a/packages/livechat/src/i18next.ts +++ b/packages/livechat/src/i18next.ts @@ -33,4 +33,7 @@ export default i18next bindI18n: 'loaded languageChanged', bindI18nStore: 'added', }, + interpolation: { + escapeValue: false, + }, }); diff --git a/packages/livechat/src/lib/api.js b/packages/livechat/src/lib/api.ts similarity index 68% rename from packages/livechat/src/lib/api.js rename to packages/livechat/src/lib/api.ts index ac7df77072b4..84cf63a97ce3 100644 --- a/packages/livechat/src/lib/api.js +++ b/packages/livechat/src/lib/api.ts @@ -1,10 +1,12 @@ +import type { IOmnichannelAgent } from '@rocket.chat/core-typings'; import i18next from 'i18next'; import { getDateFnsLocale } from './locale'; -export const normalizeAgent = (agentData) => agentData && { name: agentData.name, username: agentData.username, status: agentData.status }; +export const normalizeAgent = (agentData: IOmnichannelAgent) => + agentData && { name: agentData.name, username: agentData.username, status: agentData.status }; -export const normalizeQueueAlert = async (queueInfo) => { +export const normalizeQueueAlert = async (queueInfo: any) => { if (!queueInfo) { return; } diff --git a/packages/livechat/src/lib/commands.js b/packages/livechat/src/lib/commands.ts similarity index 100% rename from packages/livechat/src/lib/commands.js rename to packages/livechat/src/lib/commands.ts diff --git a/packages/livechat/src/lib/connection.js b/packages/livechat/src/lib/connection.ts similarity index 72% rename from packages/livechat/src/lib/connection.js rename to packages/livechat/src/lib/connection.ts index 0bd850d6d83e..34bcf77541dd 100644 --- a/packages/livechat/src/lib/connection.js +++ b/packages/livechat/src/lib/connection.ts @@ -6,12 +6,11 @@ import constants from './constants'; import { loadConfig } from './main'; import { loadMessages } from './room'; -let self; -let connectedListener; -let disconnectedListener; +let connectedListener: Promise<() => void> | false; +let disconnectedListener: Promise<() => void> | false; let initiated = false; const { livechatDisconnectedAlertId, livechatConnectedAlertId } = constants; -const removeListener = (l) => l.stop(); +const removeListener = (l: any) => l.stop(); const Connection = { async init() { @@ -20,7 +19,6 @@ const Connection = { } initiated = true; - self = this; await this.connect(); }, @@ -65,24 +63,29 @@ const Connection = { }, async handleConnected() { - await self.clearAlerts(); - await self.displayAlert({ id: livechatConnectedAlertId, children: i18next.t('livechat_connected'), success: true }); + await Connection.clearAlerts(); + await Connection.displayAlert({ id: livechatConnectedAlertId, children: i18next.t('livechat_connected'), success: true }); await loadMessages(); }, async handleDisconnected() { - await self.clearAlerts(); - await self.displayAlert({ id: livechatDisconnectedAlertId, children: i18next.t('livechat_is_not_connected'), error: true, timeout: 0 }); + await Connection.clearAlerts(); + await Connection.displayAlert({ + id: livechatDisconnectedAlertId, + children: i18next.t('livechat_is_not_connected'), + error: true, + timeout: 0, + }); // self.reconnect(); }, addListeners() { if (!connectedListener) { - connectedListener = Livechat.connection.on('connected', this.handleConnected); + connectedListener = Promise.resolve(Livechat.connection.on('connected', this.handleConnected)); } if (!disconnectedListener) { - disconnectedListener = Livechat.connection.on('disconnected', this.handleDisconnected); + disconnectedListener = Promise.resolve(Livechat.connection.on('disconnected', this.handleDisconnected)); } }, diff --git a/packages/livechat/src/lib/constants.js b/packages/livechat/src/lib/constants.ts similarity index 96% rename from packages/livechat/src/lib/constants.js rename to packages/livechat/src/lib/constants.ts index 6c968a707f3e..780228b73882 100644 --- a/packages/livechat/src/lib/constants.js +++ b/packages/livechat/src/lib/constants.ts @@ -5,4 +5,4 @@ export default { livechatDisconnectedAlertId: 'LIVECHAT_DISCONNECTED', livechatQueueMessageId: 'LIVECHAT_QUEUE_MESSAGE', webRTCCallStartedMessageType: 'livechat_webrtc_video_call', -}; +} as const; diff --git a/packages/livechat/src/components/Emoji/ascii.ts b/packages/livechat/src/lib/emoji/ascii.ts similarity index 100% rename from packages/livechat/src/components/Emoji/ascii.ts rename to packages/livechat/src/lib/emoji/ascii.ts diff --git a/packages/livechat/src/components/Emoji/emojis.ts b/packages/livechat/src/lib/emoji/emojis.ts similarity index 100% rename from packages/livechat/src/components/Emoji/emojis.ts rename to packages/livechat/src/lib/emoji/emojis.ts diff --git a/packages/livechat/src/components/Emoji/isBigEmoji.ts b/packages/livechat/src/lib/emoji/isBigEmoji.ts similarity index 100% rename from packages/livechat/src/components/Emoji/isBigEmoji.ts rename to packages/livechat/src/lib/emoji/isBigEmoji.ts diff --git a/packages/livechat/src/components/Emoji/shortnameToUnicode.ts b/packages/livechat/src/lib/emoji/shortnameToUnicode.ts similarity index 100% rename from packages/livechat/src/components/Emoji/shortnameToUnicode.ts rename to packages/livechat/src/lib/emoji/shortnameToUnicode.ts diff --git a/packages/livechat/src/routes/Chat/stories.tsx b/packages/livechat/src/routes/Chat/stories.tsx index f2aa1a4f6c80..2c91e6b5c132 100644 --- a/packages/livechat/src/routes/Chat/stories.tsx +++ b/packages/livechat/src/routes/Chat/stories.tsx @@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; -import { screenProps, avatarResolver, beepAudio, screenDecorator } from '../../helpers.stories'; +import { screenProps, avatarResolver, beepAudio, screenDecorator } from '../../../.storybook/helpers'; import Chat from './component'; const now = new Date(Date.parse('2021-01-01T00:00:00.000Z')); diff --git a/packages/livechat/src/routes/ChatFinished/stories.tsx b/packages/livechat/src/routes/ChatFinished/stories.tsx index 4479ee57d99d..6a4cc0f2b259 100644 --- a/packages/livechat/src/routes/ChatFinished/stories.tsx +++ b/packages/livechat/src/routes/ChatFinished/stories.tsx @@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; -import { screenProps, loremIpsum, screenDecorator } from '../../helpers.stories'; +import { screenProps, loremIpsum, screenDecorator } from '../../../.storybook/helpers'; import ChatFinished from './component'; export default { diff --git a/packages/livechat/src/routes/GDPRAgreement/stories.tsx b/packages/livechat/src/routes/GDPRAgreement/stories.tsx index cc764b888441..fb0139cd2e53 100644 --- a/packages/livechat/src/routes/GDPRAgreement/stories.tsx +++ b/packages/livechat/src/routes/GDPRAgreement/stories.tsx @@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; -import { screenDecorator, screenProps } from '../../helpers.stories'; +import { screenDecorator, screenProps } from '../../../.storybook/helpers'; import GDPRAgreement from './component'; export default { diff --git a/packages/livechat/src/routes/LeaveMessage/stories.tsx b/packages/livechat/src/routes/LeaveMessage/stories.tsx index 7560a8e1f7b5..61c2a3b803b0 100644 --- a/packages/livechat/src/routes/LeaveMessage/stories.tsx +++ b/packages/livechat/src/routes/LeaveMessage/stories.tsx @@ -1,7 +1,7 @@ import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; -import { screenDecorator } from '../../helpers.stories'; +import { screenDecorator } from '../../../.storybook/helpers'; import LeaveMessage from './index'; export default { diff --git a/packages/livechat/src/routes/Register/stories.tsx b/packages/livechat/src/routes/Register/stories.tsx index 1e7170c9f189..09cedd1958c6 100644 --- a/packages/livechat/src/routes/Register/stories.tsx +++ b/packages/livechat/src/routes/Register/stories.tsx @@ -2,7 +2,7 @@ import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; import Register from '.'; -import { screenDecorator, screenProps } from '../../helpers.stories'; +import { screenDecorator, screenProps } from '../../../.storybook/helpers'; export default { title: 'Routes/Register', diff --git a/packages/livechat/src/routes/SwitchDepartment/stories.tsx b/packages/livechat/src/routes/SwitchDepartment/stories.tsx index 7c30604644af..37405c97bbc7 100644 --- a/packages/livechat/src/routes/SwitchDepartment/stories.tsx +++ b/packages/livechat/src/routes/SwitchDepartment/stories.tsx @@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; -import { screenDecorator, screenProps } from '../../helpers.stories'; +import { screenDecorator, screenProps } from '../../../.storybook/helpers'; import SwitchDepartment from './index'; export default { diff --git a/packages/livechat/src/routes/TriggerMessage/stories.tsx b/packages/livechat/src/routes/TriggerMessage/stories.tsx index b530461a9d51..8ee5291e8e44 100644 --- a/packages/livechat/src/routes/TriggerMessage/stories.tsx +++ b/packages/livechat/src/routes/TriggerMessage/stories.tsx @@ -2,7 +2,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta, Story } from '@storybook/preact'; import type { ComponentProps } from 'preact'; -import { screenDecorator, screenProps } from '../../helpers.stories'; +import { screenDecorator, screenProps } from '../../../.storybook/helpers'; import TriggerMessage from './component'; const now = new Date(Date.parse('2021-01-01T00:00:00.000Z')); diff --git a/packages/livechat/src/store/index.tsx b/packages/livechat/src/store/index.tsx index d682d95b34c1..48e3e1df3ddd 100644 --- a/packages/livechat/src/store/index.tsx +++ b/packages/livechat/src/store/index.tsx @@ -66,6 +66,7 @@ type StoreState = { lastReadMessageId?: any; triggerAgent?: any; queueInfo?: any; + connecting?: boolean; }; export const initialState = (): StoreState => ({ diff --git a/packages/livechat/tsconfig.json b/packages/livechat/tsconfig.json index 0abc3fefa013..a276085c9a61 100644 --- a/packages/livechat/tsconfig.json +++ b/packages/livechat/tsconfig.json @@ -1,12 +1,13 @@ { "extends": "../../tsconfig.base.client.json", "compilerOptions": { + "module": "CommonJS", "outDir": "./dist", "allowJs": true, "checkJs": false, "jsxImportSource": "preact", }, - "include": ["./src", "./webpack.config.ts", "./svg-component-loader.ts"], + "include": ["./src", "./webpack.config.ts", "./svg-component-loader.ts", ".storybook/**/*.ts"], "exclude": [ "./node_modules", "./dist" diff --git a/packages/livechat/webpack.config.ts b/packages/livechat/webpack.config.ts index 361077be2a68..24bd213a4386 100644 --- a/packages/livechat/webpack.config.ts +++ b/packages/livechat/webpack.config.ts @@ -162,12 +162,12 @@ const config = (_env: any, args: webpack.WebpackOptionsNormalized): webpack.Conf { ...common(args), entry: { - script: _('./src/widget.js'), + 'rocketchat-livechat.min': _('./src/widget.js'), } as webpack.Entry, output: { path: _('./dist'), publicPath: args.mode === 'production' ? 'livechat/' : '/', - filename: 'rocketchat-livechat.min.js', + filename: '[name].js', }, module: { rules: [ diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 15a4db77eb10..1ec9ff09c283 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -77,11 +77,7 @@ export class MockedAppRootBuilder { }; private user: ContextType = { - loginWithPassword: () => Promise.reject(new Error('not implemented')), logout: () => Promise.reject(new Error('not implemented')), - loginWithService: () => () => Promise.reject(new Error('not implemented')), - loginWithToken: () => Promise.reject(new Error('not implemented')), - queryAllServices: () => [() => () => undefined, () => []], queryPreference: () => [() => () => undefined, () => undefined], queryRoom: () => [() => () => undefined, () => undefined], querySubscription: () => [() => () => undefined, () => undefined], diff --git a/packages/mock-providers/src/MockedUserContext.tsx b/packages/mock-providers/src/MockedUserContext.tsx index 10abe3915b42..973a6768846e 100644 --- a/packages/mock-providers/src/MockedUserContext.tsx +++ b/packages/mock-providers/src/MockedUserContext.tsx @@ -1,4 +1,3 @@ -import type { LoginService } from '@rocket.chat/ui-contexts'; import { UserContext } from '@rocket.chat/ui-contexts'; import React from 'react'; import type { ContextType } from 'react'; @@ -23,10 +22,6 @@ const userContextValue: ContextType = { querySubscription: () => [() => () => undefined, () => undefined], queryRoom: () => [() => () => undefined, () => undefined], - queryAllServices: () => [() => (): void => undefined, (): LoginService[] => []], - loginWithService: () => () => Promise.reject('loginWithService not implemented'), - loginWithPassword: async () => Promise.reject('loginWithPassword not implemented'), - loginWithToken: async () => Promise.reject('loginWithToken not implemented'), logout: () => Promise.resolve(), }; diff --git a/packages/model-typings/CHANGELOG.md b/packages/model-typings/CHANGELOG.md index d3cd53378808..26a040f67739 100644 --- a/packages/model-typings/CHANGELOG.md +++ b/packages/model-typings/CHANGELOG.md @@ -1,5 +1,17 @@ # @rocket.chat/model-typings +## 0.2.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 + +## 0.2.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 + ## 0.2.1 ### Patch Changes diff --git a/packages/model-typings/package.json b/packages/model-typings/package.json index e8dbf46d9a6f..5e89e978adb9 100644 --- a/packages/model-typings/package.json +++ b/packages/model-typings/package.json @@ -1,13 +1,13 @@ { "name": "@rocket.chat/model-typings", - "version": "0.2.1", + "version": "0.2.3", "private": true, "devDependencies": { "@types/jest": "~29.5.7", "@types/node-rsa": "^1.1.3", "eslint": "~8.45.0", "jest": "~29.6.4", - "mongodb": "^4.17.1", + "mongodb": "^4.17.2", "ts-jest": "~29.1.1", "typescript": "~5.3.2" }, diff --git a/packages/model-typings/src/models/IAppsTokensModel.ts b/packages/model-typings/src/models/IAppsTokensModel.ts index 13ef9b0cb240..4043ec719393 100644 --- a/packages/model-typings/src/models/IAppsTokensModel.ts +++ b/packages/model-typings/src/models/IAppsTokensModel.ts @@ -1,5 +1,9 @@ -import type { IAppsTokens } from '@rocket.chat/core-typings'; +import type { IAppsTokens, IUser } from '@rocket.chat/core-typings'; import type { IBaseModel } from './IBaseModel'; -export type IAppsTokensModel = IBaseModel; +export interface IAppsTokensModel extends IBaseModel { + countTokensByUserId(userId: IUser['_id']): Promise; + countGcmTokens(): Promise; + countApnTokens(): Promise; +} diff --git a/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts b/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts index 7d8f8eda0ef4..dfc2dfc5d1f2 100644 --- a/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts +++ b/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts @@ -95,4 +95,5 @@ export interface ILivechatDepartmentAgentsModel extends IBaseModel; enableAgentsByDepartmentId(departmentId: string): Promise; findAllAgentsConnectedToListOfDepartments(departmentIds: string[]): Promise; + findByAgentIds(agentIds: string[], options?: FindOptions): FindCursor; } diff --git a/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts b/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts index e515040dfa1b..5a26607eda06 100644 --- a/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts +++ b/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts @@ -1,8 +1,10 @@ -import type { ILoginServiceConfiguration } from '@rocket.chat/core-typings'; +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import type { DeleteResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ILoginServiceConfigurationModel extends IBaseModel { - // +export interface ILoginServiceConfigurationModel extends IBaseModel { + createOrUpdateService(serviceName: string, serviceData: Partial): Promise; + removeService(serviceName: string): Promise; } diff --git a/packages/model-typings/src/models/IModerationReportsModel.ts b/packages/model-typings/src/models/IModerationReportsModel.ts index db5ba9f85526..ef17b5261d05 100644 --- a/packages/model-typings/src/models/IModerationReportsModel.ts +++ b/packages/model-typings/src/models/IModerationReportsModel.ts @@ -1,4 +1,4 @@ -import type { IModerationReport, IMessage, IModerationAudit, MessageReport } from '@rocket.chat/core-typings'; +import type { IModerationReport, IMessage, IModerationAudit, MessageReport, UserReport } from '@rocket.chat/core-typings'; import type { AggregationCursor, Document, FindCursor, FindOptions, UpdateResult } from 'mongodb'; import type { FindPaginated, IBaseModel } from './IBaseModel'; @@ -30,6 +30,15 @@ export interface IModerationReportsModel extends IBaseModel { pagination: PaginationParams, ): AggregationCursor; + findUserReports( + latest: Date, + oldest: Date, + selector: string, + pagination: PaginationParams, + ): AggregationCursor & { count: number }>; + + getTotalUniqueReportedUsers(latest: Date, oldest: Date, selector: string, isMessageReports?: boolean): Promise; + countMessageReportsInRange(latest: Date, oldest: Date, selector: string): Promise; findReportsByMessageId( @@ -46,6 +55,13 @@ export interface IModerationReportsModel extends IBaseModel { options?: FindOptions, ): FindPaginated>>; + findUserReportsByReportedUserId( + userId: string, + selector: string, + pagination: PaginationParams, + options?: FindOptions, + ): FindPaginated>>; + hideMessageReportsByMessageId( messageId: IMessage['_id'], userId: string, @@ -54,4 +70,6 @@ export interface IModerationReportsModel extends IBaseModel { ): Promise; hideMessageReportsByUserId(userId: string, moderatorId: string, reason: string, action: string): Promise; + + hideUserReportsByUserId(userId: string, moderatorId: string, reason: string, action: string): Promise; } diff --git a/packages/model-typings/src/models/IOEmbedCacheModel.ts b/packages/model-typings/src/models/IOEmbedCacheModel.ts index 43efc296f967..7f9fddc9b885 100644 --- a/packages/model-typings/src/models/IOEmbedCacheModel.ts +++ b/packages/model-typings/src/models/IOEmbedCacheModel.ts @@ -6,5 +6,5 @@ import type { IBaseModel } from './IBaseModel'; export interface IOEmbedCacheModel extends IBaseModel { createWithIdAndData(_id: string, data: any): Promise; - removeAfterDate(date: Date): Promise; + removeBeforeDate(date: Date): Promise; } diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 215eac8b232d..a4743216f7d4 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -110,6 +110,8 @@ export interface IRoomsModel extends IBaseModel { findOneByNameOrFname(name: NonNullable, options?: FindOptions): Promise; + findOneByJoinCodeAndId(joinCode: string, rid: IRoom['_id'], options?: FindOptions): Promise; + findOneByNonValidatedName(name: NonNullable, options?: FindOptions): Promise; allRoomSourcesCount(): AggregationCursor<{ _id: Required; count: number }>; diff --git a/packages/model-typings/src/models/ISettingsModel.ts b/packages/model-typings/src/models/ISettingsModel.ts index d382d4853a4b..cb11d4b1b405 100644 --- a/packages/model-typings/src/models/ISettingsModel.ts +++ b/packages/model-typings/src/models/ISettingsModel.ts @@ -1,5 +1,5 @@ import type { ISetting, ISettingColor, ISettingSelectOption } from '@rocket.chat/core-typings'; -import type { FindCursor, UpdateFilter, UpdateResult, Document } from 'mongodb'; +import type { FindCursor, UpdateFilter, UpdateResult, Document, FindOptions } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -10,7 +10,7 @@ export interface ISettingsModel extends IBaseModel { findOneNotHiddenById(_id: string): Promise; - findByIds(_id?: string[] | string): FindCursor; + findByIds(_id?: string[] | string, options?: FindOptions): FindCursor; updateValueById( _id: string, diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 56119982afb3..bb7d4718b7ff 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -131,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; + updateAllAutoTranslateLanguagesByUserId(userId: IUser['_id'], language: string): Promise; disableAutoTranslateByRoomId(roomId: IRoom['_id']): Promise; findAlwaysNotifyDesktopUsersByRoomId(roomId: string): FindCursor; diff --git a/packages/models/CHANGELOG.md b/packages/models/CHANGELOG.md index 8861ba27c492..ddf94f570707 100644 --- a/packages/models/CHANGELOG.md +++ b/packages/models/CHANGELOG.md @@ -1,5 +1,17 @@ # @rocket.chat/models +## 0.0.27 + +### Patch Changes + +- @rocket.chat/model-typings@0.2.3 + +## 0.0.26 + +### Patch Changes + +- @rocket.chat/model-typings@0.2.2 + ## 0.0.25 ### Patch Changes diff --git a/packages/models/package.json b/packages/models/package.json index 8c752d838109..0da22ee5885b 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/models", - "version": "0.0.25", + "version": "0.0.27", "private": true, "devDependencies": { "@types/jest": "~29.5.7", diff --git a/packages/release-action/src/index.ts b/packages/release-action/src/index.ts index ca9dab36ac65..51956e9e2a8d 100644 --- a/packages/release-action/src/index.ts +++ b/packages/release-action/src/index.ts @@ -7,6 +7,7 @@ import { bumpNextVersion } from './bumpNextVersion'; import { setupGitUser } from './gitUtils'; import { publishRelease } from './publishRelease'; import { startPatchRelease } from './startPatchRelease'; +import { updatePRDescription } from './updatePRDescription'; // const getOptionalInput = (name: string) => core.getInput(name) || undefined; @@ -45,6 +46,8 @@ import { startPatchRelease } from './startPatchRelease'; await bumpNextVersion({ githubToken, mainPackagePath }); } else if (action === 'patch') { await startPatchRelease({ baseRef, githubToken, mainPackagePath }); + } else if (action === 'update-pr-description') { + await updatePRDescription({ githubToken, mainPackagePath }); } })().catch((err) => { core.error(err); diff --git a/packages/release-action/src/updatePRDescription.ts b/packages/release-action/src/updatePRDescription.ts new file mode 100644 index 000000000000..0fc6386636ea --- /dev/null +++ b/packages/release-action/src/updatePRDescription.ts @@ -0,0 +1,75 @@ +import fs from 'fs'; +import path from 'path'; + +import * as core from '@actions/core'; +import { exec } from '@actions/exec'; +import * as github from '@actions/github'; + +import { setupOctokit } from './setupOctokit'; +import { getChangelogEntry, getEngineVersionsMd, readPackageJson } from './utils'; + +export async function updatePRDescription({ + githubToken, + mainPackagePath, + cwd = process.cwd(), +}: { + githubToken: string; + mainPackagePath: string; + cwd?: string; +}) { + const octokit = setupOctokit(githubToken); + + // generate change logs from changesets + await exec('yarn', ['changeset', 'version']); + + // get version from main package + const { version: newVersion } = await readPackageJson(mainPackagePath); + + const mainPackageChangelog = path.join(mainPackagePath, 'CHANGELOG.md'); + + const changelogContents = fs.readFileSync(mainPackageChangelog, 'utf8'); + const changelogEntry = getChangelogEntry(changelogContents, newVersion); + if (!changelogEntry) { + // we can find a changelog but not the entry for this version + // if this is true, something has probably gone wrong + throw new Error('Could not find changelog entry for version newVersion'); + } + + const releaseBody = (await getEngineVersionsMd(cwd)) + changelogEntry.content; + + core.info('get PR description'); + const result = await octokit.rest.pulls.get({ + pull_number: github.context.issue.number, + body: releaseBody, + ...github.context.repo, + }); + + const { body: originalBody = '' } = result.data; + + const cleanBody = originalBody?.replace(/.*/s, '').trim() || ''; + + const bodyUpdated = `${cleanBody} + + + + +_You can see below a preview of the release change log:_ + +# ${newVersion} + +${releaseBody} +`; + + if (bodyUpdated === originalBody) { + core.info('no changes to PR description'); + return; + } + + core.info('update PR description'); + await octokit.rest.pulls.update({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + pull_number: github.context.issue.number, + body: bodyUpdated, + }); +} diff --git a/packages/rest-typings/CHANGELOG.md b/packages/rest-typings/CHANGELOG.md index 0e7df4130b53..3ad6f8cc5577 100644 --- a/packages/rest-typings/CHANGELOG.md +++ b/packages/rest-typings/CHANGELOG.md @@ -1,5 +1,17 @@ # @rocket.chat/rest-typings +## 6.5.3 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.3 + +## 6.5.2 + +### Patch Changes + +- @rocket.chat/core-typings@6.5.2 + ## 6.5.1 ### Patch Changes diff --git a/packages/rest-typings/package.json b/packages/rest-typings/package.json index 5e3708da8d75..759e0097ec36 100644 --- a/packages/rest-typings/package.json +++ b/packages/rest-typings/package.json @@ -7,7 +7,7 @@ "eslint": "~8.45.0", "jest": "~29.6.4", "jest-environment-jsdom": "~29.6.4", - "mongodb": "^4.17.1", + "mongodb": "^4.17.2", "ts-jest": "~29.1.1", "typescript": "~5.3.2" }, @@ -26,7 +26,7 @@ "dependencies": { "@rocket.chat/apps-engine": "1.41.0", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/message-parser": "~0.31.27", + "@rocket.chat/message-parser": "~0.31.28", "@rocket.chat/ui-kit": "workspace:~", "ajv": "^8.11.0", "ajv-formats": "^2.1.1" diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index 5d4dfa8f4588..b4362d87ba41 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -209,7 +209,7 @@ export type AppsEndpoints = { }; }; - '/apps/': { + '/apps': { GET: | ((params: { buildExternalUrl: 'true'; purchaseType?: 'buy' | 'subscription'; appId?: string; details?: 'true' | 'false' }) => { url: string; @@ -239,15 +239,27 @@ export type AppsEndpoints = { }[]) | (() => { apps: App[] }); - POST: (params: { - appId: string; - marketplace: boolean; - version: string; - permissionsGranted?: IPermission[]; - url?: string; - downloadOnly?: boolean; - }) => { - app: App; + POST: { + ( + params: + | { + appId: string; + marketplace: boolean; + version: string; + permissionsGranted?: IPermission[]; + url?: string; + downloadOnly?: boolean; + } + | { url: string; downloadOnly?: boolean }, + ): + | { + app: App; + } + | { + buff: { + data: ArrayLike; + }; + }; }; }; diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 3b8197ce20bf..044282784cdf 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -186,7 +186,7 @@ export type MatchPathPattern = TPath extends any ? Extract = Extract< PathPattern, - `${TBasePath}/${TSubPathPattern}` | TSubPathPattern + `${TBasePath}${TSubPathPattern extends '' ? TSubPathPattern : `/${TSubPathPattern}`}` | TSubPathPattern >; type GetParams = TOperation extends (...args: any) => any ? Parameters[0] : never; diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index c29e420a47f3..0af9fabced58 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -1,4 +1,4 @@ -import type { IMessage, IRoom, MessageAttachment, ReadReceipt, OtrSystemMessages } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, MessageAttachment, ReadReceipt, OtrSystemMessages, MessageUrl } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; @@ -789,6 +789,27 @@ const ChatPostMessageSchema = { export const isChatPostMessageProps = ajv.compile(ChatPostMessageSchema); +type ChatGetURLPreview = { + roomId: IRoom['_id']; + url: string; +}; + +const ChatGetURLPreviewSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + url: { + type: 'string', + }, + }, + required: ['roomId', 'url'], + additionalProperties: false, +}; + +export const isChatGetURLPreviewProps = ajv.compile(ChatGetURLPreviewSchema); + export type ChatEndpoints = { '/v1/chat.sendMessage': { POST: (params: ChatSendMessage) => { @@ -935,4 +956,7 @@ export type ChatEndpoints = { '/v1/chat.otr': { POST: (params: { roomId: string; type: OtrSystemMessages }) => void; }; + '/v1/chat.getURLPreview': { + GET: (params: ChatGetURLPreview) => { urlPreview: MessageUrl }; + }; }; diff --git a/packages/rest-typings/src/v1/misc.ts b/packages/rest-typings/src/v1/misc.ts index 804b72a763de..cc06e1cc330a 100644 --- a/packages/rest-typings/src/v1/misc.ts +++ b/packages/rest-typings/src/v1/misc.ts @@ -235,13 +235,13 @@ export type MiscEndpoints = { '/v1/method.call/:method': { POST: (params: { message: string }) => { - message: unknown; + message: string; }; }; '/v1/method.callAnon/:method': { POST: (params: { message: string }) => { - message: unknown; + message: string; }; }; diff --git a/packages/rest-typings/src/v1/moderation/ReportMessageHistoryParams.ts b/packages/rest-typings/src/v1/moderation/GetUserReportsParams.ts similarity index 74% rename from packages/rest-typings/src/v1/moderation/ReportMessageHistoryParams.ts rename to packages/rest-typings/src/v1/moderation/GetUserReportsParams.ts index dbd959b4cab0..a15dae65e19c 100644 --- a/packages/rest-typings/src/v1/moderation/ReportMessageHistoryParams.ts +++ b/packages/rest-typings/src/v1/moderation/GetUserReportsParams.ts @@ -1,12 +1,12 @@ import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; import { ajv } from '../Ajv'; -type ReportMessageHistoryParams = { +type GetUserReportsParams = { userId: string; selector?: string; }; -export type ReportMessageHistoryParamsGET = PaginatedRequest; +export type GetUserReportsParamsGET = PaginatedRequest; const ajvParams = { type: 'object', @@ -37,4 +37,4 @@ const ajvParams = { additionalProperties: false, }; -export const isReportMessageHistoryParams = ajv.compile(ajvParams); +export const isGetUserReportsParams = ajv.compile(ajvParams); diff --git a/packages/rest-typings/src/v1/moderation/index.ts b/packages/rest-typings/src/v1/moderation/index.ts index 7f111ed172ec..c332bad0e2c4 100644 --- a/packages/rest-typings/src/v1/moderation/index.ts +++ b/packages/rest-typings/src/v1/moderation/index.ts @@ -5,5 +5,5 @@ export * from './ModerationDeleteMsgHistoryParams'; export * from './ReportHistoryProps'; export * from './ReportInfoParams'; export * from './ReportsByMsgIdParams'; -export * from './ReportMessageHistoryParams'; +export * from './GetUserReportsParams'; export * from './ModerationReportUserPOST'; diff --git a/packages/rest-typings/src/v1/moderation/moderation.ts b/packages/rest-typings/src/v1/moderation/moderation.ts index 7b605f6a45d5..7ce7f4276a3c 100644 --- a/packages/rest-typings/src/v1/moderation/moderation.ts +++ b/packages/rest-typings/src/v1/moderation/moderation.ts @@ -1,12 +1,12 @@ -import type { IModerationAudit, IModerationReport, IUser, MessageReport } from '@rocket.chat/core-typings'; +import type { IModerationAudit, IModerationReport, IUser, MessageReport, UserReport } from '@rocket.chat/core-typings'; import type { PaginatedResult } from '../../helpers/PaginatedResult'; import type { ArchiveReportPropsPOST } from './ArchiveReportProps'; +import type { GetUserReportsParamsGET } from './GetUserReportsParams'; import type { ModerationDeleteMsgHistoryParamsPOST } from './ModerationDeleteMsgHistoryParams'; import type { ModerationReportUserPOST } from './ModerationReportUserPOST'; import type { ReportHistoryPropsGET } from './ReportHistoryProps'; import type { ReportInfoParams } from './ReportInfoParams'; -import type { ReportMessageHistoryParamsGET } from './ReportMessageHistoryParams'; import type { ReportsByMsgIdParamsGET } from './ReportsByMsgIdParams'; export type ModerationEndpoints = { @@ -19,18 +19,35 @@ export type ModerationEndpoints = { total: number; }>; }; + '/v1/moderation.userReports': { + GET: (params: ReportHistoryPropsGET) => PaginatedResult<{ + reports: (Pick & { count: number })[]; + count: number; + offset: number; + total: number; + }>; + }; '/v1/moderation.user.reportedMessages': { - GET: (params: ReportMessageHistoryParamsGET) => PaginatedResult<{ + GET: (params: GetUserReportsParamsGET) => PaginatedResult<{ user: Pick | null; messages: Pick[]; }>; }; + '/v1/moderation.user.reportsByUserId': { + GET: (params: GetUserReportsParamsGET) => PaginatedResult<{ + user: IUser | null; + reports: Omit[]; + }>; + }; '/v1/moderation.user.deleteReportedMessages': { POST: (params: ModerationDeleteMsgHistoryParamsPOST) => void; }; '/v1/moderation.dismissReports': { POST: (params: ArchiveReportPropsPOST) => void; }; + '/v1/moderation.dismissUserReports': { + POST: (params: ArchiveReportPropsPOST) => void; + }; '/v1/moderation.reports': { GET: (params: ReportsByMsgIdParamsGET) => PaginatedResult<{ reports: Pick[]; diff --git a/packages/rest-typings/src/v1/push.ts b/packages/rest-typings/src/v1/push.ts index 63e2015dbc36..51314ec5e4db 100644 --- a/packages/rest-typings/src/v1/push.ts +++ b/packages/rest-typings/src/v1/push.ts @@ -71,4 +71,9 @@ export type PushEndpoints = { defaultPushGateway: boolean; }; }; + '/v1/push.test': { + POST: () => { + tokensCount: boolean; + }; + }; }; diff --git a/packages/rest-typings/src/v1/settings.ts b/packages/rest-typings/src/v1/settings.ts index cbc789e22051..6da53c04ae68 100644 --- a/packages/rest-typings/src/v1/settings.ts +++ b/packages/rest-typings/src/v1/settings.ts @@ -1,4 +1,4 @@ -import type { ISetting, ISettingColor } from '@rocket.chat/core-typings'; +import type { ISetting, ISettingColor, LoginServiceConfiguration } from '@rocket.chat/core-typings'; import type { PaginatedResult } from '../helpers/PaginatedResult'; @@ -8,46 +8,6 @@ type SettingsUpdatePropsActions = { execute: boolean; }; -export type OauthCustomConfiguration = { - _id: string; - clientId?: string; - custom: boolean; - service?: string; - serverURL: string; - tokenPath: string; - identityPath: string; - authorizePath: string; - scope: string; - loginStyle: 'popup' | 'redirect'; - tokenSentVia: 'header' | 'payload'; - identityTokenSentVia: 'default' | 'header' | 'payload'; - keyField: 'username' | 'email'; - usernameField: string; - emailField: string; - nameField: string; - avatarField: string; - rolesClaim: string; - groupsClaim: string; - mapChannels: string; - channelsMap: string; - channelsAdmin: string; - mergeUsers: boolean; - mergeUsersDistinctServices: boolean; - mergeRoles: boolean; - accessTokenParam: string; - showButton: boolean; - - appId: string; - consumerKey?: string; - - clientConfig: unknown; - buttonLabelText: string; - buttonLabelColor: string; - buttonColor: string; -}; - -export const isOauthCustomConfiguration = (config: any): config is OauthCustomConfiguration => Boolean(config); - export const isSettingsUpdatePropsActions = (props: Partial): props is SettingsUpdatePropsActions => 'execute' in props; @@ -74,7 +34,7 @@ export type SettingsEndpoints = { '/v1/settings.oauth': { GET: () => { - services: Partial[]; + services: Partial[]; }; }; @@ -95,10 +55,7 @@ export type SettingsEndpoints = { '/v1/service.configurations': { GET: () => { - configurations: Array<{ - appId: string; - secret: string; - }>; + configurations: Array; }; }; }; diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index c47f4be6404d..15f9ad840d42 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -1,12 +1,4 @@ -import type { - IExportOperation, - AvatarServiceObject, - ISubscription, - ITeam, - IUser, - IPersonalAccessToken, - UserStatus, -} from '@rocket.chat/core-typings'; +import type { IExportOperation, ISubscription, ITeam, IUser, IPersonalAccessToken, UserStatus } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; @@ -241,7 +233,15 @@ export type UsersEndpoints = { '/v1/users.getAvatarSuggestion': { GET: () => { - suggestions: Record; + suggestions: Record< + string, + { + blob: string; + contentType: string; + service: string; + url: string; + } + >; }; }; diff --git a/packages/tools/src/getObjectKeys.ts b/packages/tools/src/getObjectKeys.ts new file mode 100644 index 000000000000..00b0f4d1e10a --- /dev/null +++ b/packages/tools/src/getObjectKeys.ts @@ -0,0 +1 @@ +export const getObjectKeys = (object: T) => Object.keys(object) as (keyof T)[]; diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 6ec3e38d358a..b1b53ab71a90 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -1,3 +1,4 @@ +export * from './getObjectKeys'; export * from './normalizeLanguage'; export * from './pick'; export * from './stream'; diff --git a/packages/ui-client/CHANGELOG.md b/packages/ui-client/CHANGELOG.md index 183a47816e39..32681d5384de 100644 --- a/packages/ui-client/CHANGELOG.md +++ b/packages/ui-client/CHANGELOG.md @@ -1,5 +1,17 @@ # @rocket.chat/ui-client +## 3.0.3 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.3 + +## 3.0.2 + +### Patch Changes + +- @rocket.chat/ui-contexts@3.0.2 + ## 3.0.1 ### Patch Changes diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json index ab499cab0955..fa036a71e15b 100644 --- a/packages/ui-client/package.json +++ b/packages/ui-client/package.json @@ -1,13 +1,14 @@ { "name": "@rocket.chat/ui-client", - "version": "3.0.1", + "version": "3.0.3", "private": true, "devDependencies": { "@babel/core": "~7.22.20", + "@react-aria/toolbar": "^3.0.0-beta.1", "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/fuselage": "^0.41.0", - "@rocket.chat/fuselage-hooks": "~0.32.1", - "@rocket.chat/icons": "~0.32.0", + "@rocket.chat/fuselage": "^0.45.0", + "@rocket.chat/fuselage-hooks": "^0.33.0", + "@rocket.chat/icons": "^0.33.0", "@rocket.chat/mock-providers": "workspace:^", "@rocket.chat/ui-contexts": "workspace:~", "@storybook/addon-actions": "~6.5.16", @@ -57,11 +58,12 @@ "/dist" ], "peerDependencies": { + "@react-aria/toolbar": "*", "@rocket.chat/css-in-js": "*", "@rocket.chat/fuselage": "*", "@rocket.chat/fuselage-hooks": "*", "@rocket.chat/icons": "*", - "@rocket.chat/ui-contexts": "3.0.1", + "@rocket.chat/ui-contexts": "3.0.3", "react": "~17.0.2" }, "volta": { diff --git a/packages/ui-client/src/components/EmojiPicker/EmojiPickerCategoryHeader.tsx b/packages/ui-client/src/components/EmojiPicker/EmojiPickerCategoryHeader.tsx index 3da60647c580..509af85c68fe 100644 --- a/packages/ui-client/src/components/EmojiPicker/EmojiPickerCategoryHeader.tsx +++ b/packages/ui-client/src/components/EmojiPicker/EmojiPickerCategoryHeader.tsx @@ -1,8 +1,10 @@ -import { ButtonGroup } from '@rocket.chat/fuselage'; -import type { AllHTMLAttributes } from 'react'; +import { ButtonGroup, Box } from '@rocket.chat/fuselage'; +import type { ComponentProps } from 'react'; -const EmojiPickerCategoryHeader = (props: Omit, 'is' | 'wrap'>) => ( - +const EmojiPickerCategoryHeader = (props: ComponentProps) => ( + + + ); export default EmojiPickerCategoryHeader; diff --git a/packages/ui-client/src/components/FramedIcon.tsx b/packages/ui-client/src/components/FramedIcon.tsx deleted file mode 100644 index 6fa2b230c661..000000000000 --- a/packages/ui-client/src/components/FramedIcon.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Box, Icon } from '@rocket.chat/fuselage'; -import type { Keys } from '@rocket.chat/icons'; -import type { FC } from 'react'; - -type Variant = 'danger' | 'info' | 'success' | 'warning' | 'neutral'; - -type ColorMapType = { - [key in Variant]: { - color: string; - bg: string; - }; -}; - -const colorMap: ColorMapType = { - danger: { color: 'status-font-on-danger', bg: 'status-background-danger' }, - info: { color: 'status-font-on-info', bg: 'status-background-info' }, - success: { color: 'status-font-on-success', bg: 'status-background-success' }, - warning: { color: 'status-font-on-warning', bg: 'status-background-warning' }, - neutral: { color: 'font-secondary-info', bg: 'surface-tint' }, -}; - -const getColors = (type: Variant) => colorMap[type] || colorMap.neutral; - -type FramedIconProps = { - type: Variant; - icon: Keys; -}; - -export const FramedIcon: FC = ({ type, icon }) => { - const { color, bg } = getColors(type); - - return ( - - - - ); -}; diff --git a/packages/ui-client/src/components/Header/Header.stories.tsx b/packages/ui-client/src/components/Header/Header.stories.tsx index 364f442fa1d1..22cdecb20038 100644 --- a/packages/ui-client/src/components/Header/Header.stories.tsx +++ b/packages/ui-client/src/components/Header/Header.stories.tsx @@ -10,9 +10,9 @@ import { HeaderContent, HeaderContentRow, HeaderIcon, - HeaderToolbox, - HeaderToolboxAction, - HeaderToolboxActionBadge, + HeaderToolbar, + HeaderToolbarAction, + HeaderToolbarActionBadge, HeaderTitle, HeaderState, HeaderSubtitle, @@ -25,8 +25,8 @@ export default { title: 'Components/Header', component: Header, subcomponents: { - HeaderToolbox, - HeaderToolboxAction, + HeaderToolbar, + HeaderToolbarAction, HeaderAvatar, HeaderContent, HeaderContentRow, @@ -103,19 +103,19 @@ export const Default = () => ( {room.name} - - - - - + + + + + ); export const WithBurger = () => (
- - - + + + {avatar} @@ -129,11 +129,11 @@ export const WithBurger = () => ( {room.name} - - - - - + + + + +
); @@ -150,17 +150,17 @@ export const WithActionBadge = () => ( {room.name} - - - 1 - - - 2 - - - 99 - - - + + + 1 + + + 2 + + + 99 + + + ); diff --git a/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbar.tsx b/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbar.tsx new file mode 100644 index 000000000000..909814926c55 --- /dev/null +++ b/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbar.tsx @@ -0,0 +1,16 @@ +import { useToolbar } from '@react-aria/toolbar'; +import { ButtonGroup } from '@rocket.chat/fuselage'; +import { type ComponentProps, useRef } from 'react'; + +const HeaderToolbar = (props: ComponentProps) => { + const ref = useRef(null); + const { toolbarProps } = useToolbar(props, ref); + + return ( + + {props.children} + + ); +}; + +export default HeaderToolbar; diff --git a/packages/ui-client/src/components/Header/HeaderToolbox/HeaderToolboxAction.tsx b/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarAction.tsx similarity index 78% rename from packages/ui-client/src/components/Header/HeaderToolbox/HeaderToolboxAction.tsx rename to packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarAction.tsx index 48db84657959..36a124ece5ab 100644 --- a/packages/ui-client/src/components/Header/HeaderToolbox/HeaderToolboxAction.tsx +++ b/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarAction.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@rocket.chat/fuselage'; import { forwardRef } from 'react'; -const HeaderToolboxAction = forwardRef(function HeaderToolboxAction( +const HeaderToolbarAction = forwardRef(function HeaderToolbarAction( { id, icon, action, index, title, 'data-tooltip': tooltip, ...props }, ref, ) { @@ -22,4 +22,4 @@ const HeaderToolboxAction = forwardRef(function HeaderTo ); }); -export default HeaderToolboxAction; +export default HeaderToolbarAction; diff --git a/packages/ui-client/src/components/Header/HeaderToolbox/HeaderToolboxActionBadge.tsx b/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarActionBadge.tsx similarity index 71% rename from packages/ui-client/src/components/Header/HeaderToolbox/HeaderToolboxActionBadge.tsx rename to packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarActionBadge.tsx index d8da9762de9c..57f26b06bfd7 100644 --- a/packages/ui-client/src/components/Header/HeaderToolbox/HeaderToolboxActionBadge.tsx +++ b/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarActionBadge.tsx @@ -2,7 +2,7 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Badge } from '@rocket.chat/fuselage'; import type { ComponentProps, FC } from 'react'; -const HeaderToolboxActionBadge: FC> = (props) => ( +const HeaderToolbarActionActionBadge: FC> = (props) => ( > = (props) => ( ); -export default HeaderToolboxActionBadge; +export default HeaderToolbarActionActionBadge; diff --git a/packages/ui-client/src/components/Header/HeaderToolbox/HeaderToolboxDivider.tsx b/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarDivider.tsx similarity index 51% rename from packages/ui-client/src/components/Header/HeaderToolbox/HeaderToolboxDivider.tsx rename to packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarDivider.tsx index 9d7041490a62..52bb2d21222f 100644 --- a/packages/ui-client/src/components/Header/HeaderToolbox/HeaderToolboxDivider.tsx +++ b/packages/ui-client/src/components/Header/HeaderToolbar/HeaderToolbarDivider.tsx @@ -1,6 +1,6 @@ import { Divider } from '@rocket.chat/fuselage'; import type { ReactElement } from 'react'; -const HeaderToolboxDivider = (): ReactElement => ; +const HeaderToolbarDivider = (): ReactElement => ; -export default HeaderToolboxDivider; +export default HeaderToolbarDivider; diff --git a/packages/ui-client/src/components/Header/HeaderToolbar/index.ts b/packages/ui-client/src/components/Header/HeaderToolbar/index.ts new file mode 100644 index 000000000000..933f03d658c0 --- /dev/null +++ b/packages/ui-client/src/components/Header/HeaderToolbar/index.ts @@ -0,0 +1,4 @@ +export { default as HeaderToolbar } from './HeaderToolbar'; +export { default as HeaderToolbarAction } from './HeaderToolbarAction'; +export { default as HeaderToolbarActionBadge } from './HeaderToolbarActionBadge'; +export { default as HeaderToolbarDivider } from './HeaderToolbarDivider'; diff --git a/packages/ui-client/src/components/Header/HeaderToolbox/HeaderToolbox.tsx b/packages/ui-client/src/components/Header/HeaderToolbox/HeaderToolbox.tsx deleted file mode 100644 index 58ed32481b2e..000000000000 --- a/packages/ui-client/src/components/Header/HeaderToolbox/HeaderToolbox.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ButtonGroup } from '@rocket.chat/fuselage'; -import type { FC, ComponentProps } from 'react'; - -const HeaderToolbox: FC> = (props) => ; - -export default HeaderToolbox; diff --git a/packages/ui-client/src/components/Header/HeaderToolbox/index.ts b/packages/ui-client/src/components/Header/HeaderToolbox/index.ts deleted file mode 100644 index 7632bbd1a537..000000000000 --- a/packages/ui-client/src/components/Header/HeaderToolbox/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { default as HeaderToolbox } from './HeaderToolbox'; -export { default as HeaderToolboxAction } from './HeaderToolboxAction'; -export { default as HeaderToolboxActionBadge } from './HeaderToolboxActionBadge'; -export { default as HeaderToolboxDivider } from './HeaderToolboxDivider'; diff --git a/packages/ui-client/src/components/Header/index.ts b/packages/ui-client/src/components/Header/index.ts index 675bdbba9de9..b8d62fb777f9 100644 --- a/packages/ui-client/src/components/Header/index.ts +++ b/packages/ui-client/src/components/Header/index.ts @@ -8,4 +8,4 @@ export { default as HeaderState } from './HeaderState'; export { default as HeaderSubtitle } from './HeaderSubtitle'; export * from './HeaderTag'; export { default as HeaderTitle } from './HeaderTitle'; -export * from './HeaderToolbox'; +export * from './HeaderToolbar'; diff --git a/packages/ui-client/src/components/index.ts b/packages/ui-client/src/components/index.ts index f7f77474b256..f5f37ac1c878 100644 --- a/packages/ui-client/src/components/index.ts +++ b/packages/ui-client/src/components/index.ts @@ -10,4 +10,3 @@ export * as UserStatus from './UserStatus'; export * from './Header'; export * from './MultiSelectCustom/MultiSelectCustom'; export * from './FeaturePreview/FeaturePreview'; -export * from './FramedIcon'; diff --git a/packages/ui-composer/package.json b/packages/ui-composer/package.json index 7ceeee6964e0..aa1b225a2ec9 100644 --- a/packages/ui-composer/package.json +++ b/packages/ui-composer/package.json @@ -4,9 +4,10 @@ "private": true, "devDependencies": { "@babel/core": "~7.22.20", + "@react-aria/toolbar": "^3.0.0-beta.1", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/fuselage": "^0.41.0", - "@rocket.chat/icons": "~0.32.0", + "@rocket.chat/fuselage": "^0.45.0", + "@rocket.chat/icons": "^0.33.0", "@storybook/addon-actions": "~6.5.16", "@storybook/addon-docs": "~6.5.16", "@storybook/addon-essentials": "~6.5.16", @@ -26,6 +27,7 @@ "typescript": "~5.3.2" }, "peerDependencies": { + "@react-aria/toolbar": "*", "@rocket.chat/fuselage": "*", "@rocket.chat/icons": "*", "react": "^17.0.2", diff --git a/packages/ui-composer/src/MessageComposer/MessageComposerButton.tsx b/packages/ui-composer/src/MessageComposer/MessageComposerButton.tsx new file mode 100644 index 000000000000..51316bff7b08 --- /dev/null +++ b/packages/ui-composer/src/MessageComposer/MessageComposerButton.tsx @@ -0,0 +1,6 @@ +import { Button } from '@rocket.chat/fuselage'; +import type { ComponentProps, ReactElement } from 'react'; + +const MessageComposerButton = (props: ComponentProps): ReactElement => + {openCreateNewScreen && ( + + {projects[activeProject]?.screens + .map((id) => screens[id]) + .map((screen, i) => ( + screens[id]) + .length <= 1 + } + /> + ))} + + + )} + + + ); +}; + +export default CreateNewScreenContainer; diff --git a/packages/uikit-playground/src/Components/CreateNewScreen/ScreenThumbnail.tsx b/packages/uikit-playground/src/Components/CreateNewScreen/ScreenThumbnail.tsx new file mode 100644 index 000000000000..d3720a07595c --- /dev/null +++ b/packages/uikit-playground/src/Components/CreateNewScreen/ScreenThumbnail.tsx @@ -0,0 +1,77 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar'; +import type { ChangeEvent, MouseEvent } from 'react'; +import { useContext, useState } from 'react'; + +import ScreenThumbnailWrapper from '../ScreenThumbnail/ScreenThumbnailWrapper'; +import Thumbnail from '../ScreenThumbnail/Thumbnail'; +import { context, renameScreenAction } from '../../Context'; +import { activeScreenAction } from '../../Context/action/activeScreenAction'; +import { deleteScreenAction } from '../../Context/action/deleteScreenAction'; +import { duplicateScreenAction } from '../../Context/action/duplicateScreenAction'; +import renderPayload from '../RenderPayload/RenderPayload'; +import { ScreenType } from '../../Context/initialState'; +import EditMenu from '../ScreenThumbnail/EditMenu/EditMenu'; + +const ScreenThumbnail = ({ + screen, + disableDelete, +}: { + screen: ScreenType; + disableDelete: boolean; +}) => { + const { dispatch } = useContext(context); + const [name, setName] = useState(screen?.name); + const toast = useToastBarDispatch(); + + const activateScreenHandler = (e: MouseEvent) => { + e.stopPropagation(); + dispatch(activeScreenAction(screen?.id)); + }; + + const duplicateScreenHandler = () => { + dispatch(duplicateScreenAction({ id: screen?.id })); + }; + + const onChangeNameHandler = (e: ChangeEvent) => { + setName(e.currentTarget.value); + }; + + const nameSaveHandler = () => { + if (!name.trim()) { + setName(screen.name); + return toast({ + type: 'error', + message: 'Cannot rename screen to empty name.', + }); + } + dispatch(renameScreenAction({ id: screen.id, name })); + }; + + const deleteScreenHandler = () => { + if (disableDelete) + return toast({ + type: 'info', + message: 'Cannot delete last screen.', + }); + dispatch(deleteScreenAction(screen?.id)); + }; + return ( + + + + + + + ); +}; + +export default ScreenThumbnail; diff --git a/packages/uikit-playground/src/Components/CreateNewScreen/index.ts b/packages/uikit-playground/src/Components/CreateNewScreen/index.ts new file mode 100644 index 000000000000..a11dbe0843ff --- /dev/null +++ b/packages/uikit-playground/src/Components/CreateNewScreen/index.ts @@ -0,0 +1 @@ +export { default } from './CreateNewScreenContainer'; diff --git a/packages/uikit-playground/src/Components/Draggable/DraggableList.tsx b/packages/uikit-playground/src/Components/Draggable/DraggableList.tsx index 76e7af15538d..af48ac55539f 100644 --- a/packages/uikit-playground/src/Components/Draggable/DraggableList.tsx +++ b/packages/uikit-playground/src/Components/Draggable/DraggableList.tsx @@ -1,50 +1,45 @@ -import type { LayoutBlock } from '@rocket.chat/ui-kit'; -import * as React from 'react'; +import React from 'react'; import type { OnDragEndResponder } from 'react-beautiful-dnd'; import { DragDropContext, Droppable } from 'react-beautiful-dnd'; import DraggableListItem from './DraggableListItem'; +import { SurfaceOptions } from '../Preview/Display/Surface/constant'; +import { ILayoutBlock } from '../../Context/initialState'; export type Block = { id: string; - payload: LayoutBlock; + payload: ILayoutBlock; }; export type DraggableListProps = { blocks: Block[]; - surface?: number; + surface?: SurfaceOptions; onDragEnd: OnDragEndResponder; }; const DraggableList = React.memo( ({ blocks, surface, onDragEnd }: DraggableListProps) => ( - <> - - <> - - {(provided) => ( -
- <> - {blocks.map((block, index) => ( - - ))} - {provided.placeholder} - -
- )} -
- -
- + + + {(provided) => ( +
+ {blocks.map((block, index) => ( + + ))} + {provided.placeholder} +
+ )} +
+
) ); diff --git a/packages/uikit-playground/src/Components/Draggable/DraggableListItem.tsx b/packages/uikit-playground/src/Components/Draggable/DraggableListItem.tsx index d06d2769f15e..1660ee4b4cb1 100644 --- a/packages/uikit-playground/src/Components/Draggable/DraggableListItem.tsx +++ b/packages/uikit-playground/src/Components/Draggable/DraggableListItem.tsx @@ -1,6 +1,8 @@ import { Draggable } from 'react-beautiful-dnd'; -import RenderPayload from '../Preview/Display/RenderPayload/RenderPayload'; +import DeleteElementBtn from '../Preview/Display/UiKitElementWrapper/DeleteElementBtn'; +import UiKitElementWrapper from '../Preview/Display/UiKitElementWrapper/UiKitElementWrapper'; +import RenderPayload from '../RenderPayload/RenderPayload'; import type { Block } from './DraggableList'; export type DraggableListItemProps = { @@ -21,11 +23,10 @@ const DraggableListItem = ({ {...provided.draggableProps} {...provided.dragHandleProps} > - + + + +
)} diff --git a/packages/uikit-playground/src/Components/DropDown/DropDown.tsx b/packages/uikit-playground/src/Components/DropDown/DropDown.tsx index dd6470a4d632..8377a96c8a99 100644 --- a/packages/uikit-playground/src/Components/DropDown/DropDown.tsx +++ b/packages/uikit-playground/src/Components/DropDown/DropDown.tsx @@ -23,7 +23,7 @@ const DropDown = ({ BlocksTree }: DropDownProps) => { ); return ( - + {BlocksTree.map((branch: ItemBranch, i: number) => ( {recursiveComponentTree(branch, layer)} ))} diff --git a/packages/uikit-playground/src/Components/DropDown/Items.tsx b/packages/uikit-playground/src/Components/DropDown/Items.tsx index a8cc4acea83f..18eb6a10dcc5 100644 --- a/packages/uikit-playground/src/Components/DropDown/Items.tsx +++ b/packages/uikit-playground/src/Components/DropDown/Items.tsx @@ -2,61 +2,68 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, Label, Chevron } from '@rocket.chat/fuselage'; import { useState, useContext } from 'react'; -import { context } from '../../Context'; +import { context, updatePayloadAction } from '../../Context'; import ItemsIcon from './ItemsIcon'; import { itemStyle, labelStyle } from './itemsStyle'; import type { ItemProps } from './types'; -import { docAction } from '../../Context/action'; +import getUniqueId from '../../utils/getUniqueId'; const Items = ({ label, children, layer, payload }: ItemProps) => { - const [isOpen, toggleItemOpen] = useState(layer === 1); - const [hover, setHover] = useState(false); - const { state, dispatch } = useContext(context); + const [isOpen, toggleItemOpen] = useState(layer === 1); + const [hover, setHover] = useState(false); + const { state, dispatch } = useContext(context); - const itemClickHandler = () => { - toggleItemOpen(!isOpen); - payload && - dispatch( - docAction({ - payload: [...state.doc.payload, payload[0]], - changedByEditor: false, - }), - ); - }; + const itemClickHandler = () => { + toggleItemOpen(!isOpen); + payload && + dispatch( + updatePayloadAction({ + blocks: [ + ...state.screens[state.activeScreen].payload.blocks, + { actionId: getUniqueId(), ...payload[0] }, + ], + changedByEditor: false, + }) + ); + }; - return ( - - setHover(true)} - onMouseLeave={() => setHover(false)} - onClick={itemClickHandler} - > - - {children && children.length > 0 && ( - - - - )} - - - - - - - {isOpen && children} - - ); + return ( + + setHover(true)} + onMouseLeave={() => setHover(false)} + onClick={itemClickHandler} + > + + {children && children.length > 0 && ( + + + + )} + + + + + + + {isOpen && children} + + ); }; export default Items; diff --git a/packages/uikit-playground/src/Components/DropDown/ItemsIcon.tsx b/packages/uikit-playground/src/Components/DropDown/ItemsIcon.tsx index 9a1d74f3f1f1..fb74ae397113 100644 --- a/packages/uikit-playground/src/Components/DropDown/ItemsIcon.tsx +++ b/packages/uikit-playground/src/Components/DropDown/ItemsIcon.tsx @@ -12,15 +12,13 @@ const ItemsIcon = ({ const selectIcon = (layer: number, hover: boolean) => { if (layer === 1) { return ( - + ); } if (lastNode) { - return ; + return ; } - return ( - - ); + return ; }; return <>{selectIcon(layer, hover)}; }; diff --git a/packages/uikit-playground/src/Components/DropDown/itemsStyle.ts b/packages/uikit-playground/src/Components/DropDown/itemsStyle.ts index da9c9ff0b2c5..d91dc5b80f9d 100644 --- a/packages/uikit-playground/src/Components/DropDown/itemsStyle.ts +++ b/packages/uikit-playground/src/Components/DropDown/itemsStyle.ts @@ -2,9 +2,11 @@ import { css } from '@rocket.chat/css-in-js'; export const itemStyle = (layer: number, hover: boolean) => { const style = css` - cursor: pointer; - padding-left: ${10 + (layer - 1) * 16}px; - background-color: ${hover ? 'var(--RCPG-primary-color)' : 'transparent'}; + cursor: pointer !important; + padding-left: ${10 + (layer - 1) * 16}px !important; + background-color: ${hover + ? 'var(--RCPG-primary-color) !important' + : 'transparent !important'}; `; return style; }; @@ -18,26 +20,26 @@ export const labelStyle = (layer: number, hover: boolean) => { switch (layer) { case 1: customStyle = css` - font-weight: 700; - font-size: 14px; - letter-spacing: 0.3px; - color: ${hover ? '#fff' : '#999'}; - text-transform: uppercase; + font-weight: 700 !important; + font-size: 14px !important; + letter-spacing: 0.3px !important; + color: ${hover ? '#fff !important' : '#999 !important'}; + text-transform: uppercase !important; `; break; case 2: customStyle = css` - letter-spacing: 0.1px; - font-size: 12px; - color: ${hover ? '#fff' : '#555'}; - text-transform: capitalize; + letter-spacing: 0.1px !important; + font-size: 12px !important; + color: ${hover ? '#fff !important' : '#555 !important'}; + text-transform: capitalize !important; `; break; default: customStyle = css` - font-size: 12px; - color: ${hover ? '#fff' : '#555'}; - text-transform: capitalize; + font-size: 12px !important; + color: ${hover ? '#fff !important' : '#555 !important'}; + text-transform: capitalize !important; `; break; } diff --git a/packages/uikit-playground/src/Components/FlowContainer/ConnectionLine.tsx b/packages/uikit-playground/src/Components/FlowContainer/ConnectionLine.tsx new file mode 100644 index 000000000000..82fb979ff075 --- /dev/null +++ b/packages/uikit-playground/src/Components/FlowContainer/ConnectionLine.tsx @@ -0,0 +1,34 @@ +const ConnectionLine = ({ + fromX, + fromY, + toX, + toY, +}: { + fromX: number; + fromY: number; + fromPosition: string; + toX: number; + toY: number; + toPosition: string; + connectionLineType: string; +}) => ( + + + + +); + +export default ConnectionLine; diff --git a/packages/uikit-playground/src/Components/FlowContainer/ControlButtons/ControlButtons.tsx b/packages/uikit-playground/src/Components/FlowContainer/ControlButtons/ControlButtons.tsx new file mode 100644 index 000000000000..ecea95fdd067 --- /dev/null +++ b/packages/uikit-playground/src/Components/FlowContainer/ControlButtons/ControlButtons.tsx @@ -0,0 +1,7 @@ +import { Controls } from 'reactflow'; + +const ControlButtons = () => { + return ; +}; + +export default ControlButtons; diff --git a/packages/uikit-playground/src/Components/FlowContainer/ControlButtons/index.ts b/packages/uikit-playground/src/Components/FlowContainer/ControlButtons/index.ts new file mode 100644 index 000000000000..63963693d46d --- /dev/null +++ b/packages/uikit-playground/src/Components/FlowContainer/ControlButtons/index.ts @@ -0,0 +1 @@ +export {default} from './ControlButtons'; \ No newline at end of file diff --git a/packages/uikit-playground/src/Components/FlowContainer/FlowContainer.tsx b/packages/uikit-playground/src/Components/FlowContainer/FlowContainer.tsx new file mode 100644 index 000000000000..f04d95b274c2 --- /dev/null +++ b/packages/uikit-playground/src/Components/FlowContainer/FlowContainer.tsx @@ -0,0 +1,115 @@ +import { useCallback, useContext, useMemo, useRef, useState } from 'react'; +import ReactFlow, { + MiniMap, + Background, + addEdge, + updateEdge, + Node, + Viewport, + ReactFlowInstance, + useReactFlow, +} from 'reactflow'; + +import 'reactflow/dist/style.css'; +import { context } from '../../Context'; +import ConnectionLine from './ConnectionLine'; +import UIKitWrapper from './UIKitWrapper/UIKitWrapper'; +import { FlowParams } from './utils'; +import ControlButton from './ControlButtons'; +import { useNodesAndEdges } from '../../hooks/useNodesAndEdges'; +import { updateNodesAndViewPortAction } from '../../Context/action/updateNodesAndViewPortAction'; + +const FlowContainer = () => { + const { dispatch } = useContext(context); + + const { nodes, edges, Viewport, onNodesChange, onEdgesChange, setEdges } = + useNodesAndEdges(); + const { setViewport } = useReactFlow(); + + const nodeTypes = useMemo( + () => ({ + custom: UIKitWrapper, + }), + // used to rerender edge lines on reorder payload + // eslint-disable-next-line react-hooks/exhaustive-deps + [edges] + ); + + const [rfInstance, setRfInstance] = useState(); + const edgeUpdateSuccessful = useRef(true); + + const onConnect = useCallback( + (params) => { + if (params.source === params.target) return; + const newEdge = { + ...params, + type: FlowParams.edgeType, + markerEnd: FlowParams.markerEnd, + style: FlowParams.style, + }; + setEdges((eds) => addEdge(newEdge, eds)); + }, + [setEdges] + ); + + const onEdgeUpdateStart = useCallback(() => { + edgeUpdateSuccessful.current = false; + }, []); + + const onEdgeUpdate = useCallback( + (oldEdge, newConnection) => { + edgeUpdateSuccessful.current = true; + setEdges((els) => updateEdge(oldEdge, newConnection, els)); + }, + [setEdges] + ); + + const onEdgeUpdateEnd = useCallback( + (_, edge) => { + if (!edgeUpdateSuccessful.current) { + setEdges((eds) => { + return eds.filter((e) => e.id !== edge.id); + }); + } + edgeUpdateSuccessful.current = true; + }, + [setEdges] + ); + + const onNodeDragStop = () => { + if (!rfInstance?.toObject()) return; + const { nodes, viewport }: { nodes: Node[]; viewport: Viewport } = + rfInstance.toObject(); + dispatch(updateNodesAndViewPortAction({ nodes, viewport })); + }; + + const onInit = (instance: ReactFlowInstance) => { + setRfInstance(instance); + Viewport && setViewport(Viewport); + }; + + return ( + + + + + + ); +}; + +export default FlowContainer; diff --git a/packages/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.scss b/packages/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.scss new file mode 100644 index 000000000000..cc3f56cc7990 --- /dev/null +++ b/packages/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.scss @@ -0,0 +1,7 @@ +.rc-scrollbars-view{ + position: relative !important; + overflow: hidden !important; + width: 100% !important; + z-index: 1000 !important; + margin: 0 !important; +} \ No newline at end of file diff --git a/packages/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.tsx b/packages/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.tsx new file mode 100644 index 000000000000..79010f9442cc --- /dev/null +++ b/packages/uikit-playground/src/Components/FlowContainer/UIKitWrapper/UIKitWrapper.tsx @@ -0,0 +1,53 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useContext } from 'react'; +import { Handle, Position } from 'reactflow'; +import './UIKitWrapper.scss'; + +import RenderPayload from '../../RenderPayload/RenderPayload'; +import SurfaceRender from '../../Preview/Display/Surface/SurfaceRender'; +import { idType } from '../../../Context/initialState'; +import { context } from '../../../Context'; + +const UIKitWrapper = ({ id, data }: { id: string; data: idType }) => { + const { + state: { screens }, + } = useContext(context); + if (!screens[data]) return null; + const { blocks, surface } = screens[data].payload; + return ( + + + + {blocks.map((block, index) => ( + + + + + + + ))} + + + ); +}; + +export default UIKitWrapper; diff --git a/packages/uikit-playground/src/Components/FlowContainer/UIKitWrapper/index.ts b/packages/uikit-playground/src/Components/FlowContainer/UIKitWrapper/index.ts new file mode 100644 index 000000000000..82f4f03d69d3 --- /dev/null +++ b/packages/uikit-playground/src/Components/FlowContainer/UIKitWrapper/index.ts @@ -0,0 +1 @@ +export {default} from './UIKitWrapper'; \ No newline at end of file diff --git a/packages/uikit-playground/src/Components/FlowContainer/index.ts b/packages/uikit-playground/src/Components/FlowContainer/index.ts new file mode 100644 index 000000000000..7d6e867f2119 --- /dev/null +++ b/packages/uikit-playground/src/Components/FlowContainer/index.ts @@ -0,0 +1 @@ +export { default } from './FlowContainer'; diff --git a/packages/uikit-playground/src/Components/FlowContainer/utils.ts b/packages/uikit-playground/src/Components/FlowContainer/utils.ts new file mode 100644 index 000000000000..2318fe1f1efc --- /dev/null +++ b/packages/uikit-playground/src/Components/FlowContainer/utils.ts @@ -0,0 +1,34 @@ +import { MarkerType } from 'reactflow'; + +import type { ScreenType } from '../../Context/initialState'; + +export function createNodesAndEdges(screens: ScreenType[]) { + const center = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + + const nodes = screens.map((screen, i) => { + const degrees = i * (360 / 8); + const radians = degrees * (Math.PI / 180); + const x = 250 * Math.cos(radians) + center.x; + const y = 250 * Math.sin(radians) + center.y; + + return { + id: screen.id, + type: 'custom', + position: { x, y }, + data: screen, + }; + }); + + return { nodes }; +} + +export const FlowParams = { + edgeType: 'smoothstep', + markerEnd: { + type: MarkerType.Arrow, + }, + style: { + strokeWidth: 2, + stroke: 'var(--RCPG-primary-color)', + }, +}; diff --git a/packages/uikit-playground/src/Components/HomeContainer/HomeContainer.tsx b/packages/uikit-playground/src/Components/HomeContainer/HomeContainer.tsx new file mode 100644 index 000000000000..e6bf0e9274a7 --- /dev/null +++ b/packages/uikit-playground/src/Components/HomeContainer/HomeContainer.tsx @@ -0,0 +1,40 @@ +import { Box, Label } from '@rocket.chat/fuselage'; +import ProjectsList from './ProjectsList/ProjectsList'; +import { useContext } from 'react'; +import { context, createNewProjectAction } from '../../Context'; +import CreateNewScreenButton from '../ScreenThumbnail/CreateNewScreenButton'; +import { css } from '@rocket.chat/css-in-js'; + +const HomeContainer = () => { + const { dispatch } = useContext(context); + return ( + + + + + dispatch(createNewProjectAction())} + /> + + + + + ); +}; + +export default HomeContainer; diff --git a/packages/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsList.tsx b/packages/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsList.tsx new file mode 100644 index 000000000000..c767f406ad4e --- /dev/null +++ b/packages/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsList.tsx @@ -0,0 +1,34 @@ +import { Box } from '@rocket.chat/fuselage'; +import { context } from '../../../Context'; +import { useContext } from 'react'; +import ProjectsThumbnail from './ProjectsThumbnail'; +import { css } from '@rocket.chat/css-in-js'; + +const ProjectsList = () => { + const { + state: { screens, projects }, + } = useContext(context); + + return ( + + {Object.values(projects).map((project) => ( + + ))} + + ); +}; + +export default ProjectsList; diff --git a/packages/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsThumbnail.tsx b/packages/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsThumbnail.tsx new file mode 100644 index 000000000000..bcba0fca6306 --- /dev/null +++ b/packages/uikit-playground/src/Components/HomeContainer/ProjectsList/ProjectsThumbnail.tsx @@ -0,0 +1,99 @@ +import { Box } from '@rocket.chat/fuselage'; +import ScreenThumbnailWrapper from '../../ScreenThumbnail/ScreenThumbnailWrapper'; +import Thumbnail from '../../ScreenThumbnail/Thumbnail'; +import RenderPayload from '../../RenderPayload/RenderPayload'; +import { + activeProjectAction, + context, + renameProjectAction, +} from '../../../Context'; +import { ChangeEvent, useContext, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { formatDate } from '../../../utils/formatDate'; +import EditMenu from '../../ScreenThumbnail/EditMenu'; +import EditableLabel from '../../ScreenThumbnail/EditableLabel/EditableLabel'; +import { css } from '@rocket.chat/css-in-js'; +import { deleteProjectAction } from '../../../Context/action/deleteProjectAction'; +import { useToastBarDispatch } from '@rocket.chat/fuselage-toastbar'; +import routes from '../../../Routes/Routes'; +import { ILayoutBlock } from '../../../Context/initialState'; + +const ProjectsThumbnail = ({ + id, + name: _name, + date, + blocks, +}: { + id: string; + name: string; + date: string; + blocks: ILayoutBlock[]; +}) => { + const [name, setName] = useState(_name); + const navigate = useNavigate(); + const { dispatch } = useContext(context); + const toast = useToastBarDispatch(); + const activeProjectHandler = () => { + dispatch(activeProjectAction(id)); + navigate(`/${id}/${routes.project}`); + }; + + const deleteScreenHandler = () => { + dispatch(deleteProjectAction(id)); + }; + + const onChangeNameHandler = (e: ChangeEvent) => { + setName(e.currentTarget.value); + }; + + const nameSaveHandler = () => { + if (!name.trim()) { + setName(_name); + return toast({ + type: 'error', + message: 'Cannot rename project to empty name.', + }); + } + dispatch(renameProjectAction({ id, name })); + }; + + return ( + + + + + e.stopPropagation()}> + + + + {formatDate(date)} + + + ); +}; + +export default ProjectsThumbnail; diff --git a/packages/uikit-playground/src/Components/HomeContainer/ProjectsList/index.ts b/packages/uikit-playground/src/Components/HomeContainer/ProjectsList/index.ts new file mode 100644 index 000000000000..257bedbfc719 --- /dev/null +++ b/packages/uikit-playground/src/Components/HomeContainer/ProjectsList/index.ts @@ -0,0 +1 @@ +export { default } from './ProjectsList'; diff --git a/packages/uikit-playground/src/Components/HomeContainer/index.ts b/packages/uikit-playground/src/Components/HomeContainer/index.ts new file mode 100644 index 000000000000..b78a849a231a --- /dev/null +++ b/packages/uikit-playground/src/Components/HomeContainer/index.ts @@ -0,0 +1 @@ +export { default } from './HomeContainer'; diff --git a/packages/uikit-playground/src/Components/NavBar/BurgerIcon/Line.tsx b/packages/uikit-playground/src/Components/NavBar/BurgerIcon/Line.tsx index e1ddfed3ac0b..64eb886b2967 100644 --- a/packages/uikit-playground/src/Components/NavBar/BurgerIcon/Line.tsx +++ b/packages/uikit-playground/src/Components/NavBar/BurgerIcon/Line.tsx @@ -39,12 +39,12 @@ const Line = ({ return (