Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a746188
feat(e2ee): passphrase requirements
cardoso Oct 28, 2025
5152cc3
chore: reuse PasswordVerifier
cardoso Oct 28, 2025
6ad591f
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Oct 28, 2025
3379080
test: fix passphrase conformance
cardoso Oct 28, 2025
6cda95d
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Oct 28, 2025
4f49988
fix: keysExist reactivity
cardoso Oct 28, 2025
b6f776c
Merge branch 'develop' into feat/ESH-43
cardoso Oct 28, 2025
6ca60b2
Merge branch 'develop' into feat/ESH-43
cardoso Oct 29, 2025
1859f0a
Merge branch 'develop' into feat/ESH-43
cardoso Oct 29, 2025
9f4b310
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Oct 30, 2025
eee5b95
refactor(e2ee): split change and reset components
cardoso Oct 30, 2025
0683b3b
Merge branch 'develop' of https://github.com/RocketChat/Rocket.Chat i…
cardoso Oct 30, 2025
bb14a39
refactor(e2ee): change password hook
cardoso Oct 30, 2025
f034226
Merge branch 'develop' into feat/ESH-43
cardoso Oct 31, 2025
a595fca
refactor: remove unneeded param
cardoso Oct 31, 2025
f0ecb7a
Merge branch 'develop' into feat/ESH-43
cardoso Oct 31, 2025
8b5b953
Merge branch 'develop' into feat/ESH-43
cardoso Nov 1, 2025
cb5572a
fix: change passphrase form reactivity
cardoso Nov 1, 2025
213ee9d
chore: remove unneeded comments
cardoso Nov 3, 2025
f482202
chore: reuse PasswordValidation type
cardoso Nov 3, 2025
b72382b
Merge branch 'develop' into feat/ESH-43
cardoso Nov 3, 2025
2297bea
fix: password verifier accessibility
cardoso Nov 3, 2025
3540a40
Merge branch 'develop' into feat/ESH-43
cardoso Nov 3, 2025
71bce62
fix: relax password policy type
cardoso Nov 3, 2025
0649230
chore: remove unneeded type constraint
cardoso Nov 3, 2025
323039c
fix: inneffective memoization
cardoso Nov 3, 2025
a33533b
Merge branch 'develop' into feat/ESH-43
cardoso Nov 4, 2025
afca159
fix: useKeysExist
cardoso Nov 4, 2025
4350c2f
Merge branch 'develop' into feat/ESH-43
cardoso Nov 4, 2025
82a4ab1
fix: accessibility
cardoso Nov 4, 2025
3772dc9
Merge branch 'develop' into feat/ESH-43
cardoso Nov 4, 2025
cb536c8
fix: aria-required and aria-invalid
cardoso Nov 4, 2025
f262d1e
Merge branch 'develop' into feat/ESH-43
cardoso Nov 4, 2025
578a672
chore: apply review suggestions
cardoso Nov 4, 2025
612a1a4
chore: add changeset
cardoso Nov 4, 2025
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
211 changes: 211 additions & 0 deletions apps/meteor/client/views/account/security/ChangePassphrase.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { Box, Field, FieldError, FieldGroup, FieldHint, FieldLabel, FieldRow, PasswordInput, Button } from '@rocket.chat/fuselage';
import { PasswordVerifierList } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, usePasswordPolicy } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import DOMPurify from 'dompurify';
import { useEffect, useId } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';

import { e2e } from '../../../lib/e2ee/rocketchat.e2e';
import { useE2EEState } from '../../room/hooks/useE2EEState';

const PASSPHRASE_POLICY = Object.freeze({
enabled: true,
minLength: 30,
mustContainAtLeastOneLowercase: true,
mustContainAtLeastOneUppercase: true,
mustContainAtLeastOneNumber: true,
mustContainAtLeastOneSpecialCharacter: true,
forbidRepeatingCharacters: false,
});

const useKeysExist = () => {
const state = useE2EEState();
return state === 'READY' || state === 'SAVE_PASSWORD';
};

const useValidatePassphrase = (passphrase: string) => {
const validate = usePasswordPolicy(PASSPHRASE_POLICY);
return validate(passphrase);
};

const useChangeE2EPasswordMutation = () => {
return useMutation({
mutationFn: async (newPassword: string) => {
await e2e.changePassword(newPassword);
},
});
};

const defaultValues = {
passphrase: '',
confirmationPassphrase: '',
};

export const ChangePassphrase = (): JSX.Element => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();

const uniqueId = useId();
const passphraseId = `passphrase-${uniqueId}`;
const passphraseHintId = `${passphraseId}-hint`;
const passphraseErrorId = `${passphraseId}-error`;
const confirmPassphraseId = `confirm-passphrase-${uniqueId}`;
const confirmPassphraseErrorId = `${confirmPassphraseId}-error`;
const passphraseVerifierId = `verifier-${uniqueId}`;
const e2ePasswordExplanationId = `explanation-${uniqueId}`;

const {
watch,
formState: { errors, isValid },
handleSubmit,
reset,
resetField,
control,
trigger,
} = useForm({
defaultValues,
mode: 'all',
});

const { passphrase, confirmationPassphrase } = watch();
const { validations, valid } = useValidatePassphrase(passphrase);
useEffect(() => {
if (!valid) {
resetField('confirmationPassphrase');
return;
}
if (confirmationPassphrase) {
const validateConfirmation = async () => {
await trigger('confirmationPassphrase');
};
void validateConfirmation();
}
}, [valid, confirmationPassphrase, resetField, trigger]);
const keysExist = useKeysExist();

const updatePassword = useChangeE2EPasswordMutation();

const handleSave = async ({ passphrase }: { passphrase: string }) => {
try {
await updatePassword.mutateAsync(passphrase);
dispatchToastMessage({ type: 'success', message: t('Encryption_key_saved_successfully') });
reset();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};

return (
<>
<Box
is='p'
fontScale='p1'
id={e2ePasswordExplanationId}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(t('E2E_Encryption_Password_Explanation')) }}
/>
<Box mbs={36} w='full'>
<Box is='h3' fontScale='h4' mbe={12}>
{t('Change_E2EE_password')}
</Box>
<FieldGroup w='full'>
<Field>
<FieldLabel htmlFor={passphraseId}>{t('New_E2EE_password')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='passphrase'
rules={{
required: t('Required_field', { field: t('New_E2EE_password') }),
validate: () => (valid ? true : t('Password_must_meet_the_complexity_requirements')),
}}
render={({ field }) => (
<PasswordInput
{...field}
id={passphraseId}
error={errors.passphrase?.message}
disabled={!keysExist}
aria-describedby={[
e2ePasswordExplanationId,
keysExist ? passphraseVerifierId : passphraseHintId,
errors.passphrase && passphraseErrorId,
]
.filter(Boolean)
.join(' ')}
aria-invalid={errors.passphrase ? 'true' : 'false'}
/>
)}
/>
</FieldRow>
{errors.passphrase && (
<FieldError role='alert' id={passphraseErrorId} hidden aria-hidden={!errors.passphrase}>
{errors.passphrase.message}
</FieldError>
)}
{keysExist ? (
<PasswordVerifierList id={passphraseVerifierId} validations={validations} />
) : (
<FieldHint id={passphraseHintId}>
<Trans i18nKey='Enter_current_E2EE_password_to_set_new'>
To set a new password, first
<Box
is='a'
href='#'
onClick={async (e) => {
e.preventDefault();
await e2e.decodePrivateKeyFlow();
}}
>
enter your current E2EE password.
</Box>
</Trans>
</FieldHint>
)}
</Field>
{valid && (
<Field>
<FieldLabel htmlFor={confirmPassphraseId}>{t('Confirm_new_E2EE_password')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='confirmationPassphrase'
rules={{
required: t('Required_field', { field: t('Confirm_password') }),
validate: (value) => (passphrase !== value ? t('Passwords_do_not_match') : true),
}}
render={({ field }) => (
<PasswordInput
{...field}
id={confirmPassphraseId}
error={errors.confirmationPassphrase?.message}
flexGrow={1}
disabled={!keysExist || !valid}
aria-required={passphrase ? 'true' : 'false'}
aria-invalid={errors.confirmationPassphrase ? 'true' : 'false'}
aria-describedby={errors.confirmationPassphrase ? confirmPassphraseErrorId : undefined}
/>
)}
/>
</FieldRow>
{errors.confirmationPassphrase && (
<FieldError aria-live='assertive' id={confirmPassphraseErrorId} role='alert'>
{errors.confirmationPassphrase.message}
</FieldError>
)}
</Field>
)}
</FieldGroup>
<Button
primary
disabled={!(keysExist && valid && isValid)}
onClick={handleSubmit(handleSave)}
mbs={12}
data-qa-type='e2e-encryption-save-password-button'
>
{t('Save_changes')}
</Button>
</Box>
</>
);
};
174 changes: 7 additions & 167 deletions apps/meteor/client/views/account/security/EndToEnd.tsx
Original file line number Diff line number Diff line change
@@ -1,174 +1,14 @@
import { Box, PasswordInput, Field, FieldGroup, FieldLabel, FieldRow, FieldError, FieldHint, Button, Divider } from '@rocket.chat/fuselage';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import DOMPurify from 'dompurify';
import { Accounts } from 'meteor/accounts-base';
import type { ComponentProps, ReactElement } from 'react';
import { useId, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { Box, Divider } from '@rocket.chat/fuselage';

import { e2e } from '../../../lib/e2ee/rocketchat.e2e';
import { useResetE2EPasswordMutation } from '../../hooks/useResetE2EPasswordMutation';

const EndToEnd = (props: ComponentProps<typeof Box>): ReactElement => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();

const publicKey = Accounts.storageLocation.getItem('public_key');
const privateKey = Accounts.storageLocation.getItem('private_key');

const resetE2EPassword = useResetE2EPasswordMutation();

const {
handleSubmit,
watch,
resetField,
formState: { errors, isValid },
control,
} = useForm({
defaultValues: {
password: '',
passwordConfirm: '',
},
});

const { password } = watch();

/**
* TODO: We need to figure out a way to make this reactive,
* so the form will allow change password as soon the user enter the current E2EE password
*/
const keysExist = Boolean(publicKey && privateKey);

const hasTypedPassword = Boolean(password?.trim().length);

const saveNewPassword = async (data: { password: string; passwordConfirm: string }) => {
try {
await e2e.changePassword(data.password);
resetField('password');
dispatchToastMessage({ type: 'success', message: t('Encryption_key_saved_successfully') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};

useEffect(() => {
if (password?.trim() === '') {
resetField('passwordConfirm');
}
}, [password, resetField]);

const passwordId = useId();
const e2ePasswordExplanationId = useId();
const passwordConfirmId = useId();
import { ChangePassphrase } from './ChangePassphrase';
import { ResetPassphrase } from './ResetPassphrase';

const EndToEnd = (): JSX.Element => {
return (
<Box display='flex' flexDirection='column' alignItems='flex-start' {...props}>
<Box
is='p'
fontScale='p1'
id={e2ePasswordExplanationId}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(t('E2E_Encryption_Password_Explanation')) }}
/>
<Box mbs={36} w='full'>
<Box is='h4' fontScale='h4' mbe={12}>
{t('Change_E2EE_password')}
</Box>
<FieldGroup w='full'>
<Field>
<FieldLabel htmlFor={passwordId}>{t('New_E2EE_password')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='password'
rules={{ required: t('Required_field', { field: t('New_E2EE_password') }) }}
render={({ field }) => (
<PasswordInput
{...field}
id={passwordId}
error={errors.password?.message}
disabled={!keysExist}
aria-describedby={`${e2ePasswordExplanationId} ${passwordId}-hint ${passwordId}-error`}
aria-invalid={errors.password ? 'true' : 'false'}
/>
)}
/>
</FieldRow>
{!keysExist && (
<FieldHint id={`${passwordId}-hint`}>
<Trans i18nKey='Enter_current_E2EE_password_to_set_new'>
To set a new password, first
<Box
is='a'
href='#'
onClick={async (e) => {
e.preventDefault();
await e2e.decodePrivateKeyFlow();
}}
>
enter your current E2EE password.
</Box>
</Trans>
</FieldHint>
)}
{errors?.password && (
<FieldError role='alert' id={`${passwordId}-error`}>
{errors.password.message}
</FieldError>
)}
</Field>
{hasTypedPassword && (
<Field>
<FieldLabel htmlFor={passwordConfirmId}>{t('Confirm_new_E2EE_password')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='passwordConfirm'
rules={{
required: t('Required_field', { field: t('Confirm_new_E2EE_password') }),
validate: (value: string) => (password !== value ? t('Passwords_do_not_match') : true),
}}
render={({ field }) => (
<PasswordInput
{...field}
id={passwordConfirmId}
error={errors.passwordConfirm?.message}
aria-describedby={`${passwordConfirmId}-error`}
aria-invalid={errors.passwordConfirm ? 'true' : 'false'}
/>
)}
/>
</FieldRow>
{errors.passwordConfirm && (
<FieldError role='alert' id={`${passwordConfirmId}-error`}>
{errors.passwordConfirm.message}
</FieldError>
)}
</Field>
)}
</FieldGroup>
<Button
primary
disabled={!(keysExist && isValid)}
onClick={handleSubmit(saveNewPassword)}
mbs={12}
data-qa-type='e2e-encryption-save-password-button'
>
{t('Save_changes')}
</Button>
</Box>
<Box display='flex' flexDirection='column' alignItems='flex-start'>
<ChangePassphrase />
<Divider mb={36} width='full' />
<Box>
<Box is='h4' fontScale='h4' mbe={12}>
{t('Reset_E2EE_password')}
</Box>
<Box is='p' fontScale='p1' mbe={12}>
{t('Reset_E2EE_password_description')}
</Box>
<Button onClick={() => resetE2EPassword.mutate()} data-qa-type='e2e-encryption-reset-key-button'>
{t('Reset_E2EE_password')}
</Button>
</Box>
<ResetPassphrase />
</Box>
);
};
Expand Down
Loading
Loading