diff --git a/packages/fxa-settings/src/components/Banner/index.tsx b/packages/fxa-settings/src/components/Banner/index.tsx
index f554be2abd..1965e11a0c 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 9c8e6bd8e7..602e19e679 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 1a6e09f045..7895766581 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}}
-
- >
+ {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 0000000000..969012db84
--- /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 5aee34712c..82a4e08833 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 8f71c93972..0000000000
--- 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 c502f6fb1c..0000000000
--- 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 (
-
- );
-};
-
-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 b1387f97bf..0000000000
--- 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 e2898a2c0a..56e4b97f76 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 18cc4242fb..2c42f665e1 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 513b8bfb27..73133b38a7 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 fb388afc4d..b200610996 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 3416c23a94..e722dfe51b 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',
},
};