feat: E2EE warnings on search and audit panel (#32551)

Co-authored-by: Tasso Evangelista <tasso.evangelista@rocket.chat>
Co-authored-by: Guilherme Gazzo <guilhermegazzo@gmail.com>
pull/32652/head
Yash Rajpal 2 years ago committed by GitHub
parent 9e8370d59e
commit 768cad6de5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/friendly-months-attack.md
  2. 1
      apps/meteor/app/api/server/lib/rooms.ts
  3. 2
      apps/meteor/app/lib/server/functions/notifications/email.js
  4. 25
      apps/meteor/client/components/RoomAutoComplete/RoomAutoComplete.tsx
  5. 39
      apps/meteor/client/lib/getRoomTypeTranslation.ts
  6. 14
      apps/meteor/client/views/audit/AuditPage.tsx
  7. 7
      apps/meteor/client/views/audit/components/AuditForm.tsx
  8. 10
      apps/meteor/client/views/audit/components/tabs/RoomsTab.tsx
  9. 13
      apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearchForm.tsx
  10. 1
      packages/core-typings/src/IRoom.ts
  11. 7
      packages/i18n/src/locales/en.i18n.json

@ -0,0 +1,6 @@
---
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---
Implement E2EE warning callouts letting users know that encrypted messages can't be searched and auditted on search contextual bar and audit panel.

@ -98,6 +98,7 @@ export async function findAdminRoomsAutocomplete({ uid, selector }: { uid: strin
name: 1,
t: 1,
avatarETag: 1,
encrypted: 1,
},
limit: 10,
sort: {

@ -43,7 +43,7 @@ async function getEmailContent({ message, user, room }) {
let messageContent = escapeHTML(message.msg);
if (message.t === 'e2e') {
messageContent = i18n.t('Encrypted_message', { lng });
messageContent = i18n.t('Encrypted_message_preview_unavailable', { lng });
}
message = await callbacks.run('renderMessage', message);

@ -1,9 +1,10 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { AutoComplete, Option, Box } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { RoomAvatar } from '@rocket.chat/ui-avatar';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ComponentProps } from 'react';
import type { ComponentProps, ReactElement } from 'react';
import React, { memo, useMemo, useState } from 'react';
const generateQuery = (
@ -12,7 +13,11 @@ const generateQuery = (
selector: string;
} => ({ selector: JSON.stringify({ name: term }) });
type RoomAutoCompleteProps = Omit<ComponentProps<typeof AutoComplete>, 'filter'> & { scope?: 'admin' | 'regular' };
type RoomAutoCompleteProps = Omit<ComponentProps<typeof AutoComplete>, 'filter'> & {
scope?: 'admin' | 'regular';
renderRoomIcon?: (props: { encrypted: IRoom['encrypted']; type: IRoom['t'] }) => ReactElement | null;
setSelectedRoom?: React.Dispatch<React.SetStateAction<IRoom | undefined>>;
};
const AVATAR_SIZE = 'x20';
@ -27,7 +32,7 @@ const ROOM_AUTOCOMPLETE_PARAMS = {
},
} as const;
const RoomAutoComplete = ({ value, onChange, scope = 'regular', ...props }: RoomAutoCompleteProps) => {
const RoomAutoComplete = ({ value, onChange, scope = 'regular', renderRoomIcon, setSelectedRoom, ...props }: RoomAutoCompleteProps) => {
const [filter, setFilter] = useState('');
const filterDebounced = useDebouncedValue(filter, 300);
const roomsAutoCompleteEndpoint = useEndpoint('GET', ROOM_AUTOCOMPLETE_PARAMS[scope].endpoint);
@ -43,9 +48,9 @@ const RoomAutoComplete = ({ value, onChange, scope = 'regular', ...props }: Room
const options = useMemo(
() =>
result.isSuccess
? result.data.items.map(({ name, fname, _id, avatarETag, t }) => ({
? result.data.items.map(({ name, fname, _id, avatarETag, t, encrypted }) => ({
value: _id,
label: { name: fname || name, avatarETag, type: t },
label: { name: fname || name, avatarETag, type: t, encrypted },
}))
: [],
[result.data?.items, result.isSuccess],
@ -55,7 +60,14 @@ const RoomAutoComplete = ({ value, onChange, scope = 'regular', ...props }: Room
<AutoComplete
{...props}
value={value}
onChange={onChange}
onChange={(val) => {
onChange(val);
if (setSelectedRoom && typeof setSelectedRoom === 'function') {
const selectedRoom = result?.data?.items.find(({ _id }) => _id === val) as unknown as IRoom;
setSelectedRoom(selectedRoom);
}
}}
filter={filter}
setFilter={setFilter}
renderSelected={({ selected: { value, label } }) => (
@ -66,6 +78,7 @@ const RoomAutoComplete = ({ value, onChange, scope = 'regular', ...props }: Room
<Box margin='none' mi={2}>
{label?.name}
</Box>
{renderRoomIcon?.({ ...label })}
</>
)}
renderItem={({ value, label, ...props }) => (

@ -0,0 +1,39 @@
import {
isPublicRoom,
type IRoom,
isDirectMessageRoom,
isPrivateTeamRoom,
isPublicTeamRoom,
isPrivateDiscussion,
isPrivateRoom,
} from '@rocket.chat/core-typings';
import { t } from '../../app/utils/lib/i18n';
export const getRoomTypeTranslation = (room: IRoom) => {
if (isPublicRoom(room)) {
return t('Channel');
}
if (isPrivateDiscussion(room)) {
return t('Private_Discussion');
}
if (isPrivateRoom(room)) {
return t('Private_Group');
}
if (isDirectMessageRoom(room)) {
return t('Direct_Message');
}
if (isPrivateTeamRoom(room)) {
return t('Teams_Private_Team');
}
if (isPublicTeamRoom(room)) {
return t('Teams_Public_Team');
}
return t('Room');
};

@ -1,6 +1,7 @@
import { Margins, States, StatesIcon, StatesSubtitle, StatesTitle, Tabs } from '@rocket.chat/fuselage';
import type { IRoom } from '@rocket.chat/core-typings';
import { Box, Callout, Margins, States, StatesIcon, StatesSubtitle, StatesTitle, Tabs } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
import React, { useState } from 'react';
import { Page, PageHeader, PageScrollableContentWithShadow } from '../../components/Page';
import MessageListSkeleton from '../../components/message/list/MessageListSkeleton';
@ -12,6 +13,7 @@ import { useAuditTab } from './hooks/useAuditTab';
const AuditPage = () => {
const [type, setType] = useAuditTab();
const [selectedRoom, setSelectedRoom] = useState<IRoom | undefined>();
const auditMutation = useAuditMutation(type);
const t = useTranslation();
@ -34,7 +36,13 @@ const AuditPage = () => {
</Tabs>
<PageScrollableContentWithShadow mb={-4}>
<Margins block={4}>
<AuditForm key={type} type={type} onSubmit={auditMutation.mutate} />
<AuditForm key={type} type={type} setSelectedRoom={setSelectedRoom} onSubmit={auditMutation.mutate} />
{selectedRoom?.encrypted && type === '' ? (
<Callout type='warning' icon='circle-exclamation' marginBlock='x16'>
<Box fontScale='p2b'>{t('Encrypted_content_cannot_be_searched_and_audited')}</Box>
{t('Encrypted_content_cannot_be_searched_and_audited_subtitle')}
</Callout>
) : null}
{auditMutation.isLoading && <MessageListSkeleton messageCount={5} />}
{auditMutation.isError && (
<States>

@ -1,4 +1,4 @@
import type { IAuditLog } from '@rocket.chat/core-typings';
import type { IAuditLog, IRoom } from '@rocket.chat/core-typings';
import { Box, Field, FieldLabel, FieldRow, FieldError, TextInput, Button, ButtonGroup } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
@ -16,9 +16,10 @@ import UsersTab from './tabs/UsersTab';
type AuditFormProps = {
type: IAuditLog['fields']['type'];
onSubmit?: (payload: { type: IAuditLog['fields']['type'] } & AuditFields) => void;
setSelectedRoom: React.Dispatch<React.SetStateAction<IRoom | undefined>>;
};
const AuditForm = ({ type, onSubmit }: AuditFormProps) => {
const AuditForm = ({ type, onSubmit, setSelectedRoom }: AuditFormProps) => {
const t = useTranslation();
const form = useAuditForm();
@ -55,7 +56,7 @@ const AuditForm = ({ type, onSubmit }: AuditFormProps) => {
</Field>
</Box>
<Box display='flex' flexDirection='row' alignItems='flex-start'>
{type === '' && <RoomsTab form={form} />}
{type === '' && <RoomsTab form={form} setSelectedRoom={setSelectedRoom} />}
{type === 'u' && <UsersTab form={form} />}
{type === 'd' && <DirectTab form={form} />}
{type === 'l' && <OmnichannelTab form={form} />}

@ -1,4 +1,5 @@
import { Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage';
import type { IRoom } from '@rocket.chat/core-typings';
import { Field, FieldLabel, FieldRow, FieldError, Icon } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
import type { UseFormReturn } from 'react-hook-form';
@ -9,9 +10,10 @@ import type { AuditFields } from '../../hooks/useAuditForm';
type RoomsTabProps = {
form: UseFormReturn<AuditFields>;
setSelectedRoom: React.Dispatch<React.SetStateAction<IRoom | undefined>>;
};
const RoomsTab = ({ form: { control } }: RoomsTabProps) => {
const RoomsTab = ({ form: { control }, setSelectedRoom }: RoomsTabProps) => {
const t = useTranslation();
const { field: ridField, fieldState: ridFieldState } = useController({ name: 'rid', control, rules: { required: true } });
@ -22,10 +24,14 @@ const RoomsTab = ({ form: { control } }: RoomsTabProps) => {
<FieldRow>
<RoomAutoComplete
scope='admin'
setSelectedRoom={setSelectedRoom}
value={ridField.value}
error={!!ridFieldState.error}
placeholder={t('Channel_Name_Placeholder')}
onChange={ridField.onChange}
renderRoomIcon={({ encrypted }) =>
encrypted ? <Icon name='key' color='danger' title={t('Encrypted_content_will_not_appear_search')} /> : null
}
/>
</FieldRow>
{ridFieldState.error?.type === 'required' && <FieldError>{t('The_field_is_required', t('Channel_name'))}</FieldError>}

@ -1,11 +1,14 @@
import type { IMessageSearchProvider } from '@rocket.chat/core-typings';
import { Box, Field, FieldLabel, FieldRow, FieldHint, Icon, TextInput, ToggleSwitch } from '@rocket.chat/fuselage';
import { Box, Field, FieldLabel, FieldRow, FieldHint, Icon, TextInput, ToggleSwitch, Callout } from '@rocket.chat/fuselage';
import { useDebouncedCallback, useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useEffect } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { getRoomTypeTranslation } from '../../../../../lib/getRoomTypeTranslation';
import { useRoom } from '../../../contexts/RoomContext';
type MessageSearchFormProps = {
provider: IMessageSearchProvider;
onSearch: (params: { searchText: string; globalSearch: boolean }) => void;
@ -19,6 +22,8 @@ const MessageSearchForm = ({ provider, onSearch }: MessageSearchFormProps) => {
},
});
const room = useRoom();
useEffect(() => {
setFocus('searchText');
}, [setFocus]);
@ -75,6 +80,12 @@ const MessageSearchForm = ({ provider, onSearch }: MessageSearchFormProps) => {
</Field>
)}
</Box>
{room.encrypted && (
<Callout type='warning' mbs={12} icon='circle-exclamation'>
<Box fontScale='p2b'>{t('Encrypted_RoomType', { roomType: getRoomTypeTranslation(room).toLowerCase() })}</Box>
{t('Encrypted_content_cannot_be_searched')}
</Callout>
)}
</Box>
);
};

@ -129,6 +129,7 @@ export const isPrivateDiscussion = (room: Partial<IRoom>): room is IRoom => isDi
export const isPublicDiscussion = (room: Partial<IRoom>): room is IRoom => isDiscussion(room) && room.t === 'c';
export const isPublicRoom = (room: Partial<IRoom>): room is IRoom => room.t === 'c';
export const isPrivateRoom = (room: Partial<IRoom>): room is IRoom => room.t === 'p';
export interface IDirectMessageRoom extends Omit<IRoom, 'default' | 'featured' | 'u' | 'name'> {
t: 'd';

@ -1941,8 +1941,11 @@
"Enabled": "Enabled",
"Encrypted": "Encrypted",
"Encrypted_channel_Description": "Messages are end-to-end encrypted, search will not work and notifications may not show message content",
"Encrypted_content_cannot_be_searched": "Encrypted content cannot be searched.",
"Encrypted_key_title": "Click here to disable end-to-end encryption for this channel (requires e2ee-permission)",
"Encrypted_message": "Encrypted message",
"Encrypted_RoomType": "Encrypted {{roomType}}",
"Encrypted_message_preview_unavailable": "Encrypted message, preview unavailable",
"Encrypted_setting_changed_successfully": "Encrypted setting changed successfully",
"Encrypted_not_available": "Not available for public {{roomType}}",
"Encryption_key_saved_successfully": "Your encryption key was saved successfully.",
@ -4255,6 +4258,7 @@
"Private_Channel": "Private Channel",
"Private_Channels": "Private channels",
"Private_Chats": "Private Chats",
"Private_Discussion": "Private discussion",
"Private_Group": "Private Group",
"Private_Groups": "Private groups",
"Private_Groups_list": "List of Private Groups",
@ -6452,6 +6456,9 @@
"unread_messages_other": "{{count}} unread messages",
"Encrypted_messages": "End-to-end encrypted {{roomType}}. Search will not work with encrypted {{roomType}} and notifications may not show the messages content.",
"Encrypted_messages_false": "Messages are not encrypted",
"Encrypted_content_will_not_appear_search": "Room encrypted, encrypted content will not appear in search",
"Encrypted_content_cannot_be_searched_and_audited": "Encrypted content cannot be searched and audited",
"Encrypted_content_cannot_be_searched_and_audited_subtitle": "There are one or more encrypted rooms selected for audit.",
"Not_available_for_broadcast": "Not available for broadcast {{roomType}}",
"Not_available_for_this_workspace": "Not available for this workspace",
"People_can_only_join_by_being_invited": "People can only join by being invited",

Loading…
Cancel
Save