feat: E2EE room key reset modal (#33503)
Co-authored-by: Kevin Aleman <11577696+KevLehman@users.noreply.github.com>pull/33433/head^2
parent
4aa731d6e9
commit
2806cb5d3e
@ -0,0 +1,8 @@ |
||||
--- |
||||
"@rocket.chat/meteor": major |
||||
"@rocket.chat/i18n": patch |
||||
--- |
||||
|
||||
Adds modal confirmation to enable and disable End-to-end encryption |
||||
|
||||
Adds a reset room key option to the modal that disables End-to-end encryption, this is useful when all the members of a room lose their room E2EE keys |
||||
@ -0,0 +1,114 @@ |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { renderHook, waitFor } from '@testing-library/react'; |
||||
|
||||
import { e2e } from '../../../../app/e2e/client'; |
||||
import { useE2EEResetRoomKey } from './useE2EEResetRoomKey'; |
||||
|
||||
jest.mock('../../../../app/e2e/client', () => ({ |
||||
e2e: { |
||||
getInstanceByRoomId: jest.fn(), |
||||
}, |
||||
})); |
||||
|
||||
describe('useE2EEResetRoomKey', () => { |
||||
const e2eResetRoomKeyMock = jest.fn().mockResolvedValue({ |
||||
e2eKeyId: 'E2E_KEY_ID', |
||||
e2eKey: 'E2E_KEY', |
||||
}); |
||||
const resetRoomKeyMock = jest.fn(); |
||||
const roomId = 'ROOM_ID'; |
||||
|
||||
afterEach(() => { |
||||
jest.clearAllMocks(); |
||||
}); |
||||
|
||||
beforeEach(() => { |
||||
(e2e.getInstanceByRoomId as jest.Mock).mockImplementation(() => ({ |
||||
resetRoomKey: e2eResetRoomKeyMock, |
||||
})); |
||||
}); |
||||
|
||||
it('should call resetRoomKey endpoint with correct params', async () => { |
||||
const { result } = renderHook(() => useE2EEResetRoomKey(), { |
||||
legacyRoot: true, |
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/e2e.resetRoomKey', resetRoomKeyMock).build(), |
||||
}); |
||||
|
||||
await waitFor(() => result.current.mutate({ roomId })); |
||||
|
||||
expect(e2e.getInstanceByRoomId).toHaveBeenCalledTimes(1); |
||||
expect(e2e.getInstanceByRoomId).toHaveBeenCalledWith('ROOM_ID'); |
||||
expect(e2eResetRoomKeyMock).toHaveBeenCalledTimes(1); |
||||
|
||||
expect(resetRoomKeyMock).toHaveBeenCalledWith({ |
||||
rid: roomId, |
||||
e2eKeyId: 'E2E_KEY_ID', |
||||
e2eKey: 'E2E_KEY', |
||||
}); |
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('success')); |
||||
}); |
||||
|
||||
it('should return an errror if e2e.getInstanceByRoomId() does not return correct params', async () => { |
||||
(e2e.getInstanceByRoomId as jest.Mock).mockReturnValue(null); |
||||
|
||||
const { result } = renderHook(() => useE2EEResetRoomKey(), { |
||||
legacyRoot: true, |
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/e2e.resetRoomKey', resetRoomKeyMock).build(), |
||||
}); |
||||
|
||||
await waitFor(() => result.current.mutate({ roomId })); |
||||
|
||||
expect(e2e.getInstanceByRoomId).toHaveBeenCalledTimes(1); |
||||
expect(e2e.getInstanceByRoomId).toHaveBeenCalledWith('ROOM_ID'); |
||||
expect(e2eResetRoomKeyMock).toHaveBeenCalledTimes(0); |
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('error')); |
||||
}); |
||||
|
||||
it('should return an errror if e2e.resetRoomKey() does not return correct params', async () => { |
||||
const e2eResetRoomKeyMock = jest.fn().mockResolvedValue(null); |
||||
const roomId = 'ROOM_ID'; |
||||
|
||||
(e2e.getInstanceByRoomId as jest.Mock).mockImplementation(() => ({ |
||||
resetRoomKey: e2eResetRoomKeyMock, |
||||
})); |
||||
|
||||
const { result } = renderHook(() => useE2EEResetRoomKey(), { |
||||
legacyRoot: true, |
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/e2e.resetRoomKey', resetRoomKeyMock).build(), |
||||
}); |
||||
|
||||
await waitFor(() => result.current.mutate({ roomId })); |
||||
|
||||
expect(e2e.getInstanceByRoomId).toHaveBeenCalledTimes(1); |
||||
expect(e2e.getInstanceByRoomId).toHaveBeenCalledWith('ROOM_ID'); |
||||
expect(e2eResetRoomKeyMock).toHaveBeenCalledTimes(1); |
||||
|
||||
expect(resetRoomKeyMock).toHaveBeenCalledTimes(0); |
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('error')); |
||||
}); |
||||
|
||||
it('should return an error if resetRoomKey does not resolve', async () => { |
||||
resetRoomKeyMock.mockRejectedValue(new Error('error-e2e-key-reset-in-progress')); |
||||
const { result } = renderHook(() => useE2EEResetRoomKey(), { |
||||
legacyRoot: true, |
||||
wrapper: mockAppRoot().withEndpoint('POST', '/v1/e2e.resetRoomKey', resetRoomKeyMock).build(), |
||||
}); |
||||
|
||||
await waitFor(() => result.current.mutate({ roomId })); |
||||
|
||||
expect(e2e.getInstanceByRoomId).toHaveBeenCalledTimes(1); |
||||
expect(e2e.getInstanceByRoomId).toHaveBeenCalledWith('ROOM_ID'); |
||||
expect(e2eResetRoomKeyMock).toHaveBeenCalledTimes(1); |
||||
|
||||
expect(resetRoomKeyMock).toHaveBeenCalledWith({ |
||||
rid: roomId, |
||||
e2eKeyId: 'E2E_KEY_ID', |
||||
e2eKey: 'E2E_KEY', |
||||
}); |
||||
|
||||
await waitFor(() => expect(result.current.status).toBe('error')); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,35 @@ |
||||
import type { RoomID } from '@rocket.chat/core-typings'; |
||||
import { useEndpoint } from '@rocket.chat/ui-contexts'; |
||||
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; |
||||
import { useMutation } from '@tanstack/react-query'; |
||||
|
||||
import { e2e } from '../../../../app/e2e/client'; |
||||
|
||||
type UseE2EEResetRoomKeyVariables = { |
||||
roomId: RoomID; |
||||
}; |
||||
|
||||
export const useE2EEResetRoomKey = ( |
||||
options?: Omit<UseMutationOptions<void, Error, UseE2EEResetRoomKeyVariables>, 'mutationFn'>, |
||||
): UseMutationResult<void, Error, UseE2EEResetRoomKeyVariables> => { |
||||
const resetRoomKey = useEndpoint('POST', '/v1/e2e.resetRoomKey'); |
||||
|
||||
return useMutation(async ({ roomId }) => { |
||||
const e2eRoom = await e2e.getInstanceByRoomId(roomId); |
||||
if (!e2eRoom) { |
||||
throw new Error('Cannot reset room key'); |
||||
} |
||||
|
||||
const { e2eKey, e2eKeyId } = (await e2eRoom.resetRoomKey()) ?? {}; |
||||
|
||||
if (!e2eKey || !e2eKeyId) { |
||||
throw new Error('Cannot reset room key'); |
||||
} |
||||
|
||||
try { |
||||
await resetRoomKey({ rid: roomId, e2eKeyId, e2eKey }); |
||||
} catch (error) { |
||||
throw error; |
||||
} |
||||
}, options); |
||||
}; |
||||
@ -0,0 +1,49 @@ |
||||
import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; |
||||
import type { ReactElement } from 'react'; |
||||
import React, { useState } from 'react'; |
||||
|
||||
import DisableE2EEModal from './DisableE2EEModal'; |
||||
import ResetKeysE2EEModal from './ResetKeysE2EEModal'; |
||||
|
||||
const STEPS = { |
||||
DISABLE_E2EE: 'DISABLE_E2EE', |
||||
RESET_ROOM_KEY: 'RESET_ROOM_KEY', |
||||
}; |
||||
|
||||
type BaseDisableE2EEModalProps = { |
||||
onConfirm: () => void; |
||||
onClose: () => void; |
||||
roomType: string; |
||||
roomId: string; |
||||
canResetRoomKey: boolean; |
||||
}; |
||||
|
||||
const BaseDisableE2EEModal = ({ |
||||
onConfirm, |
||||
onClose, |
||||
roomType, |
||||
roomId, |
||||
canResetRoomKey, |
||||
}: BaseDisableE2EEModalProps): ReactElement | null => { |
||||
const [step, setStep] = useState(STEPS.DISABLE_E2EE); |
||||
|
||||
const onResetRoomKey = useEffectEvent(() => { |
||||
setStep(STEPS.RESET_ROOM_KEY); |
||||
}); |
||||
|
||||
if (step === STEPS.RESET_ROOM_KEY && canResetRoomKey) { |
||||
return <ResetKeysE2EEModal roomType={roomType} roomId={roomId} onCancel={onClose} />; |
||||
} |
||||
|
||||
return ( |
||||
<DisableE2EEModal |
||||
onConfirm={onConfirm} |
||||
onCancel={onClose} |
||||
roomType={roomType} |
||||
canResetRoomKey={canResetRoomKey} |
||||
onResetRoomKey={onResetRoomKey} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
export default BaseDisableE2EEModal; |
||||
@ -0,0 +1,54 @@ |
||||
import { Accordion, Box, Button } from '@rocket.chat/fuselage'; |
||||
import type { ReactElement } from 'react'; |
||||
import React from 'react'; |
||||
import { Trans, useTranslation } from 'react-i18next'; |
||||
|
||||
import GenericModal from '../../../../components/GenericModal'; |
||||
|
||||
type DisableE2EEModalProps = { |
||||
onConfirm: () => void; |
||||
onCancel: () => void; |
||||
roomType: string; |
||||
canResetRoomKey: boolean; |
||||
onResetRoomKey: () => void; |
||||
}; |
||||
|
||||
const DisableE2EEModal = ({ onConfirm, onCancel, roomType, canResetRoomKey, onResetRoomKey }: DisableE2EEModalProps): ReactElement => { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<GenericModal |
||||
icon='key' |
||||
title={t('E2E_disable_encryption')} |
||||
variant='warning' |
||||
confirmText={t('E2E_disable_encryption')} |
||||
onConfirm={onConfirm} |
||||
onCancel={onCancel} |
||||
onDismiss={() => undefined} |
||||
> |
||||
<Box mbe={16} is='p'> |
||||
<Trans i18nKey='E2E_disable_encryption_description' tOptions={{ roomType }} /> |
||||
</Box> |
||||
|
||||
{canResetRoomKey && ( |
||||
<> |
||||
<Box mbe={16} is='p'> |
||||
{t('E2E_disable_encryption_reset_keys_description')} |
||||
</Box> |
||||
<Accordion> |
||||
<Accordion.Item title={t('E2E_reset_encryption_keys')}> |
||||
<Box mbe={16} is='p'> |
||||
{t('E2E_reset_encryption_keys_description')} |
||||
</Box> |
||||
<Button secondary danger small onClick={onResetRoomKey}> |
||||
{t('E2E_reset_encryption_keys_button', { roomType })} |
||||
</Button> |
||||
</Accordion.Item> |
||||
</Accordion> |
||||
</> |
||||
)} |
||||
</GenericModal> |
||||
); |
||||
}; |
||||
|
||||
export default DisableE2EEModal; |
||||
@ -0,0 +1,33 @@ |
||||
import { Box } from '@rocket.chat/fuselage'; |
||||
import type { ReactElement } from 'react'; |
||||
import React from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import GenericModal from '../../../../components/GenericModal'; |
||||
|
||||
type EnableE2EEModalProps = { |
||||
onConfirm: () => void; |
||||
onClose: () => void; |
||||
roomType: string; |
||||
}; |
||||
|
||||
const EnableE2EEModal = ({ onConfirm, onClose, roomType }: EnableE2EEModalProps): ReactElement => { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<GenericModal |
||||
icon='key' |
||||
title={t('E2E_enable_encryption')} |
||||
variant='warning' |
||||
confirmText={t('E2E_enable_encryption')} |
||||
onConfirm={onConfirm} |
||||
onCancel={onClose} |
||||
> |
||||
<Box mbe={16} is='p'> |
||||
{t('E2E_enable_encryption_description', { roomType })} |
||||
</Box> |
||||
</GenericModal> |
||||
); |
||||
}; |
||||
|
||||
export default EnableE2EEModal; |
||||
@ -0,0 +1,62 @@ |
||||
import { Box, Modal } from '@rocket.chat/fuselage'; |
||||
import { ExternalLink } from '@rocket.chat/ui-client'; |
||||
import type { ReactElement } from 'react'; |
||||
import React from 'react'; |
||||
import { Trans, useTranslation } from 'react-i18next'; |
||||
|
||||
import GenericModal from '../../../../components/GenericModal'; |
||||
import { dispatchToastMessage } from '../../../../lib/toast'; |
||||
import { useE2EEResetRoomKey } from '../../hooks/useE2EEResetRoomKey'; |
||||
|
||||
const E2EE_RESET_KEY_LINK = 'https://go.rocket.chat/i/e2ee-guide'; |
||||
|
||||
type ResetKeysE2EEModalProps = { |
||||
roomType: string; |
||||
roomId: string; |
||||
onCancel: () => void; |
||||
}; |
||||
|
||||
const ResetKeysE2EEModal = ({ roomType, roomId, onCancel }: ResetKeysE2EEModalProps): ReactElement => { |
||||
const { t } = useTranslation(); |
||||
const resetRoomKeyMutation = useE2EEResetRoomKey(); |
||||
|
||||
const handleResetRoomKey = () => { |
||||
resetRoomKeyMutation.mutate( |
||||
{ roomId }, |
||||
{ |
||||
onSuccess: () => { |
||||
dispatchToastMessage({ type: 'success', message: t('E2E_reset_encryption_keys_success') }); |
||||
}, |
||||
onError: () => { |
||||
dispatchToastMessage({ type: 'error', message: t('E2E_reset_encryption_keys_error') }); |
||||
}, |
||||
onSettled: () => { |
||||
onCancel(); |
||||
}, |
||||
}, |
||||
); |
||||
}; |
||||
|
||||
return ( |
||||
<GenericModal |
||||
icon={<Modal.Icon color='danger' name='key' />} |
||||
title={t('E2E_reset_encryption_keys')} |
||||
variant='danger' |
||||
confirmText={t('E2E_reset_encryption_keys')} |
||||
dontAskAgain={<Modal.FooterAnnotation>{t('This_action_cannot_be_undone')}</Modal.FooterAnnotation>} |
||||
onCancel={onCancel} |
||||
onConfirm={handleResetRoomKey} |
||||
onDismiss={() => undefined} |
||||
> |
||||
<Box mbe={16} is='p'> |
||||
<Trans i18nKey='E2E_reset_encryption_keys_modal_description' tOptions={{ roomType }}> |
||||
Resetting E2EE keys is only recommend if no {roomType} member has a valid key to regain access to the previously encrypted |
||||
content. All members may lose access to previously encrypted content. |
||||
<ExternalLink to={E2EE_RESET_KEY_LINK}>Learn more</ExternalLink> about resetting encryption keys. Proceed with caution. |
||||
</Trans> |
||||
</Box> |
||||
</GenericModal> |
||||
); |
||||
}; |
||||
|
||||
export default ResetKeysE2EEModal; |
||||
Loading…
Reference in new issue