Skip to content

Commit

Permalink
feat: E2EE warnings on search and audit panel (#32551)
Browse files Browse the repository at this point in the history
Co-authored-by: Tasso Evangelista <[email protected]>
Co-authored-by: Guilherme Gazzo <[email protected]>
  • Loading branch information
3 people authored Jun 21, 2024
1 parent 9e8370d commit 768cad6
Show file tree
Hide file tree
Showing 11 changed files with 109 additions and 16 deletions.
6 changes: 6 additions & 0 deletions .changeset/friendly-months-attack.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/meteor/app/api/server/lib/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export async function findAdminRoomsAutocomplete({ uid, selector }: { uid: strin
name: 1,
t: 1,
avatarETag: 1,
encrypted: 1,
},
limit: 10,
sort: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = (
Expand All @@ -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';

Expand All @@ -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);
Expand All @@ -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],
Expand All @@ -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 } }) => (
Expand All @@ -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 }) => (
Expand Down
39 changes: 39 additions & 0 deletions apps/meteor/client/lib/getRoomTypeTranslation.ts
Original file line number Diff line number Diff line change
@@ -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');
};
14 changes: 11 additions & 3 deletions apps/meteor/client/views/audit/AuditPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();

Expand All @@ -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>
Expand Down
7 changes: 4 additions & 3 deletions apps/meteor/client/views/audit/components/AuditForm.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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} />}
Expand Down
10 changes: 8 additions & 2 deletions apps/meteor/client/views/audit/components/tabs/RoomsTab.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 } });
Expand All @@ -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>}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -19,6 +22,8 @@ const MessageSearchForm = ({ provider, onSearch }: MessageSearchFormProps) => {
},
});

const room = useRoom();

useEffect(() => {
setFocus('searchText');
}, [setFocus]);
Expand Down Expand Up @@ -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>
);
};
Expand Down
1 change: 1 addition & 0 deletions packages/core-typings/src/IRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down

0 comments on commit 768cad6

Please sign in to comment.