diff --git a/packages/functional-tests/pages/resetPassword.ts b/packages/functional-tests/pages/resetPassword.ts index efeaf65cbc7..55efe9077dd 100644 --- a/packages/functional-tests/pages/resetPassword.ts +++ b/packages/functional-tests/pages/resetPassword.ts @@ -71,13 +71,17 @@ export class ResetPasswordPage extends BaseLayout { } get newPasswordHeading() { - return this.page.getByRole('heading', { name: 'Create new password' }); + return this.page.getByRole('heading', { name: 'Create a new password' }); } get newPasswordTextbox() { return this.page.getByRole('textbox', { name: 'New password' }); } + get newPasswordInput() { + return this.page.getByTestId('new-password-input-container'); + } + get reenterPasswordTextbox() { return this.page.getByRole('textbox', { name: 'Re-enter password' }); } @@ -110,6 +114,18 @@ export class ResetPasswordPage extends BaseLayout { }); } + get dataLossWarning() { + return this.page.getByText( + 'Resetting your password may delete your encrypted browser data.' + ); + } + + get resetPasswordWithRecoveryKey() { + return this.page.getByRole('link', { + name: 'Reset your password with your recovery key.', + }); + } + get forgotKeyLink() { return this.page.getByRole('link', { name: 'Don’t have an account recovery key?', @@ -138,12 +154,15 @@ export class ResetPasswordPage extends BaseLayout { await this.beginResetButton.click(); } - async fillOutNewPasswordForm(password: string) { + async fillOutNewPasswordForm(password: string, submit = true) { await expect(this.newPasswordHeading).toBeVisible(); await this.newPasswordTextbox.fill(password); await this.reenterPasswordTextbox.fill(password); - await this.resetPasswordButton.click(); + + if (submit) { + await this.resetPasswordButton.click(); + } } async fillOutRecoveryKeyForm(key: string) { diff --git a/packages/functional-tests/tests/resetPassword/resetPasswordWithCode/resetPassword.spec.ts b/packages/functional-tests/tests/resetPassword/resetPasswordWithCode/resetPassword.spec.ts index 2a5dafe1363..bfe39fecbbf 100644 --- a/packages/functional-tests/tests/resetPassword/resetPasswordWithCode/resetPassword.spec.ts +++ b/packages/functional-tests/tests/resetPassword/resetPasswordWithCode/resetPassword.spec.ts @@ -83,10 +83,13 @@ test.describe('severity-1 #smoke', () => { await resetPassword.fillOutResetPasswordCodeForm(code); - await resetPassword.fillOutNewPasswordForm(passwordValue); + await resetPassword.fillOutNewPasswordForm(passwordValue, false); await expect(page.getByText(error)).toBeVisible(); - await expect(resetPassword.newPasswordTextbox).toBeFocused(); + await expect(resetPassword.resetPasswordButton).toBeDisabled(); + await expect(resetPassword.newPasswordInput).toHaveClass( + /border-red-700/ + ); }); } diff --git a/packages/functional-tests/tests/resetPassword/resetPasswordWithCode/resetPasswordRecoveryKey.spec.ts b/packages/functional-tests/tests/resetPassword/resetPasswordWithCode/resetPasswordRecoveryKey.spec.ts index 3dc824615f5..b0a7a6b08fd 100644 --- a/packages/functional-tests/tests/resetPassword/resetPasswordWithCode/resetPasswordRecoveryKey.spec.ts +++ b/packages/functional-tests/tests/resetPassword/resetPasswordWithCode/resetPasswordRecoveryKey.spec.ts @@ -133,6 +133,7 @@ test.describe('severity-1 #smoke', () => { await resetPassword.fillOutResetPasswordCodeForm(code); await resetPassword.forgotKeyLink.click(); + await expect(resetPassword.dataLossWarning).toBeVisible(); await resetPassword.fillOutNewPasswordForm(newPassword); await expect( diff --git a/packages/functional-tests/tests/resetPassword/resetPasswordWithCode/syncV3ResetPassword.spec.ts b/packages/functional-tests/tests/resetPassword/resetPasswordWithCode/syncV3ResetPassword.spec.ts index 53a08f3db78..f6c37ec99e7 100644 --- a/packages/functional-tests/tests/resetPassword/resetPasswordWithCode/syncV3ResetPassword.spec.ts +++ b/packages/functional-tests/tests/resetPassword/resetPasswordWithCode/syncV3ResetPassword.spec.ts @@ -31,6 +31,8 @@ test.describe('severity-1 #smoke', () => { ); await resetPassword.fillOutResetPasswordCodeForm(code); + + await expect(resetPassword.dataLossWarning).toBeVisible(); await resetPassword.fillOutNewPasswordForm(newPassword); await expect(page).toHaveURL(/reset_password_verified/); diff --git a/packages/functional-tests/tests/resetPassword/resetPasswordWithLink/resetPassword.spec.ts b/packages/functional-tests/tests/resetPassword/resetPasswordWithLink/resetPassword.spec.ts index 3a8c048839c..508f5ccb3c5 100644 --- a/packages/functional-tests/tests/resetPassword/resetPasswordWithLink/resetPassword.spec.ts +++ b/packages/functional-tests/tests/resetPassword/resetPasswordWithLink/resetPassword.spec.ts @@ -136,7 +136,10 @@ test.describe('severity-1 #smoke', () => { await diffResetPasswordReact.fillOutNewPasswordForm(passwordValue); await expect(diffPage.getByText(error)).toBeVisible(); - await expect(diffResetPasswordReact.newPasswordTextbox).toBeFocused(); + await expect(diffResetPasswordReact.resetPasswordButton).toBeDisabled(); + await expect(diffResetPasswordReact.newPasswordTextbox).toHaveClass( + 'error' + ); }); } diff --git a/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/en.ftl b/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/en.ftl new file mode 100644 index 00000000000..c4ef2332f84 --- /dev/null +++ b/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/en.ftl @@ -0,0 +1,20 @@ +## FormPasswordInlineCriteria + +form-password-with-inline-criteria-signup-new-password-label = + .label = Password +form-password-with-inline-criteria-signup-confirm-password-label = + .label = Repeat password +form-password-with-inline-criteria-signup-submit-button = Create account + +form-password-with-inline-criteria-reset-new-password = + .label = New password +form-password-with-inline-criteria-confirm-password = + .label = Re-enter password +form-password-with-inline-criteria-reset-submit-button = Reset password + +form-password-with-inline-criteria-match-error = Passwords do not match +form-password-with-inline-criteria-sr-too-short-message = Password must contain at least 8 characters. +form-password-with-inline-criteria-sr-not-email-message = Password must not contain your email address. +form-password-with-inline-criteria-sr-not-common-message = Password must not be a commonly used password. +form-password-with-inline-criteria-sr-requirements-met = The entered password respects all password requirements. +form-password-with-inline-criteria-sr-passwords-match = Entered passwords match. diff --git a/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/index.stories.tsx b/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/index.stories.tsx new file mode 100644 index 00000000000..fb487f5fd7f --- /dev/null +++ b/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/index.stories.tsx @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { Subject } from './mocks'; +import AppLayout from '../AppLayout'; +import FormPasswordInline from '.'; +import { Meta } from '@storybook/react'; +import { withLocalization } from 'fxa-react/lib/storybooks'; + +export default { + title: 'Components/FormPasswordWithInlineCriteria', + component: FormPasswordInline, + decorators: [withLocalization], +} as Meta; + +export const ResetPassword = () => ( + +
+ +
+
+); + +export const Signup = () => ( + +
+ +
+
+); diff --git a/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/index.test.tsx b/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/index.test.tsx new file mode 100644 index 00000000000..050bba4c152 --- /dev/null +++ b/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/index.test.tsx @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { UserEvent, userEvent } from '@testing-library/user-event'; +import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import { getFtlBundle, testAllL10n } from 'fxa-react/lib/test-utils'; +import { FluentBundle } from '@fluent/bundle'; +import { Subject } from './mocks'; + +describe('FormPasswordWithInlineCriteria component', () => { + let bundle: FluentBundle; + let user: UserEvent; + + beforeEach(() => { + user = userEvent.setup(); + }); + beforeAll(async () => { + bundle = await getFtlBundle('settings'); + }); + it('renders as expected for the reset form type', async () => { + renderWithLocalizationProvider(); + testAllL10n(screen, bundle); + + await waitFor(() => { + screen.getByLabelText('New password'); + }); + screen.getByLabelText('Re-enter password'); + screen.getByRole('button', { name: 'Reset password' }); + }); + + it('renders as expected for the signup form type', async () => { + renderWithLocalizationProvider(); + + await waitFor(() => { + screen.getByLabelText('Password'); + }); + screen.getByLabelText('Repeat password'); + screen.getByRole('button', { name: 'Create account' }); + }); + + it('displays the Password Strength Criteria when the new password field is in focus', async () => { + renderWithLocalizationProvider(); + const newPasswordField = screen.getByLabelText('New password'); + + fireEvent.focus(newPasswordField); + + await waitFor(() => screen.getByText('At least 8 characters')); + await waitFor(() => screen.getByText('Not your email address')); + await waitFor(() => screen.getByText('Not a commonly used password')); + }); + + // TODO in FXA-7482, review our password requirements and best way to display them + it('disallows space-only passwords', async () => { + renderWithLocalizationProvider(); + const passwordField = screen.getByLabelText('Password'); + await user.type(passwordField, ' '); + + expect(screen.getAllByLabelText('passed')).toHaveLength(2); + expect(screen.getAllByLabelText('failed')).toHaveLength(1); + const passwordMinCharRequirement = screen.getByTestId( + 'password-min-char-req' + ); + expect(passwordMinCharRequirement.querySelector('svg')).toHaveTextContent( + 'icon-x.svg' + ); + }); + + it('disallows common passwords', async () => { + renderWithLocalizationProvider(); + const passwordField = screen.getByLabelText('Password'); + await user.type(passwordField, 'mozilla accounts'); + expect(screen.getAllByLabelText('passed')).toHaveLength(2); + expect(screen.getAllByLabelText('failed')).toHaveLength(1); + expect( + screen.getByTestId('password-not-common-req').querySelector('svg') + ).toHaveTextContent('icon-x.svg'); + }); +}); diff --git a/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/index.tsx b/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/index.tsx new file mode 100644 index 00000000000..6d57f2eda5e --- /dev/null +++ b/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/index.tsx @@ -0,0 +1,326 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useCallback, useState } from 'react'; +import { UseFormMethods } from 'react-hook-form'; +import InputPassword from '../InputPassword'; +import PasswordValidator from '../../lib/password-validator'; +import { FtlMsg } from 'fxa-react/lib/utils'; +import { useFtlMsgResolver } from '../../models'; +import PasswordStrengthInline from '../PasswordStrengthInline'; + +export type PasswordFormType = 'signup' | 'reset'; + +export type FormPasswordWithInlineCriteriaProps = { + passwordFormType: PasswordFormType; + formState: UseFormMethods['formState']; + errors: UseFormMethods['errors']; + onSubmit: () => void; + trigger: UseFormMethods['trigger']; + register: UseFormMethods['register']; + getValues: UseFormMethods['getValues']; + email: string; + onFocusMetricsEvent?: () => void; + loading: boolean; + children?: React.ReactNode; + disableButtonUntilValid?: boolean; +}; + +const getTemplateValues = (passwordFormType: PasswordFormType) => { + let templateValues = { + passwordFtlId: '', + passwordLabel: '', + confirmPasswordFtlId: '', + confirmPasswordLabel: '', + buttonFtlId: '', + buttonText: '', + }; + switch (passwordFormType) { + case 'signup': + templateValues.passwordFtlId = + 'form-password-with-inline-criteria-signup-new-password-label'; + templateValues.passwordLabel = 'Password'; + templateValues.confirmPasswordFtlId = + 'form-password-with-inline-criteria-signup-confirm-password-label'; + templateValues.confirmPasswordLabel = 'Repeat password'; + templateValues.buttonFtlId = + 'form-password-with-inline-criteria-signup-submit-button'; + templateValues.buttonText = 'Create account'; + break; + case 'reset': + templateValues.passwordFtlId = + 'form-password-with-inline-criteria-reset-new-password'; + templateValues.passwordLabel = 'New password'; + templateValues.confirmPasswordFtlId = + 'form-password-with-inline-criteria-confirm-password'; + templateValues.confirmPasswordLabel = 'Re-enter password'; + templateValues.buttonFtlId = + 'form-password-with-inline-criteria-reset-submit-button'; + templateValues.buttonText = 'Reset password'; + break; + } + return templateValues; +}; + +export const FormPasswordWithInlineCriteria = ({ + passwordFormType, + formState, + errors, + onSubmit, + email, + trigger, + register, + getValues, + onFocusMetricsEvent, + loading, + children, + disableButtonUntilValid = true, +}: FormPasswordWithInlineCriteriaProps) => { + const passwordValidator = new PasswordValidator(email); + const [passwordMatchErrorText, setPasswordMatchErrorText] = + useState(''); + const [hasNewPwdFocused, setHasNewPwdFocused] = useState(false); + const [srOnlyPwdFeedbackMessage, setSROnlyPwdFeedbackMessage] = + useState(); + const [srOnlyConfirmPwdFeedbackMessage, setSROnlyConfirmPwdFeedbackMessage] = + useState(); + + const ftlMsgResolver = useFtlMsgResolver(); + const localizedPasswordMatchError = ftlMsgResolver.getMsg( + 'form-password-with-inline-criteria-match-error', + 'Passwords do not match' + ); + + const templateValues = getTemplateValues(passwordFormType); + + const onNewPwdFocus = () => { + setSROnlyPwdFeedbackMessage(''); + setSROnlyConfirmPwdFeedbackMessage(''); + setPasswordMatchErrorText(''); + if (!hasNewPwdFocused) { + if (onFocusMetricsEvent) { + onFocusMetricsEvent(); + } + setHasNewPwdFocused(true); + } + }; + + const onNewPwdBlur = () => { + // do not hide the password strength info if there are errors in the new password + if (!errors.newPassword) { + // Without balloons, this created a jumpy ux. + // hideNewPwdCriteria(); + const srOnlyPasswordMeetsRequirements = ftlMsgResolver.getMsg( + 'form-password-with-inline-criteria-sr-requirements-met', + 'The entered password respects all password requirements.' + ); + setSROnlyPwdFeedbackMessage(srOnlyPasswordMeetsRequirements); + } else { + // if there are errors on blur, announce a screen-reader only message + // visual feedback is provided by the password strength ballon + if (errors.newPassword?.types?.length) { + const srOnlyTooShortMessage = ftlMsgResolver.getMsg( + 'form-password-with-inline-criteria-sr-too-short-message', + 'Password must contain at least 8 characters.' + ); + setSROnlyPwdFeedbackMessage(srOnlyTooShortMessage); + } else if (errors.newPassword?.types?.notEmail) { + const srOnlyNotEmailMessage = ftlMsgResolver.getMsg( + 'form-password-with-inline-criteria-sr-not-email-message', + 'Password must not contain your email address.' + ); + setSROnlyPwdFeedbackMessage(srOnlyNotEmailMessage); + } else if (errors.newPassword?.types?.uncommon) { + const srOnlyNotCommonMessage = ftlMsgResolver.getMsg( + 'form-password-with-inline-criteria-sr-not-common-message', + 'Password must not be a commonly used password.' + ); + setSROnlyPwdFeedbackMessage(srOnlyNotCommonMessage); + } + } + if ( + !errors.newPassword && + getValues('confirmPassword') !== '' && + getValues('confirmPassword') !== getValues('newPassword') + ) { + setPasswordMatchErrorText(localizedPasswordMatchError); + } + + if (!formState.isValid) { + trigger('confirmPassword'); + } + }; + + const onFocusConfirmPassword = useCallback(() => { + setPasswordMatchErrorText(''); + setSROnlyPwdFeedbackMessage(''); + setSROnlyConfirmPwdFeedbackMessage(''); + }, []); + + const onBlurConfirmPassword = useCallback(() => { + if (getValues('confirmPassword') !== getValues('newPassword')) { + setPasswordMatchErrorText(localizedPasswordMatchError); + } else { + const srOnlyPasswordsMatch = ftlMsgResolver.getMsg( + 'form-password-with-inline-criteria-sr-passwords-match', + 'Entered passwords match.' + ); + setSROnlyConfirmPwdFeedbackMessage(srOnlyPasswordsMatch); + } + + if (!formState.isValid) { + trigger('newPassword'); + } + }, [ + formState, + ftlMsgResolver, + getValues, + localizedPasswordMatchError, + setPasswordMatchErrorText, + trigger, + ]); + + const onChangePassword = (inputName: string) => { + const newPassword = getValues('newPassword'); + const confirmPassword = getValues('confirmPassword'); + if (inputName === 'newPassword') { + trigger('newPassword'); + } + + if (!errors.newPassword) { + setSROnlyPwdFeedbackMessage(''); + } + + if (confirmPassword !== newPassword && confirmPassword !== '') { + setPasswordMatchErrorText(localizedPasswordMatchError); + } else { + setPasswordMatchErrorText(''); + } + + trigger('confirmPassword'); + }; + + return ( + <> +
+ {/* Hidden email field is to help password managers + correctly associate the email and password. Without this, + password managers may try to use another field as username */} + +
+ + + {srOnlyPwdFeedbackMessage} + +
+ +
+ + onChangePassword('newPassword')} + hasErrors={ + formState.dirtyFields.newPassword ? errors.newPassword : false + } + inputRef={register({ + required: true, + validate: { + length: (value: string) => + value.length > 7 && value.trim() !== '', + notEmail: (value: string) => { + return !passwordValidator.isSameAsEmail( + value.toLowerCase() + ); + }, + uncommon: async (value: string) => { + // @ts-ignore + const list = await import('fxa-common-password-list'); + const input = value.toLowerCase(); + return ( + !list.test(input) && !passwordValidator.isBanned(input) + ); + }, + }, + })} + prefixDataTestId="new-password" + aria-describedby="password-requirements" + /> + +
+ +
+ + onChangePassword('confirmPassword')} + hasErrors={errors.confirmPassword && passwordMatchErrorText} + inputRef={register({ + required: true, + validate: (value: string) => value === getValues().newPassword, + })} + anchorPosition="end" + tooltipPosition="bottom" + prefixDataTestId="verify-password" + aria-describedby="repeat-password-information" + /> + + + + {srOnlyConfirmPwdFeedbackMessage} + +
+ + {children} + + + +
+ + ); +}; + +export default FormPasswordWithInlineCriteria; diff --git a/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/invalid.svg b/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/invalid.svg new file mode 100644 index 00000000000..d63bd229946 --- /dev/null +++ b/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/invalid.svg @@ -0,0 +1 @@ + diff --git a/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/mocks.tsx b/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/mocks.tsx new file mode 100644 index 00000000000..6fb08391161 --- /dev/null +++ b/packages/fxa-settings/src/components/FormPasswordWithInlineCriteria/mocks.tsx @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { useForm } from 'react-hook-form'; +import FormPasswordWithInlineCriteria, { PasswordFormType } from '.'; +import { MOCK_ACCOUNT } from '../../models/mocks'; + +type SubjectProps = { + passwordFormType: PasswordFormType; +}; + +export const Subject = ({ passwordFormType }: SubjectProps) => { + type FormData = { + oldPassword?: string; + newPassword: string; + confirmPassword: string; + }; + const onFormSubmit = () => { + // this alert is for Storybook + alert('Form submitted! (onFormSubmit called)'); + }; + + const { handleSubmit, register, getValues, errors, formState, trigger } = + useForm({ + mode: 'onTouched', + criteriaMode: 'all', + defaultValues: { + newPassword: '', + confirmPassword: '', + }, + }); + + return ( + {}} + /> + ); +}; diff --git a/packages/fxa-settings/src/components/InputPassword/index.test.tsx b/packages/fxa-settings/src/components/InputPassword/index.test.tsx index 8092c997ce5..74dc51dbc8f 100644 --- a/packages/fxa-settings/src/components/InputPassword/index.test.tsx +++ b/packages/fxa-settings/src/components/InputPassword/index.test.tsx @@ -36,6 +36,14 @@ it('can have a default value', () => { it('can be toggled', () => { renderWithLocalizationProvider(); expect(screen.getByTestId('input-field')).toHaveAttribute('type', 'password'); + expect(screen.getByTestId('visibility-toggle')).toHaveAttribute( + 'aria-pressed', + 'false' + ); fireEvent.click(screen.getByTestId('visibility-toggle')); expect(screen.getByTestId('input-field')).toHaveAttribute('type', 'text'); + expect(screen.getByTestId('visibility-toggle')).toHaveAttribute( + 'aria-pressed', + 'true' + ); }); diff --git a/packages/fxa-settings/src/components/InputPassword/index.tsx b/packages/fxa-settings/src/components/InputPassword/index.tsx index 28e3115f443..aec18719a21 100644 --- a/packages/fxa-settings/src/components/InputPassword/index.tsx +++ b/packages/fxa-settings/src/components/InputPassword/index.tsx @@ -107,6 +107,7 @@ export const InputPassword = ({ 'Your password is currently hidden.' ) } + aria-pressed={visible ? 'true' : 'false'} > {visible ? ( + + diff --git a/packages/fxa-settings/src/components/PasswordStrengthInline/icon-x.svg b/packages/fxa-settings/src/components/PasswordStrengthInline/icon-x.svg new file mode 100644 index 00000000000..ac744d133e2 --- /dev/null +++ b/packages/fxa-settings/src/components/PasswordStrengthInline/icon-x.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/fxa-settings/src/components/PasswordStrengthInline/index.stories.tsx b/packages/fxa-settings/src/components/PasswordStrengthInline/index.stories.tsx new file mode 100644 index 00000000000..42e0496807c --- /dev/null +++ b/packages/fxa-settings/src/components/PasswordStrengthInline/index.stories.tsx @@ -0,0 +1,152 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import AppLayout from '../AppLayout'; +import { Meta } from '@storybook/react'; +import PasswordStrengthInline, { PasswordStrengthInlineProps } from '.'; +import InputPassword from '../InputPassword'; +import { withLocalization } from 'fxa-react/lib/storybooks'; + +export default { + title: 'Components/PasswordStrengthInline', + component: PasswordStrengthInline, + decorators: [withLocalization], +} as Meta; + +const storyWithProps = ( + props: PasswordStrengthInlineProps, + passwordExample: string, + confirmPasswordExample: string +) => { + const story = () => ( + +
+ +
+ +
+ + +
+
+ ); + return story; +}; + +export const BeforePasswordInput = storyWithProps( + { + ...{ + isPasswordEmpty: true, + isConfirmedPasswordEmpty: true, + isTooShort: false, + isSameAsEmail: false, + isCommon: false, + isUnconfirmed: true, + }, + }, + '', + '' +); + +export const isTooShort = storyWithProps( + { + ...{ + isPasswordEmpty: false, + isConfirmedPasswordEmpty: true, + isTooShort: true, + isSameAsEmail: false, + isCommon: false, + isUnconfirmed: true, + }, + }, + 'fg5hs34', + '' +); + +export const isSameAsEmail = storyWithProps( + { + ...{ + isPasswordEmpty: false, + isConfirmedPasswordEmpty: true, + isTooShort: false, + isSameAsEmail: true, + isCommon: false, + isUnconfirmed: true, + }, + }, + 'test@example.com', + '' +); + +export const isCommon = storyWithProps( + { + ...{ + isPasswordEmpty: false, + isConfirmedPasswordEmpty: true, + isTooShort: false, + isSameAsEmail: false, + isCommon: true, + isUnconfirmed: true, + }, + }, + '123456789', + '' +); + +export const beforeConfirmedInput = storyWithProps( + { + ...{ + isPasswordEmpty: false, + isConfirmedPasswordEmpty: true, + isTooShort: false, + isSameAsEmail: false, + isCommon: false, + isUnconfirmed: true, + }, + }, + '123456789', + '' +); + +export const afterConfirmedInputDoesNotMatch = storyWithProps( + { + ...{ + isPasswordEmpty: false, + isConfirmedPasswordEmpty: false, + isTooShort: false, + isSameAsEmail: false, + isCommon: false, + isUnconfirmed: true, + }, + }, + '123456789', + '12345678' +); + +export const afterConfirmedInputMatches = storyWithProps( + { + ...{ + isPasswordEmpty: false, + isConfirmedPasswordEmpty: false, + confirmPassword: '1324abcde', + isTooShort: false, + isSameAsEmail: false, + isCommon: false, + isUnconfirmed: false, + }, + }, + '123456789', + '123456789' +); diff --git a/packages/fxa-settings/src/components/PasswordStrengthInline/index.test.tsx b/packages/fxa-settings/src/components/PasswordStrengthInline/index.test.tsx new file mode 100644 index 00000000000..6cb85f6695a --- /dev/null +++ b/packages/fxa-settings/src/components/PasswordStrengthInline/index.test.tsx @@ -0,0 +1,147 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { screen, within } from '@testing-library/react'; +import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import PasswordStrengthInline from '.'; +// import { getFtlBundle, testAllL10n } from 'fxa-react/lib/test-utils'; +// import { FluentBundle } from '@fluent/bundle'; + +describe('PasswordStrengthInline component', () => { + // TODO: enable l10n tests when FXA-6461 is resolved (handle embedded tags) + // let bundle: FluentBundle; + // beforeAll(async () => { + // bundle = await getFtlBundle('settings'); + // }); + + it('renders password requirements as expected', () => { + renderWithLocalizationProvider( + + ); + // testAllL10n(screen, bundle); + + screen.getByText('At least 8 characters'); + screen.getByText('Not your email address'); + screen.getByText('Not a commonly used password'); + screen.getByText('Confirmation matches the new password'); + }); + + it('displays checkmark icon when password requirements are respected', () => { + renderWithLocalizationProvider( + + ); + + const checkmarks = screen.queryAllByText('icon-check.svg'); + expect(checkmarks).toHaveLength(4); + + const warnings = screen.queryAllByText('icon-x.svg'); + expect(warnings).toHaveLength(0); + }); + + it('displays x icon when password is too short', () => { + renderWithLocalizationProvider( + + ); + + const container = screen.getByTestId('password-min-char-req'); + expect(container.getElementsByTagName('svg').item(0)).toHaveTextContent( + 'icon-x.svg' + ); + }); + + it('displays x icon when password is the same as email', () => { + renderWithLocalizationProvider( + + ); + + const container = screen.getByTestId('password-not-email-req'); + expect(container.getElementsByTagName('svg').item(0)).toHaveTextContent( + 'icon-x.svg' + ); + }); + + it('displays x icon when password is common', () => { + renderWithLocalizationProvider( + + ); + + const container = screen.getByTestId('password-not-common-req'); + expect(container.getElementsByTagName('svg').item(0)).toHaveTextContent( + 'icon-x.svg' + ); + }); + + it('displays dot icon when confirmed password is empty', () => { + renderWithLocalizationProvider( + + ); + + const container = screen.getByTestId('passwords-match'); + expect(container.getElementsByTagName('svg').item(0)).toHaveTextContent( + 'icon-x.svg' + ); + }); + + it('displays x icon when password do not match', () => { + renderWithLocalizationProvider( + + ); + + const container = screen.getByTestId('passwords-match'); + expect(container.getElementsByTagName('svg').item(0)).toHaveTextContent( + 'icon-x.svg' + ); + }); + +}); diff --git a/packages/fxa-settings/src/components/PasswordStrengthInline/index.tsx b/packages/fxa-settings/src/components/PasswordStrengthInline/index.tsx new file mode 100644 index 00000000000..8fc95607731 --- /dev/null +++ b/packages/fxa-settings/src/components/PasswordStrengthInline/index.tsx @@ -0,0 +1,121 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { ReactComponent as PassedIcon } from './icon-check.svg'; +import { ReactComponent as Failed } from './icon-x.svg'; +import { FtlMsg } from 'fxa-react/lib/utils'; + +export type PasswordStrengthInlineProps = { + isPasswordEmpty: boolean; + isConfirmedPasswordEmpty: boolean; + isTooShort: boolean; + isSameAsEmail: boolean; + isCommon: boolean; + isUnconfirmed: boolean | undefined; +}; + +const ValidationIcon = ({ hasError }: { hasError: boolean }) => { + return hasError ? ( + + ) : ( + + ); +}; + +export const PasswordStrengthInline = ({ + isPasswordEmpty, + isConfirmedPasswordEmpty, + isTooShort, + isSameAsEmail, + isCommon, + isUnconfirmed, +}: PasswordStrengthInlineProps) => { + return ( +
+

+ Pick a strong password you haven’t used on other sites. Ensure it meets + the security requirements: +

+
    +
  • + + {isPasswordEmpty && '•'} + {!isPasswordEmpty && } + + + + At least 8 characters + + +
  • +
  • + + {isPasswordEmpty && '•'} + {!isPasswordEmpty && ( + + )} + + + + Not your email address + + +
  • +
  • + + {isPasswordEmpty && '•'} + {!isPasswordEmpty && } + + + + Not a commonly used password + + +
  • + {isUnconfirmed !== undefined && ( +
  • + + {(isPasswordEmpty || isConfirmedPasswordEmpty) && '•'} + {!(isPasswordEmpty || isConfirmedPasswordEmpty) && ( + + )} + + + + Confirmation matches the new password + + +
  • + )} +
+
+ ); +}; + +export default PasswordStrengthInline; diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx index b91fe109b09..3b6efc9bcc0 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/container.tsx @@ -55,6 +55,8 @@ const CompleteResetPasswordContainer = ({ emailToHashWith, kB, recoveryKeyId, + recoveryKeyExists, + estimatedSyncDeviceCount, } = location.state as CompleteResetPasswordLocationState; const hasConfirmedRecoveryKey = !!( @@ -181,6 +183,7 @@ const CompleteResetPasswordContainer = ({ if (!(hasConfirmedRecoveryKey || isResetWithoutRecoveryKey)) { navigate('/reset_password', { replace: true }); } + return ( diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/en.ftl b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/en.ftl index ddded7e0f89..6bf25a63a2c 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/en.ftl +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/en.ftl @@ -1,15 +1,19 @@ ## CompleteResetPassword component ## User followed a password reset link and is now prompted to create a new password -complete-reset-pw-header = Create new password -complete-reset-password-warning-message-2 = Remember: When you reset your password, you reset your account. You may lose some of your personal information (including history, bookmarks, and passwords). That’s because we encrypt your data with your password to protect your privacy. You’ll still keep any subscriptions you may have and { -product-pocket } data will not be affected. +complete-reset-pw-header-v2 = Create a new password # A new password was successfully set for the user's account # Displayed in an alert bar complete-reset-password-success-alert = Password set # An error occurred while attempting to set a new password (password reset flow) # Displayed in an alert bar complete-reset-password-error-alert = Sorry, there was a problem setting your password -complete-reset-password-recovery-key-error-v2 = Sorry, there was a problem checking if you have an account recovery key. -complete-reset-password-recovery-key-link = Reset your password with your account recovery key. -account-restored-success-message = You have successfully restored your account using your account recovery key. Create a new password to secure your data, and store it in a safe location. +password-reset-data-may-not-be-recovered = Resetting your password may delete your encrypted browser data. +password-reset-could-not-determine-account-recovery-key = Have an account recovery key? +password-reset-use-account-recovery-key = Reset your password with your recovery key. +password-reset-previously-signed-in-device = Have a device where you previously signed in? +password-reset-data-may-be-saved-locally = Your browser data may be locally saved on that device. Sign in there with your new password to restore and sync. +password-reset-no-old-device = Have a new device but don’t have your old one? +password-reset-encrypted-data-cannot-be-recovered = We’re sorry, but your encrypted browser data on Firefox servers can’t be recovered. +password-reset-learn-about-restoring-account-data = Learn more about restoring account data. diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/icon-bang.svg b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/icon-bang.svg new file mode 100644 index 00000000000..6edc4b55fbe --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/icon-bang.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/icon-non-sync-device.svg b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/icon-non-sync-device.svg new file mode 100644 index 00000000000..872db1bcb09 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/icon-non-sync-device.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/icon-sync-device.svg b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/icon-sync-device.svg new file mode 100644 index 00000000000..dd766800c52 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/icon-sync-device.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/icon-warn.svg b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/icon-warn.svg new file mode 100644 index 00000000000..8cbd1b4b347 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/icon-warn.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.stories.tsx index 241d285e477..c4637692b31 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.stories.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.stories.tsx @@ -14,10 +14,35 @@ export default { decorators: [withLocalization], } as Meta; -export const DefaultNoRecoveryKey = () => ; -export const WithConfirmedRecoveryKey = () => ( - +export const NoSync = () => ( + ); -export const UnknownRecoveryKeyStatus = () => ; +export const SyncAndNoRecoveryKey = () => ( + +); + +export const SyncAndUnconfirmedRecoveryKey = () => ( + +); + +export const SyncAndConfirmedRecoveryKey = () => ( + +); + +export const SyncAndUnableToDetermineRecoveryKey = () => ( + +); diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx index 1802bb13349..04ebe667f6f 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.test.tsx @@ -33,27 +33,26 @@ describe('CompleteResetPassword page', () => { }); describe('default reset without recovery key', () => { - it('renders as expected', async () => { - renderWithLocalizationProvider(); + it('renders as expected for account with sync', async () => { + renderWithLocalizationProvider( + + ); await waitFor(() => expect( screen.getByRole('heading', { - name: 'Create new password', + name: 'Create a new password', }) ).toBeVisible() ); + + // Warning message about data loss should should be displayed expect( screen.getByText( - 'When you reset your password, you reset your account. You may lose some of your personal information (including history, bookmarks, and passwords). That’s because we encrypt your data with your password to protect your privacy. You’ll still keep any subscriptions you may have and Pocket data will not be affected.' + 'Resetting your password may delete your encrypted browser data.' ) ).toBeVisible(); - // no recovery key specific messaging - expect( - screen.queryByText( - 'You have successfully restored your account using your account recovery key. Create a new password to secure your data, and store it in a safe location.' - ) - ).not.toBeInTheDocument(); + const inputs = screen.getAllByRole('textbox'); expect(inputs).toHaveLength(2); expect(screen.getByLabelText('New password')).toBeVisible(); @@ -70,8 +69,36 @@ describe('CompleteResetPassword page', () => { ); }); + it('renders as expected for account without sync', async () => { + renderWithLocalizationProvider( + + ); + + await waitFor(() => + expect( + screen.getByRole('heading', { + name: 'Create a new password', + }) + ).toBeVisible() + ); + + // Warning messages about data loss should not be displayed. + expect( + screen.queryByText( + 'Resetting your password may delete your encrypted browser data.' + ) + ).not.toBeInTheDocument(); + + // Warning message about using recovery ke should not be displayed + expect( + screen.queryByText('Reset your password with your recovery key.') + ).not.toBeInTheDocument(); + }); + it('sends the expected metrics on render', () => { - renderWithLocalizationProvider(); + renderWithLocalizationProvider( + + ); expect(GleanMetrics.passwordReset.createNewView).toHaveBeenCalledTimes(1); }); }); @@ -83,22 +110,23 @@ describe('CompleteResetPassword page', () => { await waitFor(() => expect( screen.getByRole('heading', { - name: 'Create new password', + name: 'Create a new password', }) ).toBeVisible() ); - // recovery key specific messaging - expect( - screen.getByText( - 'You have successfully restored your account using your account recovery key. Create a new password to secure your data, and store it in a safe location.' - ) - ).toBeVisible(); - // no warning + + // Warning messages about data loss should not be displayed. expect( screen.queryByText( - 'When you reset your password, you reset your account. You may lose some of your personal information (including history, bookmarks, and passwords). That’s because we encrypt your data with your password to protect your privacy. You’ll still keep any subscriptions you may have and Pocket data will not be affected.' + 'Resetting your password may delete your encrypted browser data.' ) ).not.toBeInTheDocument(); + + // Warning message about using recovery key should not be displayed + expect( + screen.queryByText('Reset your password with your recovery key.') + ).not.toBeInTheDocument(); + const inputs = screen.getAllByRole('textbox'); expect(inputs).toHaveLength(2); expect(screen.getByLabelText('New password')).toBeVisible(); @@ -123,6 +151,70 @@ describe('CompleteResetPassword page', () => { }); }); + describe('reset with unconfimred account recovery key', () => { + it('renders as expected', async () => { + renderWithLocalizationProvider( + + ); + + await waitFor(() => + expect( + screen.getByRole('heading', { + name: 'Create a new password', + }) + ).toBeVisible() + ); + + // Warning messages about data loss should not be displayed. + expect( + screen.queryByText( + 'Resetting your password may delete your encrypted browser data.' + ) + ).toBeInTheDocument(); + + // Warning message about using recovery key should be displayed + expect( + screen.queryByText('Reset your password with your recovery key.') + ).not.toBeInTheDocument(); + }); + }); + + describe('reset with issue determining if recovery key exists', () => { + it('renders as expected', async () => { + renderWithLocalizationProvider( + + ); + + await waitFor(() => + expect( + screen.getByRole('heading', { + name: 'Create a new password', + }) + ).toBeVisible() + ); + + // Warning messages about data loss should not be displayed. + expect( + screen.queryByText( + 'Resetting your password may delete your encrypted browser data.' + ) + ).not.toBeInTheDocument(); + + // Warning message about using recovery key should be displayed + expect( + screen.getByText('Reset your password with your recovery key.') + ).toBeVisible(); + }); + }); + it('handles submit with valid password', async () => { const user = userEvent.setup(); renderWithLocalizationProvider( @@ -151,7 +243,7 @@ describe('CompleteResetPassword page', () => { await waitFor(() => expect( screen.getByRole('heading', { - name: 'Create new password', + name: 'Create a new password', }) ).toBeVisible() ); diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx index 9cd16f2226d..0bd00a9641c 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/index.tsx @@ -8,10 +8,12 @@ import GleanMetrics from '../../../lib/glean'; import AppLayout from '../../../components/AppLayout'; import Banner, { BannerType } from '../../../components/Banner'; -import CardHeader from '../../../components/CardHeader'; -import FormPasswordWithBalloons from '../../../components/FormPasswordWithBalloons'; +import FormPasswordWithInlineCriteria from '../../../components/FormPasswordWithInlineCriteria'; import LinkRememberPassword from '../../../components/LinkRememberPassword'; -import WarningMessage from '../../../components/WarningMessage'; +import { ReactComponent as BangIcon } from './icon-bang.svg'; +import { ReactComponent as WarnIcon } from './icon-warn.svg'; +import { ReactComponent as IconNonSyncDevice } from './icon-non-sync-device.svg'; +import { ReactComponent as IconSyncDevice } from './icon-sync-device.svg'; import { CompleteResetPasswordFormData, @@ -26,6 +28,8 @@ const CompleteResetPassword = ({ hasConfirmedRecoveryKey, locationState, submitNewPassword, + estimatedSyncDeviceCount, + recoveryKeyExists, }: CompleteResetPasswordProps) => { const location = useLocation(); @@ -36,6 +40,8 @@ const CompleteResetPassword = ({ }, [hasConfirmedRecoveryKey]); const [isSubmitting, setIsSubmitting] = useState(false); + const hasSyncDevices = + estimatedSyncDeviceCount !== undefined && estimatedSyncDeviceCount > 0; const { handleSubmit, register, getValues, errors, formState, trigger } = useForm({ @@ -55,67 +61,117 @@ const CompleteResetPassword = ({ return ( - - - {!hasConfirmedRecoveryKey && - locationState.recoveryKeyExists === undefined && ( - - <> - -

- Sorry, there was a problem checking if you have an account - recovery key. -

-
- - - GleanMetrics.passwordReset.createNewRecoveryKeyMessageClick() - } - > - Reset your password with your account recovery key. - - - -
- )} +

+ Reset your password +

+ {/* + In the event of serious error. A bright red banner will be displayed indicating + the problem. + */} {errorMessage && {errorMessage}} - {hasConfirmedRecoveryKey ? ( - -

- You have successfully restored your account using your account - recovery key. Create a new password to secure your data, and store - it in a safe location. -

-
- ) : ( - - When you reset your password, you reset your account. You may lose - some of your personal information (including history, bookmarks, and - passwords). That’s because we encrypt your data with your password to - protect your privacy. You’ll still keep any subscriptions you may have - and Pocket data will not be affected. - +
+ +
+ + Have an account recovery key? + {' '} +
+ + GleanMetrics.passwordReset.createNewRecoveryKeyMessageClick() + } + > + + Reset your password with your recovery key. + + {' '} +
+
+ )} - {/* Hidden email field is to allow Fx password manager - to correctly save the updated password. Without it, - the password manager tries to save the old password - as the username. */} + {hasConfirmedRecoveryKey === false && + recoveryKeyExists !== undefined && + hasSyncDevices && ( +
+
+ +

+ + Resetting your password may delete your encrypted browser + data. + +

+
+
+

+ + + Have a device where you previously signed in? + +

+

+ + Your browser data may be locally saved on that device. Sign in + there with your new password to restore and sync. + +

+

+ + + Have a new device but don’t have your old one? + +

+

+ + We’re sorry, but your encrypted browser data on Firefox + servers can’t be recovered. + +

+

+ + + Learn more about restoring account data. + + +

+
+
+ )} + {/* + Hidden email field is to allow Fx password manager + to correctly save the updated password. Without it, + the password manager tries to save the old password + as the username. + */} -
- + Create a new password + +
+ Promise; hasConfirmedRecoveryKey?: boolean; + estimatedSyncDeviceCount?: number | undefined; + recoveryKeyExists?: boolean | undefined; } export type AccountResetData = { diff --git a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/mocks.tsx b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/mocks.tsx index 00769cf1824..a6575696e93 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/mocks.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/CompleteResetPassword/mocks.tsx @@ -16,10 +16,10 @@ const mockSubmitNewPassword = (newPassword: string) => Promise.resolve(); export const Subject = ({ submitNewPassword = mockSubmitNewPassword, hasConfirmedRecoveryKey = false, - recoveryKeyExists = undefined, + recoveryKeyExists, testErrorMessage = '', + estimatedSyncDeviceCount, }: Partial & { - recoveryKeyExists?: boolean | undefined; testErrorMessage?: string; }) => { const email = MOCK_EMAIL; @@ -42,6 +42,8 @@ export const Subject = ({ setErrorMessage, submitNewPassword, hasConfirmedRecoveryKey, + recoveryKeyExists, + estimatedSyncDeviceCount, }} />