Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/force-e2ee-private-rooms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/meteor': minor
'@rocket.chat/i18n': minor
---

Adds a workspace setting **Force end-to-end encryption on private rooms** (`E2E_Force_Encryption_For_Private_Rooms`) under **Admin → Settings → End-to-End Encryption**. When enabled, every newly created private room is encrypted by default and users can no longer opt out: the encryption toggle in the create-room modal is locked on for private rooms, and the server rejects any attempt to create a private room with `encrypted: false` (e.g. via `groups.create`) with the error `error-encrypted-private-rooms-enforced`. Public rooms are unaffected. Federated rooms are exempt since federation does not support E2EE. Creating a discussion under an unencrypted private parent room is rejected with a dedicated error instructing the user to make the parent public or enable encryption on it, and the create-discussion dialog now surfaces creation errors as toasts.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from '@rocket.chat/fuselage-forms';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import { useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
Expand Down Expand Up @@ -82,13 +82,17 @@ const CreateDiscussion = ({
const createDiscussion = useEndpoint('POST', '/v1/rooms.createDiscussion');

const goToRoom = useGoToRoom();
const dispatchToastMessage = useToastMessageDispatch();

const createDiscussionMutation = useMutation({
mutationFn: createDiscussion,
onSuccess: ({ discussion }) => {
goToRoom(discussion._id);
onClose();
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});

const handleCreate = async ({ name, parentRoom, encrypted, usernames, firstMessage, topic }: CreateDiscussionFormValues) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ exports[`CreateDiscussion renders Default without crashing 1`] = `
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-box rcx-box--full rcx-autocomplete rcx-css-t3n91h"
class="rcx-box rcx-box--full rcx-box--animated rcx-autocomplete rcx-css-t3n91h"
>
<div
class="rcx-box rcx-box--full rcx-css-6d871f"
Expand All @@ -100,7 +100,7 @@ exports[`CreateDiscussion renders Default without crashing 1`] = `
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-rm4moq"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-l5txo2"
>
</i>
Expand Down Expand Up @@ -200,7 +200,7 @@ exports[`CreateDiscussion renders Default without crashing 1`] = `
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-box rcx-box--full rcx-select"
class="rcx-box rcx-box--full rcx-box--animated rcx-select"
>
<div
class="rcx-box rcx-box--full rcx-css-1sr8su7"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,18 +270,18 @@ exports[`Story Tests renders Default without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_b_"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-n501yz"
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_b_"
>
timestamps.fullDateTime (timestamps.fullDateTimeDescription)
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-rm4moq"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
Expand Down Expand Up @@ -461,18 +461,18 @@ exports[`Story Tests renders Default without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_j_"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-n501yz"
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_j_"
>
Local_Time
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-rm4moq"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,34 @@ describe('CreateChannelModal', () => {
expect(encrypted).not.toBeChecked();
expect(encrypted).toBeDisabled();
});

it('should render a private channel with encryption checked and disabled for changes when private room encryption is forced', async () => {
render(<CreateChannelModal onClose={() => null} />, {
wrapper: mockAppRoot().withSetting('E2E_Enable', true).withSetting('E2E_Force_Encryption_For_Private_Rooms', true).build(),
});

await userEvent.click(screen.getByText('Advanced_settings'));

const encrypted = screen.getByLabelText('Encrypted') as HTMLInputElement;
const priv = screen.getByLabelText('Private') as HTMLInputElement;

// private by default: encrypted is forced ON and cannot be changed
expect(priv).toBeChecked();
expect(encrypted).toBeChecked();
expect(encrypted).toBeDisabled();

// Private ON -> OFF: encrypted turns OFF and stays disabled (public rooms cannot be encrypted)
await userEvent.click(priv);
expect(priv).not.toBeChecked();
expect(encrypted).not.toBeChecked();
expect(encrypted).toBeDisabled();

// Private OFF -> ON: encryption is forced back ON and remains disabled
await userEvent.click(priv);
expect(priv).toBeChecked();
expect(encrypted).toBeChecked();
expect(encrypted).toBeDisabled();
});
});

describe('Federation', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh
const namesValidation = useSetting('UTF8_Channel_Names_Validation');
const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars');
const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms') && e2eEnabled;
const e2eEnforcedForPrivate = Boolean(useSetting('E2E_Force_Encryption_For_Private_Rooms')) && Boolean(e2eEnabled);

const getEncryptedHint = useEncryptedRoomDescription('channel');

Expand Down Expand Up @@ -109,7 +110,7 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh
topic: '',
isPrivate: canOnlyCreateOneType ? canOnlyCreateOneType === 'p' : true,
readOnly: false,
encrypted: (e2eEnabledForPrivateByDefault as boolean) ?? false,
encrypted: Boolean(e2eEnforcedForPrivate || e2eEnabledForPrivateByDefault),
broadcast: false,
federated: false,
},
Expand All @@ -132,6 +133,12 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh
}
}, [isPrivate, setValue]);

useEffect(() => {
if (isPrivate && e2eEnforcedForPrivate && !federated) {
setValue('encrypted', true);
}
}, [isPrivate, e2eEnforcedForPrivate, federated, setValue]);

useEffect(() => {
setValue('readOnly', broadcast);
}, [broadcast, setValue]);
Expand Down Expand Up @@ -185,7 +192,10 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh
}
};

const e2eDisabled = useMemo<boolean>(() => !isPrivate || Boolean(!e2eEnabled) || federated, [e2eEnabled, federated, isPrivate]);
const e2eDisabled = useMemo<boolean>(
() => !isPrivate || Boolean(!e2eEnabled) || federated || (e2eEnforcedForPrivate && isPrivate),
[e2eEnabled, federated, isPrivate, e2eEnforcedForPrivate],
);

const createChannelFormId = useId();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,34 @@ describe('CreateTeamModal', () => {
expect(encrypted).toBeDisabled();
});

it('should render a private team with encryption checked and disabled for changes when private room encryption is forced', async () => {
render(<CreateTeamModal onClose={() => null} />, {
wrapper: mockAppRoot().withSetting('E2E_Enable', true).withSetting('E2E_Force_Encryption_For_Private_Rooms', true).build(),
});

await userEvent.click(screen.getByText('Advanced_settings'));

const encrypted = screen.getByLabelText('Teams_New_Encrypted_Label') as HTMLInputElement;
const priv = screen.getByLabelText('Teams_New_Private_Label') as HTMLInputElement;

// private by default: encrypted is forced ON and cannot be changed
expect(priv).toBeChecked();
expect(encrypted).toBeChecked();
expect(encrypted).toBeDisabled();

// Private ON -> OFF: encrypted turns OFF and stays disabled (public teams cannot be encrypted)
await userEvent.click(priv);
expect(priv).not.toBeChecked();
expect(encrypted).not.toBeChecked();
expect(encrypted).toBeDisabled();

// Private OFF -> ON: encryption is forced back ON and remains disabled
await userEvent.click(priv);
expect(priv).toBeChecked();
expect(encrypted).toBeChecked();
expect(encrypted).toBeDisabled();
});

it('should disable and turn on ReadOnly toggle when Broadcast is ON and no set-readonly permission', async () => {
render(<CreateTeamModal onClose={() => null} />, {
wrapper: mockAppRoot().build(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => {
const t = useTranslation();
const e2eEnabled = useSetting('E2E_Enable');
const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms') && e2eEnabled;
const e2eEnforcedForPrivate = Boolean(useSetting('E2E_Force_Encryption_For_Private_Rooms')) && Boolean(e2eEnabled);
const namesValidation = useSetting('UTF8_Channel_Names_Validation');
const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars');
const canSetReadOnly = usePermissionWithScopedRoles('set-readonly', ['owner']);
Expand Down Expand Up @@ -93,7 +94,7 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => {
topic: '',
isPrivate: canOnlyCreateOneType ? canOnlyCreateOneType === 'p' : true,
readOnly: false,
encrypted: (e2eEnabledForPrivateByDefault as boolean) ?? false,
encrypted: Boolean(e2eEnforcedForPrivate || e2eEnabledForPrivateByDefault),
broadcast: false,
members: [],
},
Expand All @@ -104,13 +105,15 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => {
useEffect(() => {
if (!isPrivate) {
setValue('encrypted', false);
} else if (e2eEnforcedForPrivate) {
setValue('encrypted', true);
}

setValue('readOnly', broadcast);
}, [watch, setValue, broadcast, isPrivate]);
}, [watch, setValue, broadcast, isPrivate, e2eEnforcedForPrivate]);

const readOnlyDisabled = broadcast || !canSetReadOnly;
const canChangeEncrypted = isPrivate && e2eEnabled;
const canChangeEncrypted = isPrivate && e2eEnabled && !e2eEnforcedForPrivate;
const getEncryptedHint = useEncryptedRoomDescription('team');

const goToRoom = useGoToRoom();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
export const useEncryptedRoomDescription = (roomType: 'channel' | 'team' | 'discussion') => {
const { t } = useTranslation();
const e2eEnabled = useSetting('E2E_Enable');
const e2eEnforcedForPrivate = Boolean(useSetting('E2E_Force_Encryption_For_Private_Rooms')) && Boolean(e2eEnabled);

return ({ isPrivate, encrypted }: { isPrivate: boolean; encrypted: boolean }) => {
if (!e2eEnabled) {
Expand All @@ -12,6 +13,9 @@ export const useEncryptedRoomDescription = (roomType: 'channel' | 'team' | 'disc
if (!isPrivate) {
return t('Encrypted_not_available', { roomType: t(roomType) });
}
if (e2eEnforcedForPrivate && encrypted) {
return t('Encrypted_enforced_by_workspace_policy');
}
if (encrypted) {
return t('Encrypted_messages', { roomType: t(roomType) });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ exports[`RoomFormAttributeField renders Default without crashing 1`] = `
<div
aria-invalid="false"
aria-required="false"
class="rcx-box rcx-box--full rcx-select rcx-css-1hx4au7"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1hx4au7"
name="attributes.0.key"
>
<div
Expand Down Expand Up @@ -48,7 +48,7 @@ exports[`RoomFormAttributeField renders Default without crashing 1`] = `
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-box rcx-box--full rcx-select rcx-css-1te28na"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1te28na"
>
<div
class="rcx-box rcx-box--full rcx-css-1sr8su7"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ exports[`RoomFormAutocomplete renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-autocomplete rcx-css-t3n91h"
class="rcx-box rcx-box--full rcx-box--animated rcx-autocomplete rcx-css-t3n91h"
>
<div
class="rcx-box rcx-box--full rcx-css-6d871f"
Expand All @@ -20,7 +20,7 @@ exports[`RoomFormAutocomplete renders Default without crashing 1`] = `
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-rm4moq"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-l5txo2"
>
</i>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ exports[`renders AppLogsItem without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_8_ timeFilterLabel"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="timeFilter"
type="button"
>
Expand All @@ -155,7 +155,7 @@ exports[`renders AppLogsItem without crashing 1`] = `
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-rm4moq"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
Expand Down Expand Up @@ -352,19 +352,19 @@ exports[`renders AppLogsItem without crashing 1`] = `
aria-haspopup="listbox"
aria-label="Severity"
aria-labelledby="react-aria-_r_g_ react-aria-_r_b_ severityFilterLabel"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="severityFilter"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-n501yz"
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_g_"
>
All
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-rm4moq"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,19 +103,19 @@ exports[`renders AppLogsItem without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_7_ timeFilterLabel"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="timeFilter"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-n501yz"
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_7_"
>
All_time
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-rm4moq"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
Expand Down Expand Up @@ -194,19 +194,19 @@ exports[`renders AppLogsItem without crashing 1`] = `
aria-haspopup="listbox"
aria-label="Severity"
aria-labelledby="react-aria-_r_f_ react-aria-_r_a_"
class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="severityFilter"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-n501yz"
class="rcx-box rcx-box--full rcx-css-1ah8hdc"
id="react-aria-_r_f_"
>
All
</span>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-rm4moq"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
Expand Down
Loading
Loading