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
This commit is contained in:
Valerie Pomerleau 2024-09-16 11:05:06 -07:00
Родитель 33814cb2c4
Коммит 59a31b78ff
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 33A451F0BB2180B4
20 изменённых файлов: 225 добавлений и 647 удалений

Просмотреть файл

@ -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 (
<div

Просмотреть файл

@ -0,0 +1,14 @@
## FormVerifyTotp component
## Form to enter a time-based one-time-passcode (e.g., 6-digit numeric code or 8-digit alphanumeric code)
# Information explaining why button is disabled, also read to screen readers
# Submit button is disabled unless a valid code format is entered
# Used when the code may only contain numbers
# $codeLength : number of digits in a valid code
form-verify-totp-disabled-button-title-numeric = Enter { $codeLength }-digit code to continue
# Information explaining why button is disabled, also read to screen readers
# Submit button is disabled unless a valid code format is entered
# Used when the code may contain numbers and/or letters
# $codeLength : number of characters in a valid code
form-verify-totp-disabled-button-title-alphanumeric = Enter {$ codeLength }-character code to continue bloop

Просмотреть файл

@ -18,4 +18,8 @@ export const With6DigitCode = () => <Subject />;
export const With8DigitCode = () => <Subject codeLength={8} />;
export const With10CharAlphanumericCode = () => (
<Subject codeLength={10} codeType="alphanumeric" />
);
export const WithErrorOnSubmit = () => <Subject success={false} />;

Просмотреть файл

@ -12,7 +12,7 @@ describe('FormVerifyTotp component', () => {
it('renders as expected with default props', async () => {
renderWithLocalizationProvider(<Subject />);
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(<Subject />);
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(<Subject />);
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(
<Subject initialErrorMessage="Something went wrong" />
);
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();
});
});
});

Просмотреть файл

@ -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<string | undefined>;
export type FormVerifyTotpProps = {
codeLength: 6 | 8;
errorMessage: string;
localizedInputGroupLabel: string;
localizedSubmitButtonText: string;
setErrorMessage: React.Dispatch<React.SetStateAction<string>>;
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<HTMLInputElement>()
)
);
const [isSubmitDisabled, setIsSubmitDisabled] = useState(true);
const [codeArray, setCodeArray] = useState<CodeArray>(new Array(codeLength));
const [isSubmitting, setIsSubmitting] = useState(false);
const ftlMsgResolver = useFtlMsgResolver();
const stringifiedCode = codeArray.join('');
const { handleSubmit, register } = useForm<VerifyTotpFormData>({
mode: 'onBlur',
criteriaMode: 'all',
defaultValues: {
code: '',
},
});
const isFormValid = stringifiedCode.length === codeLength && !errorMessage;
const isSubmitDisabled = isSubmitting || !isFormValid;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 && <Banner type={BannerType.error}>{errorMessage}</Banner>}
<form
noValidate
className="flex flex-col gap-4 my-6"
onSubmit={handleSubmit}
<form
noValidate
className="flex flex-col gap-4 my-6"
onSubmit={handleSubmit(onSubmit)}
>
{/* Using `type="text" inputmode="numeric"` shows the numeric keyboard on mobile
and strips out whitespace on desktop, but does not add an incrementer. */}
<InputText
name="code"
type="text"
inputMode={codeType === 'numeric' ? 'numeric' : 'text'}
label={localizedInputLabel}
onChange={handleChange}
autoFocus
maxLength={codeLength}
className="text-start"
anchorPosition="start"
autoComplete="one-time-code"
spellCheck={false}
inputRef={register({ required: true })}
hasErrors={!!errorMessage}
/>
<button
type="submit"
className="cta-primary cta-xl"
disabled={isSubmitDisabled}
title={isSubmitDisabled ? getDisabledButtonTitle() : ''}
>
<TotpInputGroup
{...{
codeArray,
codeLength,
inputRefs,
localizedInputGroupLabel,
setCodeArray,
setErrorMessage,
errorMessage,
}}
/>
<FtlMsg
id="form-verify-code-submit-button-2"
vars={{ codeValue: stringifiedCode }}
>
<button
type="submit"
className="cta-primary cta-xl"
disabled={isSubmitDisabled}
>
{localizedSubmitButtonText}
</button>
</FtlMsg>
</form>
</>
{localizedSubmitButtonText}
</button>
</form>
);
};

Просмотреть файл

@ -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<React.SetStateAction<string>>;
verifyCode: (code: string) => void;
};
export type VerifyTotpFormData = {
code: string;
};

Просмотреть файл

@ -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<FormVerifyTotpProps> & {
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 = ({
<FormVerifyTotp
{...{
codeLength,
codeType,
errorMessage,
localizedInputGroupLabel,
localizedInputLabel,
localizedSubmitButtonText,
setErrorMessage,
verifyCode,

Просмотреть файл

@ -34,7 +34,13 @@ export type InputTextProps = {
pattern?: string;
anchorPosition?: 'start' | 'middle' | 'end';
spellCheck?: boolean;
autoComplete?: string;
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute
autoComplete?:
| 'off'
| 'username'
| 'current-password'
| 'new-password'
| 'one-time-code';
inputMode?: 'text' | 'numeric' | 'tel' | 'email';
required?: boolean;
tooltipPosition?: 'top' | 'bottom';

Просмотреть файл

@ -1,7 +0,0 @@
## TotpInputGroup component
## This component is composed of 6 or 8 single digit inputs for verification codes
# Screen reader only label for each single-digit input, e.g., Code digit 1 of 6
# $inputNumber is a number from 1 to 8
# $codeLength is a number, it represents the total length of the code
single-char-input-label = Digit { $inputNumber } of { $codeLength }

Просмотреть файл

@ -1,23 +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 { Meta } from '@storybook/react';
import TotpInputGroup from '.';
import { withLocalization } from 'fxa-react/lib/storybooks';
import Subject from './mocks';
export default {
title: 'Components/TotpInputGroup',
component: TotpInputGroup,
decorators: [withLocalization],
} as Meta;
export const With6Digits = () => <Subject />;
export const With8Digits = () => <Subject codeLength={8} />;
export const WithError = () => (
<Subject initialErrorMessage="Sample error message." />
);

Просмотреть файл

@ -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(<Subject codeLength={6} />);
const inputs = screen.getAllByRole('textbox');
expect(inputs).toHaveLength(6);
});
it('renders as expected for 8 digit code', () => {
renderWithLocalizationProvider(<Subject codeLength={8} />);
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(<Subject codeLength={6} />);
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(<Subject codeLength={6} />);
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(<Subject codeLength={6} />);
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(<Subject codeLength={6} />);
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(<Subject codeLength={6} />);
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());
}
});
});
});

Просмотреть файл

@ -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<React.RefObject<HTMLInputElement>[]>;
localizedInputGroupLabel: string;
setCodeArray: React.Dispatch<React.SetStateAction<CodeArray>>;
setErrorMessage: React.Dispatch<React.SetStateAction<string>>;
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<HTMLInputElement>,
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<HTMLInputElement>,
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<HTMLInputElement>,
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 (
<span
key={`code-box-${index}`}
className={classNames('flex-1 min-h-14 rounded outline-none border', {
'min-w-12': codeLength === 6,
'min-w-8': codeLength === 8,
'border-grey-200 ': !errorMessage && !isFocused,
'border-blue-400 shadow-input-blue-focus': !errorMessage && isFocused,
'border-red-700': errorMessage,
'shadow-input-red-focus': errorMessage && isFocused,
})}
>
<label className="sr-only" htmlFor={`code-input-${index}`}>
{localizedLabel}
</label>
<input
id={`code-input-${index}`}
value={codeArray[index]}
ref={inputRefs.current[index]}
type="text"
autoComplete="one-time-code"
inputMode="numeric"
size={1}
pattern="[0-9]{1}"
className="text-xl text-center font-mono h-full w-full rounded outline-none border-none"
onBlur={() => setIsFocused(false)}
onClick={(e: React.MouseEvent<HTMLInputElement>) => {
e.currentTarget.setSelectionRange(0, e.currentTarget.value.length);
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
handleInput(e, index);
}}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(true);
e.currentTarget.setSelectionRange(0, e.currentTarget.value.length);
}}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
handleKeyDown(e, index);
}}
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
handlePaste(e, index);
}}
/>
</span>
);
};
const getAllSingleDigitInputs = () => {
return [...Array(codeLength)].map((value: undefined, index: number) => {
return (
<SingleDigitInput {...{ index }} key={`single-digit-input-${index}`} />
);
});
};
return (
<fieldset>
<legend id="totp-input-group-label" className="text-start text-sm">
{localizedInputGroupLabel}
</legend>
<div
className={classNames(
// OTP code input must be displayed LTR for both LTR and RTL languages
'flex my-2 rtl:flex-row-reverse',
codeLength === 6 && 'gap-2 mobileLandscape:gap-4',
codeLength === 8 && 'gap-1 mobileLandscape:gap-2'
)}
aria-describedby="totp-input-group-label totp-input-group-error"
>
{getAllSingleDigitInputs()}
</div>
</fieldset>
);
};
export default TotpInputGroup;

Просмотреть файл

@ -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<TotpInputGroupProps> & { initialErrorMessage?: string }) => {
const [codeArray, setCodeArray] = useState<CodeArray>([]);
const [errorMessage, setErrorMessage] = useState(initialErrorMessage || '');
const inputRefs = useRef(
Array.from({ length: codeLength }, () =>
React.createRef<HTMLInputElement>()
)
);
return (
<TotpInputGroup
localizedInputGroupLabel={`Enter ${codeLength.toString()}-digit code`}
{...{
codeArray,
codeLength,
inputRefs,
setCodeArray,
errorMessage,
setErrorMessage,
}}
/>
);
};
export default Subject;

Просмотреть файл

@ -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 (
<ConfirmResetPassword
{...{
clearBanners,
email,
errorMessage,
resendCode,

Просмотреть файл

@ -14,4 +14,6 @@ export default {
decorators: [withLocalization],
} as Meta;
export const Default = () => <Subject />;
export const WithResendSuccess = () => <Subject />;
export const WithResendError = () => <Subject resendSuccess={false} />;

Просмотреть файл

@ -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');

Просмотреть файл

@ -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 = <span className="font-bold">{email}</span>;
const hasResendError = !!(
@ -58,6 +50,11 @@ const ConfirmResetPassword = ({
<FtlMsg id="password-reset-flow-heading">
<p className="text-start text-grey-400 text-sm">Reset your password</p>
</FtlMsg>
{resendStatus === ResendStatus.sent && <ResendEmailSuccessBanner />}
{hasResendError && (
<Banner type={BannerType.error}>{resendErrorMessage}</Banner>
)}
{errorMessage && <Banner type={BannerType.error}>{errorMessage}</Banner>}
<EmailCodeImage className="mx-auto" />
<FtlMsg id="confirm-reset-password-with-code-heading">
<h2 className="card-header text-start my-4">Check your email</h2>
@ -71,16 +68,20 @@ const ConfirmResetPassword = ({
We sent a confirmation code to {spanElement}.
</p>
</FtlMsg>
{resendStatus === ResendStatus.sent && <ResendEmailSuccessBanner />}
{hasResendError && (
<Banner type={BannerType.error}>{resendErrorMessage}</Banner>
)}
<FormVerifyTotp
codeLength={8}
codeType="numeric"
localizedInputLabel={ftlMsgResolver.getMsg(
'confirm-reset-password-code-input-group-label',
'Enter 8-digit code within 10 minutes'
)}
localizedSubmitButtonText={ftlMsgResolver.getMsg(
'confirm-reset-password-otp-submit-button',
'Continue'
)}
{...{
clearBanners,
errorMessage,
localizedInputGroupLabel,
localizedSubmitButtonText,
setErrorMessage,
verifyCode,
}}

Просмотреть файл

@ -16,6 +16,7 @@ export type RecoveryKeyCheckResult = {
};
export type ConfirmResetPasswordProps = {
clearBanners?: () => void;
email: string;
errorMessage: string;
setErrorMessage: React.Dispatch<React.SetStateAction<string>>;

Просмотреть файл

@ -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<ConfirmResetPasswordProps>) => {
}: Partial<ConfirmResetPasswordProps> & { 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 (
<LocationProvider>
<ConfirmResetPassword
resendCode={resendCode || mockResendCode}
resendErrorMessage={codeResendErrorMessage}
resendStatus={codeResendStatus}
{...{
clearBanners,
email,
errorMessage,
setErrorMessage,
resendCode,
resendStatus,
resendErrorMessage,
verifyCode,
}}
/>

Просмотреть файл

@ -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',
},
};