From d5d7d5074bf3f4abc63fdf93cb44b0a6d66962b3 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Sat, 6 Jan 2024 01:46:16 +0530 Subject: [PATCH 01/12] chore: add gitpod config file for quick setup (#30921) Co-authored-by: Debdut Chakraborty <76006232+debdutdeb@users.noreply.github.com> --- .gitpod.yml | 26 ++++++++++++++++++++++++++ README.md | 8 ++++++++ 2 files changed, 34 insertions(+) create mode 100644 .gitpod.yml 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/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 From 43335c838556280db7b0410408ce47a7a51f0602 Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Fri, 5 Jan 2024 17:58:01 -0300 Subject: [PATCH 02/12] fix: Editing room type or name doesn't always refresh the room (#31387) --- .changeset/nice-points-notice.md | 5 ++ .../ui-utils/client/lib/LegacyRoomManager.ts | 40 +------------- .../views/room/providers/RoomProvider.tsx | 3 ++ .../hooks/useRedirectOnSettingsChanged.ts | 52 +++++++++++++++++++ 4 files changed, 61 insertions(+), 39 deletions(-) create mode 100644 .changeset/nice-points-notice.md create mode 100644 apps/meteor/client/views/room/providers/hooks/useRedirectOnSettingsChanged.ts 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/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts b/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts index 23221a49a293..04446ebd3bde 100644 --- a/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/LegacyRoomManager.ts @@ -7,9 +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 { CachedChatRoom, ChatMessage, ChatSubscription, CachedChatSubscription } from '../../../models/client'; import { Notifications } from '../../../notifications/client'; import { sdk } from '../../../utils/client/lib/SDKClient'; import { upsertMessage, RoomHistoryManager } from './RoomHistoryManager'; @@ -80,41 +79,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 +116,6 @@ const computation = Tracker.autorun(() => { } } - handleTrackSettingsChange({ ...msg }); - await callbacks.run('streamMessage', { ...msg, name: room.name || '' }); fireGlobalEvent('new-message', { diff --git a/apps/meteor/client/views/room/providers/RoomProvider.tsx b/apps/meteor/client/views/room/providers/RoomProvider.tsx index 47d65562460a..5c539828a914 100644 --- a/apps/meteor/client/views/room/providers/RoomProvider.tsx +++ b/apps/meteor/client/views/room/providers/RoomProvider.tsx @@ -17,6 +17,7 @@ import { useRoomRolesManagement } from '../body/hooks/useRoomRolesManagement'; import { RoomContext } from '../contexts/RoomContext'; import ComposerPopupProvider from './ComposerPopupProvider'; import RoomToolboxProvider from './RoomToolboxProvider'; +import { useRedirectOnSettingsChanged } from './hooks/useRedirectOnSettingsChanged'; import { useRoomQuery } from './hooks/useRoomQuery'; type RoomProviderProps = { @@ -39,6 +40,8 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => { const subscriptionQuery = useReactiveQuery(['subscriptions', { rid }], () => ChatSubscription.findOne({ rid }) ?? null); + useRedirectOnSettingsChanged(subscriptionQuery.data); + const pseudoRoom = useMemo(() => { if (!room) { return null; diff --git a/apps/meteor/client/views/room/providers/hooks/useRedirectOnSettingsChanged.ts b/apps/meteor/client/views/room/providers/hooks/useRedirectOnSettingsChanged.ts new file mode 100644 index 000000000000..79b3195ddce5 --- /dev/null +++ b/apps/meteor/client/views/room/providers/hooks/useRedirectOnSettingsChanged.ts @@ -0,0 +1,52 @@ +import type { ISubscription } from '@rocket.chat/core-typings'; +import { useRouter } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { LegacyRoomManager } from '../../../../../app/ui-utils/client'; +import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; + +const routeNameToRoomTypeMap: Record = { + channel: 'c', + group: 'p', + direct: 'd', + live: 'l', +}; + +export const useRedirectOnSettingsChanged = (subscription?: ISubscription | null) => { + const router = useRouter(); + + const subExists = !!subscription; + + useEffect(() => { + if (!subExists) { + return; + } + const redirect = async () => { + const routeConfig = roomCoordinator.getRoomDirectives(subscription.t).config.route; + + const channelName = router.getRouteParameters().name; + const routeName = router.getRouteName() as string; + + if (!routeConfig?.path || !routeName || !channelName) { + return; + } + + if (routeConfig.name === routeName && channelName === subscription.name) { + return; + } + + const routeRoomType = routeNameToRoomTypeMap[routeName]; + + if (routeRoomType) { + await LegacyRoomManager.close(routeRoomType + routeName); + } + + router.navigate({ + pattern: routeConfig.path, + params: { ...router.getRouteParameters(), name: subscription.name }, + search: router.getSearchParameters(), + }); + }; + redirect(); + }, [subscription?.t, subscription?.name, router, subExists]); +}; From f92d2ecd471d9d2e8bb803ee7e8d1dd6a613fb3f Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Fri, 5 Jan 2024 18:56:26 -0300 Subject: [PATCH 03/12] fix: Admin's edit room form not triggering (#31384) --- .changeset/breezy-ladybugs-sip.md | 5 ++ .../views/admin/emailInbox/EmailInboxForm.tsx | 2 +- .../client/views/admin/rooms/EditRoom.tsx | 23 ++++++--- .../rooms/useEditAdminRoomPermissions.ts | 47 +++++++++++++------ apps/meteor/tests/e2e/administration.spec.ts | 14 ++++++ apps/meteor/tests/e2e/page-objects/admin.ts | 12 +++++ 6 files changed, 80 insertions(+), 23 deletions(-) create mode 100644 .changeset/breezy-ladybugs-sip.md diff --git a/.changeset/breezy-ladybugs-sip.md b/.changeset/breezy-ladybugs-sip.md new file mode 100644 index 000000000000..9a8911e79fcf --- /dev/null +++ b/.changeset/breezy-ladybugs-sip.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue not allowing admin users to edit rooms diff --git a/apps/meteor/client/views/admin/emailInbox/EmailInboxForm.tsx b/apps/meteor/client/views/admin/emailInbox/EmailInboxForm.tsx index 5101cb160de1..8d2e6f7ab0c4 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)} /> diff --git a/apps/meteor/client/views/admin/rooms/EditRoom.tsx b/apps/meteor/client/views/admin/rooms/EditRoom.tsx index 0ac1a387df21..acbbcf57c880 100644 --- a/apps/meteor/client/views/admin/rooms/EditRoom.tsx +++ b/apps/meteor/client/views/admin/rooms/EditRoom.tsx @@ -72,10 +72,18 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { reset, handleSubmit, formState: { isDirty, errors, dirtyFields }, - } = useForm({ defaultValues: getInitialValues(room) }); + } = useForm({ values: getInitialValues(room) }); - const { canViewName, canViewTopic, canViewAnnouncement, canViewArchived, canViewDescription, canViewType, canViewReadOnly } = - useEditAdminRoomPermissions(room); + const { + canViewName, + canViewTopic, + canViewAnnouncement, + canViewArchived, + canViewDescription, + canViewType, + canViewReadOnly, + canViewReactWhenReadOnly, + } = useEditAdminRoomPermissions(room); const { roomType, readOnly, archived } = watch(); @@ -110,6 +118,7 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { await Promise.all([isDirty && handleUpdateRoomData(data), changeArchiving && handleArchive()].filter(Boolean)); }); + const formId = useUniqueId(); const roomNameField = useUniqueId(); const ownerField = useUniqueId(); const roomDescription = useUniqueId(); @@ -125,7 +134,7 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { return ( <> - + {room.t !== 'd' && ( { {...field} disabled={isDeleting || isRoomFederated(room)} checked={value} - aria-aria-describedby={`${readOnlyField}-hint`} + aria-describedby={`${readOnlyField}-hint`} /> )} /> @@ -257,7 +266,7 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { {t('Only_authorized_users_can_write_new_messages')} )} - {readOnly && ( + {canViewReactWhenReadOnly && readOnly && ( {t('React_when_read_only')} @@ -335,7 +344,7 @@ const EditRoom = ({ room, onChange, onDelete }: EditRoomProps) => { - diff --git a/apps/meteor/client/views/admin/rooms/useEditAdminRoomPermissions.ts b/apps/meteor/client/views/admin/rooms/useEditAdminRoomPermissions.ts index a250015098a1..2f7d6cb7e0b9 100644 --- a/apps/meteor/client/views/admin/rooms/useEditAdminRoomPermissions.ts +++ b/apps/meteor/client/views/admin/rooms/useEditAdminRoomPermissions.ts @@ -5,20 +5,37 @@ import { RoomSettingsEnum } from '../../../../definition/IRoomTypeConfig'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; export const useEditAdminRoomPermissions = (room: Pick) => { - const [canViewName, canViewTopic, canViewAnnouncement, canViewArchived, canViewDescription, canViewType, canViewReadOnly] = - useMemo(() => { - const isAllowed = roomCoordinator.getRoomDirectives(room.t).allowRoomSettingChange; - return [ - isAllowed?.(room, RoomSettingsEnum.NAME), - isAllowed?.(room, RoomSettingsEnum.TOPIC), - isAllowed?.(room, RoomSettingsEnum.ANNOUNCEMENT), - isAllowed?.(room, RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE), - isAllowed?.(room, RoomSettingsEnum.DESCRIPTION), - isAllowed?.(room, RoomSettingsEnum.TYPE), - isAllowed?.(room, RoomSettingsEnum.READ_ONLY), - isAllowed?.(room, RoomSettingsEnum.REACT_WHEN_READ_ONLY), - ]; - }, [room]); + const [ + canViewName, + canViewTopic, + canViewAnnouncement, + canViewArchived, + canViewDescription, + canViewType, + canViewReadOnly, + canViewReactWhenReadOnly, + ] = useMemo(() => { + const isAllowed = roomCoordinator.getRoomDirectives(room.t).allowRoomSettingChange; + return [ + isAllowed?.(room, RoomSettingsEnum.NAME), + isAllowed?.(room, RoomSettingsEnum.TOPIC), + isAllowed?.(room, RoomSettingsEnum.ANNOUNCEMENT), + isAllowed?.(room, RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE), + isAllowed?.(room, RoomSettingsEnum.DESCRIPTION), + isAllowed?.(room, RoomSettingsEnum.TYPE), + isAllowed?.(room, RoomSettingsEnum.READ_ONLY), + isAllowed?.(room, RoomSettingsEnum.REACT_WHEN_READ_ONLY), + ]; + }, [room]); - return { canViewName, canViewTopic, canViewAnnouncement, canViewArchived, canViewDescription, canViewType, canViewReadOnly }; + return { + canViewName, + canViewTopic, + canViewAnnouncement, + canViewArchived, + canViewDescription, + canViewType, + canViewReadOnly, + canViewReactWhenReadOnly, + }; }; diff --git a/apps/meteor/tests/e2e/administration.spec.ts b/apps/meteor/tests/e2e/administration.spec.ts index 2601c2409b61..f2103dbd3c82 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,6 +58,9 @@ test.describe.parallel('administration', () => { }); test.describe('Rooms', () => { + test.beforeAll(async ({ api }) => { + targetChannel = await createTargetChannel(api); + }); test.beforeEach(async ({ page }) => { await page.goto('/admin/rooms'); }); @@ -64,6 +69,15 @@ test.describe.parallel('administration', () => { 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.describe('Permissions', () => { diff --git a/apps/meteor/tests/e2e/page-objects/admin.ts b/apps/meteor/tests/e2e/page-objects/admin.ts index 112d285a205f..f9a365a4d20d 100644 --- a/apps/meteor/tests/e2e/page-objects/admin.ts +++ b/apps/meteor/tests/e2e/page-objects/admin.ts @@ -16,6 +16,18 @@ 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 inputSearchUsers(): Locator { return this.page.locator('input[placeholder="Search Users"]'); } From a9564a4176b3355accfa7a1cfab6c0bdb8fe5968 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 8 Jan 2024 09:49:13 -0300 Subject: [PATCH 04/12] chore: remove RedHat stuff (#31388) --- .github/workflows/ci.yml | 15 ---------- apps/meteor/.docker/Dockerfile.rhel | 44 ----------------------------- package.json | 1 - 3 files changed, 60 deletions(-) delete mode 100644 apps/meteor/.docker/Dockerfile.rhel 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/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/package.json b/package.json index c0ffec861cda..b6e3ba26cc0f 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "updateFiles": [ "package.json", "apps/meteor/package.json", - "apps/meteor/.docker/Dockerfile.rhel", "apps/meteor/app/utils/rocketchat.info" ] }, From d6165ad77fabac3427cc28c3b12abf3d3f9821bc Mon Sep 17 00:00:00 2001 From: Hardik Bhatia <98163873+hardikbhatia777@users.noreply.github.com> Date: Mon, 8 Jan 2024 20:09:36 +0530 Subject: [PATCH 05/12] fix: Disable quote avatars according to user preference (#31393) Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com> --- .changeset/itchy-zoos-appear.md | 5 +++++ .../message/content/attachments/QuoteAttachment.tsx | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changeset/itchy-zoos-appear.md 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/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx index 16f4764fb63c..493e3e9ea918 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 && } From 7a187dcbaa0f621be5e1c30225342db21bc5b8a6 Mon Sep 17 00:00:00 2001 From: Sayan4444 <112304873+Sayan4444@users.noreply.github.com> Date: Mon, 8 Jan 2024 20:55:07 +0530 Subject: [PATCH 06/12] fix: Dropping a file from another browser window creates two upload dialogs (#31332) Co-authored-by: Hugo Costa <20212776+hugocostadev@users.noreply.github.com> --- .changeset/clean-melons-return.md | 5 +++++ .../room/body/hooks/useFileUploadDropTarget.ts | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 .changeset/clean-melons-return.md 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/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts index 2df567e77fb0..2427f7217401 100644 --- a/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts +++ b/apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts @@ -40,7 +40,21 @@ export const useFileUploadDropTarget = (): readonly [ const onFileDrop = useMutableCallback(async (files: File[]) => { const { mime } = await import('../../../../../app/utils/lib/mimeTypes'); - const uploads = Array.from(files).map((file) => { + const getUniqueFiles = () => { + const uniqueFiles: File[] = []; + const st: Set = new Set(); + files.forEach((file) => { + const key = file.size; + if (!st.has(key)) { + uniqueFiles.push(file); + st.add(key); + } + }); + return uniqueFiles; + }; + const uniqueFiles = getUniqueFiles(); + + const uploads = Array.from(uniqueFiles).map((file) => { Object.defineProperty(file, 'type', { value: mime.lookup(file.name) }); return file; }); From 2fa8055d067f6e26d1774dd975b2f1a0e5038faf Mon Sep 17 00:00:00 2001 From: Hugo Costa Date: Mon, 8 Jan 2024 13:08:46 -0300 Subject: [PATCH 07/12] fix: room avatar UnHandledPromiseRejection (#31389) --- apps/meteor/server/ufs/ufs-methods.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 Date: Mon, 8 Jan 2024 15:25:03 -0300 Subject: [PATCH 08/12] feat: Hide UI elements through window postmessage (#31184) --- .changeset/big-teachers-change.md | 6 + .../app/ui-utils/client/lib/messageBox.ts | 3 +- .../message/toolbox/MessageToolbox.tsx | 11 +- .../client/hooks/useAppActionButtons.ts | 2 +- apps/meteor/client/hooks/useFileInput.ts | 23 ++++ .../client/providers/LayoutProvider.tsx | 23 +++- .../room/Header/RoomToolbox/RoomToolbox.tsx | 2 +- .../room/composer/messageBox/MessageBox.tsx | 10 +- .../ActionsToolbarDropdown.tsx | 96 ++------------- .../MessageBoxActionsToolbar.tsx | 93 ++++++++++----- .../actions/FileUploadAction.tsx | 75 ------------ .../hooks/ToolbarAction.ts | 10 ++ .../useAudioMessageAction.ts} | 39 +++--- .../useCreateDiscussionAction.tsx} | 24 ++-- .../hooks/useFileUploadAction.ts | 52 ++++++++ .../useShareLocationAction.tsx} | 27 +++-- .../hooks/useToolbarActions.ts | 112 ++++++++++++++++++ .../useVideoMessageAction.ts} | 45 ++----- .../useWebdavActions.tsx} | 48 ++++---- .../useUserInfoActions/useUserInfoActions.ts | 6 +- .../room/providers/RoomToolboxProvider.tsx | 5 +- .../page-objects/fragments/home-content.ts | 2 +- packages/ui-contexts/src/LayoutContext.ts | 12 ++ .../src/hooks/useLayoutHiddenActions.ts | 6 + packages/ui-contexts/src/index.ts | 1 + 25 files changed, 420 insertions(+), 313 deletions(-) create mode 100644 .changeset/big-teachers-change.md create mode 100644 apps/meteor/client/hooks/useFileInput.ts delete mode 100644 apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/FileUploadAction.tsx create mode 100644 apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/ToolbarAction.ts rename apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/{actions/AudioMessageAction.tsx => hooks/useAudioMessageAction.ts} (68%) rename apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/{actions/CreateDiscussionAction.tsx => hooks/useCreateDiscussionAction.tsx} (68%) create mode 100644 apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts rename apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/{actions/ShareLocationAction.tsx => hooks/useShareLocationAction.tsx} (65%) create mode 100644 apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useToolbarActions.ts rename apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/{actions/VideoMessageAction.tsx => hooks/useVideoMessageAction.ts} (63%) rename apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/{actions/WebdavAction.tsx => hooks/useWebdavActions.tsx} (60%) create mode 100644 packages/ui-contexts/src/hooks/useLayoutHiddenActions.ts 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/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/client/components/message/toolbox/MessageToolbox.tsx b/apps/meteor/client/components/message/toolbox/MessageToolbox.tsx index 3b9cdd84c25d..d0c426dcc466 100644 --- a/apps/meteor/client/components/message/toolbox/MessageToolbox.tsx +++ b/apps/meteor/client/components/message/toolbox/MessageToolbox.tsx @@ -2,7 +2,7 @@ import type { IMessage, IRoom, ISubscription, ITranslatedMessage } from '@rocket import { isThreadMessage, isRoomFederated, isVideoConfMessage } from '@rocket.chat/core-typings'; import { MessageToolbox as FuselageMessageToolbox, MessageToolboxItem } 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'; @@ -70,13 +70,18 @@ const MessageToolbox = ({ const actionButtonApps = useMessageActionAppsActionButtons(context); + const { messageToolbox: hiddenActions } = useLayoutHiddenActions(); + 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 +90,7 @@ const MessageToolbox = ({ const autoTranslateOptions = useAutoTranslate(subscription); - if (selecting) { + if (selecting || (!actionsQueryResult.data?.message.length && !actionsQueryResult.data?.menu.length)) { return null; } 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/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/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/views/room/Header/RoomToolbox/RoomToolbox.tsx b/apps/meteor/client/views/room/Header/RoomToolbox/RoomToolbox.tsx index 00c9d9cdeecd..17f80a490064 100644 --- a/apps/meteor/client/views/room/Header/RoomToolbox/RoomToolbox.tsx +++ b/apps/meteor/client/views/room/Header/RoomToolbox/RoomToolbox.tsx @@ -91,7 +91,7 @@ const RoomToolbox = ({ className }: RoomToolboxProps) => { {featuredActions.map(mapToToolboxItem)} {featuredActions.length > 0 && } {visibleActions.map(mapToToolboxItem)} - {(normalActions.length > 6 || !roomToolboxExpanded) && ( + {(normalActions.length > 6 || !roomToolboxExpanded) && !!hiddenActions.length && ( )} diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index 330fdbb8771d..25bc84e2a64e 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import type { IMessage, ISubscription } from '@rocket.chat/core-typings'; import { Button, Tag, Box } from '@rocket.chat/fuselage'; import { useContentBoxSize, useMutableCallback } from '@rocket.chat/fuselage-hooks'; @@ -410,15 +411,14 @@ const MessageBox = ({ disabled={isRecording || !canSend} /> )} - diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/ActionsToolbarDropdown.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/ActionsToolbarDropdown.tsx index 5066ecb192e1..8da907c99c35 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/ActionsToolbarDropdown.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/ActionsToolbarDropdown.tsx @@ -1,106 +1,26 @@ -import type { IRoom } from '@rocket.chat/core-typings'; -import { Dropdown, IconButton, Option, OptionTitle, OptionIcon, OptionContent } from '@rocket.chat/fuselage'; -import { useTranslation, useUserRoom } from '@rocket.chat/ui-contexts'; -import type { ComponentProps, ReactNode } from 'react'; -import React, { useRef, Fragment } from 'react'; +import { Dropdown, IconButton } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; +import React, { useRef } from 'react'; -import { messageBox } from '../../../../../../app/ui-utils/client'; -import { useMessageboxAppsActionButtons } from '../../../../../hooks/useAppActionButtons'; -import type { ChatAPI } from '../../../../../lib/chats/ChatAPI'; import { useDropdownVisibility } from '../../../../../sidebar/header/hooks/useDropdownVisibility'; -import { useChat } from '../../../contexts/ChatContext'; -import CreateDiscussionAction from './actions/CreateDiscussionAction'; -import ShareLocationAction from './actions/ShareLocationAction'; -import WebdavAction from './actions/WebdavAction'; type ActionsToolbarDropdownProps = { - chatContext?: ChatAPI; - rid: IRoom['_id']; - isRecording?: boolean; - tmid?: string; - actions?: ReactNode[]; + disabled?: boolean; + children: () => ReactNode[]; }; -const ActionsToolbarDropdown = ({ isRecording, rid, tmid, actions, ...props }: ActionsToolbarDropdownProps) => { - const chatContext = useChat(); - - if (!chatContext) { - throw new Error('useChat must be used within a ChatProvider'); - } - - const t = useTranslation(); +const ActionsToolbarDropdown = ({ children, ...props }: ActionsToolbarDropdownProps) => { const reference = useRef(null); const target = useRef(null); - const room = useUserRoom(rid); - const { isVisible, toggle } = useDropdownVisibility({ reference, target }); - const apps = useMessageboxAppsActionButtons(); - - const groups = { - ...(apps.isSuccess && - apps.data.length > 0 && { - Apps: apps.data, - }), - ...messageBox.actions.get(), - }; - - const messageBoxActions = Object.entries(groups).map(([name, group]) => { - const items = group.map((item) => ({ - icon: item.icon, - name: t(item.label), - type: 'messagebox-action', - id: item.id, - action: item.action, - })); - - return { - title: t.has(name) && t(name), - items, - }; - }); - return ( <> - toggle()} - {...props} - /> + toggle()} {...props} /> {isVisible && ( - {t('Create_new')} - {room && } - {actions} - - {room && } - {messageBoxActions?.map((actionGroup, index) => ( - - {actionGroup.title} - {actionGroup.items.map((item) => ( - - ))} - - ))} + {children()} )} diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx index d83b134e67af..ab0c9ec1fd5d 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/MessageBoxActionsToolbar.tsx @@ -1,52 +1,91 @@ import type { IRoom, IMessage } from '@rocket.chat/core-typings'; +import { Option, OptionTitle, OptionIcon, OptionContent } from '@rocket.chat/fuselage'; +import { MessageComposerAction, MessageComposerActionsDivider } from '@rocket.chat/ui-composer'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ComponentProps } from 'react'; import React, { memo } from 'react'; +import { useChat } from '../../../contexts/ChatContext'; import ActionsToolbarDropdown from './ActionsToolbarDropdown'; -import AudioMessageAction from './actions/AudioMessageAction'; -import FileUploadAction from './actions/FileUploadAction'; -import VideoMessageAction from './actions/VideoMessageAction'; +import { useToolbarActions } from './hooks/useToolbarActions'; type MessageBoxActionsToolbarProps = { + canSend: boolean; + typing: boolean; + isMicrophoneDenied: boolean; variant: 'small' | 'large'; isRecording: boolean; - typing: boolean; - canSend: boolean; rid: IRoom['_id']; tmid?: IMessage['_id']; - isMicrophoneDenied?: boolean; }; const MessageBoxActionsToolbar = ({ - variant = 'large', - isRecording, - typing, canSend, + typing, + isRecording, rid, tmid, + variant = 'large', isMicrophoneDenied, - ...props }: MessageBoxActionsToolbarProps) => { - const actions = [ - , - , - , - ]; - - let featuredAction; - if (variant === 'small') { - featuredAction = actions.splice(1, 1); + const data = useToolbarActions({ + canSend, + typing, + isRecording, + isMicrophoneDenied: Boolean(isMicrophoneDenied), + rid, + tmid, + variant, + }); + + const { featured, menu } = data; + const t = useTranslation(); + const chatContext = useChat(); + + if (!chatContext) { + throw new Error('useChat must be used within a ChatProvider'); + } + + if (!featured.length && !menu.length) { + return null; } return ( <> - {variant !== 'small' && actions} - {variant === 'small' && featuredAction} - + + {featured.map((action) => ( + + ))} + {menu.length > 0 && ( + + {() => + menu.map((option) => { + if (typeof option === 'string') { + return {t.has(option) ? t(option) : option}; + } + + return ( + + ); + }) + } + + )} ); }; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/FileUploadAction.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/FileUploadAction.tsx deleted file mode 100644 index f9c826fceb4b..000000000000 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/FileUploadAction.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Option, OptionContent, OptionIcon } from '@rocket.chat/fuselage'; -import { MessageComposerAction } from '@rocket.chat/ui-composer'; -import { useTranslation, useSetting } from '@rocket.chat/ui-contexts'; -import type { ChangeEvent, AllHTMLAttributes } from 'react'; -import React, { useRef } from 'react'; - -import type { ChatAPI } from '../../../../../../lib/chats/ChatAPI'; -import { useChat } from '../../../../contexts/ChatContext'; - -type FileUploadActionProps = { - collapsed?: boolean; - chatContext?: ChatAPI; // TODO: remove this when the composer is migrated to React -} & Omit, 'is'>; - -const FileUploadAction = ({ collapsed, chatContext, disabled, ...props }: FileUploadActionProps) => { - const t = useTranslation(); - const fileUploadEnabled = useSetting('FileUpload_Enabled'); - const fileInputRef = useRef(null); - const chat = useChat() ?? chatContext; - - const resetFileInput = () => { - if (!fileInputRef.current) { - return; - } - - fileInputRef.current.value = ''; - }; - - const handleUploadChange = async (e: ChangeEvent) => { - const { mime } = await import('../../../../../../../app/utils/lib/mimeTypes'); - const filesToUpload = Array.from(e.target.files ?? []).map((file) => { - Object.defineProperty(file, 'type', { - value: mime.lookup(file.name), - }); - return file; - }); - chat?.flows.uploadFiles(filesToUpload, resetFileInput); - }; - - const handleUpload = () => { - fileInputRef.current?.click(); - }; - - if (collapsed) { - return ( - <> - - - - ); - } - - return ( - <> - - - - ); -}; - -export default FileUploadAction; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/ToolbarAction.ts b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/ToolbarAction.ts new file mode 100644 index 000000000000..63f8ded271f5 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/ToolbarAction.ts @@ -0,0 +1,10 @@ +import type { Keys as IconName } from '@rocket.chat/icons'; + +export type ToolbarAction = { + title?: string; + disabled?: boolean; + onClick: (...params: any) => unknown; + icon: IconName; + label: string; + id: string; +}; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/AudioMessageAction.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useAudioMessageAction.ts similarity index 68% rename from apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/AudioMessageAction.tsx rename to apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useAudioMessageAction.ts index 41bee06c19c0..87ece1793299 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/AudioMessageAction.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useAudioMessageAction.ts @@ -1,28 +1,22 @@ import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { MessageComposerAction } from '@rocket.chat/ui-composer'; -import { useSetting } from '@rocket.chat/ui-contexts'; -import type { AllHTMLAttributes } from 'react'; -import React, { useEffect, useMemo } from 'react'; +import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; +import { useEffect, useMemo } from 'react'; import { AudioRecorder } from '../../../../../../../app/ui/client/lib/recorderjs/AudioRecorder'; -import type { ChatAPI } from '../../../../../../lib/chats/ChatAPI'; import { useChat } from '../../../../contexts/ChatContext'; import { useMediaActionTitle } from '../../hooks/useMediaActionTitle'; import { useMediaPermissions } from '../../hooks/useMediaPermissions'; +import type { ToolbarAction } from './ToolbarAction'; const audioRecorder = new AudioRecorder(); -type AudioMessageActionProps = { - chatContext?: ChatAPI; - isMicrophoneDenied?: boolean; -} & Omit, 'is'>; - -const AudioMessageAction = ({ chatContext, disabled, isMicrophoneDenied, ...props }: AudioMessageActionProps) => { +export const useAudioMessageAction = (disabled: boolean, isMicrophoneDenied: boolean): ToolbarAction => { const isFileUploadEnabled = useSetting('FileUpload_Enabled') as boolean; const isAudioRecorderEnabled = useSetting('Message_AudioRecorderEnabled') as boolean; const fileUploadMediaTypeBlackList = useSetting('FileUpload_MediaTypeBlackList') as string; const fileUploadMediaTypeWhiteList = useSetting('FileUpload_MediaTypeWhiteList') as string; const [isPermissionDenied] = useMediaPermissions('microphone'); + const t = useTranslation(); const isAllowed = useMemo( () => @@ -39,7 +33,7 @@ const AudioMessageAction = ({ chatContext, disabled, isMicrophoneDenied, ...prop const getMediaActionTitle = useMediaActionTitle('audio', isPermissionDenied, isFileUploadEnabled, isAudioRecorderEnabled, isAllowed); - const chat = useChat() ?? chatContext; + const chat = useChat(); const stopRecording = useMutableCallback(() => { chat?.action.stop('recording'); @@ -61,17 +55,12 @@ const AudioMessageAction = ({ chatContext, disabled, isMicrophoneDenied, ...prop const handleRecordButtonClick = () => chat?.composer?.setRecordingMode(true); - return ( - - ); + return { + id: 'audio-message', + title: getMediaActionTitle, + disabled: !isAllowed || Boolean(disabled), + onClick: handleRecordButtonClick, + icon: 'mic', + label: t('Audio_message'), + }; }; - -export default AudioMessageAction; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/CreateDiscussionAction.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useCreateDiscussionAction.tsx similarity index 68% rename from apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/CreateDiscussionAction.tsx rename to apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useCreateDiscussionAction.tsx index 419c6c2cfdda..9b85a8a7a6c3 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/CreateDiscussionAction.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useCreateDiscussionAction.tsx @@ -1,12 +1,16 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { isRoomFederated } from '@rocket.chat/core-typings'; -import { Option, OptionIcon, OptionContent } from '@rocket.chat/fuselage'; import { useTranslation, useSetting, usePermission, useSetModal } from '@rocket.chat/ui-contexts'; import React from 'react'; import CreateDiscussion from '../../../../../../components/CreateDiscussion'; +import type { ToolbarAction } from './ToolbarAction'; + +export const useCreateDiscussionAction = (room?: IRoom): ToolbarAction => { + if (!room) { + throw new Error('Invalid room'); + } -const CreateDiscussionAction = ({ room }: { room: IRoom }) => { const setModal = useSetModal(); const t = useTranslation(); @@ -19,12 +23,12 @@ const CreateDiscussionAction = ({ room }: { room: IRoom }) => { const allowDiscussion = room && discussionEnabled && !isRoomFederated(room) && (canStartDiscussion || canSstartDiscussionOtherUser); - return ( - - ); + return { + id: 'create-discussion', + title: !allowDiscussion ? t('Not_Available') : undefined, + disabled: !allowDiscussion, + onClick: handleCreateDiscussion, + icon: 'discussion', + label: t('Discussion'), + }; }; - -export default CreateDiscussionAction; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts new file mode 100644 index 000000000000..8794aa687b28 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useFileUploadAction.ts @@ -0,0 +1,52 @@ +import { useTranslation, useSetting } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { useFileInput } from '../../../../../../hooks/useFileInput'; +import { useChat } from '../../../../contexts/ChatContext'; +import type { ToolbarAction } from './ToolbarAction'; + +const fileInputProps = { type: 'file', multiple: true }; + +export const useFileUploadAction = (disabled: boolean): ToolbarAction => { + const t = useTranslation(); + const fileUploadEnabled = useSetting('FileUpload_Enabled'); + const fileInputRef = useFileInput(fileInputProps); + const chat = useChat(); + + useEffect(() => { + const resetFileInput = () => { + if (!fileInputRef?.current) { + return; + } + + fileInputRef.current.value = ''; + }; + + const handleUploadChange = async () => { + const { mime } = await import('../../../../../../../app/utils/lib/mimeTypes'); + const filesToUpload = Array.from(fileInputRef?.current?.files ?? []).map((file) => { + Object.defineProperty(file, 'type', { + value: mime.lookup(file.name), + }); + return file; + }); + chat?.flows.uploadFiles(filesToUpload, resetFileInput); + }; + + fileInputRef.current?.addEventListener('change', handleUploadChange); + return () => fileInputRef?.current?.removeEventListener('change', handleUploadChange); + }, [chat, fileInputRef]); + + const handleUpload = () => { + fileInputRef?.current?.click(); + }; + + return { + id: 'file-upload', + icon: 'clip', + label: t('File'), + title: t('File'), + onClick: handleUpload, + disabled: !fileUploadEnabled || disabled, + }; +}; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/ShareLocationAction.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useShareLocationAction.tsx similarity index 65% rename from apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/ShareLocationAction.tsx rename to apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useShareLocationAction.tsx index 3589406108f9..4a30e3b2b646 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/ShareLocationAction.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useShareLocationAction.tsx @@ -1,12 +1,16 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { isRoomFederated } from '@rocket.chat/core-typings'; -import { Option, OptionTitle, OptionIcon, OptionContent } from '@rocket.chat/fuselage'; import { useSetting, useSetModal, useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; import ShareLocationModal from '../../../../ShareLocation/ShareLocationModal'; +import type { ToolbarAction } from './ToolbarAction'; + +export const useShareLocationAction = (room?: IRoom, tmid?: string): ToolbarAction => { + if (!room) { + throw new Error('Invalid room'); + } -const ShareLocationAction = ({ room, tmid }: { room: IRoom; tmid?: string }) => { const t = useTranslation(); const setModal = useSetModal(); @@ -19,15 +23,12 @@ const ShareLocationAction = ({ room, tmid }: { room: IRoom; tmid?: string }) => const allowGeolocation = room && canGetGeolocation && !isRoomFederated(room); - return ( - <> - {t('Share')} - - - ); + return { + id: 'share-location', + icon: 'map-pin', + label: t('Location'), + title: !allowGeolocation ? t('Not_Available') : undefined, + onClick: handleShareLocation, + disabled: !allowGeolocation, + }; }; - -export default ShareLocationAction; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useToolbarActions.ts b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useToolbarActions.ts new file mode 100644 index 000000000000..a98d2e885671 --- /dev/null +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useToolbarActions.ts @@ -0,0 +1,112 @@ +import { useUserRoom, useTranslation, useLayoutHiddenActions } from '@rocket.chat/ui-contexts'; + +import { messageBox } from '../../../../../../../app/ui-utils/client'; +import { isTruthy } from '../../../../../../../lib/isTruthy'; +import { useMessageboxAppsActionButtons } from '../../../../../../hooks/useAppActionButtons'; +import type { ToolbarAction } from './ToolbarAction'; +import { useAudioMessageAction } from './useAudioMessageAction'; +import { useCreateDiscussionAction } from './useCreateDiscussionAction'; +import { useFileUploadAction } from './useFileUploadAction'; +import { useShareLocationAction } from './useShareLocationAction'; +import { useVideoMessageAction } from './useVideoMessageAction'; +import { useWebdavActions } from './useWebdavActions'; + +type ToolbarActionsOptions = { + variant: 'small' | 'large'; + canSend: boolean; + typing: boolean; + isRecording: boolean; + isMicrophoneDenied: boolean; + rid: string; + tmid?: string; +}; + +const isHidden = (hiddenActions: Array, action: ToolbarAction) => { + if (!action) { + return true; + } + return hiddenActions.includes(action.id); +}; + +export const useToolbarActions = ({ canSend, typing, isRecording, isMicrophoneDenied, rid, tmid, variant }: ToolbarActionsOptions) => { + const room = useUserRoom(rid); + const t = useTranslation(); + + const videoMessageAction = useVideoMessageAction(!canSend || typing || isRecording); + const audioMessageAction = useAudioMessageAction(!canSend || typing || isRecording || isMicrophoneDenied, isMicrophoneDenied); + const fileUploadAction = useFileUploadAction(!canSend || typing || isRecording); + const webdavActions = useWebdavActions(); + const createDiscussionAction = useCreateDiscussionAction(room); + const shareLocationAction = useShareLocationAction(room, tmid); + + const apps = useMessageboxAppsActionButtons(); + const { composerToolbox: hiddenActions } = useLayoutHiddenActions(); + + const allActions = { + ...(!isHidden(hiddenActions, videoMessageAction) && { videoMessageAction }), + ...(!isHidden(hiddenActions, audioMessageAction) && { audioMessageAction }), + ...(!isHidden(hiddenActions, fileUploadAction) && { fileUploadAction }), + ...(!isHidden(hiddenActions, createDiscussionAction) && { createDiscussionAction }), + ...(!isHidden(hiddenActions, shareLocationAction) && { shareLocationAction }), + ...(!hiddenActions.includes('webdav-add') && { webdavActions }), + }; + + const data: { featured: ToolbarAction[]; menu: Array } = (() => { + const featured: Array = []; + const createNew = []; + const share = []; + + if (variant === 'small') { + featured.push(allActions.audioMessageAction); + createNew.push(allActions.videoMessageAction, allActions.fileUploadAction); + } else { + featured.push(allActions.videoMessageAction, allActions.audioMessageAction, allActions.fileUploadAction); + } + + if (allActions.webdavActions) { + createNew.push(...allActions.webdavActions); + } + + share.push(allActions.shareLocationAction); + + const groups = { + ...(apps.isSuccess && + apps.data.length > 0 && { + Apps: apps.data, + }), + ...messageBox.actions.get(), + }; + + const messageBoxActions = Object.entries(groups).reduce>((acc, [name, group]) => { + const items = group + .filter((item) => !hiddenActions.includes(item.id)) + .map( + (item): ToolbarAction => ({ + id: item.id, + icon: item.icon, + label: t(item.label), + onClick: item.action, + }), + ); + + if (items.length === 0) { + return acc; + } + return [...acc, (t.has(name) && t(name)) || name, ...items]; + }, []); + + const createNewFiltered = createNew.filter(isTruthy); + const shareFiltered = share.filter(isTruthy); + + return { + featured: featured.filter(isTruthy), + menu: [ + ...(createNewFiltered.length > 0 ? ['Create_new', ...createNewFiltered] : []), + ...(shareFiltered.length > 0 ? ['Share', ...shareFiltered] : []), + ...messageBoxActions, + ], + }; + })(); + + return data; +}; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/VideoMessageAction.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useVideoMessageAction.ts similarity index 63% rename from apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/VideoMessageAction.tsx rename to apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useVideoMessageAction.ts index fa6fa2484c9f..7068f1338b11 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/VideoMessageAction.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useVideoMessageAction.ts @@ -1,22 +1,14 @@ -import { Option, OptionIcon, OptionContent } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { MessageComposerAction } from '@rocket.chat/ui-composer'; import { useTranslation, useSetting } from '@rocket.chat/ui-contexts'; -import type { AllHTMLAttributes } from 'react'; -import React, { useEffect, useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { VideoRecorder } from '../../../../../../../app/ui/client/lib/recorderjs/videoRecorder'; -import type { ChatAPI } from '../../../../../../lib/chats/ChatAPI'; import { useChat } from '../../../../contexts/ChatContext'; import { useMediaActionTitle } from '../../hooks/useMediaActionTitle'; import { useMediaPermissions } from '../../hooks/useMediaPermissions'; +import type { ToolbarAction } from './ToolbarAction'; -type VideoMessageActionProps = { - collapsed?: boolean; - chatContext?: ChatAPI; // TODO: remove this when the composer is migrated to React -} & Omit, 'is'>; - -const VideoMessageAction = ({ collapsed, chatContext, disabled, ...props }: VideoMessageActionProps) => { +export const useVideoMessageAction = (disabled: boolean): ToolbarAction => { const t = useTranslation(); const isFileUploadEnabled = useSetting('FileUpload_Enabled') as boolean; const isVideoRecorderEnabled = useSetting('Message_VideoRecorderEnabled') as boolean; @@ -41,7 +33,7 @@ const VideoMessageAction = ({ collapsed, chatContext, disabled, ...props }: Vide const getMediaActionTitle = useMediaActionTitle('video', isPermissionDenied, isFileUploadEnabled, isVideoRecorderEnabled, isAllowed); - const chat = useChat() ?? chatContext; + const chat = useChat(); const handleOpenVideoMessage = () => { if (!chat?.composer?.recordingVideo.get()) { @@ -61,25 +53,12 @@ const VideoMessageAction = ({ collapsed, chatContext, disabled, ...props }: Vide handleDenyVideo(isPermissionDenied); }, [handleDenyVideo, isPermissionDenied]); - if (collapsed) { - return ( - - ); - } - - return ( - - ); + return { + id: 'video-message', + title: getMediaActionTitle, + disabled: !isAllowed || Boolean(disabled), + onClick: handleOpenVideoMessage, + icon: 'video', + label: t('Video_message'), + }; }; - -export default VideoMessageAction; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/WebdavAction.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useWebdavActions.tsx similarity index 60% rename from apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/WebdavAction.tsx rename to apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useWebdavActions.tsx index 333c1e4968f3..c60d4d533f75 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/actions/WebdavAction.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBoxActionsToolbar/hooks/useWebdavActions.tsx @@ -1,18 +1,17 @@ import type { IWebdavAccountIntegration } from '@rocket.chat/core-typings'; -import { Option, OptionIcon, OptionContent } from '@rocket.chat/fuselage'; import { useTranslation, useSetting, useSetModal } from '@rocket.chat/ui-contexts'; import React from 'react'; import { WebdavAccounts } from '../../../../../../../app/models/client'; import { useReactiveValue } from '../../../../../../hooks/useReactiveValue'; -import type { ChatAPI } from '../../../../../../lib/chats/ChatAPI'; import { useChat } from '../../../../contexts/ChatContext'; import AddWebdavAccountModal from '../../../../webdav/AddWebdavAccountModal'; import WebdavFilePickerModal from '../../../../webdav/WebdavFilePickerModal'; +import type { ToolbarAction } from './ToolbarAction'; const getWebdavAccounts = (): IWebdavAccountIntegration[] => WebdavAccounts.find().fetch(); -const WebdavAction = ({ chatContext }: { chatContext?: ChatAPI }) => { +export const useWebdavActions = (): Array => { const t = useTranslation(); const setModal = useSetModal(); const webDavAccounts = useReactiveValue(getWebdavAccounts); @@ -21,7 +20,7 @@ const WebdavAction = ({ chatContext }: { chatContext?: ChatAPI }) => { const handleCreateWebDav = () => setModal( setModal(null)} onConfirm={() => setModal(null)} />); - const chat = useChat() ?? chatContext; + const chat = useChat(); const handleUpload = async (file: File, description?: string) => chat?.uploads.send(file, { @@ -31,26 +30,23 @@ const WebdavAction = ({ chatContext }: { chatContext?: ChatAPI }) => { const handleOpenWebdav = (account: IWebdavAccountIntegration) => setModal( setModal(null)} />); - return ( - <> - - {webDavEnabled && - webDavAccounts.length > 0 && - webDavAccounts.map((account) => ( - - ))} - - ); + return [ + { + id: 'webdav-add', + title: !webDavEnabled ? t('WebDAV_Integration_Not_Allowed') : undefined, + disabled: !webDavEnabled, + onClick: handleCreateWebDav, + icon: 'cloud-plus', + label: t('Add_Server'), + }, + ...(webDavEnabled && webDavAccounts.length > 0 + ? webDavAccounts.map((account) => ({ + id: account._id, + disabled: false, + onClick: () => handleOpenWebdav(account), + icon: 'cloud-plus' as const, + label: account.name, + })) + : []), + ]; }; - -export default WebdavAction; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts index dfe9c0341e00..a058fb862ad5 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts @@ -1,5 +1,6 @@ import type { IRoom, IUser } from '@rocket.chat/core-typings'; import type { Icon } from '@rocket.chat/fuselage'; +import { useLayoutHiddenActions } from '@rocket.chat/ui-contexts'; import type { ComponentProps } from 'react'; import { useMemo } from 'react'; @@ -51,6 +52,7 @@ export const useUserInfoActions = ( const call = useCallAction(user); const reportUserOption = useReportUser(user); const isLayoutEmbedded = useEmbeddedLayout(); + const { userToolbox: hiddenActions } = useLayoutHiddenActions(); const userinfoActions = useMemo( () => ({ @@ -83,7 +85,7 @@ export const useUserInfoActions = ( ); const actionSpread = useMemo(() => { - const entries = Object.entries(userinfoActions); + const entries = Object.entries(userinfoActions).filter(([key]) => !hiddenActions.includes(key)); const options = entries.slice(0, size); const slicedOptions = entries.slice(size, entries.length); @@ -105,7 +107,7 @@ export const useUserInfoActions = ( }, [] as UserMenuAction); return { actions: options, menuActions }; - }, [size, userinfoActions]); + }, [size, userinfoActions, hiddenActions]); return actionSpread; }; diff --git a/apps/meteor/client/views/room/providers/RoomToolboxProvider.tsx b/apps/meteor/client/views/room/providers/RoomToolboxProvider.tsx index c05885f5ab4d..2071a67bd7c3 100644 --- a/apps/meteor/client/views/room/providers/RoomToolboxProvider.tsx +++ b/apps/meteor/client/views/room/providers/RoomToolboxProvider.tsx @@ -1,6 +1,6 @@ import type { RoomType, IRoom } from '@rocket.chat/core-typings'; import { useMutableCallback, useStableArray } from '@rocket.chat/fuselage-hooks'; -import { useUserId, useSetting, useRouter, useRouteParameter } from '@rocket.chat/ui-contexts'; +import { useUserId, useSetting, useRouter, useRouteParameter, useLayoutHiddenActions } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; import React, { useMemo } from 'react'; @@ -87,10 +87,13 @@ const RoomToolboxProvider = ({ children }: RoomToolboxProviderProps) => { const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead', false); const uid = useUserId(); + const { roomToolbox: hiddenActions } = useLayoutHiddenActions(); + const actions = useStableArray( [...coreRoomActions, ...appsRoomActions] .filter((action) => uid || (allowAnonymousRead && 'anonymous' in action && action.anonymous)) .filter((action) => !action.groups || action.groups.includes(getGroup(room))) + .filter((action) => !hiddenActions.includes(action.id)) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), ); 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/packages/ui-contexts/src/LayoutContext.ts b/packages/ui-contexts/src/LayoutContext.ts index 694f55cffe38..2d900b5a7612 100644 --- a/packages/ui-contexts/src/LayoutContext.ts +++ b/packages/ui-contexts/src/LayoutContext.ts @@ -20,6 +20,12 @@ export type LayoutContextValue = { size: SizeLayout; contextualBarExpanded: boolean; contextualBarPosition: 'absolute' | 'relative' | 'fixed'; + hiddenActions: { + roomToolbox: Array; + messageToolbox: Array; + composerToolbox: Array; + userToolbox: Array; + }; }; export const LayoutContext = createContext({ @@ -40,4 +46,10 @@ export const LayoutContext = createContext({ }, contextualBarPosition: 'relative', contextualBarExpanded: false, + hiddenActions: { + roomToolbox: [], + messageToolbox: [], + composerToolbox: [], + userToolbox: [], + }, }); diff --git a/packages/ui-contexts/src/hooks/useLayoutHiddenActions.ts b/packages/ui-contexts/src/hooks/useLayoutHiddenActions.ts new file mode 100644 index 000000000000..d578f02e9bc9 --- /dev/null +++ b/packages/ui-contexts/src/hooks/useLayoutHiddenActions.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; + +import type { LayoutContextValue } from '../LayoutContext'; +import { LayoutContext } from '../LayoutContext'; + +export const useLayoutHiddenActions = (): LayoutContextValue['hiddenActions'] => useContext(LayoutContext).hiddenActions; diff --git a/packages/ui-contexts/src/index.ts b/packages/ui-contexts/src/index.ts index 6e7b31d8eaf3..fb2f2b84d377 100644 --- a/packages/ui-contexts/src/index.ts +++ b/packages/ui-contexts/src/index.ts @@ -38,6 +38,7 @@ export { useLayout } from './hooks/useLayout'; export { useLayoutContextualBarExpanded } from './hooks/useLayoutContextualBarExpanded'; export { useLayoutContextualBarPosition } from './hooks/useLayoutContextualBarPosition'; export { useLayoutSizes } from './hooks/useLayoutSizes'; +export { useLayoutHiddenActions } from './hooks/useLayoutHiddenActions'; export { useLoadLanguage } from './hooks/useLoadLanguage'; export { useLoginWithPassword } from './hooks/useLoginWithPassword'; export { useLoginServices } from './hooks/useLoginServices'; From 86d75ce221b3699163d717c481d2ef5e6b427076 Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Mon, 8 Jan 2024 16:07:27 -0300 Subject: [PATCH 09/12] refactor: Subscribe to only one stream at a time (#31345) --- apps/meteor/app/utils/client/lib/SDKClient.ts | 196 +++++++++++------- .../client/providers/ServerProvider.tsx | 62 +----- 2 files changed, 134 insertions(+), 124 deletions(-) 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/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, From 319f05ec79b577045133a3b5752aeb18780f4f8b Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 8 Jan 2024 18:11:43 -0300 Subject: [PATCH 10/12] fix: users being logged out after using 2FA (#31380) --- .changeset/beige-deers-laugh.md | 5 +++++ .../2fa/server/methods/validateTempToken.ts | 20 +++++++++++++++---- apps/meteor/app/api/server/v1/users.ts | 17 +++++++++++++++- .../TwoFactorModal/TwoFactorModal.tsx | 13 +++--------- .../views/account/security/TwoFactorTOTP.tsx | 6 ++---- 5 files changed, 42 insertions(+), 19 deletions(-) create mode 100644 .changeset/beige-deers-laugh.md 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/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/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/client/components/TwoFactorModal/TwoFactorModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx index 3e9824673bf2..0382b6eab52e 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx @@ -1,4 +1,3 @@ -import { useEndpoint } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; @@ -29,24 +28,18 @@ type TwoFactorModalProps = { ); const TwoFactorModal = ({ onConfirm, onClose, invalidAttempt, ...props }: TwoFactorModalProps): ReactElement => { - const logoutOtherSessions = useEndpoint('POST', '/v1/users.logoutOtherClients'); - - const confirm = (code: any, method: Method): void => { - onConfirm(code, method); - logoutOtherSessions(); - }; if (props.method === Method.TOTP) { - return ; + return ; } if (props.method === Method.EMAIL) { const { emailOrUsername } = props; - return ; + return ; } if (props.method === Method.PASSWORD) { - return ; + return ; } throw new Error('Invalid Two Factor method'); 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(() => { From 9a6e9b4e280b6044cd49344921e426f31622faeb Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Tue, 9 Jan 2024 15:01:38 -0300 Subject: [PATCH 11/12] fix: login buttons remain visible until refresh after disabling authentication service (#31371) --- .changeset/little-planes-wonder.md | 7 +++ .../rocketchat-mongo-config/server/index.js | 4 +- .../modules/watchers/watchers.module.ts | 6 +++ apps/meteor/server/services/meteor/service.ts | 8 +-- .../tests/e2e/fixtures/inject-initial-data.ts | 4 ++ apps/meteor/tests/e2e/oauth.spec.ts | 26 ++++++++++ apps/meteor/tests/e2e/page-objects/auth.ts | 4 ++ .../tests/end-to-end/api/08-settings.js | 49 +++++++++++++++++++ ee/apps/ddp-streamer/src/DDPStreamer.ts | 4 +- packages/core-services/src/events/Events.ts | 15 +++++- 10 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 .changeset/little-planes-wonder.md create mode 100644 apps/meteor/tests/e2e/oauth.spec.ts 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/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/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 88e465edc018..efa0866ab4a0 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -339,7 +339,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/services/meteor/service.ts b/apps/meteor/server/services/meteor/service.ts index 8b9462740c6c..95d2061e2f67 100644 --- a/apps/meteor/server/services/meteor/service.ts +++ b/apps/meteor/server/services/meteor/service.ts @@ -152,9 +152,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); + }); + } }); } diff --git a/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts b/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts index 11cea78b3f3d..38835db4aaa6 100644 --- a/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts +++ b/apps/meteor/tests/e2e/fixtures/inject-initial-data.ts @@ -57,6 +57,10 @@ export default async function injectInitialData() { _id: 'API_Enable_Rate_Limiter_Dev', value: false, }, + { + _id: 'Accounts_OAuth_Google', + value: false, + }, ].map((setting) => connection .db() diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts new file mode 100644 index 000000000000..e8ad6a6c7e54 --- /dev/null +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -0,0 +1,26 @@ +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); + + await page.goto('/home'); + }); + + test('Login Page', async ({ api }) => { + await test.step('expect OAuth button to be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', true)).status()).toBe(200); + await expect(poRegistration.btnLoginWithGoogle).toBeVisible({ timeout: 10000 }); + }); + + await test.step('expect OAuth button to not be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); + + await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible({ timeout: 10000 }); + }); + }); +}); \ No newline at end of file diff --git a/apps/meteor/tests/e2e/page-objects/auth.ts b/apps/meteor/tests/e2e/page-objects/auth.ts index 9b47d2e44adc..d0a7e13d6505 100644 --- a/apps/meteor/tests/e2e/page-objects/auth.ts +++ b/apps/meteor/tests/e2e/page-objects/auth.ts @@ -20,6 +20,10 @@ export class Registration { return this.page.locator('role=button[name="Login"]'); } + 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/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/ee/apps/ddp-streamer/src/DDPStreamer.ts b/ee/apps/ddp-streamer/src/DDPStreamer.ts index 8cc55a09134c..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 => { diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index 67327c3ea215..6315bf43fa13 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -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; @@ -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; From 1b486a1cc3ded5363a8f1c6ff0b9f0b715a86de3 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Tue, 9 Jan 2024 16:09:38 -0300 Subject: [PATCH 12/12] refactor(client): Move Meteor overrides (#28366) --- apps/meteor/app/2fa/client/TOTPCrowd.js | 38 ----- apps/meteor/app/2fa/client/TOTPGoogle.js | 39 ----- apps/meteor/app/2fa/client/TOTPLDAP.js | 54 ------- apps/meteor/app/2fa/client/TOTPOAuth.js | 142 ------------------ apps/meteor/app/2fa/client/TOTPPassword.js | 71 --------- apps/meteor/app/2fa/client/TOTPSaml.js | 35 ----- apps/meteor/app/2fa/client/index.ts | 7 - .../app/2fa/client/overrideMeteorCall.ts | 53 ------- apps/meteor/app/apple/client/index.ts | 2 +- .../apple/server/appleOauthRegisterService.ts | 2 +- apps/meteor/app/cas/client/cas_client.ts | 68 --------- apps/meteor/app/cas/client/index.ts | 1 - apps/meteor/app/crowd/client/index.ts | 1 - apps/meteor/app/crowd/client/loginHelper.js | 26 ---- ...{custom_oauth_client.js => CustomOAuth.ts} | 45 +++--- .../server/custom_oauth_server.js | 2 +- apps/meteor/app/dolphin/client/lib.ts | 2 +- apps/meteor/app/drupal/client/lib.ts | 2 +- .../app/github-enterprise/client/lib.ts | 2 +- apps/meteor/app/gitlab/client/lib.ts | 2 +- apps/meteor/app/lib/server/oauth/oauth.js | 2 +- .../app/meteor-accounts-saml/client/index.ts | 1 - .../client/saml_client.js | 86 ----------- .../server/methods/samlLogout.ts | 2 +- apps/meteor/app/models/client/index.ts | 13 -- apps/meteor/app/nextcloud/client/lib.ts | 2 +- apps/meteor/app/tokenpass/client/lib.ts | 2 +- apps/meteor/app/wordpress/client/lib.ts | 2 +- .../client/definitions/IOAuthProvider.ts | 9 ++ apps/meteor/client/importPackages.ts | 5 - .../client/lib/2fa/overrideLoginMethod.ts | 109 +++++++++++--- .../meteor/client/lib/2fa/process2faReturn.ts | 34 +++-- apps/meteor/client/lib/2fa/utils.ts | 23 --- apps/meteor/client/lib/openCASLoginPopup.ts | 62 ++++++++ apps/meteor/client/main.ts | 2 +- .../ddpOverREST.ts} | 35 +++-- apps/meteor/client/meteorOverrides/index.ts | 16 ++ .../client/meteorOverrides/login/cas.ts | 20 +++ .../client/meteorOverrides/login/crowd.ts | 49 ++++++ .../client/meteorOverrides/login/facebook.ts | 11 ++ .../client/meteorOverrides/login/github.ts | 11 ++ .../client/meteorOverrides/login/google.ts | 72 +++++++++ .../client/meteorOverrides/login/ldap.ts | 52 +++++++ .../client/meteorOverrides/login/linkedin.ts | 18 +++ .../login/meteorDeveloperAccount.ts | 11 ++ .../client/meteorOverrides/login/oauth.ts | 127 ++++++++++++++++ .../client/meteorOverrides/login/password.ts | 67 +++++++++ .../client/meteorOverrides/login/saml.ts | 111 ++++++++++++++ .../client/meteorOverrides/login/twitter.ts | 11 ++ .../meteorOverrides/oauthRedirectUri.ts} | 7 + .../client/meteorOverrides/totpOnCall.ts | 63 ++++++++ .../client/meteorOverrides/userAndUsers.ts | 14 ++ .../providers/UserProvider/UserProvider.tsx | 8 +- apps/meteor/client/startup/customOAuth.ts | 2 +- apps/meteor/client/startup/index.ts | 2 - apps/meteor/client/startup/ldap.ts | 16 -- apps/meteor/client/startup/oauth.ts | 20 --- .../marketplace/hooks/useAppRequestStats.ts | 3 +- .../externals/meteor/accounts-base.d.ts | 12 +- .../externals/meteor/facebook-oauth.d.ts | 3 + .../externals/meteor/github-oauth.d.ts | 3 + .../externals/meteor/google-oauth.d.ts | 3 + .../meteor/meteor-developer-oauth.d.ts | 3 + .../definition/externals/meteor/meteor.d.ts | 19 +-- .../definition/externals/meteor/oauth.d.ts | 24 ++- .../meteor/pauli-linkedin-oauth.d.ts | 3 + .../externals/meteor/twitter-oauth.d.ts | 3 + .../externals/service-configuration.d.ts | 11 +- apps/meteor/lib/oauthRedirectUriServer.ts | 2 +- .../linkedin-oauth/linkedin-client.js | 2 +- .../linkedin-oauth/linkedin-server.js | 2 +- .../core-typings/src/ICustomOAuthConfig.ts | 3 +- packages/rest-typings/src/v1/misc.ts | 4 +- packages/ui-contexts/src/UserContext.ts | 2 +- 74 files changed, 970 insertions(+), 823 deletions(-) delete mode 100644 apps/meteor/app/2fa/client/TOTPCrowd.js delete mode 100644 apps/meteor/app/2fa/client/TOTPGoogle.js delete mode 100644 apps/meteor/app/2fa/client/TOTPLDAP.js delete mode 100644 apps/meteor/app/2fa/client/TOTPOAuth.js delete mode 100644 apps/meteor/app/2fa/client/TOTPPassword.js delete mode 100644 apps/meteor/app/2fa/client/TOTPSaml.js delete mode 100644 apps/meteor/app/2fa/client/index.ts delete mode 100644 apps/meteor/app/2fa/client/overrideMeteorCall.ts delete mode 100644 apps/meteor/app/cas/client/cas_client.ts delete mode 100644 apps/meteor/app/cas/client/index.ts delete mode 100644 apps/meteor/app/crowd/client/index.ts delete mode 100644 apps/meteor/app/crowd/client/loginHelper.js rename apps/meteor/app/custom-oauth/client/{custom_oauth_client.js => CustomOAuth.ts} (68%) delete mode 100644 apps/meteor/app/meteor-accounts-saml/client/index.ts delete mode 100644 apps/meteor/app/meteor-accounts-saml/client/saml_client.js create mode 100644 apps/meteor/client/definitions/IOAuthProvider.ts create mode 100644 apps/meteor/client/lib/openCASLoginPopup.ts rename apps/meteor/client/{lib/meteorCallWrapper.ts => meteorOverrides/ddpOverREST.ts} (63%) create mode 100644 apps/meteor/client/meteorOverrides/index.ts create mode 100644 apps/meteor/client/meteorOverrides/login/cas.ts create mode 100644 apps/meteor/client/meteorOverrides/login/crowd.ts create mode 100644 apps/meteor/client/meteorOverrides/login/facebook.ts create mode 100644 apps/meteor/client/meteorOverrides/login/github.ts create mode 100644 apps/meteor/client/meteorOverrides/login/google.ts create mode 100644 apps/meteor/client/meteorOverrides/login/ldap.ts create mode 100644 apps/meteor/client/meteorOverrides/login/linkedin.ts create mode 100644 apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts create mode 100644 apps/meteor/client/meteorOverrides/login/oauth.ts create mode 100644 apps/meteor/client/meteorOverrides/login/password.ts create mode 100644 apps/meteor/client/meteorOverrides/login/saml.ts create mode 100644 apps/meteor/client/meteorOverrides/login/twitter.ts rename apps/meteor/{lib/oauthRedirectUriClient.ts => client/meteorOverrides/oauthRedirectUri.ts} (80%) create mode 100644 apps/meteor/client/meteorOverrides/totpOnCall.ts create mode 100644 apps/meteor/client/meteorOverrides/userAndUsers.ts delete mode 100644 apps/meteor/client/startup/ldap.ts delete mode 100644 apps/meteor/client/startup/oauth.ts create mode 100644 apps/meteor/definition/externals/meteor/facebook-oauth.d.ts create mode 100644 apps/meteor/definition/externals/meteor/github-oauth.d.ts create mode 100644 apps/meteor/definition/externals/meteor/google-oauth.d.ts create mode 100644 apps/meteor/definition/externals/meteor/meteor-developer-oauth.d.ts create mode 100644 apps/meteor/definition/externals/meteor/pauli-linkedin-oauth.d.ts create mode 100644 apps/meteor/definition/externals/meteor/twitter-oauth.d.ts 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/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/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/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/custom-oauth/client/custom_oauth_client.js b/apps/meteor/app/custom-oauth/client/CustomOAuth.ts similarity index 68% rename from apps/meteor/app/custom-oauth/client/custom_oauth_client.js rename to apps/meteor/app/custom-oauth/client/CustomOAuth.ts index c516f115aede..58c4142d1349 100644 --- a/apps/meteor/app/custom-oauth/client/custom_oauth_client.js +++ b/apps/meteor/app/custom-oauth/client/CustomOAuth.ts @@ -1,3 +1,4 @@ +import type { 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'; @@ -6,6 +7,9 @@ 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 { 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,31 +68,28 @@ 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); + }; + } + async requestCredential( + options: Meteor.LoginWithExternalServiceOptions = {}, + credentialRequestCompleteCallback: (credentialTokenOrError?: string | Error) => void, + ) { const config = await ServiceConfiguration.configurations.findOneAsync({ service: this.name }); if (!config) { if (credentialRequestCompleteCallback) { - credentialRequestCompleteCallback(new ServiceConfiguration.ConfigError()); + credentialRequestCompleteCallback(new Accounts.ConfigError()); } return; } 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/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/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/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/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/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/models/client/index.ts b/apps/meteor/app/models/client/index.ts index b4c540318a9b..6b023c3cc7c7 100644 --- a/apps/meteor/app/models/client/index.ts +++ b/apps/meteor/app/models/client/index.ts @@ -16,19 +16,6 @@ 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, Roles, 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/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/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/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/importPackages.ts b/apps/meteor/client/importPackages.ts index fc9ce52e9993..c60a13dbfc50 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'; @@ -36,7 +32,6 @@ 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'; 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/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/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..09875021238c --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/facebook.ts @@ -0,0 +1,11 @@ +import { Facebook } from 'meteor/facebook-oauth'; +import { Meteor } from 'meteor/meteor'; + +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { createOAuthTotpLoginMethod } from './oauth'; + +const { loginWithFacebook } = Meteor; +const loginWithFacebookAndTOTP = createOAuthTotpLoginMethod(Facebook); +Meteor.loginWithFacebook = (options, callback) => { + overrideLoginMethod(loginWithFacebook, [options], callback, loginWithFacebookAndTOTP); +}; diff --git a/apps/meteor/client/meteorOverrides/login/github.ts b/apps/meteor/client/meteorOverrides/login/github.ts new file mode 100644 index 000000000000..15e514ab6d56 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/github.ts @@ -0,0 +1,11 @@ +import { Github } from 'meteor/github-oauth'; +import { Meteor } from 'meteor/meteor'; + +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { createOAuthTotpLoginMethod } from './oauth'; + +const { loginWithGithub } = Meteor; +const loginWithGithubAndTOTP = createOAuthTotpLoginMethod(Github); +Meteor.loginWithGithub = (options, callback) => { + overrideLoginMethod(loginWithGithub, [options], callback, loginWithGithubAndTOTP); +}; diff --git a/apps/meteor/client/meteorOverrides/login/google.ts b/apps/meteor/client/meteorOverrides/login/google.ts new file mode 100644 index 000000000000..149f55b00ace --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/google.ts @@ -0,0 +1,72 @@ +import { Accounts } from 'meteor/accounts-base'; +import { Google } from 'meteor/google-oauth'; +import { Meteor } from 'meteor/meteor'; + +import { overrideLoginMethod, type LoginCallback } from '../../lib/2fa/overrideLoginMethod'; +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); + }; + } +} + +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); +}; 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..0f309ee360f6 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/linkedin.ts @@ -0,0 +1,18 @@ +import { Meteor } from 'meteor/meteor'; +import { Linkedin } from 'meteor/pauli:linkedin-oauth'; + +import type { LoginCallback } from '../../lib/2fa/overrideLoginMethod'; +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +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); +}; diff --git a/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts b/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts new file mode 100644 index 000000000000..9577194f4043 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/meteorDeveloperAccount.ts @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; +import { MeteorDeveloperAccounts } from 'meteor/meteor-developer-oauth'; + +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { createOAuthTotpLoginMethod } from './oauth'; + +const { loginWithMeteorDeveloperAccount } = Meteor; +const loginWithMeteorDeveloperAccountAndTOTP = createOAuthTotpLoginMethod(MeteorDeveloperAccounts); +Meteor.loginWithMeteorDeveloperAccount = (options, callback) => { + overrideLoginMethod(loginWithMeteorDeveloperAccount, [options], callback, loginWithMeteorDeveloperAccountAndTOTP); +}; 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..8972cfe4812f --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/saml.ts @@ -0,0 +1,111 @@ +import { Random } from '@rocket.chat/random'; +import { Accounts } from 'meteor/accounts-base'; +import { Meteor } from 'meteor/meteor'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { type LoginCallback, callLoginMethod, handleLogin } from '../../lib/2fa/overrideLoginMethod'; + +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'); + const samlService = await ServiceConfiguration.configurations.findOneAsync({ service: '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..955277b1ce56 --- /dev/null +++ b/apps/meteor/client/meteorOverrides/login/twitter.ts @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; +import { Twitter } from 'meteor/twitter-oauth'; + +import { overrideLoginMethod } from '../../lib/2fa/overrideLoginMethod'; +import { createOAuthTotpLoginMethod } from './oauth'; + +const { loginWithTwitter } = Meteor; +const loginWithTwitterAndTOTP = createOAuthTotpLoginMethod(Twitter); +Meteor.loginWithTwitter = (options, callback) => { + overrideLoginMethod(loginWithTwitter, [options], callback, loginWithTwitterAndTOTP); +}; 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/UserProvider/UserProvider.tsx b/apps/meteor/client/providers/UserProvider/UserProvider.tsx index 09f631ffa6a6..b6e30134cdaa 100644 --- a/apps/meteor/client/providers/UserProvider/UserProvider.tsx +++ b/apps/meteor/client/providers/UserProvider/UserProvider.tsx @@ -53,7 +53,7 @@ const logout = (): Promise => }); }); -export type LoginMethods = keyof typeof Meteor; +export type LoginMethods = keyof typeof Meteor extends infer T ? (T extends `loginWith${string}` ? T : never) : never; type UserProviderProps = { children: ReactNode; @@ -107,7 +107,7 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { ), 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) => { + Meteor[loginMethod](user, password, (error) => { if (error) { reject(error); return; @@ -120,9 +120,9 @@ const UserProvider = ({ children }: UserProviderProps): ReactElement => { loginWithService: ({ service, clientConfig = {} }: T): (() => Promise) => { const loginMethods = { 'meteor-developer': 'MeteorDeveloperAccount', - }; + } as const; - const loginWithService = `loginWith${(loginMethods as any)[service] || capitalize(String(service || ''))}`; + const loginWithService = `loginWith${loginMethods[service] || capitalize(String(service || ''))}`; const method: (config: unknown, cb: (error: any) => void) => Promise = (Meteor as any)[loginWithService] as any; diff --git a/apps/meteor/client/startup/customOAuth.ts b/apps/meteor/client/startup/customOAuth.ts index 5b0e3dfb4261..529540566b95 100644 --- a/apps/meteor/client/startup/customOAuth.ts +++ b/apps/meteor/client/startup/customOAuth.ts @@ -1,7 +1,7 @@ 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'; Meteor.startup(() => { ServiceConfiguration.configurations diff --git a/apps/meteor/client/startup/index.ts b/apps/meteor/client/startup/index.ts index 61eaa0da16ed..5fa2bb0bc5ec 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'; 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/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/views/marketplace/hooks/useAppRequestStats.ts b/apps/meteor/client/views/marketplace/hooks/useAppRequestStats.ts index af25282b7e53..3fff24cc9216 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppRequestStats.ts +++ b/apps/meteor/client/views/marketplace/hooks/useAppRequestStats.ts @@ -8,7 +8,8 @@ export const useAppRequestStats = () => { return useQuery({ queryKey: ['app-requests-stats'], - queryFn: async () => (await fetchRequestStats()).data, + queryFn: () => fetchRequestStats(), + select: ({ data }) => data, refetchOnWindowFocus: false, retry: false, enabled: canManageApp, diff --git a/apps/meteor/definition/externals/meteor/accounts-base.d.ts b/apps/meteor/definition/externals/meteor/accounts-base.d.ts index 0e15e50b39fb..3f0b148120e7 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -24,7 +24,7 @@ declare module 'meteor/accounts-base' { function _runLoginHandlers(methodInvocation: T, loginRequest: Record): LoginMethodResult | undefined; - function registerLoginHandler(name: string, handler: (options: any) => undefined | Object): void; + function registerLoginHandler(name: string, handler: (options: any) => undefined | object): void; function _storedLoginToken(): unknown; @@ -53,5 +53,15 @@ declare module 'meteor/accounts-base' { const LOGIN_TOKEN_KEY: string; const _accountData: Record; + + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace oauth { + function credentialRequestCompleteHandler( + callback?: (error?: globalThis.Error | Meteor.Error | Meteor.TypedError) => void, + totpCode?: string, + ): (credentialTokenOrError?: string | globalThis.Error | Meteor.Error | Meteor.TypedError) => void; + + function registerService(name: string): void; + } } } diff --git a/apps/meteor/definition/externals/meteor/facebook-oauth.d.ts b/apps/meteor/definition/externals/meteor/facebook-oauth.d.ts new file mode 100644 index 000000000000..d16e36efb67c --- /dev/null +++ b/apps/meteor/definition/externals/meteor/facebook-oauth.d.ts @@ -0,0 +1,3 @@ +declare module 'meteor/facebook-oauth' { + export const Facebook: any; +} diff --git a/apps/meteor/definition/externals/meteor/github-oauth.d.ts b/apps/meteor/definition/externals/meteor/github-oauth.d.ts new file mode 100644 index 000000000000..ac67405bd4e4 --- /dev/null +++ b/apps/meteor/definition/externals/meteor/github-oauth.d.ts @@ -0,0 +1,3 @@ +declare module 'meteor/github-oauth' { + export const Github: any; +} diff --git a/apps/meteor/definition/externals/meteor/google-oauth.d.ts b/apps/meteor/definition/externals/meteor/google-oauth.d.ts new file mode 100644 index 000000000000..a15f3de64b7f --- /dev/null +++ b/apps/meteor/definition/externals/meteor/google-oauth.d.ts @@ -0,0 +1,3 @@ +declare module 'meteor/google-oauth' { + export const Google: any; +} diff --git a/apps/meteor/definition/externals/meteor/meteor-developer-oauth.d.ts b/apps/meteor/definition/externals/meteor/meteor-developer-oauth.d.ts new file mode 100644 index 000000000000..9a86a4b483e7 --- /dev/null +++ b/apps/meteor/definition/externals/meteor/meteor-developer-oauth.d.ts @@ -0,0 +1,3 @@ +declare module 'meteor/meteor-developer-oauth' { + export const MeteorDeveloperAccounts: any; +} diff --git a/apps/meteor/definition/externals/meteor/meteor.d.ts b/apps/meteor/definition/externals/meteor/meteor.d.ts index 4854d24a37ba..7d8722896f8e 100644 --- a/apps/meteor/definition/externals/meteor/meteor.d.ts +++ b/apps/meteor/definition/externals/meteor/meteor.d.ts @@ -110,20 +110,6 @@ declare module 'meteor/meteor' { function _relativeToSiteRootUrl(path: string): string; const _localStorage: Window['localStorage']; - function loginWithLDAP( - username: string | object, - password: string, - cb: (error?: Error | Meteor.Error | Meteor.TypedError) => void, - ): void; - - function loginWithCrowd( - username: string | object, - password: string, - cb: (error?: Error | Meteor.Error | Meteor.TypedError) => void, - ): void; - - function loginWithSamlToken(token: string, cb: (error?: Error | Meteor.Error | Meteor.TypedError) => void): void; - function methods(methods: { [TMethodName in keyof TServerMethods]?: ( this: MethodThisType, @@ -137,4 +123,9 @@ declare module 'meteor/meteor' { } | undefined; } + + // eslint-disable-next-line no-var + var Meteor: { + [key: `loginWith${string}`]: any; + }; } diff --git a/apps/meteor/definition/externals/meteor/oauth.d.ts b/apps/meteor/definition/externals/meteor/oauth.d.ts index 0f75bc611996..9573b2888f49 100644 --- a/apps/meteor/definition/externals/meteor/oauth.d.ts +++ b/apps/meteor/definition/externals/meteor/oauth.d.ts @@ -1,6 +1,7 @@ declare module 'meteor/oauth' { import type { IRocketChatRecord } from '@rocket.chat/core-typings'; import type { Mongo } from 'meteor/mongo'; + import type { Configuration } from 'meteor/service-configuration'; interface IOauthCredentials extends IRocketChatRecord { key: string; @@ -13,12 +14,29 @@ declare module 'meteor/oauth' { } namespace OAuth { - function _redirectUri(serviceName: string, config: any, params: any, absoluteUrlOptions: any): string; - function _retrieveCredentialSecret(credentialToken: string): string | null; function _retrievePendingCredential(key: string, ...args: string[]): void; function openSecret(secret: string): string; function retrieveCredential(credentialToken: string, credentialSecret: string); - const _storageTokenPrefix: string; + function _retrieveCredentialSecret(credentialToken: string): string | null; const _pendingCredentials: Mongo.Collection; + const _storageTokenPrefix: string; + + function launchLogin(options: { + loginService: string; + loginStyle: string; + loginUrl: string; + credentialRequestCompleteCallback?: (credentialTokenOrError?: string | Error) => void; + credentialToken: string; + popupOptions: { + width: number; + height: number; + }; + }): void; + + function _stateParam(loginStyle: string, credentialToken: string, redirectUrl?: string): string; + + function _redirectUri(serviceName: string, config: Configuration, params?: any, absoluteUrlOptions?: any): string; + + function _loginStyle(serviceName: string, config: Configuration, options?: Meteor.LoginWithExternalServiceOptions): string; } } diff --git a/apps/meteor/definition/externals/meteor/pauli-linkedin-oauth.d.ts b/apps/meteor/definition/externals/meteor/pauli-linkedin-oauth.d.ts new file mode 100644 index 000000000000..14c313059710 --- /dev/null +++ b/apps/meteor/definition/externals/meteor/pauli-linkedin-oauth.d.ts @@ -0,0 +1,3 @@ +declare module 'meteor/pauli:linkedin-oauth' { + export const Linkedin: any; +} diff --git a/apps/meteor/definition/externals/meteor/twitter-oauth.d.ts b/apps/meteor/definition/externals/meteor/twitter-oauth.d.ts new file mode 100644 index 000000000000..9bef027292dc --- /dev/null +++ b/apps/meteor/definition/externals/meteor/twitter-oauth.d.ts @@ -0,0 +1,3 @@ +declare module 'meteor/twitter-oauth' { + export const Twitter: any; +} diff --git a/apps/meteor/definition/externals/service-configuration.d.ts b/apps/meteor/definition/externals/service-configuration.d.ts index 1a208c71e044..bdea46eedab2 100644 --- a/apps/meteor/definition/externals/service-configuration.d.ts +++ b/apps/meteor/definition/externals/service-configuration.d.ts @@ -9,15 +9,6 @@ declare module 'meteor/service-configuration' { buttonColor?: string; clientConfig: unknown; + clientId?: string; } } - -declare module 'meteor' { - interface Configuration { - appId: string; - secret: string; - } - const ServiceConfiguration: { - configurations: Mongo.Collection; - }; -} diff --git a/apps/meteor/lib/oauthRedirectUriServer.ts b/apps/meteor/lib/oauthRedirectUriServer.ts index ce89f00ef2a2..fe2bb788f6fd 100644 --- a/apps/meteor/lib/oauthRedirectUriServer.ts +++ b/apps/meteor/lib/oauthRedirectUriServer.ts @@ -4,7 +4,7 @@ import { SystemLogger } from '../server/lib/logger/system'; const { _redirectUri } = OAuth; -OAuth._redirectUri = (serviceName: string, config: any, params: unknown, absoluteUrlOptions: unknown): string => { +OAuth._redirectUri = (serviceName, config, params, absoluteUrlOptions) => { const ret = _redirectUri(serviceName, config, params, absoluteUrlOptions); // DEPRECATED: Remove in v5.0.0 diff --git a/apps/meteor/packages/linkedin-oauth/linkedin-client.js b/apps/meteor/packages/linkedin-oauth/linkedin-client.js index 29be9dd1af22..4803d69d34b1 100644 --- a/apps/meteor/packages/linkedin-oauth/linkedin-client.js +++ b/apps/meteor/packages/linkedin-oauth/linkedin-client.js @@ -18,7 +18,7 @@ Linkedin.requestCredential = async function (options, credentialRequestCompleteC const config = await ServiceConfiguration.configurations.findOneAsync({ service: 'linkedin' }); if (!config) { - throw new ServiceConfiguration.ConfigError('Service not configured'); + throw new Accounts.ConfigError('Service not configured'); } const credentialToken = Random.secret(); diff --git a/apps/meteor/packages/linkedin-oauth/linkedin-server.js b/apps/meteor/packages/linkedin-oauth/linkedin-server.js index 62e4dad143ce..09a12a528dda 100644 --- a/apps/meteor/packages/linkedin-oauth/linkedin-server.js +++ b/apps/meteor/packages/linkedin-oauth/linkedin-server.js @@ -9,7 +9,7 @@ export const Linkedin = {}; // - expiresIn: lifetime of token in seconds const getTokenResponse = async function (query) { const config = await ServiceConfiguration.configurations.findOneAsync({ service: 'linkedin' }); - if (!config) throw new ServiceConfiguration.ConfigError('Service not configured'); + if (!config) throw new Accounts.ConfigError('Service not configured'); let responseContent; try { 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/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/ui-contexts/src/UserContext.ts b/packages/ui-contexts/src/UserContext.ts index 92587c61fa05..14b9644e6a3c 100644 --- a/packages/ui-contexts/src/UserContext.ts +++ b/packages/ui-contexts/src/UserContext.ts @@ -29,7 +29,7 @@ export type LoginService = { clientConfig: unknown; title: string; - service: string; + service: 'meteor-developer'; buttonLabelText?: string; icon?: string;