diff --git a/.changeset/popular-bulldogs-accept.md b/.changeset/popular-bulldogs-accept.md new file mode 100644 index 000000000000..b18e3382148b --- /dev/null +++ b/.changeset/popular-bulldogs-accept.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Disable slash commands in encrypted rooms and show a disabled warning. diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index ceffab987a64..10589b8b7c97 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -140,7 +140,15 @@ export type ChatAPI = { readonly flows: { readonly uploadFiles: (files: readonly File[], resetFileInput?: () => void) => Promise; - readonly sendMessage: ({ text, tshow }: { text: string; tshow?: boolean; previewUrls?: string[] }) => Promise; + readonly sendMessage: ({ + text, + tshow, + }: { + text: string; + tshow?: boolean; + previewUrls?: string[]; + isSlashCommandAllowed?: boolean; + }) => Promise; readonly processSlashCommand: (message: IMessage, userId: string | null) => Promise; readonly processTooLongMessage: (message: IMessage) => Promise; readonly processMessageEditing: ( diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index 62adbd80eb3e..c691c40b3faf 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -10,7 +10,7 @@ import { processSetReaction } from './processSetReaction'; import { processSlashCommand } from './processSlashCommand'; import { processTooLongMessage } from './processTooLongMessage'; -const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[]): Promise => { +const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], isSlashCommandAllowed?: boolean): Promise => { KonchatNotification.removeRoomNotification(message.rid); if (await processSetReaction(chat, message)) { @@ -25,7 +25,7 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[]) return; } - if (await processSlashCommand(chat, message)) { + if (isSlashCommandAllowed && (await processSlashCommand(chat, message))) { return; } @@ -34,7 +34,12 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[]) export const sendMessage = async ( chat: ChatAPI, - { text, tshow, previewUrls }: { text: string; tshow?: boolean; previewUrls?: string[] }, + { + text, + tshow, + previewUrls, + isSlashCommandAllowed, + }: { text: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean }, ): Promise => { if (!(await chat.data.isSubscribedToRoom())) { try { @@ -63,7 +68,7 @@ export const sendMessage = async ( }); try { - await process(chat, message, previewUrls); + await process(chat, message, previewUrls, isSlashCommandAllowed); chat.composer?.dismissAllQuotedMessages(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); diff --git a/apps/meteor/client/views/room/composer/ComposerBoxPopup.tsx b/apps/meteor/client/views/room/composer/ComposerBoxPopup.tsx index dd7b19797530..523c0e44af86 100644 --- a/apps/meteor/client/views/room/composer/ComposerBoxPopup.tsx +++ b/apps/meteor/client/views/room/composer/ComposerBoxPopup.tsx @@ -9,6 +9,7 @@ export type ComposerBoxPopupProps< T extends { _id: string; sort?: number; + disabled?: boolean; }, > = { title?: string; @@ -22,6 +23,7 @@ function ComposerBoxPopup< T extends { _id: string; sort?: number; + disabled?: boolean; }, >({ title, @@ -37,7 +39,9 @@ function ComposerBoxPopup< const variant = popupSizes && popupSizes.inlineSize < 480 ? 'small' : 'large'; - const getOptionTitle = (item: T) => { + const getOptionTitle = ( + item: T, + ) => { if (variant !== 'small') { return undefined; } @@ -49,6 +53,10 @@ function ComposerBoxPopup< if (item.suggestion) { return t('Suggestion_from_recent_messages'); } + + if (item.disabled) { + return t('Unavailable_in_encrypted_channels'); + } }; const itemsFlat = useMemo( @@ -96,6 +104,7 @@ function ComposerBoxPopup< id={`popup-item-${item._id}`} tabIndex={item === focused ? 0 : -1} aria-selected={item === focused} + disabled={item.disabled} > {renderItem({ item: { ...item, variant } })} diff --git a/apps/meteor/client/views/room/composer/ComposerBoxPopupSlashCommand.tsx b/apps/meteor/client/views/room/composer/ComposerBoxPopupSlashCommand.tsx index 24dd07d067b3..fe69136c3d26 100644 --- a/apps/meteor/client/views/room/composer/ComposerBoxPopupSlashCommand.tsx +++ b/apps/meteor/client/views/room/composer/ComposerBoxPopupSlashCommand.tsx @@ -1,20 +1,24 @@ import { OptionColumn, OptionContent, OptionDescription, OptionInput } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; import React from 'react'; export type ComposerBoxPopupSlashCommandProps = { _id: string; description?: string; params?: string; + disabled?: boolean; }; -function ComposerBoxPopupSlashCommand({ _id, description, params }: ComposerBoxPopupSlashCommandProps) { +function ComposerBoxPopupSlashCommand({ _id, description, params, disabled }: ComposerBoxPopupSlashCommandProps) { + const t = useTranslation(); + return ( <> {_id} {params} - {description} + {disabled ? t('Unavailable_in_encrypted_channels') : description} ); diff --git a/apps/meteor/client/views/room/composer/ComposerMessage.tsx b/apps/meteor/client/views/room/composer/ComposerMessage.tsx index 28824acab05a..790b1739bde7 100644 --- a/apps/meteor/client/views/room/composer/ComposerMessage.tsx +++ b/apps/meteor/client/views/room/composer/ComposerMessage.tsx @@ -41,13 +41,24 @@ const ComposerMessage = ({ tmid, readOnly, onSend, ...props }: ComposerMessagePr } }, - onSend: async ({ value: text, tshow, previewUrls }: { value: string; tshow?: boolean; previewUrls?: string[] }): Promise => { + onSend: async ({ + value: text, + tshow, + previewUrls, + isSlashCommandAllowed, + }: { + value: string; + tshow?: boolean; + previewUrls?: string[]; + isSlashCommandAllowed?: boolean; + }): Promise => { try { await chat?.action.stop('typing'); const newMessageSent = await chat?.flows.sendMessage({ text, tshow, previewUrls, + isSlashCommandAllowed, }); if (newMessageSent) onSend?.(); } catch (error) { diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index 043837f8c8c6..4b7a466d6de0 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -12,7 +12,7 @@ import { MessageComposerHint, MessageComposerButton, } from '@rocket.chat/ui-composer'; -import { useTranslation, useUserPreference, useLayout } from '@rocket.chat/ui-contexts'; +import { useTranslation, useUserPreference, useLayout, useSetting } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import type { ReactElement, @@ -92,7 +92,7 @@ const getEmptyArray = () => a; type MessageBoxProps = { tmid?: IMessage['_id']; readOnly: boolean; - onSend?: (params: { value: string; tshow?: boolean; previewUrls?: string[] }) => Promise; + onSend?: (params: { value: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean }) => Promise; onJoin?: () => Promise; onResize?: () => void; onTyping?: () => void; @@ -123,6 +123,9 @@ const MessageBox = ({ const chat = useChat(); const room = useRoom(); const t = useTranslation(); + const e2eEnabled = useSetting('E2E_Enable'); + const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages'); + const isSlashCommandAllowed = !e2eEnabled || !room.encrypted || unencryptedMessagesAllowed; const composerPlaceholder = useMessageBoxPlaceholder(t('Message'), room); const [typing, setTyping] = useReducer(reducer, false); @@ -176,6 +179,7 @@ const MessageBox = ({ value: text, tshow, previewUrls, + isSlashCommandAllowed, }); }); diff --git a/apps/meteor/client/views/room/contexts/ComposerPopupContext.ts b/apps/meteor/client/views/room/contexts/ComposerPopupContext.ts index 21d239ee6bd7..e42bbe5e38fe 100644 --- a/apps/meteor/client/views/room/contexts/ComposerPopupContext.ts +++ b/apps/meteor/client/views/room/contexts/ComposerPopupContext.ts @@ -21,6 +21,7 @@ export type ComposerPopupOption string; renderItem?: ({ item }: { item: T }) => ReactElement; + disabled?: boolean; }; export type ComposerPopupContextValue = ComposerPopupOption[]; diff --git a/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx b/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx index cb8e74fbe142..245a2a0112f3 100644 --- a/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx +++ b/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx @@ -25,7 +25,7 @@ import type { ComposerPopupContextValue } from '../contexts/ComposerPopupContext import { ComposerPopupContext, createMessageBoxPopupConfig } from '../contexts/ComposerPopupContext'; const ComposerPopupProvider = ({ children, room }: { children: ReactNode; room: IRoom }) => { - const { _id: rid } = room; + const { _id: rid, encrypted: isRoomEncrypted } = room; const userSpotlight = useMethod('spotlight'); const suggestionsCount = useSetting('Number_of_users_autocomplete_suggestions'); const cannedResponseEnabled = useSetting('Canned_Responses_Enable'); @@ -33,6 +33,9 @@ const ComposerPopupProvider = ({ children, room }: { children: ReactNode; room: const isOmnichannel = isOmnichannelRoom(room); const useEmoji = useUserPreference('useEmojis'); const t = useTranslation(); + const e2eEnabled = useSetting('E2E_Enable'); + const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages'); + const encrypted = isRoomEncrypted && e2eEnabled && !unencryptedMessagesAllowed; const call = useMethod('getSlashCommandPreviews'); const value: ComposerPopupContextValue = useMemo(() => { @@ -278,6 +281,7 @@ const ComposerPopupProvider = ({ children, room }: { children: ReactNode; room: trigger: '/', suffix: ' ', triggerAnywhere: false, + disabled: encrypted, renderItem: ({ item }) => , getItemsFromLocal: async (filter: string) => { return Object.keys(slashCommands.commands) @@ -288,6 +292,7 @@ const ComposerPopupProvider = ({ children, room }: { children: ReactNode; room: params: item.params && t.has(item.params) ? t(item.params) : item.params ?? '', description: item.description && t.has(item.description) ? t(item.description) : item.description, permission: item.permission, + ...(encrypted && { disabled: encrypted }), }; }) .filter((command) => { @@ -360,7 +365,7 @@ const ComposerPopupProvider = ({ children, room }: { children: ReactNode; room: }, }), ].filter(Boolean); - }, [t, cannedResponseEnabled, isOmnichannel, recentEmojis, suggestionsCount, userSpotlight, rid, call, useEmoji]); + }, [t, cannedResponseEnabled, isOmnichannel, recentEmojis, suggestionsCount, userSpotlight, rid, call, useEmoji, encrypted]); return ; }; diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index 8b0753c952d2..58adc3e8654b 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -299,6 +299,69 @@ test.describe.serial('e2e-encryption', () => { await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible(); }); + test('expect slash commands to be enabled in an e2ee room', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.createEncryptedChannel(channelName); + + 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(); + + await page.locator('[name="msg"]').type('/'); + await expect(page.locator('#popup-item-contextualbar')).not.toHaveClass(/disabled/); + await page.locator('[name="msg"]').clear(); + + await poHomeChannel.content.dispatchSlashCommand('/contextualbar'); + await expect(poHomeChannel.btnContextualbarClose).toBeVisible(); + + await poHomeChannel.btnContextualbarClose.click(); + await expect(poHomeChannel.btnContextualbarClose).toBeHidden(); + }); + + test.describe('un-encrypted messages not allowed in e2ee rooms', () => { + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await page.goto('/home'); + }); + test.beforeAll(async ({ api }) => { + expect((await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false })).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect((await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true })).status()).toBe(200); + }); + + test('expect slash commands to be disabled in an e2ee room', async ({ page }) => { + const channelName = faker.string.uuid(); + + await poHomeChannel.sidenav.createEncryptedChannel(channelName); + + 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(); + + await page.locator('[name="msg"]').type('/'); + await expect(page.locator('#popup-item-contextualbar')).toHaveClass(/disabled/); + }); + }); + test('expect create a private channel, send unecrypted messages, encrypt the channel and delete the last message and check the last message in the sidebar', async ({ page, }) => { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 1277a02375bf..863a773d45c6 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -5475,6 +5475,7 @@ "Unassigned": "Unassigned", "unauthorized": "Not authorized", "Unavailable": "Unavailable", + "Unavailable_in_encrypted_channels": "Unavailable in encrypted channels", "Unblock": "Unblock", "Unblock_User": "Unblock User", "Uncheck_All": "Uncheck All",