feat: Add `ChangePassword` field to Account/Security (#30306)

Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com>
pull/30321/head^2
Júlia Jaeger Foresti 2 years ago committed by GitHub
parent 9bdbc9b086
commit ee3815fce4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      .changeset/yellow-buttons-agree.md
  2. 62
      apps/meteor/client/views/account/profile/AccountProfileForm.tsx
  3. 2
      apps/meteor/client/views/account/profile/AccountProfilePage.tsx
  4. 4
      apps/meteor/client/views/account/profile/getProfileInitialValues.ts
  5. 35
      apps/meteor/client/views/account/security/AccountSecurityPage.tsx
  6. 115
      apps/meteor/client/views/account/security/ChangePassword.tsx
  7. 0
      apps/meteor/client/views/account/security/useAllowPasswordChange.ts
  8. 9
      apps/meteor/tests/e2e/account-profile.spec.ts
  9. 4
      packages/ui-client/src/components/PasswordVerifier/PasswordVerifier.tsx

@ -0,0 +1,6 @@
---
'@rocket.chat/ui-client': minor
'@rocket.chat/meteor': minor
---
feat: add ChangePassword field to Account/Security

@ -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,
@ -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();
@ -46,7 +44,6 @@ const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>): ReactEle
requireName,
namesRegex,
} = useAccountProfileSettings();
const { allowPasswordChange } = useAllowPasswordChange();
const {
register,
@ -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;
@ -91,8 +88,6 @@ const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>): ReactEle
}
};
const passwordIsValid = useValidatePassword(password);
// FIXME: replace to endpoint
const updateOwnBasicInfo = useMethod('saveUserProfile');
@ -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,
@ -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)}>
@ -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>

@ -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 => {

@ -6,8 +6,6 @@ export type AccountProfileFormValues = {
email: string;
name: string;
username: string;
password: string;
confirmationPassword: string;
avatar: AvatarObject;
url: string;
statusText: string;
@ -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 ?? '',

@ -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 />;
}
@ -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>
@ -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>
);
};

@ -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;

@ -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');

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { PasswordVerifierItem } from './PasswordVerifierItem';
type PasswordVerifierProps = {
password: string;
password: string | undefined;
id?: string;
};
@ -14,7 +14,7 @@ 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} />;

Loading…
Cancel
Save