Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add password validation to ConfirmPassword #2900

Merged
merged 88 commits into from
Nov 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
88 commits
Select commit Hold shift + click to select a range
1290992
Initial commit
wlee221 Nov 3, 2022
ea6d226
Revert initial draft
wlee221 Nov 6, 2022
6e2a79d
confirm password initial draft
wlee221 Nov 7, 2022
d59b029
Use shared type
wlee221 Nov 7, 2022
ca4f435
add validationErrors prop
wlee221 Nov 7, 2022
4425e61
Render ValidationErrors
wlee221 Nov 7, 2022
15595b7
Validate onBlur
wlee221 Nov 7, 2022
156c151
Add confirmPassword logic
wlee221 Nov 7, 2022
120e814
Remove unnecessary memo
wlee221 Nov 7, 2022
7a2254c
Don't export internal validators
wlee221 Nov 7, 2022
dad960d
Revert "Don't export internal validators"
wlee221 Nov 7, 2022
fb424f9
FieldValidator => PasswordValidator
wlee221 Nov 7, 2022
d4952ab
Remove unnecessary classname
wlee221 Nov 7, 2022
ccf9234
Remove unused variable
wlee221 Nov 7, 2022
245e88b
util for isDisabled
wlee221 Nov 7, 2022
c4d9f96
rename validateField => runValidation
wlee221 Nov 7, 2022
097029e
Update snapshot
wlee221 Nov 7, 2022
ad222c4
Remove unneeded waitFor
wlee221 Nov 7, 2022
33f88b5
share default password validator
wlee221 Nov 7, 2022
15d2334
Consolidate defaultPasswordValidator
wlee221 Nov 7, 2022
ab093a2
Remove unrelated changes
wlee221 Nov 7, 2022
185c998
disable form until all three forms are present
wlee221 Nov 7, 2022
ffcad39
Remove extra util funciton
wlee221 Nov 7, 2022
2a2767c
remove validator.ts util file
wlee221 Nov 7, 2022
71c813d
undo renaming
wlee221 Nov 7, 2022
ead7fae
Remove unnecessary type file
wlee221 Nov 7, 2022
7af71ba
Remove unrelated file pt.2
wlee221 Nov 7, 2022
2b67649
Update confirmPassword to return string[]
wlee221 Nov 7, 2022
26d658a
Remove util
wlee221 Nov 7, 2022
762033e
Remove unused util
wlee221 Nov 7, 2022
1d90805
Minor wording edit
wlee221 Nov 7, 2022
f94dd6d
Share ValidationErrors
wlee221 Nov 7, 2022
8dd2b1b
use shared ValidationErrors
wlee221 Nov 7, 2022
a37d48a
Share common Authenticator utils
wlee221 Nov 7, 2022
9200fef
ValidationErrors unit test
wlee221 Nov 7, 2022
ec0261a
Remove extra function layer
wlee221 Nov 7, 2022
9bcc6f3
defaultPasswordValidator unit test
wlee221 Nov 7, 2022
e8163d0
Update password validator usage
wlee221 Nov 7, 2022
2b45df5
Merge branch 'account-settings/share-authenticator-utils' into accoun…
wlee221 Nov 7, 2022
070a2c6
Actually use DefaultConfirmPassword...
wlee221 Nov 7, 2022
681f32e
Update unit tests with new features
wlee221 Nov 7, 2022
e38dd89
Memoize passwordSetting
wlee221 Nov 7, 2022
6078356
Remove `.only` and fix equality assertions
wlee221 Nov 7, 2022
4e570a5
return null from validator, and add unit test
wlee221 Nov 7, 2022
8cb72b4
Test against null instead of []
wlee221 Nov 7, 2022
4277e36
Merge branch 'account-settings/share-authenticator-utils' into accoun…
wlee221 Nov 7, 2022
d2da024
ChangePassword now takes in multiple validators
wlee221 Nov 7, 2022
4ea77b6
Merge branch 'account-settings/main' into account-settings/confirm-pa…
wlee221 Nov 8, 2022
ddfc4d8
Revert defaultService changes
wlee221 Nov 8, 2022
5df789d
Merge branch 'account-settings/confirm-password' of github.com:aws-am…
wlee221 Nov 8, 2022
62bcf8a
Revert more unrelated changes
wlee221 Nov 8, 2022
0563f97
This works!
wlee221 Nov 8, 2022
7cbfdf4
minor optimizations
wlee221 Nov 8, 2022
36405f0
Update unit test
wlee221 Nov 8, 2022
05ae9b5
Alphabetize imports
wlee221 Nov 8, 2022
3c86e81
rename event -> validationMode
wlee221 Nov 8, 2022
4e6fec3
@aws-amplify/ui unit tests
wlee221 Nov 8, 2022
6bc2edb
Rename handler => validate
wlee221 Nov 8, 2022
0e3911e
Remove unused type
wlee221 Nov 8, 2022
b6d5113
Add comment
wlee221 Nov 8, 2022
5d7f4a3
More comments!
wlee221 Nov 8, 2022
52d5b02
Simplify getIsDisabled
wlee221 Nov 8, 2022
11593c3
Remove out-of-place comment
wlee221 Nov 8, 2022
c0403a1
Use `FormEventType`
wlee221 Nov 8, 2022
dc6411f
Rename `FormEventType` -> `InputEventType`
wlee221 Nov 8, 2022
7f84d0d
Update packages/ui/src/helpers/accountSettings/validators/util.ts
wlee221 Nov 8, 2022
5058f22
Use object as inputs
wlee221 Nov 8, 2022
a2a7f15
Merge branch 'account-settings/confirm-password' of github.com:aws-am…
wlee221 Nov 8, 2022
1388d25
create type for organizing password zeroconfig
wlee221 Nov 9, 2022
77710d8
Use reduce
wlee221 Nov 9, 2022
6b777b0
Update packages/react/src/components/AccountSettings/ChangePassword/C…
wlee221 Nov 9, 2022
ff5dc10
Rename validate => validator
wlee221 Nov 9, 2022
aa7223a
Merge branch 'account-settings/confirm-password' of github.com:aws-am…
wlee221 Nov 9, 2022
627644d
Consoldiate validator utils
wlee221 Nov 9, 2022
c07c230
align get*Validators names
wlee221 Nov 9, 2022
ee476ae
Variable name improvement
wlee221 Nov 9, 2022
e187a92
Optimize blurredFields logic
wlee221 Nov 9, 2022
9a9fa07
Add jsdoc comment
wlee221 Nov 9, 2022
5c3906a
Use common DefaultPasswordField
wlee221 Nov 9, 2022
d560eaa
Update snapshot
wlee221 Nov 9, 2022
9a71373
retrigger ci
wlee221 Nov 9, 2022
78e73c2
Change BlurredFields to an array
wlee221 Nov 10, 2022
7ff2acb
Rename ValidatorSpec => ValidatiorOptions
wlee221 Nov 10, 2022
2a1d469
Remove extraneous ternary
wlee221 Nov 10, 2022
6a7349b
Share ValidateParams type
wlee221 Nov 10, 2022
39a189c
use ref to store blurredFields
wlee221 Nov 10, 2022
b38848d
Early return from getIsValid util
wlee221 Nov 10, 2022
9771d2d
Merge branch 'account-settings/main' into account-settings/confirm-pa…
wlee221 Nov 14, 2022
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
Original file line number Diff line number Diff line change
@@ -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 = (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulled this outside ChangePassword because it's really an util that doesn't need to be redefined everytime.

formValues: FormValues,
validationError: ValidationError
): boolean => {
const { currentPassword, newPassword, confirmPassword } = formValues;

const hasEmptyField = !currentPassword || !newPassword || !confirmPassword;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't super important or anything but for readability it would be a bit easier if we just early return from this line since we know we don't need to evaluate the lengths of the validation errors below

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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<string>(null);
const [formValues, setFormValues] = React.useState<FormValues>({});

const [validationError, setValidationError] = React.useState<ValidationError>(
{}
);
const blurredFields = React.useRef<BlurredFields>([]);
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('<ChangePassword /> 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLFormElement>) => {
Expand All @@ -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('<ChangePassword /> requires user to be authenticated.');
return null;
}

return (
<View as="form" className="amplify-changepassword" onSubmit={handleSubmit}>
<Flex direction="column">
<DefaultCurrentPassword
<DefaultPasswordField
autoComplete="current-password"
isRequired
label={currentPasswordLabel}
name="currentPassword"
onBlur={handleBlur}
onChange={handleChange}
/>
<DefaultNewPassword
<DefaultPasswordField
autoComplete="new-password"
isRequired
label={newPasswordLabel}
name="newPassword"
onBlur={handleBlur}
onChange={handleChange}
validationErrors={validationError?.newPassword}
/>
<DefaultPasswordField
autoComplete="new-password"
isRequired
label={confirmPasswordLabel}
name="confirmPassword"
onBlur={handleBlur}
onChange={handleChange}
validationErrors={validationError?.confirmPassword}
/>
<DefaultSubmitButton type="submit">
<DefaultSubmitButton isDisabled={isDisabled} type="submit">
{updatePasswordText}
</DefaultSubmitButton>
{errorMessage ? <DefaultError>{errorMessage}</DefaultError> : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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();
Expand All @@ -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(<ChangePassword />);

const submitButton = await screen.findByRole('button', {
name: 'Update password',
});

expect(submitButton).toHaveAttribute('disabled');
});

it('enables submit button once formValues are valid', async () => {
render(<ChangePassword />);

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(<ChangePassword />);

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(<ChangePassword />);

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(<ChangePassword validators={[minLength, hasSpecialChar]} />);

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');
});
});
Loading