Skip to content

Commit

Permalink
feat: Add ChangePassword field to Account/Security (#30306)
Browse files Browse the repository at this point in the history
Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com>
  • Loading branch information
juliajforesti and dougfabris committed Sep 8, 2023
1 parent 9bdbc9b commit ee3815f
Show file tree
Hide file tree
Showing 9 changed files with 169 additions and 68 deletions.
6 changes: 6 additions & 0 deletions .changeset/yellow-buttons-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/ui-client': minor
'@rocket.chat/meteor': minor
---

feat: add ChangePassword field to Account/Security
62 changes: 3 additions & 59 deletions apps/meteor/client/views/account/profile/AccountProfileForm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Field, FieldGroup, TextInput, TextAreaInput, Box, Icon, PasswordInput, Button } from '@rocket.chat/fuselage';
import { Field, FieldGroup, TextInput, TextAreaInput, Box, Icon, Button } from '@rocket.chat/fuselage';
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import { CustomFieldsForm, PasswordVerifier, useValidatePassword } from '@rocket.chat/ui-client';
import { CustomFieldsForm } from '@rocket.chat/ui-client';
import {
useAccountsCustomFields,
useToastMessageDispatch,
Expand All @@ -24,9 +24,7 @@ import { USER_STATUS_TEXT_MAX_LENGTH, BIO_TEXT_MAX_LENGTH } from '../../../lib/c
import type { AccountProfileFormValues } from './getProfileInitialValues';
import { getProfileInitialValues } from './getProfileInitialValues';
import { useAccountProfileSettings } from './useAccountProfileSettings';
import { useAllowPasswordChange } from './useAllowPasswordChange';

// TODO: add password validation on UI
const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>): ReactElement => {
const t = useTranslation();
const user = useUser();
Expand All @@ -46,7 +44,6 @@ const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>): ReactEle
requireName,
namesRegex,
} = useAccountProfileSettings();
const { allowPasswordChange } = useAllowPasswordChange();

const {
register,
Expand All @@ -57,7 +54,7 @@ const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>): ReactEle
formState: { errors },
} = useFormContext<AccountProfileFormValues>();

const { email, avatar, password, username } = watch();
const { email, avatar, username } = watch();

const previousEmail = user ? getUserEmailAddress(user) : '';
const isUserVerified = user?.emails?.[0]?.verified ?? false;
Expand Down Expand Up @@ -91,8 +88,6 @@ const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>): ReactEle
}
};

const passwordIsValid = useValidatePassword(password);

// FIXME: replace to endpoint
const updateOwnBasicInfo = useMethod('saveUserProfile');

Expand All @@ -104,7 +99,6 @@ const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>): ReactEle
{
...(allowRealNameChange ? { realname: name } : {}),
...(allowEmailChange && user ? getUserEmailAddress(user) !== email && { email } : {}),
...(allowPasswordChange ? { newPassword: password } : {}),
...(canChangeUsername ? { username } : {}),
...(allowUserStatusMessageChange ? { statusText } : {}),
statusType,
Expand All @@ -128,9 +122,6 @@ const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>): ReactEle
const statusTextId = useUniqueId();
const bioId = useUniqueId();
const emailId = useUniqueId();
const passwordId = useUniqueId();
const confirmPasswordId = useUniqueId();
const passwordVerifierId = useUniqueId();

return (
<Box {...props} is='form' autoComplete='off' onSubmit={handleSubmit(handleSave)}>
Expand Down Expand Up @@ -290,53 +281,6 @@ const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>): ReactEle
)}
{!allowEmailChange && <Field.Hint id={`${emailId}-hint`}>{t('Email_Change_Disabled')}</Field.Hint>}
</Field>
<Field>
<Field.Label htmlFor={passwordId}>{t('New_password')}</Field.Label>
<Field.Row>
<PasswordInput
id={passwordId}
{...register('password', {
validate: () => (!passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true),
})}
error={errors.password?.message}
flexGrow={1}
addon={<Icon name='key' size='x20' />}
disabled={!allowPasswordChange}
aria-describedby={passwordVerifierId}
aria-invalid={errors.password ? 'true' : 'false'}
/>
</Field.Row>
{errors?.password && (
<Field.Error aria-live='assertive' id={`${passwordId}-error`}>
{errors.password.message}
</Field.Error>
)}
{allowPasswordChange && <PasswordVerifier password={password} id={passwordVerifierId} />}
</Field>
<Field>
<Field.Label htmlFor={confirmPasswordId}>{t('Confirm_password')}</Field.Label>
<Field.Row>
<PasswordInput
id={confirmPasswordId}
{...register('confirmationPassword', {
validate: (confirmationPassword) => (password !== confirmationPassword ? t('Passwords_do_not_match') : true),
})}
error={errors.confirmationPassword?.message}
flexGrow={1}
addon={<Icon name='key' size='x20' />}
disabled={!allowPasswordChange || !passwordIsValid}
aria-required={password !== '' ? 'true' : 'false'}
aria-invalid={errors.confirmationPassword ? 'true' : 'false'}
aria-describedby={`${confirmPasswordId}-error ${confirmPasswordId}-hint`}
/>
</Field.Row>
{!allowPasswordChange && <Field.Hint id={`${confirmPasswordId}-hint`}>{t('Password_Change_Disabled')}</Field.Hint>}
{errors.confirmationPassword && (
<Field.Error aria-live='assertive' id={`${confirmPasswordId}-error`}>
{errors.confirmationPassword.message}
</Field.Error>
)}
</Field>
{customFieldsMetadata && <CustomFieldsForm formName='customFields' formControl={control} metadata={customFieldsMetadata} />}
</FieldGroup>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import { FormProvider, useForm } from 'react-hook-form';

import ConfirmOwnerChangeModal from '../../../components/ConfirmOwnerChangeModal';
import Page from '../../../components/Page';
import { useAllowPasswordChange } from '../security/useAllowPasswordChange';
import AccountProfileForm from './AccountProfileForm';
import ActionConfirmModal from './ActionConfirmModal';
import { getProfileInitialValues } from './getProfileInitialValues';
import { useAllowPasswordChange } from './useAllowPasswordChange';

// TODO: enforce useMutation
const AccountProfilePage = (): ReactElement => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ export type AccountProfileFormValues = {
email: string;
name: string;
username: string;
password: string;
confirmationPassword: string;
avatar: AvatarObject;
url: string;
statusText: string;
Expand All @@ -21,8 +19,6 @@ export const getProfileInitialValues = (user: IUser | null): AccountProfileFormV
email: user ? getUserEmailAddress(user) || '' : '',
name: user?.name ?? '',
username: user?.username ?? '',
password: '',
confirmationPassword: '',
avatar: '' as AvatarObject,
url: '',
statusText: user?.statusText ?? '',
Expand Down
35 changes: 33 additions & 2 deletions apps/meteor/client/views/account/security/AccountSecurityPage.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
import { Box, Accordion } from '@rocket.chat/fuselage';
import { Box, Accordion, ButtonGroup, Button } from '@rocket.chat/fuselage';
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import { useSetting, useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React from 'react';
import { FormProvider, useForm } from 'react-hook-form';

import Page from '../../../components/Page';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
import ChangePassword from './ChangePassword';
import EndToEnd from './EndToEnd';
import TwoFactorEmail from './TwoFactorEmail';
import TwoFactorTOTP from './TwoFactorTOTP';

const passwordDefaultValues = { password: '', confirmationPassword: '' };

const AccountSecurityPage = (): ReactElement => {
const t = useTranslation();

const methods = useForm({
defaultValues: passwordDefaultValues,
mode: 'onBlur',
});
const {
reset,
formState: { isDirty },
} = methods;

const twoFactorEnabled = useSetting('Accounts_TwoFactorAuthentication_Enabled');
const twoFactorTOTP = useSetting('Accounts_TwoFactorAuthentication_By_TOTP_Enabled');
const twoFactorByEmailEnabled = useSetting('Accounts_TwoFactorAuthentication_By_Email_Enabled');
const e2eEnabled = useSetting('E2E_Enable');

const passwordFormId = useUniqueId();

if (!twoFactorEnabled && !e2eEnabled) {
return <NotAuthorizedPage />;
}
Expand All @@ -26,9 +42,16 @@ const AccountSecurityPage = (): ReactElement => {
<Page.Header title={t('Security')} />
<Page.ScrollableContentWithShadow>
<Box maxWidth='x600' w='full' alignSelf='center' color='default'>
<FormProvider {...methods}>
<Accordion>
<Accordion.Item title={t('Password')} defaultExpanded>
<ChangePassword id={passwordFormId} />
</Accordion.Item>
</Accordion>
</FormProvider>
<Accordion>
{(twoFactorTOTP || twoFactorByEmailEnabled) && twoFactorEnabled && (
<Accordion.Item title={t('Two Factor Authentication')} defaultExpanded>
<Accordion.Item title={t('Two Factor Authentication')}>
{twoFactorTOTP && <TwoFactorTOTP />}
{twoFactorByEmailEnabled && <TwoFactorEmail />}
</Accordion.Item>
Expand All @@ -46,6 +69,14 @@ const AccountSecurityPage = (): ReactElement => {
</Accordion>
</Box>
</Page.ScrollableContentWithShadow>
<Page.Footer isDirty={isDirty}>
<ButtonGroup>
<Button onClick={() => reset(passwordDefaultValues)}>{t('Cancel')}</Button>
<Button form={passwordFormId} primary disabled={!isDirty} type='submit'>
{t('Save_changes')}
</Button>
</ButtonGroup>
</Page.Footer>
</Page>
);
};
Expand Down
115 changes: 115 additions & 0 deletions apps/meteor/client/views/account/security/ChangePassword.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Box, Field, FieldError, FieldGroup, FieldHint, FieldLabel, FieldRow, Icon, PasswordInput } from '@rocket.chat/fuselage';
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import { PasswordVerifier, useValidatePassword } from '@rocket.chat/ui-client';
import { useMethod, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import type { AllHTMLAttributes } from 'react';
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';

import { useAllowPasswordChange } from './useAllowPasswordChange';

type PasswordFieldValues = { password: string; confirmationPassword: string };

const ChangePassword = (props: AllHTMLAttributes<HTMLFormElement>) => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();

const passwordId = useUniqueId();
const confirmPasswordId = useUniqueId();
const passwordVerifierId = useUniqueId();

const {
watch,
formState: { errors },
handleSubmit,
reset,
control,
} = useFormContext<PasswordFieldValues>();

const password = watch('password');
const passwordIsValid = useValidatePassword(password);
const { allowPasswordChange } = useAllowPasswordChange();

// FIXME: replace to endpoint
const updatePassword = useMethod('saveUserProfile');

const handleSave = async ({ password }: { password?: string }) => {
try {
await updatePassword({ newPassword: password }, {});
dispatchToastMessage({ type: 'success', message: t('Password_changed_successfully') });
reset();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};

return (
<Box {...props} is='form' autoComplete='off' onSubmit={handleSubmit(handleSave)}>
<FieldGroup>
<Field>
<FieldLabel htmlFor={passwordId}>{t('New_password')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='password'
rules={{
validate: () => (password?.length && !passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true),
}}
render={({ field: { onChange, value } }) => (
<PasswordInput
id={passwordId}
onChange={onChange}
value={value}
error={errors.password?.message}
flexGrow={1}
addon={<Icon name='key' size='x20' />}
disabled={!allowPasswordChange}
aria-describedby={`${passwordVerifierId} ${passwordId}-hint ${passwordId}-error`}
aria-invalid={errors.password ? 'true' : 'false'}
/>
)}
/>
</FieldRow>
{!allowPasswordChange && <FieldHint id={`${passwordId}-hint`}>{t('Password_Change_Disabled')}</FieldHint>}
{errors?.password && (
<FieldError aria-live='assertive' id={`${passwordId}-error`}>
{errors.password.message}
</FieldError>
)}
{allowPasswordChange && <PasswordVerifier password={password} id={passwordVerifierId} />}
</Field>
<Field>
<FieldLabel htmlFor={confirmPasswordId}>{t('Confirm_password')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='confirmationPassword'
rules={{ validate: (confirmationPassword) => (password !== confirmationPassword ? t('Passwords_do_not_match') : true) }}
render={({ field: { onChange, value } }) => (
<PasswordInput
id={confirmPasswordId}
onChange={onChange}
value={value}
error={errors.confirmationPassword?.message}
flexGrow={1}
addon={<Icon name='key' size='x20' />}
disabled={!allowPasswordChange || !passwordIsValid}
aria-required={password !== '' ? 'true' : 'false'}
aria-invalid={errors.confirmationPassword ? 'true' : 'false'}
aria-describedby={`${confirmPasswordId}-error`}
/>
)}
/>
</FieldRow>
{errors.confirmationPassword && (
<FieldError aria-live='assertive' id={`${confirmPasswordId}-error`}>
{errors.confirmationPassword.message}
</FieldError>
)}
</Field>
</FieldGroup>
</Box>
);
};

export default ChangePassword;
9 changes: 9 additions & 0 deletions apps/meteor/tests/e2e/account-profile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ test.describe.serial('settings-account-profile', () => {
});
});

test.describe('Security', () => {
test('should not have any accessibility violations', async ({ page, makeAxeBuilder }) => {
await page.goto('/account/security');

const results = await makeAxeBuilder().analyze();
expect(results.violations).toEqual([]);
})
})

test('Personal Access Tokens', async ({ page }) => {
const response = page.waitForResponse('**/api/v1/users.getPersonalAccessTokens');
await page.goto('/account/tokens');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import { useTranslation } from 'react-i18next';
import { PasswordVerifierItem } from './PasswordVerifierItem';

type PasswordVerifierProps = {
password: string;
password: string | undefined;
id?: string;
};

export const PasswordVerifier = ({ password, id }: PasswordVerifierProps) => {
const { t } = useTranslation();
const uniqueId = useUniqueId();

const { data: passwordVerifications, isLoading } = useVerifyPassword(password);
const { data: passwordVerifications, isLoading } = useVerifyPassword(password || '');

if (isLoading) {
return <Skeleton data-testid='password-verifier-skeleton' w='full' mbe={8} />;
Expand Down

0 comments on commit ee3815f

Please sign in to comment.