diff --git a/.changeset/eleven-news-stare.md b/.changeset/eleven-news-stare.md new file mode 100644 index 000000000000..8bf62b7aeafa --- /dev/null +++ b/.changeset/eleven-news-stare.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed issue with login via SAML not redirecting to invite link diff --git a/.changeset/great-moles-rest.md b/.changeset/great-moles-rest.md new file mode 100644 index 000000000000..a615edd7df62 --- /dev/null +++ b/.changeset/great-moles-rest.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Encrypt file descriptions in E2EE rooms diff --git a/.changeset/nervous-elephants-jam.md b/.changeset/nervous-elephants-jam.md new file mode 100644 index 000000000000..cc74cd85842e --- /dev/null +++ b/.changeset/nervous-elephants-jam.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Added a new setting to automatically disable users from LDAP that can no longer be found by the background sync diff --git a/.changeset/strange-comics-camp.md b/.changeset/strange-comics-camp.md new file mode 100644 index 000000000000..667ba409a7f3 --- /dev/null +++ b/.changeset/strange-comics-camp.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed an issue where an endpoint was called before checking configuration that enables automatic translation when launching the application diff --git a/apps/meteor/.meteorMocks/index.ts b/apps/meteor/.meteorMocks/index.ts new file mode 100644 index 000000000000..e70ffa7f7c46 --- /dev/null +++ b/apps/meteor/.meteorMocks/index.ts @@ -0,0 +1,5 @@ +import sinon from 'sinon'; + +export const Meteor = { + loginWithSamlToken: sinon.stub(), +}; diff --git a/apps/meteor/app/api/server/api.helpers.ts b/apps/meteor/app/api/server/api.helpers.ts index bbc429e0bbc5..365e50701685 100644 --- a/apps/meteor/app/api/server/api.helpers.ts +++ b/apps/meteor/app/api/server/api.helpers.ts @@ -1,6 +1,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { hasAllPermissionAsync, hasAtLeastOnePermissionAsync } from '../../authorization/server/functions/hasPermission'; +import { apiDeprecationLogger } from '../../lib/server/lib/deprecationWarningLogger'; type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | '*'; export type PermissionsPayload = { @@ -101,3 +102,8 @@ export function checkPermissions(options: { permissionsRequired?: PermissionsReq // If reached here, options.permissionsRequired contained an invalid payload return false; } + +export function parseDeprecation(methodThis: any, { alternatives, version }: { version: string; alternatives?: string[] }): void { + const infoMessage = alternatives?.length ? ` Please use the alternative(s): ${alternatives.join(',')}` : ''; + apiDeprecationLogger.endpoint(methodThis.request.route, version, methodThis.response, infoMessage); +} diff --git a/apps/meteor/app/api/server/api.ts b/apps/meteor/app/api/server/api.ts index 0279bcfbae27..87153440cd28 100644 --- a/apps/meteor/app/api/server/api.ts +++ b/apps/meteor/app/api/server/api.ts @@ -17,12 +17,11 @@ import { isObject } from '../../../lib/utils/isObject'; import { getRestPayload } from '../../../server/lib/logger/logPayloads'; import { checkCodeForUser } from '../../2fa/server/code'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; -import { apiDeprecationLogger } from '../../lib/server/lib/deprecationWarningLogger'; import { metrics } from '../../metrics/server'; import { settings } from '../../settings/server'; import { getDefaultUserFields } from '../../utils/server/functions/getDefaultUserFields'; import type { PermissionsPayload } from './api.helpers'; -import { checkPermissionsForInvocation, checkPermissions } from './api.helpers'; +import { checkPermissionsForInvocation, checkPermissions, parseDeprecation } from './api.helpers'; import type { FailureResult, InternalError, @@ -588,8 +587,8 @@ export class APIClass extends Restivus { const connection = { ...generateConnection(this.requestIp, this.request.headers), token: this.token }; try { - if (options.deprecationVersion) { - apiDeprecationLogger.endpoint(this.request.route, options.deprecationVersion, this.response, options.deprecationInfo || ''); + if (options.deprecation) { + parseDeprecation(this, options.deprecation); } await api.enforceRateLimit(objectForRateLimitMatch, this.request, this.response, this.userId); diff --git a/apps/meteor/app/api/server/definition.ts b/apps/meteor/app/api/server/definition.ts index 20374bcb5e84..b9825a4f9612 100644 --- a/apps/meteor/app/api/server/definition.ts +++ b/apps/meteor/app/api/server/definition.ts @@ -95,8 +95,10 @@ export type Options = ( ) & { validateParams?: ValidateFunction | { [key in Method]?: ValidateFunction }; authOrAnonRequired?: true; - deprecationVersion?: string; - deprecationInfo?: string; + deprecation?: { + version: string; + alternatives?: string[]; + }; }; export type PartialThis = { diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index 9d7cd8e231fd..931cf4be2019 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -806,7 +806,14 @@ API.v1.addRoute( API.v1.addRoute( 'channels.images', - { authRequired: true, validateParams: isRoomsImagesProps, deprecationVersion: '7.0.0', deprecationInfo: 'Use /v1/rooms.images instead.' }, + { + authRequired: true, + validateParams: isRoomsImagesProps, + deprecation: { + version: '7.0.0', + alternatives: ['rooms.images'], + }, + }, { async get() { const room = await Rooms.findOneById>(this.queryParams.roomId, { diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index 7b6c964a50bb..2d57b6d56ab4 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -24,7 +24,6 @@ import { SystemLogger } from '../../../../server/lib/logger/system'; import { getLogs } from '../../../../server/stream/stdout'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { passwordPolicy } from '../../../lib/server'; -import { apiDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { settings } from '../../../settings/server'; import { getDefaultUserFields } from '../../../utils/server/functions/getDefaultUserFields'; import { getURL } from '../../../utils/server/getURL'; @@ -409,10 +408,13 @@ API.v1.addRoute( { authRequired: false, validateParams: validateParamsPwGetPolicyRest, + deprecation: { + version: '7.0.0', + alternatives: ['pw.getPolicy'], + }, }, { async get() { - apiDeprecationLogger.endpoint(this.request.route, '7.0.0', this.response, ' Use pw.getPolicy instead.'); check( this.queryParams, Match.ObjectIncluding({ diff --git a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts index 46b858c4d3ea..1cf02277878a 100644 --- a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts +++ b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts @@ -17,6 +17,7 @@ import { } from '../../../../client/views/room/MessageList/lib/autoTranslate'; import { hasPermission } from '../../../authorization/client'; import { Subscriptions, Messages } from '../../../models/client'; +import { settings } from '../../../settings/client'; import { sdk } from '../../../utils/client/lib/SDKClient'; let userLanguage = 'en'; @@ -102,7 +103,7 @@ export const AutoTranslate = { Tracker.autorun(async (c) => { const uid = Meteor.userId(); - if (!uid || !hasPermission('auto-translate')) { + if (!settings.get('AutoTranslate_Enabled') || !uid || !hasPermission('auto-translate')) { return; } diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.room.js b/apps/meteor/app/e2e/client/rocketchat.e2e.room.js index bd0863d691a9..9e2f72d38115 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.room.js +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.room.js @@ -410,6 +410,20 @@ export class E2ERoom extends Emitter { return this.encryptText(data); } + encryptAttachmentDescription(description, _id) { + const ts = new Date(); + + const data = new TextEncoder('UTF-8').encode( + EJSON.stringify({ + userId: this.userId, + text: description, + _id, + ts, + }), + ); + return this.encryptText(data); + } + // Decrypt messages async decryptMessage(message) { diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 1a98ce857f01..472e71959933 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -2,7 +2,7 @@ import QueryString from 'querystring'; import URL from 'url'; import type { IE2EEMessage, IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings'; -import { isE2EEMessage } from '@rocket.chat/core-typings'; +import { isE2EEMessage, isFileAttachment } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import EJSON from 'ejson'; import { Meteor } from 'meteor/meteor'; @@ -422,19 +422,60 @@ class E2E extends Emitter { const data = await e2eRoom.decrypt(message.msg); - if (!data) { - return message; - } - const decryptedMessage: IE2EEMessage = { ...message, - msg: data.text, - e2e: 'done', + ...(data && { + msg: data.text, + e2e: 'done', + }), }; const decryptedMessageWithQuote = await this.parseQuoteAttachment(decryptedMessage); - return decryptedMessageWithQuote; + const decryptedMessageWithAttachments = await this.decryptMessageAttachments(decryptedMessageWithQuote); + + return decryptedMessageWithAttachments; + } + + async decryptMessageAttachments(message: IMessage): Promise { + const { attachments } = message; + + if (!attachments || !attachments.length) { + return message; + } + + const e2eRoom = await this.getInstanceByRoomId(message.rid); + + if (!e2eRoom) { + return message; + } + + const decryptedAttachments = await Promise.all( + attachments.map(async (attachment) => { + if (!isFileAttachment(attachment)) { + return attachment; + } + + if (!attachment.description) { + return attachment; + } + + const data = await e2eRoom.decrypt(attachment.description); + + if (!data) { + return attachment; + } + + attachment.description = data.text; + return attachment; + }), + ); + + return { + ...message, + attachments: decryptedAttachments, + e2e: 'done', + }; } async decryptPendingMessages(): Promise { diff --git a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts index 3d02d724290f..08e3225ad9a1 100644 --- a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts +++ b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts @@ -1,4 +1,12 @@ -import type { MessageAttachment, FileAttachmentProps, IUser, IUpload, AtLeast, FilesAndAttachments } from '@rocket.chat/core-typings'; +import type { + MessageAttachment, + FileAttachmentProps, + IUser, + IUpload, + AtLeast, + FilesAndAttachments, + IMessage, +} from '@rocket.chat/core-typings'; import { Rooms, Uploads, Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Match, check } from 'meteor/check'; @@ -178,6 +186,8 @@ export const sendFileMessage = async ( msg: Match.Optional(String), tmid: Match.Optional(String), customFields: Match.Optional(String), + t: Match.Optional(String), + e2e: Match.Optional(String), }), ); @@ -189,7 +199,7 @@ export const sendFileMessage = async ( file: files[0], files, attachments, - ...msgData, + ...(msgData as Partial), ...(msgData?.customFields && { customFields: JSON.parse(msgData.customFields) }), msg: msgData?.msg ?? '', groupable: msgData?.groupable ?? false, diff --git a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts index f5315b4f1e6c..493d14061bf2 100644 --- a/apps/meteor/app/importer/server/classes/ImportDataConverter.ts +++ b/apps/meteor/app/importer/server/classes/ImportDataConverter.ts @@ -502,6 +502,7 @@ export class ImportDataConverter { } const userId = await this.insertUser(data); + data._id = userId; insertedIds.add(userId); if (!this._options.skipDefaultChannels) { diff --git a/apps/meteor/app/livechat/imports/server/rest/inquiries.ts b/apps/meteor/app/livechat/imports/server/rest/inquiries.ts index 8118f353b167..07c69a22d08f 100644 --- a/apps/meteor/app/livechat/imports/server/rest/inquiries.ts +++ b/apps/meteor/app/livechat/imports/server/rest/inquiries.ts @@ -75,7 +75,10 @@ API.v1.addRoute( authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatInquiriesQueuedParams, - deprecationVersion: '7.0.0', + deprecation: { + version: '7.0.0', + alternatives: ['livechat/inquiries.queuedForUser'], + }, }, { async get() { diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index f610b9a9d3de..2196315ad013 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -230,7 +230,7 @@ API.v1.addRoute( API.v1.addRoute( 'livechat/room.transfer', - { validateParams: isPOSTLivechatRoomTransferParams, deprecationVersion: '7.0.0' }, + { validateParams: isPOSTLivechatRoomTransferParams, deprecation: { version: '7.0.0' } }, { async post() { const { rid, token, department } = this.bodyParams; @@ -364,7 +364,9 @@ API.v1.addRoute( authRequired: true, permissionsRequired: ['change-livechat-room-visitor'], validateParams: isPUTLivechatRoomVisitorParams, - deprecationVersion: '7.0.0', + deprecation: { + version: '7.0.0', + }, }, { async put() { diff --git a/apps/meteor/app/meteor-accounts-saml/server/definition/IServiceProviderOptions.ts b/apps/meteor/app/meteor-accounts-saml/server/definition/IServiceProviderOptions.ts index acc67681fcdc..f48c40432711 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/definition/IServiceProviderOptions.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/definition/IServiceProviderOptions.ts @@ -21,6 +21,7 @@ export interface IServiceProviderOptions { metadataTemplate: string; callbackUrl: string; - // The id attribute is filled midway through some operations + // The id and redirectUrl attributes are filled midway through some operations id?: string; + redirectUrl?: string; } diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts index f62ab71f2302..76747b599104 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/SAML.ts @@ -54,7 +54,7 @@ export class SAML { case 'sloRedirect': return this.processSLORedirectAction(req, res); case 'authorize': - return this.processAuthorizeAction(res, service, samlObject); + return this.processAuthorizeAction(req, res, service, samlObject); case 'validate': return this.processValidateAction(req, res, service, samlObject); default: @@ -378,12 +378,20 @@ export class SAML { } private static async processAuthorizeAction( + req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions, samlObject: ISAMLAction, ): Promise { service.id = samlObject.credentialToken; + // Allow redirecting to internal domains when login process is complete + const { referer } = req.headers; + const siteUrl = settings.get('Site_Url'); + if (typeof referer === 'string' && referer.startsWith(siteUrl)) { + service.redirectUrl = referer; + } + const serviceProvider = new SAMLServiceProvider(service); let url: string | undefined; @@ -430,7 +438,7 @@ export class SAML { }; await this.storeCredential(credentialToken, loginResult); - const url = Meteor.absoluteUrl(SAMLUtils.getValidationActionRedirectPath(credentialToken)); + const url = Meteor.absoluteUrl(SAMLUtils.getValidationActionRedirectPath(credentialToken, service.redirectUrl)); res.writeHead(302, { Location: url, }); diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts index 70df22120b75..984c7bc458a3 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts @@ -131,9 +131,10 @@ export class SAMLUtils { return newTemplate; } - public static getValidationActionRedirectPath(credentialToken: string): string { + public static getValidationActionRedirectPath(credentialToken: string, redirectUrl?: string): string { + const redirectUrlParam = redirectUrl ? `&redirectUrl=${encodeURIComponent(redirectUrl)}` : ''; // the saml_idp_credentialToken param is needed by the mobile app - return `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}`; + return `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}${redirectUrlParam}`; } public static log(obj: any, ...args: Array): void { diff --git a/apps/meteor/client/components/message/content/Attachments.tsx b/apps/meteor/client/components/message/content/Attachments.tsx index ea9c03b9e7d3..dea78a3f6513 100644 --- a/apps/meteor/client/components/message/content/Attachments.tsx +++ b/apps/meteor/client/components/message/content/Attachments.tsx @@ -3,18 +3,22 @@ import type { ReactElement } from 'react'; import React from 'react'; import AttachmentsItem from './attachments/AttachmentsItem'; +import { AttachmentEncryptionContext } from './attachments/contexts/AttachmentEncryptionContext'; type AttachmentsProps = { attachments: MessageAttachmentBase[]; id?: string | undefined; + isMessageEncrypted?: boolean; }; -const Attachments = ({ attachments, id }: AttachmentsProps): ReactElement => { +const Attachments = ({ attachments, id, isMessageEncrypted = false }: AttachmentsProps): ReactElement => { return ( <> - {attachments?.map((attachment, index) => ( - - ))} + + {attachments?.map((attachment, index) => ( + + ))} + ); }; diff --git a/apps/meteor/client/components/message/content/attachments/contexts/AttachmentEncryptionContext.tsx b/apps/meteor/client/components/message/content/attachments/contexts/AttachmentEncryptionContext.tsx new file mode 100644 index 000000000000..0f34410bb458 --- /dev/null +++ b/apps/meteor/client/components/message/content/attachments/contexts/AttachmentEncryptionContext.tsx @@ -0,0 +1,9 @@ +import { createContext } from 'react'; + +export type AttachmentEncryptionContextValue = { + isMessageEncrypted: boolean; +}; + +export const AttachmentEncryptionContext = createContext({ + isMessageEncrypted: false, +}); diff --git a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx index 0f2082f96e31..68cd7f973f73 100644 --- a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx @@ -3,9 +3,8 @@ import { AudioPlayer } from '@rocket.chat/fuselage'; import { useMediaUrl } from '@rocket.chat/ui-contexts'; import React from 'react'; -import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; -import MessageContentBody from '../../../MessageContentBody'; +import AttachmentDescription from '../structure/AttachmentDescription'; const AudioAttachment = ({ title, @@ -21,7 +20,7 @@ const AudioAttachment = ({ const getURL = useMediaUrl(); return ( <> - {descriptionMd ? : } + diff --git a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx index 4301520c6173..7838c0c22e2a 100644 --- a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx @@ -11,9 +11,8 @@ import type { UIEvent } from 'react'; import React from 'react'; import { getFileExtension } from '../../../../../../lib/utils/getFileExtension'; -import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; -import MessageContentBody from '../../../MessageContentBody'; +import AttachmentDescription from '../structure/AttachmentDescription'; import AttachmentSize from '../structure/AttachmentSize'; const openDocumentViewer = window.RocketChatDesktop?.openDocumentViewer; @@ -49,7 +48,7 @@ const GenericFileAttachment = ({ return ( <> - {descriptionMd ? : } + - {descriptionMd ? : } + - {descriptionMd ? : } + diff --git a/apps/meteor/client/components/message/content/attachments/structure/AttachmentDescription.tsx b/apps/meteor/client/components/message/content/attachments/structure/AttachmentDescription.tsx new file mode 100644 index 000000000000..7040265b5834 --- /dev/null +++ b/apps/meteor/client/components/message/content/attachments/structure/AttachmentDescription.tsx @@ -0,0 +1,33 @@ +import { MessageBody } from '@rocket.chat/fuselage'; +import type { Root } from '@rocket.chat/message-parser'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { memo, useContext } from 'react'; + +import MarkdownText from '../../../../MarkdownText'; +import MessageContentBody from '../../../MessageContentBody'; +import { AttachmentEncryptionContext } from '../contexts/AttachmentEncryptionContext'; + +type AttachmentDescriptionProps = { + descriptionMd?: Root; + description?: string; +}; + +const AttachmentDescription = ({ description, descriptionMd }: AttachmentDescriptionProps) => { + const t = useTranslation(); + + const { isMessageEncrypted } = useContext(AttachmentEncryptionContext); + + if (isMessageEncrypted) { + return {t('E2E_message_encrypted_placeholder')}; + } + + return descriptionMd ? ( + + ) : ( + + + + ); +}; + +export default memo(AttachmentDescription); diff --git a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx index 29932e276215..4bd548393d02 100644 --- a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx @@ -1,5 +1,6 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { isDiscussionMessage, isThreadMainMessage, isE2EEMessage } from '@rocket.chat/core-typings'; +import { MessageBody } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useSetting, useTranslation, useUserId } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -43,6 +44,7 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM const t = useTranslation(); const normalizedMessage = useNormalizedMessage(message); + const isMessageEncrypted = encrypted && normalizedMessage?.e2e === 'pending'; return ( <> @@ -57,7 +59,7 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM searchText={searchText} /> )} - {encrypted && normalizedMessage.e2e === 'pending' && t('E2E_message_encrypted_placeholder')} + {isMessageEncrypted && {t('E2E_message_encrypted_placeholder')}} )} @@ -65,7 +67,9 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM )} - {!!normalizedMessage?.attachments?.length && } + {!!normalizedMessage?.attachments?.length && ( + + )} {oembedEnabled && !!normalizedMessage.urls?.length && } diff --git a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx index 8616f1223812..1028c60874f0 100644 --- a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx @@ -1,5 +1,6 @@ import type { IThreadMainMessage, IThreadMessage } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; +import { MessageBody } from '@rocket.chat/fuselage'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useSetting, useUserId, useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; @@ -36,6 +37,7 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem const t = useTranslation(); const normalizedMessage = useNormalizedMessage(message); + const isMessageEncrypted = encrypted && normalizedMessage?.e2e === 'pending'; return ( <> @@ -44,7 +46,7 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem {(!encrypted || normalizedMessage.e2e === 'done') && ( )} - {encrypted && normalizedMessage.e2e === 'pending' && t('E2E_message_encrypted_placeholder')} + {isMessageEncrypted && {t('E2E_message_encrypted_placeholder')}} )} @@ -52,7 +54,13 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem )} - {normalizedMessage.attachments && } + {normalizedMessage.attachments && ( + + )} {oembedEnabled && !!normalizedMessage.urls?.length && } diff --git a/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.tsx b/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.tsx index 33bce63745e2..65c8e0ec97be 100644 --- a/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.tsx +++ b/apps/meteor/client/components/message/variants/threadPreview/ThreadMessagePreviewBody.tsx @@ -1,5 +1,6 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { isQuoteAttachment, isE2EEMessage } from '@rocket.chat/core-typings'; +import { MessageBody } from '@rocket.chat/fuselage'; import { PreviewMarkup } from '@rocket.chat/gazzodown'; import type { Root } from '@rocket.chat/message-parser'; import { useTranslation } from '@rocket.chat/ui-contexts'; @@ -39,7 +40,7 @@ const ThreadMessagePreviewBody = ({ message }: ThreadMessagePreviewBodyProps): R ); } if (isEncryptedMessage && message.e2e === 'pending') { - return <>{t('E2E_message_encrypted_placeholder')}; + return {t('E2E_message_encrypted_placeholder')}; } return <>{message.msg}; }; diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index ceffab987a64..1b466e396c21 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -100,7 +100,10 @@ export type UploadsAPI = { subscribe(callback: () => void): () => void; wipeFailedOnes(): void; cancel(id: Upload['id']): void; - send(file: File, { description, msg }: { description?: string; msg?: string }): Promise; + send( + file: File, + { description, msg, t, e2e }: { description?: string; msg?: string; t?: IMessage['t']; e2e?: IMessage['e2e'] }, + ): Promise; }; export type ChatAPI = { diff --git a/apps/meteor/client/lib/chats/flows/uploadFiles.ts b/apps/meteor/client/lib/chats/flows/uploadFiles.ts index 82572aa2dbf5..801ac2aa883c 100644 --- a/apps/meteor/client/lib/chats/flows/uploadFiles.ts +++ b/apps/meteor/client/lib/chats/flows/uploadFiles.ts @@ -1,5 +1,8 @@ +import type { IMessage } from '@rocket.chat/core-typings'; import { isRoomFederated } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { e2e } from '../../../../app/e2e/client'; import { fileUploadIsValidContentType } from '../../../../app/utils/client'; import FileUploadModal from '../../../views/room/modals/FileUploadModal'; import { imperativeModal } from '../../imperativeModal'; @@ -15,6 +18,17 @@ export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFi const queue = [...files]; + const uploadFile = (file: File, description?: string, extraData?: Pick) => { + chat.uploads.send(file, { + description, + msg, + ...extraData, + }); + chat.composer?.clear(); + imperativeModal.close(); + uploadNextFile(); + }; + const uploadNextFile = (): void => { const file = queue.pop(); if (!file) { @@ -33,18 +47,30 @@ export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFi imperativeModal.close(); uploadNextFile(); }, - onSubmit: (fileName: string, description?: string): void => { + onSubmit: async (fileName: string, description?: string): Promise => { Object.defineProperty(file, 'name', { writable: true, value: fileName, }); - chat.uploads.send(file, { - description, - msg, - }); - chat.composer?.clear(); - imperativeModal.close(); - uploadNextFile(); + + // encrypt attachment description + const e2eRoom = await e2e.getInstanceByRoomId(room._id); + + if (!e2eRoom) { + uploadFile(file, description); + return; + } + + const shouldConvertSentMessages = e2eRoom.shouldConvertSentMessages({ msg }); + + if (!shouldConvertSentMessages) { + uploadFile(file, description); + return; + } + + const encryptedDescription = await e2eRoom.encryptAttachmentDescription(description, Random.id()); + + uploadFile(file, encryptedDescription, { t: 'e2e', e2e: 'pending' }); }, invalidContentType: !(file.type && fileUploadIsValidContentType(file.type)), }, diff --git a/apps/meteor/client/lib/chats/uploads.ts b/apps/meteor/client/lib/chats/uploads.ts index 2a06877807ed..a3425aa29b20 100644 --- a/apps/meteor/client/lib/chats/uploads.ts +++ b/apps/meteor/client/lib/chats/uploads.ts @@ -36,11 +36,15 @@ const send = async ( msg, rid, tmid, + t, + e2e, }: { description?: string; msg?: string; rid: string; tmid?: string; + t?: IMessage['t']; + e2e?: IMessage['e2e']; }, ): Promise => { const id = Random.id(); @@ -63,6 +67,8 @@ const send = async ( tmid, file, description, + t, + e2e, }, { load: (event) => { @@ -146,6 +152,8 @@ export const createUploadsAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMes subscribe, wipeFailedOnes, cancel, - send: (file: File, { description, msg }: { description?: string; msg?: string }): Promise => - send(file, { description, msg, rid, tmid }), + send: ( + file: File, + { description, msg, t, e2e }: { description?: string; msg?: string; t?: IMessage['t']; e2e?: IMessage['e2e'] }, + ): Promise => send(file, { description, msg, rid, tmid, t, e2e }), }); diff --git a/apps/meteor/client/views/root/SAMLLoginRoute.tsx b/apps/meteor/client/views/root/SAMLLoginRoute.tsx index 5bf205c1fa2a..61ceb82a5ee7 100644 --- a/apps/meteor/client/views/root/SAMLLoginRoute.tsx +++ b/apps/meteor/client/views/root/SAMLLoginRoute.tsx @@ -1,19 +1,42 @@ -import { useRouter, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts'; +import type { LocationPathname } from '@rocket.chat/ui-contexts'; +import { useRouter, useToastMessageDispatch, useUserId, useAbsoluteUrl } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { useEffect } from 'react'; const SAMLLoginRoute = () => { + const rootUrl = useAbsoluteUrl()(''); const router = useRouter(); const dispatchToastMessage = useToastMessageDispatch(); useEffect(() => { const { token } = router.getRouteParameters(); + const { redirectUrl } = router.getSearchParameters(); + Meteor.loginWithSamlToken(token, (error?: unknown) => { if (error) { dispatchToastMessage({ type: 'error', message: error }); } + + const decodedRedirectUrl = decodeURIComponent(redirectUrl || ''); + if (decodedRedirectUrl?.startsWith(rootUrl)) { + const redirect = new URL(decodedRedirectUrl); + router.navigate( + { + pathname: redirect.pathname as LocationPathname, + search: Object.fromEntries(redirect.searchParams.entries()), + }, + { replace: true }, + ); + } else { + router.navigate( + { + pathname: '/home', + }, + { replace: true }, + ); + } }); - }, [dispatchToastMessage, router]); + }, [dispatchToastMessage, rootUrl, router]); const userId = useUserId(); useEffect(() => { diff --git a/apps/meteor/ee/server/api/licenses.ts b/apps/meteor/ee/server/api/licenses.ts index 3faac0a0950e..db3d73911ef0 100644 --- a/apps/meteor/ee/server/api/licenses.ts +++ b/apps/meteor/ee/server/api/licenses.ts @@ -5,15 +5,12 @@ import { check } from 'meteor/check'; import { API } from '../../../app/api/server/api'; import { hasPermissionAsync } from '../../../app/authorization/server/functions/hasPermission'; -import { apiDeprecationLogger } from '../../../app/lib/server/lib/deprecationWarningLogger'; API.v1.addRoute( 'licenses.get', - { authRequired: true }, + { authRequired: true, deprecation: { version: '7.0.0', alternatives: ['licenses.info'] } }, { async get() { - apiDeprecationLogger.endpoint(this.request.route, '7.0.0', this.response, ' Use licenses.info instead.'); - if (!(await hasPermissionAsync(this.userId, 'view-privileged-setting'))) { return API.v1.unauthorized(); } @@ -81,10 +78,9 @@ API.v1.addRoute( API.v1.addRoute( 'licenses.isEnterprise', - { authOrAnonRequired: true }, + { authOrAnonRequired: true, deprecation: { version: '7.0.0', alternatives: ['licenses.info'] } }, { get() { - apiDeprecationLogger.endpoint(this.request.route, '7.0.0', this.response, ' Use licenses.info instead.'); const isEnterpriseEdition = License.hasValidLicense(); return API.v1.success({ isEnterprise: isEnterpriseEdition }); }, diff --git a/apps/meteor/ee/server/lib/ldap/Manager.ts b/apps/meteor/ee/server/lib/ldap/Manager.ts index c17d81d412eb..b99b6b08cbed 100644 --- a/apps/meteor/ee/server/lib/ldap/Manager.ts +++ b/apps/meteor/ee/server/lib/ldap/Manager.ts @@ -10,6 +10,7 @@ import type { import { addUserToRoom } from '../../../../app/lib/server/functions/addUserToRoom'; import { createRoom } from '../../../../app/lib/server/functions/createRoom'; import { removeUserFromRoom } from '../../../../app/lib/server/functions/removeUserFromRoom'; +import { setUserActiveStatus } from '../../../../app/lib/server/functions/setUserActiveStatus'; import { settings } from '../../../../app/settings/server'; import { getValidRoomName } from '../../../../app/utils/server/lib/getValidRoomName'; import { ensureArray } from '../../../../lib/utils/arrayUtils'; @@ -28,6 +29,7 @@ export class LDAPEEManager extends LDAPManager { const createNewUsers = settings.get('LDAP_Background_Sync_Import_New_Users') ?? true; const updateExistingUsers = settings.get('LDAP_Background_Sync_Keep_Existant_Users_Updated') ?? true; + let disableMissingUsers = updateExistingUsers && (settings.get('LDAP_Background_Sync_Disable_Missing_Users') ?? false); const mergeExistingUsers = settings.get('LDAP_Background_Sync_Merge_Existent_Users') ?? false; const options = this.getConverterOptions(); @@ -36,6 +38,7 @@ export class LDAPEEManager extends LDAPManager { const ldap = new LDAPConnection(); const converter = new LDAPDataConverter(true, options); + const touchedUsers = new Set(); try { await ldap.connect(); @@ -43,7 +46,9 @@ export class LDAPEEManager extends LDAPManager { if (createNewUsers || mergeExistingUsers) { await this.importNewUsers(ldap, converter); } else if (updateExistingUsers) { - await this.updateExistingUsers(ldap, converter); + await this.updateExistingUsers(ldap, converter, disableMissingUsers); + // Missing users will have been disabled automatically by the update operation, so no need to do a separate query for them + disableMissingUsers = false; } const membersOfGroupFilter = await ldap.searchMembersOfGroupFilter(); @@ -60,9 +65,17 @@ export class LDAPEEManager extends LDAPManager { return membersOfGroupFilter.includes(memberFormat); }) as ImporterBeforeImportCallback, - afterImportFn: (async ({ data }, isNewRecord: boolean): Promise => - this.advancedSync(ldap, data as IImportUser, converter, isNewRecord)) as ImporterAfterImportCallback, + afterImportFn: (async ({ data }, isNewRecord: boolean): Promise => { + if (data._id) { + touchedUsers.add(data._id); + } + await this.advancedSync(ldap, data as IImportUser, converter, isNewRecord); + }) as ImporterAfterImportCallback, }); + + if (disableMissingUsers) { + await this.disableMissingUsers([...touchedUsers]); + } } catch (error) { logger.error(error); } @@ -579,7 +592,7 @@ export class LDAPEEManager extends LDAPManager { }); } - private static async updateExistingUsers(ldap: LDAPConnection, converter: LDAPDataConverter): Promise { + private static async updateExistingUsers(ldap: LDAPConnection, converter: LDAPDataConverter, disableMissingUsers = false): Promise { const users = await Users.findLDAPUsers().toArray(); for await (const user of users) { const ldapUser = await this.findLDAPUser(ldap, user); @@ -587,10 +600,18 @@ export class LDAPEEManager extends LDAPManager { if (ldapUser) { const userData = this.mapUserData(ldapUser, user.username); converter.addUserSync(userData, { dn: ldapUser.dn, username: this.getLdapUsername(ldapUser) }); + } else if (disableMissingUsers) { + await setUserActiveStatus(user._id, false, true); } } } + private static async disableMissingUsers(foundUsers: IUser['_id'][]): Promise { + const userIds = (await Users.findLDAPUsersExceptIds(foundUsers, { projection: { _id: 1 } }).toArray()).map(({ _id }) => _id); + + await Promise.allSettled(userIds.map((id) => setUserActiveStatus(id, false, true))); + } + private static async updateUserAvatars(ldap: LDAPConnection): Promise { const users = await Users.findLDAPUsers().toArray(); for await (const user of users) { diff --git a/apps/meteor/ee/server/settings/ldap.ts b/apps/meteor/ee/server/settings/ldap.ts index cc5125e37388..f85f67a40641 100644 --- a/apps/meteor/ee/server/settings/ldap.ts +++ b/apps/meteor/ee/server/settings/ldap.ts @@ -27,6 +27,7 @@ export function addSettings(): Promise { }); const backgroundSyncQuery = [enableQuery, { _id: 'LDAP_Background_Sync', value: true }]; + const backgroundUpdateQuery = [...backgroundSyncQuery, { _id: 'LDAP_Background_Sync_Keep_Existant_Users_Updated', value: true }]; await this.add('LDAP_Background_Sync_Interval', 'every_24_hours', { type: 'select', @@ -70,11 +71,13 @@ export function addSettings(): Promise { await this.add('LDAP_Background_Sync_Merge_Existent_Users', false, { type: 'boolean', - enableQuery: [ - ...backgroundSyncQuery, - { _id: 'LDAP_Background_Sync_Keep_Existant_Users_Updated', value: true }, - { _id: 'LDAP_Merge_Existing_Users', value: true }, - ], + enableQuery: [...backgroundUpdateQuery, { _id: 'LDAP_Merge_Existing_Users', value: true }], + invalidValue: false, + }); + + await this.add('LDAP_Background_Sync_Disable_Missing_Users', false, { + type: 'boolean', + enableQuery: backgroundUpdateQuery, invalidValue: false, }); diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 81938441d722..aaa76299d260 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -22,6 +22,7 @@ const config: Config = { '\\.css$': 'identity-obj-proxy', '^react($|/.+)': '/node_modules/react$1', '^@tanstack/(.+)': '/node_modules/@tanstack/$1', + '^meteor/(.*)': '/.meteorMocks/index.ts', }, }, { diff --git a/apps/meteor/server/models/raw/Users.js b/apps/meteor/server/models/raw/Users.js index a4b7bfe9a397..c6b545caedb8 100644 --- a/apps/meteor/server/models/raw/Users.js +++ b/apps/meteor/server/models/raw/Users.js @@ -432,6 +432,17 @@ export class UsersRaw extends BaseRaw { return this.find(query, options); } + findLDAPUsersExceptIds(userIds, options = {}) { + const query = { + ldap: true, + _id: { + $nin: userIds, + }, + }; + + return this.find(query, options); + } + findConnectedLDAPUsers(options) { const query = { 'ldap': true, diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index ceb456f6f08b..770732efff86 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -212,4 +212,137 @@ test.describe.serial('e2e-encryption', () => { await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world'); await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); }); + + test('expect create a private encrypted channel and send an attachment with encrypted file description', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.checkboxEncryption.click(); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('any_description'); + await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); + await poHomeChannel.content.btnModalConfirm.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + }); + + test('expect create a private encrypted channel and send an attachment with encrypted file description in a thread message', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.checkboxEncryption.click(); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('This is a thread main message.'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is a thread main message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await page.locator('[data-qa-type="message"]').last().hover(); + await page.locator('role=button[name="Reply in thread"]').click(); + + await expect(page).toHaveURL(/.*thread/); + + await poHomeChannel.content.dragAndDropTxtFileToThread(); + await poHomeChannel.content.descriptionInput.fill('any_description'); + await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); + await poHomeChannel.content.btnModalConfirm.click(); + + await expect(poHomeChannel.content.lastThreadMessageFileDescription).toHaveText('any_description'); + await expect(poHomeChannel.content.lastThreadMessageFileName).toContainText('any_file1.txt'); + }); + + test('expect placeholder text inplace of encrypted message, when E2EE is not setup', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.checkboxEncryption.click(); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.sendMessage('This is an encrypted message.'); + + await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('This is an encrypted message.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + // Logout to remove e2ee keys + await poHomeChannel.sidenav.logout(); + + // Login again + await page.locator('role=button[name="Login"]').waitFor(); + await injectInitialData(); + await restoreState(page, Users.admin, { except: ['private_key', 'public_key'] }); + + await poHomeChannel.sidenav.openChat(channelName); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await expect(poHomeChannel.content.lastUserMessage).toContainText('This message is end-to-end encrypted. To view it, you must enter your encryption key in your account settings.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); + + test('expect placeholder text inplace of encryped file description, when E2EE is not setup', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.openNewByLabel('Channel'); + await poHomeChannel.sidenav.inputChannelName.fill(channelName); + await poHomeChannel.sidenav.checkboxEncryption.click(); + await poHomeChannel.sidenav.btnCreate.click(); + + await expect(page).toHaveURL(`/group/${channelName}`); + + await poHomeChannel.dismissToast(); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await poHomeChannel.content.dragAndDropTxtFile(); + await poHomeChannel.content.descriptionInput.fill('any_description'); + await poHomeChannel.content.fileNameInput.fill('any_file1.txt'); + await poHomeChannel.content.btnModalConfirm.click(); + + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + + await expect(poHomeChannel.content.getFileDescription).toHaveText('any_description'); + await expect(poHomeChannel.content.lastMessageFileName).toContainText('any_file1.txt'); + + // Logout to remove e2ee keys + await poHomeChannel.sidenav.logout(); + + // Login again + await page.locator('role=button[name="Login"]').waitFor(); + await injectInitialData(); + await restoreState(page, Users.admin, { except: ['private_key', 'public_key'] }); + + await poHomeChannel.sidenav.openChat(channelName); + + await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible(); + + await expect(poHomeChannel.content.lastUserMessage).toContainText('This message is end-to-end encrypted. To view it, you must enter your encryption key in your account settings.'); + await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); + }); }); 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 46d2e7487b23..5b66c03454d1 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -17,6 +17,10 @@ export class HomeContent { return this.page.locator('[name="msg"]'); } + get inputThreadMessage(): Locator { + return this.page.getByRole('dialog').locator('[name="msg"]').last(); + } + get messagePopupUsers(): Locator { return this.page.locator('role=menu[name="People"]'); } @@ -143,6 +147,14 @@ export class HomeContent { return this.page.locator('div.messages-box ul.messages-list [role=link]').last(); } + get lastThreadMessageFileDescription(): Locator { + return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').last().locator('[data-qa-type="message-body"]'); + } + + get lastThreadMessageFileName(): Locator { + return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').last().locator('[data-qa-type="attachment-title-link"]'); + } + get btnOptionEditMessage(): Locator { return this.page.locator('[data-qa-id="edit-message"]'); } @@ -235,6 +247,22 @@ export class HomeContent { await this.page.locator(`role=dialog[name="Emoji picker"] >> role=tabpanel >> role=button[name="${emoji}"]`).click(); } + async dragAndDropTxtFileToThread(): Promise { + const contract = await fs.readFile('./tests/e2e/fixtures/files/any_file.txt', 'utf-8'); + const dataTransfer = await this.page.evaluateHandle((contract) => { + const data = new DataTransfer(); + const file = new File([`${contract}`], 'any_file.txt', { + type: 'text/plain', + }); + data.items.add(file); + return data; + }, contract); + + await this.inputThreadMessage.dispatchEvent('dragenter', { dataTransfer }); + + await this.page.locator('[role=dialog][data-qa="DropTargetOverlay"]').dispatchEvent('drop', { dataTransfer }); + } + async dragAndDropTxtFile(): Promise { const contract = await fs.readFile('./tests/e2e/fixtures/files/any_file.txt', 'utf-8'); const dataTransfer = await this.page.evaluateHandle((contract) => { diff --git a/apps/meteor/tests/e2e/saml.spec.ts b/apps/meteor/tests/e2e/saml.spec.ts index a85060a59ea7..c50abf19bbc0 100644 --- a/apps/meteor/tests/e2e/saml.spec.ts +++ b/apps/meteor/tests/e2e/saml.spec.ts @@ -1,6 +1,7 @@ import child_process from 'child_process'; import path from 'path'; +import { faker } from '@faker-js/faker'; import { Page } from '@playwright/test'; import { v2 as compose } from 'docker-compose' import { MongoClient } from 'mongodb'; @@ -48,6 +49,10 @@ const resetTestData = async (cleanupOnly = false) => { await Promise.all( [ + { + _id: 'Accounts_AllowAnonymousRead', + value: false, + }, { _id: 'SAML_Custom_Default_logout_behaviour', value: 'SAML', @@ -93,6 +98,9 @@ test.describe('SAML', () => { let poRegistration: Registration; let samlRoleId: string; + let targetInviteGroupId: string; + let targetInviteGroupName: string; + let inviteId: string; const containerPath = path.join(__dirname, 'containers', 'saml'); @@ -116,6 +124,19 @@ test.describe('SAML', () => { }); }); + test.beforeAll(async ({ api }) => { + const groupResponse = await api.post('/groups.create', { name: faker.string.uuid() }); + expect(groupResponse.status()).toBe(200); + const { group } = await groupResponse.json(); + targetInviteGroupId = group._id; + targetInviteGroupName = group.name; + + const inviteResponse = await api.post('/findOrCreateInvite', { rid: targetInviteGroupId, days: 1, maxUses: 0 }); + expect(inviteResponse.status()).toBe(200); + const { _id } = await inviteResponse.json(); + inviteId = _id; + }); + test.afterAll(async ({ api }) => { await compose.down({ cwd: containerPath, @@ -139,6 +160,10 @@ test.describe('SAML', () => { } }); + test.afterAll(async ({ api }) => { + expect((await api.post('/groups.delete', { roomId: targetInviteGroupId })).status()).toBe(200); + }); + test.beforeEach(async ({ page }) => { poRegistration = new Registration(page); @@ -175,7 +200,7 @@ test.describe('SAML', () => { }); }); - const doLoginStep = async (page: Page, username: string) => { + const doLoginStep = async (page: Page, username: string, redirectUrl = '/home') => { await test.step('expect successful login', async () => { await poRegistration.btnLoginWithSaml.click(); // Redirect to Idp @@ -187,7 +212,7 @@ test.describe('SAML', () => { await page.locator('role=button[name="Login"]').click(); // Redirect back to rocket.chat - await expect(page).toHaveURL('/home'); + await expect(page).toHaveURL(redirectUrl); await expect(page.getByRole('button', { name: 'User menu' })).toBeVisible(); }); @@ -314,6 +339,26 @@ test.describe('SAML', () => { }); }); + test('Redirect to a specific group after login when using a valid invite link', async ({ page }) => { + await page.goto(`/invite/${inviteId}`); + await page.getByRole('link', { name: 'Back to Login' }).click(); + + await doLoginStep(page, 'samluser1', `${constants.BASE_URL}/invite/${inviteId}`); + + await test.step('expect to be redirected to the invited room after succesful login', async () => { + await expect(page).toHaveURL(`/group/${targetInviteGroupName}`); + }); + }); + + test('Redirect to home after login when no redirectUrl is provided', async ({ page }) => { + await doLoginStep(page, 'samluser2'); + + await test.step('expect to be redirected to the homepage after succesful login', async () => { + await expect(page).toHaveURL('/home'); + }); + }); + + test.fixme('User Merge - By Custom Identifier', async () => { // Test user merge with a custom identifier configured in the fieldmap }); diff --git a/apps/meteor/tests/end-to-end/api/09-rooms.js b/apps/meteor/tests/end-to-end/api/09-rooms.js index 261d283419f9..5d91bc83f4ad 100644 --- a/apps/meteor/tests/end-to-end/api/09-rooms.js +++ b/apps/meteor/tests/end-to-end/api/09-rooms.js @@ -301,6 +301,33 @@ describe('[Rooms]', function () { await request.get(thumbUrl).set(credentials).expect('Content-Type', 'image/jpeg'); }); + + it('should correctly save e2ee file description and properties', async () => { + await request + .post(api(`rooms.upload/${testChannel._id}`)) + .set(credentials) + .field('t', 'e2e') + .field('e2e', 'pending') + .field('description', 'some_file_description') + .attach('file', imgURL) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('message'); + expect(res.body.message).to.have.property('attachments'); + expect(res.body.message.attachments).to.be.an('array').of.length(1); + expect(res.body.message.attachments[0]).to.have.property('image_type', 'image/png'); + expect(res.body.message.attachments[0]).to.have.property('title', '1024x1024.png'); + expect(res.body.message).to.have.property('files'); + expect(res.body.message.files).to.be.an('array').of.length(2); + expect(res.body.message.files[0]).to.have.property('type', 'image/png'); + expect(res.body.message.files[0]).to.have.property('name', '1024x1024.png'); + expect(res.body.message.attachments[0]).to.have.property('description', 'some_file_description'); + expect(res.body.message).to.have.property('t', 'e2e'); + expect(res.body.message).to.have.property('e2e', 'pending'); + }); + }); }); describe('/rooms.favorite', () => { diff --git a/apps/meteor/tests/mocks/client/RouterContextMock.tsx b/apps/meteor/tests/mocks/client/RouterContextMock.tsx index 61cbf5f10909..2831fe226adf 100644 --- a/apps/meteor/tests/mocks/client/RouterContextMock.tsx +++ b/apps/meteor/tests/mocks/client/RouterContextMock.tsx @@ -59,9 +59,17 @@ type RouterContextMockProps = { children?: ReactNode; navigate?: (toOrDelta: number | To) => void; currentPath?: MutableRefObject; + searchParameters?: Record; + routeParameters?: Record; }; -const RouterContextMock = ({ children, navigate, currentPath }: RouterContextMockProps): ReactElement => { +const RouterContextMock = ({ + children, + navigate, + currentPath, + searchParameters = {}, + routeParameters = {}, +}: RouterContextMockProps): ReactElement => { const history = useRef<{ stack: To[]; index: number }>({ stack: ['/'], index: 0 }); if (currentPath) { @@ -75,8 +83,8 @@ const RouterContextMock = ({ children, navigate, currentPath }: RouterContextMoc subscribeToRouteChange: () => () => undefined, getLocationPathname: () => '/', getLocationSearch: () => '', - getRouteParameters: () => ({}), - getSearchParameters: () => ({}), + getRouteParameters: () => routeParameters, + getSearchParameters: () => searchParameters, getRouteName: () => 'home', buildRoutePath, navigate: diff --git a/apps/meteor/tests/unit/app/api/server/v1/lib/checkPermissions.spec.ts b/apps/meteor/tests/unit/app/api/server/v1/lib/checkPermissions.spec.ts index ba93d8003410..18793a3c0053 100644 --- a/apps/meteor/tests/unit/app/api/server/v1/lib/checkPermissions.spec.ts +++ b/apps/meteor/tests/unit/app/api/server/v1/lib/checkPermissions.spec.ts @@ -1,8 +1,18 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; +import mock from 'proxyquire'; +import Sinon from 'sinon'; import type { PermissionsPayload } from '../../../../../../../app/api/server/api.helpers'; -import { checkPermissions } from '../../../../../../../app/api/server/api.helpers'; + +const mocks = { + '../../lib/server/lib/deprecationWarningLogger': { + apiDeprecationLogger: { + endpoint: Sinon.stub(), + }, + }, +}; +const { checkPermissions } = mock.noCallThru().load('../../../../../../../app/api/server/api.helpers', mocks); describe('checkPermissions', () => { it('should return false when no options.permissionsRequired key is present', () => { @@ -13,7 +23,6 @@ describe('checkPermissions', () => { const options = { permissionsRequired: 'invalid', }; - // @ts-expect-error - for testing purposes expect(checkPermissions(options)).to.be.false; }); it('should return true and modify options.permissionsRequired when permissionsRequired key is an array (of permissions)', () => { diff --git a/apps/meteor/tests/unit/app/api/server/v1/lib/checkPermissionsForInvocation.spec.ts b/apps/meteor/tests/unit/app/api/server/v1/lib/checkPermissionsForInvocation.spec.ts index cc6da71fe04b..994365c79375 100644 --- a/apps/meteor/tests/unit/app/api/server/v1/lib/checkPermissionsForInvocation.spec.ts +++ b/apps/meteor/tests/unit/app/api/server/v1/lib/checkPermissionsForInvocation.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; import mock from 'proxyquire'; +import Sinon from 'sinon'; import type { PermissionsPayload } from '../../../../../../../app/api/server/api.helpers'; @@ -20,6 +21,11 @@ const mocks = { return permissions.some((permission) => userPermissions[userId].includes(permission)); }, }, + '../../lib/server/lib/deprecationWarningLogger': { + apiDeprecationLogger: { + endpoint: Sinon.stub(), + }, + }, }; const { checkPermissionsForInvocation } = mock.noCallThru().load('../../../../../../../app/api/server/api.helpers', mocks); diff --git a/apps/meteor/tests/unit/client/views/root/SAMLLoginRoute.spec.tsx b/apps/meteor/tests/unit/client/views/root/SAMLLoginRoute.spec.tsx new file mode 100644 index 000000000000..bfd0d9ec0a6b --- /dev/null +++ b/apps/meteor/tests/unit/client/views/root/SAMLLoginRoute.spec.tsx @@ -0,0 +1,106 @@ +import { MockedServerContext, MockedUserContext } from '@rocket.chat/mock-providers'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Meteor } from 'meteor/meteor'; +import React from 'react'; +import sinon from 'sinon'; + +import SAMLLoginRoute from '../../../../../client/views/root/SAMLLoginRoute'; +import RouterContextMock from '../../../../mocks/client/RouterContextMock'; + +const loginWithSamlTokenStub = Meteor.loginWithSamlToken as sinon.SinonStub; +const navigateStub = sinon.stub(); + +describe('views/root/SAMLLoginRoute', () => { + beforeEach(() => { + jest.clearAllMocks(); + navigateStub.resetHistory(); + loginWithSamlTokenStub.reset(); + loginWithSamlTokenStub.callsFake((_token, callback) => callback()); + }); + + it('should redirect to /home when userId is not null', async () => { + render( + + + + + + + , + ); + + expect(navigateStub.calledTwice).toBe(true); + expect( + navigateStub.calledWith( + sinon.match({ + pathname: '/home', + }), + ), + ).toBe(true); + }); + + it('should redirect to /home when userId is null and redirectUrl is not within the workspace domain', async () => { + render( + + + + + , + ); + + expect( + navigateStub.calledOnceWith( + sinon.match({ + pathname: '/home', + }), + ), + ).toBe(true); + }); + + it('should redirect to the provided redirectUrl when userId is null and redirectUrl is within the workspace domain', async () => { + render( + + + + + , + ); + + expect( + navigateStub.calledOnceWith( + sinon.match({ + pathname: '/invite/test', + }), + ), + ).toBe(true); + }); + + it('should call loginWithSamlToken when component is mounted', async () => { + render( + + + + + , + ); + + expect(loginWithSamlTokenStub.calledOnceWith(undefined)).toBe(true); + }); + + it('should call loginWithSamlToken with the token when it is present', async () => { + render( + + + + + , + ); + + expect(loginWithSamlTokenStub.calledOnceWith('testToken')).toBe(true); + }); +}); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 71bcacbbcd58..7707942cb85d 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2989,6 +2989,8 @@ "LDAP_Background_Sync_Avatars": "Avatar Background Sync", "LDAP_Background_Sync_Avatars_Description": "Enable a separate background process to sync user avatars.", "LDAP_Background_Sync_Avatars_Interval": "Avatar Background Sync Interval", + "LDAP_Background_Sync_Disable_Missing_Users": "Automatically disable users that are no longer found on LDAP", + "LDAP_Background_Sync_Disable_Missing_Users_Description": "This option will deactivate users on Rocket.Chat when their data is not found on LDAP. Any rooms owned by those users will be automatically assigned to new owners, or removed if no other user has access to them.", "LDAP_Background_Sync_Import_New_Users": "Background Sync Import New Users", "LDAP_Background_Sync_Import_New_Users_Description": "Will import all users (based on your filter criteria) that exists in LDAP and does not exists in Rocket.Chat", "LDAP_Background_Sync_Interval": "Background Sync Interval", diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index f9a2b1c45a2a..65b7d4f53660 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -77,6 +77,8 @@ export interface IUsersModel extends IBaseModel { findLDAPUsers(options?: any): FindCursor; + findLDAPUsersExceptIds(userIds: IUser['_id'][], options?: FindOptions): FindCursor; + findConnectedLDAPUsers(options?: any): FindCursor; isUserInRole(userId: IUser['_id'], roleId: IRole['_id']): Promise; diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index 7083bef08b3d..40c9d9911e85 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -558,6 +558,8 @@ export type RoomsEndpoints = { msg?: string; tmid?: string; customFields?: string; + t?: IMessage['t']; + e2e?: IMessage['e2e']; }) => { message: IMessage | null }; };