diff --git a/.changeset/yellow-buttons-agree.md b/.changeset/yellow-buttons-agree.md new file mode 100644 index 000000000000..a86d172a4544 --- /dev/null +++ b/.changeset/yellow-buttons-agree.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/ui-client': minor +'@rocket.chat/meteor': minor +--- + +feat: add ChangePassword field to Account/Security diff --git a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx index 4efa05e32da0..cb65ce80317e 100644 --- a/apps/meteor/client/views/account/profile/AccountProfileForm.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfileForm.tsx @@ -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): ReactElement => { const t = useTranslation(); const user = useUser(); @@ -46,7 +44,6 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle requireName, namesRegex, } = useAccountProfileSettings(); - const { allowPasswordChange } = useAllowPasswordChange(); const { register, @@ -57,7 +54,7 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle formState: { errors }, } = useFormContext(); - 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): ReactEle } }; - const passwordIsValid = useValidatePassword(password); - // FIXME: replace to endpoint const updateOwnBasicInfo = useMethod('saveUserProfile'); @@ -104,7 +99,6 @@ const AccountProfileForm = (props: AllHTMLAttributes): 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): ReactEle const statusTextId = useUniqueId(); const bioId = useUniqueId(); const emailId = useUniqueId(); - const passwordId = useUniqueId(); - const confirmPasswordId = useUniqueId(); - const passwordVerifierId = useUniqueId(); return ( @@ -290,53 +281,6 @@ const AccountProfileForm = (props: AllHTMLAttributes): ReactEle )} {!allowEmailChange && {t('Email_Change_Disabled')}} - - {t('New_password')} - - (!passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true), - })} - error={errors.password?.message} - flexGrow={1} - addon={} - disabled={!allowPasswordChange} - aria-describedby={passwordVerifierId} - aria-invalid={errors.password ? 'true' : 'false'} - /> - - {errors?.password && ( - - {errors.password.message} - - )} - {allowPasswordChange && } - - - {t('Confirm_password')} - - (password !== confirmationPassword ? t('Passwords_do_not_match') : true), - })} - error={errors.confirmationPassword?.message} - flexGrow={1} - addon={} - disabled={!allowPasswordChange || !passwordIsValid} - aria-required={password !== '' ? 'true' : 'false'} - aria-invalid={errors.confirmationPassword ? 'true' : 'false'} - aria-describedby={`${confirmPasswordId}-error ${confirmPasswordId}-hint`} - /> - - {!allowPasswordChange && {t('Password_Change_Disabled')}} - {errors.confirmationPassword && ( - - {errors.confirmationPassword.message} - - )} - {customFieldsMetadata && } diff --git a/apps/meteor/client/views/account/profile/AccountProfilePage.tsx b/apps/meteor/client/views/account/profile/AccountProfilePage.tsx index e90048cb4b63..ba74c9a8f1ff 100644 --- a/apps/meteor/client/views/account/profile/AccountProfilePage.tsx +++ b/apps/meteor/client/views/account/profile/AccountProfilePage.tsx @@ -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 => { diff --git a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts index 99366a2c00dc..a15e8477d48d 100644 --- a/apps/meteor/client/views/account/profile/getProfileInitialValues.ts +++ b/apps/meteor/client/views/account/profile/getProfileInitialValues.ts @@ -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 ?? '', diff --git a/apps/meteor/client/views/account/security/AccountSecurityPage.tsx b/apps/meteor/client/views/account/security/AccountSecurityPage.tsx index 4e340596e3d9..1c1023e0a7e3 100644 --- a/apps/meteor/client/views/account/security/AccountSecurityPage.tsx +++ b/apps/meteor/client/views/account/security/AccountSecurityPage.tsx @@ -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 ; } @@ -26,9 +42,16 @@ const AccountSecurityPage = (): ReactElement => { + + + + + + + {(twoFactorTOTP || twoFactorByEmailEnabled) && twoFactorEnabled && ( - + {twoFactorTOTP && } {twoFactorByEmailEnabled && } @@ -46,6 +69,14 @@ const AccountSecurityPage = (): ReactElement => { + + + + + + ); }; diff --git a/apps/meteor/client/views/account/security/ChangePassword.tsx b/apps/meteor/client/views/account/security/ChangePassword.tsx new file mode 100644 index 000000000000..c70d9e166175 --- /dev/null +++ b/apps/meteor/client/views/account/security/ChangePassword.tsx @@ -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) => { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const passwordId = useUniqueId(); + const confirmPasswordId = useUniqueId(); + const passwordVerifierId = useUniqueId(); + + const { + watch, + formState: { errors }, + handleSubmit, + reset, + control, + } = useFormContext(); + + 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 ( + + + + {t('New_password')} + + (password?.length && !passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true), + }} + render={({ field: { onChange, value } }) => ( + } + disabled={!allowPasswordChange} + aria-describedby={`${passwordVerifierId} ${passwordId}-hint ${passwordId}-error`} + aria-invalid={errors.password ? 'true' : 'false'} + /> + )} + /> + + {!allowPasswordChange && {t('Password_Change_Disabled')}} + {errors?.password && ( + + {errors.password.message} + + )} + {allowPasswordChange && } + + + {t('Confirm_password')} + + (password !== confirmationPassword ? t('Passwords_do_not_match') : true) }} + render={({ field: { onChange, value } }) => ( + } + disabled={!allowPasswordChange || !passwordIsValid} + aria-required={password !== '' ? 'true' : 'false'} + aria-invalid={errors.confirmationPassword ? 'true' : 'false'} + aria-describedby={`${confirmPasswordId}-error`} + /> + )} + /> + + {errors.confirmationPassword && ( + + {errors.confirmationPassword.message} + + )} + + + + ); +}; + +export default ChangePassword; diff --git a/apps/meteor/client/views/account/profile/useAllowPasswordChange.ts b/apps/meteor/client/views/account/security/useAllowPasswordChange.ts similarity index 100% rename from apps/meteor/client/views/account/profile/useAllowPasswordChange.ts rename to apps/meteor/client/views/account/security/useAllowPasswordChange.ts diff --git a/apps/meteor/tests/e2e/account-profile.spec.ts b/apps/meteor/tests/e2e/account-profile.spec.ts index b850b7855c6f..49d96772bf2d 100644 --- a/apps/meteor/tests/e2e/account-profile.spec.ts +++ b/apps/meteor/tests/e2e/account-profile.spec.ts @@ -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'); diff --git a/packages/ui-client/src/components/PasswordVerifier/PasswordVerifier.tsx b/packages/ui-client/src/components/PasswordVerifier/PasswordVerifier.tsx index 465d82725d87..9599f43c6cbb 100644 --- a/packages/ui-client/src/components/PasswordVerifier/PasswordVerifier.tsx +++ b/packages/ui-client/src/components/PasswordVerifier/PasswordVerifier.tsx @@ -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 ;