From 59a31b78ff68ccf2030dd59bc2468c8ec77f2803 Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Mon, 16 Sep 2024 11:05:06 -0700 Subject: [PATCH] fix(settings): Use single input code component in ConfirmResetPassword Because: * Split input component is not recommended for accessibility This commit: * Remove the SplitInputGroup component * Update the FormVerifyTotp component to use a single input * Update component to only accept numeric or alphanumeric characters and only submit if pattern matches * Update tests * Limits updates to the ConfirmResetPassword page to limite side-effects of changes * Adds small animation on banner render to smooth the transition * Ensure only one banner shown at a time on the ConfirmResetPassword page Closes #FXA-10309 --- .../src/components/Banner/index.tsx | 2 +- .../src/components/FormVerifyTotp/en.ftl | 14 + .../FormVerifyTotp/index.stories.tsx | 4 + .../components/FormVerifyTotp/index.test.tsx | 40 +-- .../src/components/FormVerifyTotp/index.tsx | 157 ++++++----- .../components/FormVerifyTotp/interfaces.ts | 18 ++ .../src/components/FormVerifyTotp/mocks.tsx | 9 +- .../src/components/InputText/index.tsx | 8 +- .../src/components/TotpInputGroup/en.ftl | 7 - .../TotpInputGroup/index.stories.tsx | 23 -- .../components/TotpInputGroup/index.test.tsx | 198 -------------- .../src/components/TotpInputGroup/index.tsx | 256 ------------------ .../src/components/TotpInputGroup/mocks.tsx | 37 --- .../ConfirmResetPassword/container.tsx | 2 + .../ConfirmResetPassword/index.stories.tsx | 4 +- .../ConfirmResetPassword/index.test.tsx | 2 +- .../ConfirmResetPassword/index.tsx | 31 ++- .../ConfirmResetPassword/interfaces.ts | 1 + .../ConfirmResetPassword/mocks.tsx | 41 ++- packages/fxa-settings/tailwind.config.js | 18 ++ 20 files changed, 225 insertions(+), 647 deletions(-) create mode 100644 packages/fxa-settings/src/components/FormVerifyTotp/en.ftl create mode 100644 packages/fxa-settings/src/components/FormVerifyTotp/interfaces.ts delete mode 100644 packages/fxa-settings/src/components/TotpInputGroup/en.ftl delete mode 100644 packages/fxa-settings/src/components/TotpInputGroup/index.stories.tsx delete mode 100644 packages/fxa-settings/src/components/TotpInputGroup/index.test.tsx delete mode 100644 packages/fxa-settings/src/components/TotpInputGroup/index.tsx delete mode 100644 packages/fxa-settings/src/components/TotpInputGroup/mocks.tsx diff --git a/packages/fxa-settings/src/components/Banner/index.tsx b/packages/fxa-settings/src/components/Banner/index.tsx index f554be2abd1..1965e11a0c7 100644 --- a/packages/fxa-settings/src/components/Banner/index.tsx +++ b/packages/fxa-settings/src/components/Banner/index.tsx @@ -46,7 +46,7 @@ const Banner = ({ }: BannerProps) => { // Transparent border is for Windows HCM - to ensure there is a border around the banner const baseClassNames = - 'text-xs font-bold p-3 my-3 rounded border border-transparent'; + 'text-xs font-bold p-3 my-3 rounded border border-transparent animate-fade-in'; return (
; export const With8DigitCode = () => ; +export const With10CharAlphanumericCode = () => ( + +); + export const WithErrorOnSubmit = () => ; diff --git a/packages/fxa-settings/src/components/FormVerifyTotp/index.test.tsx b/packages/fxa-settings/src/components/FormVerifyTotp/index.test.tsx index 9c8e6bd8e74..602e19e6792 100644 --- a/packages/fxa-settings/src/components/FormVerifyTotp/index.test.tsx +++ b/packages/fxa-settings/src/components/FormVerifyTotp/index.test.tsx @@ -12,7 +12,7 @@ describe('FormVerifyTotp component', () => { it('renders as expected with default props', async () => { renderWithLocalizationProvider(); expect(screen.getByText('Enter 6-digit code')).toBeVisible(); - expect(screen.getAllByRole('textbox')).toHaveLength(6); + expect(screen.getAllByRole('textbox')).toHaveLength(1); const button = screen.getByRole('button'); expect(button).toHaveTextContent('Submit'); }); @@ -28,36 +28,25 @@ describe('FormVerifyTotp component', () => { it('is enabled when numbers are typed into all inputs', async () => { const user = userEvent.setup(); renderWithLocalizationProvider(); + const input = screen.getByRole('textbox'); const button = screen.getByRole('button'); expect(button).toHaveTextContent('Submit'); expect(button).toBeDisabled(); - await waitFor(() => - user.click(screen.getByRole('textbox', { name: 'Digit 1 of 6' })) - ); + await user.type(input, '123456'); - // type in each input - for (let i = 1; i <= 6; i++) { - await waitFor(() => - user.type( - screen.getByRole('textbox', { name: `Digit ${i} of 6` }), - i.toString() - ) - ); - } expect(button).toBeEnabled(); }); it('is enabled when numbers are pasted into all inputs', async () => { const user = userEvent.setup(); renderWithLocalizationProvider(); + const input = screen.getByRole('textbox'); const button = screen.getByRole('button'); expect(button).toHaveTextContent('Submit'); expect(button).toBeDisabled(); - await waitFor(() => - user.click(screen.getByRole('textbox', { name: 'Digit 1 of 6' })) - ); + await user.click(input); await waitFor(() => { user.paste('123456'); @@ -66,23 +55,4 @@ describe('FormVerifyTotp component', () => { expect(button).toBeEnabled(); }); }); - - describe('errors', () => { - it('are cleared when typing in input', async () => { - const user = userEvent.setup(); - renderWithLocalizationProvider( - - ); - - expect(screen.getByText('Something went wrong')).toBeVisible(); - - await waitFor(() => - user.type(screen.getByRole('textbox', { name: 'Digit 1 of 6' }), '1') - ); - - expect( - screen.queryByText('Something went wrong') - ).not.toBeInTheDocument(); - }); - }); }); diff --git a/packages/fxa-settings/src/components/FormVerifyTotp/index.tsx b/packages/fxa-settings/src/components/FormVerifyTotp/index.tsx index 1a6e09f0456..7895766581b 100644 --- a/packages/fxa-settings/src/components/FormVerifyTotp/index.tsx +++ b/packages/fxa-settings/src/components/FormVerifyTotp/index.tsx @@ -2,87 +2,120 @@ * 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, { useRef, useState } from 'react'; -import TotpInputGroup from '../TotpInputGroup'; -import { FtlMsg } from 'fxa-react/lib/utils'; -import Banner, { BannerType } from '../Banner'; +import React, { useState } from 'react'; +import InputText from '../InputText'; +import { useForm } from 'react-hook-form'; +import { getLocalizedErrorMessage } from '../../lib/error-utils'; +import { useFtlMsgResolver } from '../../models'; +import { AuthUiErrors } from '../../lib/auth-errors/auth-errors'; +import { FormVerifyTotpProps, VerifyTotpFormData } from './interfaces'; -export type CodeArray = Array; - -export type FormVerifyTotpProps = { - codeLength: 6 | 8; - errorMessage: string; - localizedInputGroupLabel: string; - localizedSubmitButtonText: string; - setErrorMessage: React.Dispatch>; - verifyCode: (code: string) => void; -}; +// Split inputs are not recommended for accesibility +// Code inputs should be a single input field +// See FXA-10390 for details on reverting from split input to single input const FormVerifyTotp = ({ + clearBanners, codeLength, + codeType, errorMessage, - localizedInputGroupLabel, + localizedInputLabel, localizedSubmitButtonText, setErrorMessage, verifyCode, }: FormVerifyTotpProps) => { - const inputRefs = useRef( - Array.from({ length: codeLength }, () => - React.createRef() - ) - ); + const [isSubmitDisabled, setIsSubmitDisabled] = useState(true); - const [codeArray, setCodeArray] = useState(new Array(codeLength)); - const [isSubmitting, setIsSubmitting] = useState(false); + const ftlMsgResolver = useFtlMsgResolver(); - const stringifiedCode = codeArray.join(''); + const { handleSubmit, register } = useForm({ + mode: 'onBlur', + criteriaMode: 'all', + defaultValues: { + code: '', + }, + }); - const isFormValid = stringifiedCode.length === codeLength && !errorMessage; - const isSubmitDisabled = isSubmitting || !isFormValid; + const handleChange = (e: React.ChangeEvent) => { + if (errorMessage) { + setErrorMessage(''); + } + // only accept characters that match the code type (numeric or alphanumeric) + // strip out any other characters + const filteredCode = e.target.value.replace( + codeType === 'numeric' ? /[^0-9]/g : /[^a-zA-Z0-9]/g, + '' + ); + e.target.value = filteredCode; + console.log(e.target.value.length); + e.target.value.length === codeLength + ? setIsSubmitDisabled(false) + : setIsSubmitDisabled(true); + }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const onSubmit = async ({ code }: VerifyTotpFormData) => { + clearBanners && clearBanners(); + setIsSubmitDisabled(true); + // Only submit the code if it is the correct length + // Otherwise, show an error message + if (code.length !== codeLength) { + setErrorMessage( + getLocalizedErrorMessage(ftlMsgResolver, AuthUiErrors.INVALID_OTP_CODE) + ); + } else if (!isSubmitDisabled) { + await verifyCode(code); + } + setIsSubmitDisabled(false); + }; - if (!isSubmitDisabled) { - setIsSubmitting(true); - await verifyCode(stringifiedCode); - setIsSubmitting(false); + const getDisabledButtonTitle = () => { + if (codeType === 'numeric') { + return ftlMsgResolver.getMsg( + 'form-verify-totp-disabled-button-title-numeric', + `Enter ${codeLength}-digit code to continue`, + { codeLength } + ); + } else { + return ftlMsgResolver.getMsg( + 'form-verify-totp-disabled-button-title-alphanumeric', + `Enter ${codeLength}-character code to continue`, + { codeLength } + ); } }; return ( - <> - {errorMessage && {errorMessage}} -
+ {/* Using `type="text" inputmode="numeric"` shows the numeric keyboard on mobile + and strips out whitespace on desktop, but does not add an incrementer. */} + + - - - + {localizedSubmitButtonText} + + ); }; diff --git a/packages/fxa-settings/src/components/FormVerifyTotp/interfaces.ts b/packages/fxa-settings/src/components/FormVerifyTotp/interfaces.ts new file mode 100644 index 00000000000..969012db849 --- /dev/null +++ b/packages/fxa-settings/src/components/FormVerifyTotp/interfaces.ts @@ -0,0 +1,18 @@ +/* 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/. */ + +export type FormVerifyTotpProps = { + clearBanners?: () => void; + codeLength: 6 | 8 | 10; + codeType: 'numeric' | 'alphanumeric'; + errorMessage: string; + localizedInputLabel: string; + localizedSubmitButtonText: string; + setErrorMessage: React.Dispatch>; + verifyCode: (code: string) => void; +}; + +export type VerifyTotpFormData = { + code: string; +}; diff --git a/packages/fxa-settings/src/components/FormVerifyTotp/mocks.tsx b/packages/fxa-settings/src/components/FormVerifyTotp/mocks.tsx index 5aee34712c7..82a4e088337 100644 --- a/packages/fxa-settings/src/components/FormVerifyTotp/mocks.tsx +++ b/packages/fxa-settings/src/components/FormVerifyTotp/mocks.tsx @@ -4,17 +4,19 @@ import React, { useCallback, useState } from 'react'; import AppLayout from '../AppLayout'; -import FormVerifyTotp, { FormVerifyTotpProps } from '.'; +import FormVerifyTotp from '.'; +import { FormVerifyTotpProps } from './interfaces'; export const Subject = ({ codeLength = 6, + codeType = 'numeric', success = true, initialErrorMessage = '', }: Partial & { success?: Boolean; initialErrorMessage?: string; }) => { - const localizedInputGroupLabel = `Enter ${codeLength.toString()}-digit code`; + const localizedInputLabel = `Enter ${codeLength.toString()}-digit code`; const localizedSubmitButtonText = 'Submit'; const [errorMessage, setErrorMessage] = useState(initialErrorMessage); @@ -35,8 +37,9 @@ export const Subject = ({ ; - -export const With8Digits = () => ; - -export const WithError = () => ( - -); diff --git a/packages/fxa-settings/src/components/TotpInputGroup/index.test.tsx b/packages/fxa-settings/src/components/TotpInputGroup/index.test.tsx deleted file mode 100644 index 8f71c93972b..00000000000 --- a/packages/fxa-settings/src/components/TotpInputGroup/index.test.tsx +++ /dev/null @@ -1,198 +0,0 @@ -/* 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, waitFor } from '@testing-library/react'; -import { userEvent } from '@testing-library/user-event'; -import Subject from './mocks'; -import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; - -describe('TotpInputGroup component', () => { - it('renders as expected for 6 digit code', () => { - renderWithLocalizationProvider(); - const inputs = screen.getAllByRole('textbox'); - expect(inputs).toHaveLength(6); - }); - - it('renders as expected for 8 digit code', () => { - renderWithLocalizationProvider(); - const inputs = screen.getAllByRole('textbox'); - expect(inputs).toHaveLength(8); - }); - - describe('keyboard navigation', () => { - it('can navigate between inputs with arrow keys', async () => { - const user = userEvent.setup(); - renderWithLocalizationProvider(); - const inputs = screen.getAllByRole('textbox'); - - await waitFor(() => - user.click(screen.getByRole('textbox', { name: 'Digit 1 of 6' })) - ); - - await waitFor(() => { - user.keyboard('[ArrowRight]'); - }); - expect(inputs[1]).toHaveFocus(); - - await waitFor(() => { - user.keyboard('[ArrowLeft]'); - }); - expect(inputs[0]).toHaveFocus(); - }); - - it('can backspace between inputs', async () => { - const user = userEvent.setup(); - renderWithLocalizationProvider(); - - await waitFor(() => - user.click(screen.getByRole('textbox', { name: 'Digit 1 of 6' })) - ); - - // type in each input - for (let i = 1; i <= 6; i++) { - await waitFor(() => - user.type( - screen.getByRole('textbox', { name: `Digit ${i} of 6` }), - i.toString() - ) - ); - } - - // all inputs have values - for (let i = 1; i <= 6; i++) { - expect( - screen.getByRole('textbox', { name: `Digit ${i} of 6` }) - ).toHaveValue(i.toString()); - } - - // focus is on last edited input - expect( - screen.getByRole('textbox', { name: 'Digit 6 of 6' }) - ).toHaveFocus(); - - await waitFor(() => { - user.keyboard('[Backspace]'); - }); - - // last input is cleared - expect(screen.getByRole('textbox', { name: 'Digit 6 of 6' })).toHaveValue( - '' - ); - - // and focus shifts to previous input - expect( - screen.getByRole('textbox', { name: 'Digit 5 of 6' }) - ).toHaveFocus(); - }); - - it('can forward delete inputs', async () => { - const user = userEvent.setup(); - renderWithLocalizationProvider(); - - await waitFor(() => - user.click(screen.getByRole('textbox', { name: 'Digit 1 of 6' })) - ); - - // type in each input - for (let i = 1; i <= 6; i++) { - await waitFor(() => - user.type( - screen.getByRole('textbox', { name: `Digit ${i} of 6` }), - i.toString() - ) - ); - } - - // all inputs have values - for (let i = 1; i <= 6; i++) { - expect( - screen.getByRole('textbox', { name: `Digit ${i} of 6` }) - ).toHaveValue(i.toString()); - } - - await waitFor(() => { - user.click(screen.getByRole('textbox', { name: 'Digit 1 of 6' })); - }); - - await waitFor(() => { - user.keyboard('[Delete]'); - }); - - // current input is cleared - expect(screen.getByRole('textbox', { name: 'Digit 1 of 6' })).toHaveValue( - '' - ); - - // and focus shifts to next input - expect( - screen.getByRole('textbox', { name: 'Digit 2 of 6' }) - ).toHaveFocus(); - }); - }); - - describe('paste into inputs', () => { - it('distributes clipboard content to inputs', async () => { - const user = userEvent.setup(); - renderWithLocalizationProvider(); - - await waitFor(() => - user.click(screen.getByRole('textbox', { name: 'Digit 1 of 6' })) - ); - - // inputs initially have no value - for (let i = 1; i <= 6; i++) { - expect( - screen.getByRole('textbox', { name: `Digit ${i} of 6` }) - ).toHaveValue(''); - } - - await waitFor(() => - user.click(screen.getByRole('textbox', { name: 'Digit 1 of 6' })) - ); - - await waitFor(() => { - user.paste('123456'); - }); - - // clipboard values are distributed between inputs - for (let i = 1; i <= 6; i++) { - expect( - screen.getByRole('textbox', { name: `Digit ${i} of 6` }) - ).toHaveValue(i.toString()); - } - }); - - it('skips non-numeric characters in clipboard', async () => { - const user = userEvent.setup(); - renderWithLocalizationProvider(); - - await waitFor(() => - user.click(screen.getByRole('textbox', { name: 'Digit 1 of 6' })) - ); - - // inputs initially have no value - for (let i = 1; i <= 6; i++) { - expect( - screen.getByRole('textbox', { name: `Digit ${i} of 6` }) - ).toHaveValue(''); - } - - await waitFor(() => - user.click(screen.getByRole('textbox', { name: 'Digit 1 of 6' })) - ); - - await waitFor(() => { - user.paste('1b2$3 4B5.6?'); - }); - - // clipboard values are distributed between inputs - for (let i = 1; i <= 6; i++) { - expect( - screen.getByRole('textbox', { name: `Digit ${i} of 6` }) - ).toHaveValue(i.toString()); - } - }); - }); -}); diff --git a/packages/fxa-settings/src/components/TotpInputGroup/index.tsx b/packages/fxa-settings/src/components/TotpInputGroup/index.tsx deleted file mode 100644 index c502f6fb1c1..00000000000 --- a/packages/fxa-settings/src/components/TotpInputGroup/index.tsx +++ /dev/null @@ -1,256 +0,0 @@ -/* 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, { useState } from 'react'; -import classNames from 'classnames'; -import { CodeArray } from '../FormVerifyTotp'; -import { useFtlMsgResolver } from '../../models'; - -export type TotpInputGroupProps = { - codeArray: CodeArray; - codeLength: 6 | 8; - inputRefs: React.MutableRefObject[]>; - localizedInputGroupLabel: string; - setCodeArray: React.Dispatch>; - setErrorMessage: React.Dispatch>; - errorMessage?: string; -}; - -type SingleInputProps = { - index: number; -}; - -const NUMBERS_ONLY_REGEX = /^[0-9]+$/; - -export const TotpInputGroup = ({ - codeArray, - codeLength, - inputRefs, - localizedInputGroupLabel, - setCodeArray, - setErrorMessage, - errorMessage = '', -}: TotpInputGroupProps) => { - const ftlMsgResolver = useFtlMsgResolver(); - const focusOnNextInput = (index: number) => { - inputRefs.current[index + 1].current?.focus(); - }; - - const focusOnPreviousInput = (index: number) => { - inputRefs.current[index - 1].current?.focus(); - }; - - const focusOnSpecifiedInput = (index: number) => { - inputRefs.current[index].current?.focus(); - }; - - const handleBackspace = async (index: number) => { - errorMessage && setErrorMessage(''); - const currentCodeArray = [...codeArray]; - currentCodeArray[index] = undefined; - await setCodeArray(currentCodeArray); - if (index > 0) { - focusOnPreviousInput(index); - } else { - focusOnSpecifiedInput(index); - } - }; - - const handleDelete = async (index: number) => { - errorMessage && setErrorMessage(''); - const currentCodeArray = [...codeArray]; - if (currentCodeArray[index] !== undefined) { - currentCodeArray[index] = undefined; - } else { - currentCodeArray[index + 1] = undefined; - } - await setCodeArray(currentCodeArray); - if (index < codeLength - 1) { - focusOnNextInput(index); - } else { - focusOnSpecifiedInput(index); - } - }; - - const handleKeyDown = ( - e: React.KeyboardEvent, - index: number - ) => { - switch (e.key) { - case 'Backspace': - e.preventDefault(); - if (index > 0) { - focusOnPreviousInput(index); - } - handleBackspace(index); - break; - case 'Delete': - e.preventDefault(); - if (index < codeLength) { - handleDelete(index); - } - break; - case 'ArrowRight': - if (index < codeLength - 1) { - focusOnNextInput(index); - } - break; - case 'ArrowLeft': - if (index > 0) { - focusOnPreviousInput(index); - } - break; - default: - break; - } - }; - - const handleInput = async ( - e: React.ChangeEvent, - index: number - ) => { - e.preventDefault(); - errorMessage && setErrorMessage(''); - const currentCodeArray = [...codeArray]; - - const inputValue = e.target.value; - // we only want to check new value - const newInputValue = Array.from(inputValue).filter((character) => { - return character !== codeArray[index]; - }); - - if (newInputValue.length === 1) { - // if the new value is a number, use it - if (newInputValue[0].match(NUMBERS_ONLY_REGEX)) { - currentCodeArray[index] = newInputValue[0]; - await setCodeArray(currentCodeArray); - } else { - // if the new value is not a number, keep the previous value (if it exists) or clear the input box - currentCodeArray[index] = codeArray[index]; - await setCodeArray(currentCodeArray); - } - } - - if (currentCodeArray[index] !== undefined && index < codeLength - 1) { - focusOnNextInput(index); - } else { - focusOnSpecifiedInput(index); - } - }; - - const handlePaste = async ( - e: React.ClipboardEvent, - index: number - ) => { - errorMessage && setErrorMessage(''); - let currentIndex = index; - const currentCodeArray = [...codeArray]; - const clipboardText = e.clipboardData.getData('text'); - let digitsOnlyArray = clipboardText - .split('') - .filter((character) => { - return character.match(NUMBERS_ONLY_REGEX); - }) - .slice(0, codeLength - index); - digitsOnlyArray.forEach((character: string) => { - currentCodeArray[currentIndex] = character; - if (currentIndex < codeLength - 1) { - focusOnNextInput(index); - currentIndex++; - } - }); - - await setCodeArray(currentCodeArray); - // if last pasted character is on last input, focus on that input - // otherwise focus on next input after last pasted character - focusOnSpecifiedInput(currentIndex); - }; - - const SingleDigitInput = ({ index }: SingleInputProps) => { - const [isFocused, setIsFocused] = useState(false); - - // number used for localized message starts at 1 - const inputNumber = index + 1; - const localizedLabel = ftlMsgResolver.getMsg( - 'single-char-input-label', - `Digit ${inputNumber} of ${codeLength}`, - { inputNumber, codeLength } - ); - return ( - - - setIsFocused(false)} - onClick={(e: React.MouseEvent) => { - e.currentTarget.setSelectionRange(0, e.currentTarget.value.length); - }} - onChange={(e: React.ChangeEvent) => { - handleInput(e, index); - }} - onFocus={(e: React.FocusEvent) => { - setIsFocused(true); - e.currentTarget.setSelectionRange(0, e.currentTarget.value.length); - }} - onKeyDown={(e: React.KeyboardEvent) => { - handleKeyDown(e, index); - }} - onPaste={(e: React.ClipboardEvent) => { - e.preventDefault(); - handlePaste(e, index); - }} - /> - - ); - }; - - const getAllSingleDigitInputs = () => { - return [...Array(codeLength)].map((value: undefined, index: number) => { - return ( - - ); - }); - }; - - return ( -
- - {localizedInputGroupLabel} - -
- {getAllSingleDigitInputs()} -
-
- ); -}; - -export default TotpInputGroup; diff --git a/packages/fxa-settings/src/components/TotpInputGroup/mocks.tsx b/packages/fxa-settings/src/components/TotpInputGroup/mocks.tsx deleted file mode 100644 index b1387f97bf9..00000000000 --- a/packages/fxa-settings/src/components/TotpInputGroup/mocks.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* 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, { useRef, useState } from 'react'; -import AppLayout from '../AppLayout'; -import TotpInputGroup, { TotpInputGroupProps } from '.'; -import { CodeArray } from '../FormVerifyTotp'; - -export const Subject = ({ - codeLength = 6, - initialErrorMessage = '', -}: Partial & { initialErrorMessage?: string }) => { - const [codeArray, setCodeArray] = useState([]); - const [errorMessage, setErrorMessage] = useState(initialErrorMessage || ''); - const inputRefs = useRef( - Array.from({ length: codeLength }, () => - React.createRef() - ) - ); - - return ( - - ); -}; - -export default Subject; diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/container.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/container.tsx index e2898a2c0af..56e4b97f769 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/container.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/container.tsx @@ -112,6 +112,7 @@ const ConfirmResetPasswordContainer = (_: RouteComponentProps) => { recoveryKeyExists ); } catch (error) { + // return custom error for expired or incorrect code const localizerErrorMessage = getLocalizedErrorMessage( ftlMsgResolver, error @@ -140,6 +141,7 @@ const ConfirmResetPasswordContainer = (_: RouteComponentProps) => { return ( ; +export const WithResendSuccess = () => ; + +export const WithResendError = () => ; diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx index 18cc4242fb3..2c42f665e11 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.test.tsx @@ -64,7 +64,7 @@ describe('ConfirmResetPassword', () => { expect(screen.getByText(MOCK_EMAIL)).toBeVisible(); - expect(screen.getAllByRole('textbox')).toHaveLength(8); + expect(screen.getAllByRole('textbox')).toHaveLength(1); const buttons = await screen.findAllByRole('button'); expect(buttons).toHaveLength(2); expect(buttons[0]).toHaveTextContent('Continue'); diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx index 513b8bfb27f..73133b38a74 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/index.tsx @@ -19,6 +19,7 @@ import { EmailCodeImage } from '../../../components/images'; import GleanMetrics from '../../../lib/glean'; const ConfirmResetPassword = ({ + clearBanners, email, errorMessage, setErrorMessage, @@ -34,15 +35,6 @@ const ConfirmResetPassword = ({ const ftlMsgResolver = useFtlMsgResolver(); const location = useLocation(); - const localizedInputGroupLabel = ftlMsgResolver.getMsg( - 'confirm-reset-password-code-input-group-label', - 'Enter 8-digit code within 10 minutes' - ); - const localizedSubmitButtonText = ftlMsgResolver.getMsg( - 'confirm-reset-password-otp-submit-button', - 'Continue' - ); - const spanElement = {email}; const hasResendError = !!( @@ -58,6 +50,11 @@ const ConfirmResetPassword = ({

Reset your password

+ {resendStatus === ResendStatus.sent && } + {hasResendError && ( + {resendErrorMessage} + )} + {errorMessage && {errorMessage}}

Check your email

@@ -71,16 +68,20 @@ const ConfirmResetPassword = ({ We sent a confirmation code to {spanElement}.

- {resendStatus === ResendStatus.sent && } - {hasResendError && ( - {resendErrorMessage} - )} void; email: string; errorMessage: string; setErrorMessage: React.Dispatch>; diff --git a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/mocks.tsx b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/mocks.tsx index fb388afc4dd..b200610996d 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/mocks.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/ConfirmResetPassword/mocks.tsx @@ -10,27 +10,54 @@ import { ResendStatus } from '../../../lib/types'; import { ConfirmResetPasswordProps } from './interfaces'; const mockVerifyCode = (code: string) => Promise.resolve(); -const mockResendCode = () => Promise.resolve(); export const Subject = ({ - resendStatus = ResendStatus.none, + resendCode, resendErrorMessage = '', - resendCode = mockResendCode, + resendStatus = ResendStatus.none, + resendSuccess = true, verifyCode = mockVerifyCode, -}: Partial) => { +}: Partial & { resendSuccess?: boolean }) => { const email = MOCK_EMAIL; const [errorMessage, setErrorMessage] = useState(''); + const [codeResendErrorMessage, setResendErrorMessage] = + useState(resendErrorMessage); + const [codeResendStatus, setResendStatus] = useState(resendStatus); + + const clearBanners = () => { + setErrorMessage(''); + setResendStatus(ResendStatus.none); + setResendErrorMessage(''); + }; + + const mockResendCodeSuccess = () => { + clearBanners(); + setResendStatus(ResendStatus.sent); + return Promise.resolve(); + }; + + const mockResendCodeError = () => { + clearBanners(); + setResendStatus(ResendStatus.error); + setResendErrorMessage('Resend error'); + return Promise.resolve(); + }; + + const mockResendCode = resendSuccess + ? () => mockResendCodeSuccess() + : () => mockResendCodeError(); return ( diff --git a/packages/fxa-settings/tailwind.config.js b/packages/fxa-settings/tailwind.config.js index 3416c23a94f..e722dfe51b7 100644 --- a/packages/fxa-settings/tailwind.config.js +++ b/packages/fxa-settings/tailwind.config.js @@ -7,6 +7,7 @@ const { resolve } = require('path'); const extractImportedComponents = require('fxa-react/extract-imported-components'); const config = require('fxa-react/configs/tailwind'); +const { transform } = require('typescript'); if (process.env.NODE_ENV === 'production') { const matches = extractImportedComponents( @@ -101,6 +102,22 @@ config.theme.extend = { '0%, 70%': { transform: 'rotate(0deg)' }, '80%, 100%': { transform: 'rotate(360deg)' }, }, + 'fade-in': { + '0%, 10%': { + opacity: 0, + transform: 'scale(0)', + }, + '24%': { + transform: 'scale(0.95)', + }, + '25%': { + opacity: 0.25, + }, + '30%, 100%': { + opacity: 1, + transform: 'translateY(0) scale(1)', + }, + }, }, animation: { @@ -128,6 +145,7 @@ config.theme.extend = { 'type-fourth-repeat': 'appear-fourth 5s ease-in-out infinite', 'pulse-stroke': 'pulse-stroke 2s linear infinite', 'wait-and-rotate': 'wait-and-rotate 5s infinite ease-out', + 'fade-in': 'fade-in 1s 1 ease-in', }, };