зеркало из https://github.com/mozilla/fxa.git
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:
Родитель
33814cb2c4
Коммит
59a31b78ff
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче