diff --git a/packages/react/src/components/AccountSettings/ChangePassword/ChangePassword.tsx b/packages/react/src/components/AccountSettings/ChangePassword/ChangePassword.tsx index e7ee7961e1b..9c4e5acff67 100644 --- a/packages/react/src/components/AccountSettings/ChangePassword/ChangePassword.tsx +++ b/packages/react/src/components/AccountSettings/ChangePassword/ChangePassword.tsx @@ -1,53 +1,149 @@ import React from 'react'; +import isEqual from 'lodash/isEqual'; import { Logger } from 'aws-amplify'; -import { changePassword, translate } from '@aws-amplify/ui'; +import { + changePassword, + ValidatorOptions, + getDefaultConfirmPasswordValidators, + getDefaultPasswordValidators, + runFieldValidators, + translate, +} from '@aws-amplify/ui'; import { useAuth } from '../../../internal'; import { View, Flex } from '../../../primitives'; +import { FormValues, BlurredFields, ValidationError } from '../types'; import { - DefaultCurrentPassword, + DefaultPasswordField, DefaultError, - DefaultNewPassword, DefaultSubmitButton, } from './defaultComponents'; -import { ChangePasswordProps } from './types'; -import { FormValues } from '../types'; +import { ChangePasswordProps, ValidateParams } from './types'; const logger = new Logger('ChangePassword'); +const getIsDisabled = ( + formValues: FormValues, + validationError: ValidationError +): boolean => { + const { currentPassword, newPassword, confirmPassword } = formValues; + + const hasEmptyField = !currentPassword || !newPassword || !confirmPassword; + if (hasEmptyField) { + return true; + } + + const arePasswordsInvalid = + validationError.newPassword?.length > 0 || + validationError.confirmPassword?.length > 0; + + return arePasswordsInvalid; +}; + function ChangePassword({ onSuccess, onError, + validators, }: ChangePasswordProps): JSX.Element | null { const [errorMessage, setErrorMessage] = React.useState(null); const [formValues, setFormValues] = React.useState({}); - + const [validationError, setValidationError] = React.useState( + {} + ); + const blurredFields = React.useRef([]); const { user, isLoading } = useAuth(); - /** Return null if Auth.getCurrentAuthenticatedUser is still in progress */ - if (isLoading) { - return null; - } + const isDisabled = getIsDisabled(formValues, validationError); - /** Return null if user isn't authenticated in the first place */ - if (!user) { - logger.warn(' requires user to be authenticated.'); - return null; - } + const passwordValidators: ValidatorOptions[] = React.useMemo(() => { + return validators ?? getDefaultPasswordValidators(); + }, [validators]); + + /* + * Note that formValues and other states are passed in as props so that + * it does not depend on whether or not those states have been updated yet + */ + const validateNewPassword = React.useCallback( + ({ formValues, eventType }: ValidateParams): string[] => { + const { newPassword } = formValues; + const hasBlurred = blurredFields.current.includes('newPassword'); + + return runFieldValidators({ + value: newPassword, + validators: passwordValidators, + eventType, + hasBlurred, + }); + }, + [passwordValidators] + ); + + const validateConfirmPassword = React.useCallback( + ({ formValues, eventType }: ValidateParams): string[] => { + const { newPassword, confirmPassword } = formValues; + const hasBlurred = blurredFields.current.includes('confirmPassword'); + + const confirmPasswordValidators = + getDefaultConfirmPasswordValidators(newPassword); + + return runFieldValidators({ + value: confirmPassword, + validators: confirmPasswordValidators, + eventType, + hasBlurred, + }); + }, + [] + ); + + const runValidation = React.useCallback( + (param: ValidateParams) => { + const passwordErrors = validateNewPassword(param); + const confirmPasswordErrors = validateConfirmPassword(param); - /** Translations */ + const newValidationError = { + newPassword: passwordErrors, + confirmPassword: confirmPasswordErrors, + }; + + // only re-render if errors have changed + if (!isEqual(validationError, newValidationError)) { + setValidationError(newValidationError); + } + }, + [validateConfirmPassword, validateNewPassword, validationError] + ); + + /* Translations */ // TODO: add AccountSettingsTextUtil to collect these strings const currentPasswordLabel = translate('Current Password'); const newPasswordLabel = translate('New Password'); + const confirmPasswordLabel = translate('Confirm Password'); const updatePasswordText = translate('Update password'); - /** Event Handlers */ + /* Event Handlers */ const handleChange = (event: React.ChangeEvent) => { event.preventDefault(); const { name, value } = event.target; - setFormValues({ ...formValues, [name]: value }); + + const newFormValues = { ...formValues, [name]: value }; + runValidation({ formValues: newFormValues, eventType: 'change' }); + + setFormValues(newFormValues); + }; + + const handleBlur = (event: React.FocusEvent) => { + event.preventDefault(); + + const { name } = event.target; + // only update state and run validation if this is the first time blurring the field + if (!blurredFields.current.includes(name)) { + const newBlurredFields = [...blurredFields.current, name]; + blurredFields.current = newBlurredFields; + runValidation({ formValues, eventType: 'blur' }); + } }; const handleSubmit = async (event: React.FormEvent) => { @@ -68,24 +164,47 @@ function ChangePassword({ } }; + // Return null if Auth.getCurrentAuthenticatedUser is still in progress + if (isLoading) { + return null; + } + + // Return null if user isn't authenticated in the first place + if (!user) { + logger.warn(' requires user to be authenticated.'); + return null; + } + return ( - - + - + {updatePasswordText} {errorMessage ? {errorMessage} : null} diff --git a/packages/react/src/components/AccountSettings/ChangePassword/__tests__/ChangePassword.test.tsx b/packages/react/src/components/AccountSettings/ChangePassword/__tests__/ChangePassword.test.tsx index fb3ef4e6b1f..046006f294d 100644 --- a/packages/react/src/components/AccountSettings/ChangePassword/__tests__/ChangePassword.test.tsx +++ b/packages/react/src/components/AccountSettings/ChangePassword/__tests__/ChangePassword.test.tsx @@ -14,6 +14,18 @@ jest.mock('../../../../internal', () => ({ })); const changePasswordSpy = jest.spyOn(UIModule, 'changePassword'); +jest.spyOn(UIModule, 'getDefaultPasswordValidators').mockReturnValue([ + { + validationMode: 'onTouched', + validator: (field) => /[a-z]/.test(field), + message: 'Password must have lower case letters', + }, + { + validationMode: 'onTouched', + validator: (field) => /[A-Z]/.test(field), + message: 'Password must have upper case letters', + }, +]); describe('ChangePassword', () => { beforeEach(() => { @@ -86,7 +98,7 @@ describe('ChangePassword', () => { await waitFor(() => expect(onError).toBeCalledTimes(1)); }); - it('error message is displayed after unsuccessful submit', async () => { + it('displays error message after unsuccessful submit', async () => { changePasswordSpy.mockRejectedValue(new Error('Mock Error')); const onError = jest.fn(); @@ -98,7 +110,127 @@ describe('ChangePassword', () => { fireEvent.submit(submitButton); - // submit handling is async, wait for error to be displayed - await waitFor(() => expect(screen.findByText('Mock Error')).toBeDefined()); + expect(await screen.findByText('Mock Error')).toBeDefined(); + }); + + it('disables submit button on init', async () => { + render(); + + const submitButton = await screen.findByRole('button', { + name: 'Update password', + }); + + expect(submitButton).toHaveAttribute('disabled'); + }); + + it('enables submit button once formValues are valid', async () => { + render(); + + const currentPassword = await screen.findByLabelText('Current Password'); + const newPassword = await screen.findByLabelText('New Password'); + const confirmPassword = await screen.findByLabelText('Confirm Password'); + const submitButton = await screen.findByRole('button', { + name: 'Update password', + }); + + fireEvent.input(currentPassword, { + target: { name: 'currentPassword', value: 'oldpassword' }, + }); + + fireEvent.input(newPassword, { + target: { name: 'newPassword', value: 'Newpassword' }, + }); + + fireEvent.input(confirmPassword, { + target: { name: 'newPassword', value: 'Newpassword' }, + }); + + // validation handling is async, wait for button to be re-enabled. + waitFor(() => expect(submitButton).not.toHaveAttribute('disabled')); + }); + + it('displays new password validation error message', async () => { + render(); + + const newPassword = await screen.findByLabelText('New Password'); + const submitButton = await screen.findByRole('button', { + name: 'Update password', + }); + + fireEvent.input(newPassword, { + target: { name: 'newPassword', value: 'badpassword' }, + }); + + fireEvent.blur(newPassword, { + target: { name: 'newPassword' }, + }); + + const validationError = await screen.findByText( + 'Password must have upper case letters' + ); + + expect(validationError).toBeDefined(); + expect(submitButton).toHaveAttribute('disabled'); + }); + + it('displays confirm password validation error message', async () => { + render(); + + const newPassword = await screen.findByLabelText('New Password'); + const confirmPassword = await screen.findByLabelText('Confirm Password'); + const submitButton = await screen.findByRole('button', { + name: 'Update password', + }); + + fireEvent.input(newPassword, { + target: { name: 'newPassword', value: 'newpassword' }, + }); + + fireEvent.input(confirmPassword, { + target: { name: 'confirmPassword', value: 'differentpassword' }, + }); + + fireEvent.blur(confirmPassword, { + target: { name: 'confirmPassword' }, + }); + + const validationError = await screen.findByText( + 'Your passwords must match' + ); + + expect(validationError).toBeDefined(); + expect(submitButton).toHaveAttribute('disabled'); + }); + + it('displays custom password validation error messages', async () => { + const minLength: UIModule.ValidatorOptions = { + validationMode: 'onChange', + validator: (password) => password.length >= 8, + message: 'Password must have length 4 or greater', + }; + + const hasSpecialChar: UIModule.ValidatorOptions = { + validationMode: 'onChange', + validator: (password) => password.includes('*'), + message: 'Password must have a star', + }; + render(); + + const newPassword = await screen.findByLabelText('New Password'); + const submitButton = await screen.findByRole('button', { + name: 'Update password', + }); + + fireEvent.input(newPassword, { + target: { name: 'newPassword', value: 'badpw' }, + }); + + const minLengthError = await screen.findByText(minLength.message); + + const specialCharError = await screen.findByText(hasSpecialChar.message); + + expect(minLengthError).toBeDefined(); + expect(specialCharError).toBeDefined(); + expect(submitButton).toHaveAttribute('disabled'); }); }); diff --git a/packages/react/src/components/AccountSettings/ChangePassword/__tests__/__snapshots__/ChangePassword.test.tsx.snap b/packages/react/src/components/AccountSettings/ChangePassword/__tests__/__snapshots__/ChangePassword.test.tsx.snap index 21fd209be27..81adbf4c813 100644 --- a/packages/react/src/components/AccountSettings/ChangePassword/__tests__/__snapshots__/ChangePassword.test.tsx.snap +++ b/packages/react/src/components/AccountSettings/ChangePassword/__tests__/__snapshots__/ChangePassword.test.tsx.snap @@ -139,9 +139,75 @@ exports[`ChangePassword renders as expected 1`] = ` +
+ +
+
+ +
+
+ +
+
+