Skip to content

Commit 399b18d

Browse files
committed
feat(fxa-settings): Create totp verification form component with individual character inputs
Because: * New designs for reset password with code include code inputs with individual input boxes This commit: * Create a TotpInputGroup component that contains individual input boxes for each character, with associated methods for handling input, keyboard navigation (including arrow keys, backspace, delete), pasting content into the boxes * Add inline error feedback below new code input component, instead of tooltip * Create a FormVerifyTotp component that includes the new TotpInputGroup (configurable for 6 or 8 digit codes) and submission button, with pre-submission validation that code contains the expected number of digits and no other characters * Add l10n and storybooks Closes #FXA-7888
1 parent 9f78b3e commit 399b18d

File tree

11 files changed

+752
-1
lines changed

11 files changed

+752
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
## FormVerifyTotp
2+
3+
# When focused on the button, screen reader will read the action and entire number that will be submitted
4+
form-verify-code-submit-button =
5+
.aria-label = Submit { $codeValue }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import React from 'react';
6+
import FormVerifyTotp from '.';
7+
import { Meta } from '@storybook/react';
8+
import { withLocalization } from 'fxa-react/lib/storybooks';
9+
import Subject from './mocks';
10+
11+
export default {
12+
title: 'Components/FormVerifyTotp',
13+
component: FormVerifyTotp,
14+
decorators: [withLocalization],
15+
} as Meta;
16+
17+
export const With6DigitCode = () => <Subject />;
18+
19+
export const With8DigitCode = () => <Subject codeLength={8} />;
20+
21+
export const WithErrorOnSubmit = () => <Subject success={false} />;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider';
6+
import React from 'react';
7+
import Subject from './mocks';
8+
import userEvent from '@testing-library/user-event';
9+
import { screen, waitFor } from '@testing-library/react';
10+
11+
describe('FormVerifyTotp component', () => {
12+
it('renders as expected with default props', async () => {
13+
renderWithLocalizationProvider(<Subject />);
14+
expect(screen.getByText('Enter 6-digit code')).toBeVisible();
15+
expect(screen.getAllByRole('textbox')).toHaveLength(6);
16+
const button = screen.getByRole('button');
17+
expect(button).toHaveTextContent('Submit');
18+
});
19+
20+
describe('submit button', () => {
21+
it('is disabled on render', () => {
22+
renderWithLocalizationProvider(<Subject />);
23+
const button = screen.getByRole('button');
24+
expect(button).toHaveTextContent('Submit');
25+
expect(button).toBeDisabled();
26+
});
27+
28+
it('is enabled when numbers are typed into all inputs', async () => {
29+
const user = userEvent.setup();
30+
renderWithLocalizationProvider(<Subject />);
31+
const button = screen.getByRole('button');
32+
expect(button).toHaveTextContent('Submit');
33+
expect(button).toBeDisabled();
34+
35+
expect(
36+
screen.getByRole('textbox', { name: 'Digit 1 of 6' })
37+
).toHaveFocus();
38+
39+
// type in each input
40+
for (let i = 1; i <= 6; i++) {
41+
await waitFor(() =>
42+
user.type(
43+
screen.getByRole('textbox', { name: `Digit ${i} of 6` }),
44+
i.toString()
45+
)
46+
);
47+
}
48+
expect(button).toBeEnabled();
49+
});
50+
51+
it('is enabled when numbers are pasted into all inputs', async () => {
52+
const user = userEvent.setup();
53+
renderWithLocalizationProvider(<Subject />);
54+
const button = screen.getByRole('button');
55+
expect(button).toHaveTextContent('Submit');
56+
expect(button).toBeDisabled();
57+
58+
await waitFor(() => {
59+
user.click(screen.getByRole('textbox', { name: 'Digit 1 of 6' }));
60+
user.paste('123456');
61+
});
62+
63+
expect(button).toBeEnabled();
64+
});
65+
});
66+
67+
describe('errors', () => {
68+
it('are cleared when typing in input', async () => {
69+
const user = userEvent.setup();
70+
renderWithLocalizationProvider(
71+
<Subject initialErrorMessage="Something went wrong" />
72+
);
73+
74+
expect(screen.getByText('Something went wrong')).toBeVisible();
75+
76+
await waitFor(() =>
77+
user.type(screen.getByRole('textbox', { name: 'Digit 1 of 6' }), '1')
78+
);
79+
80+
expect(
81+
screen.queryByText('Something went wrong')
82+
).not.toBeInTheDocument();
83+
});
84+
});
85+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import React, { useRef, useState } from 'react';
6+
import TotpInputGroup from '../TotpInputGroup';
7+
import { FtlMsg } from 'fxa-react/lib/utils';
8+
import Banner, { BannerType } from '../Banner';
9+
10+
export type CodeArray = Array<string | undefined>;
11+
12+
export type FormVerifyTotpProps = {
13+
codeLength: 6 | 8;
14+
errorMessage: string;
15+
localizedInputGroupLabel: string;
16+
localizedSubmitButtonText: string;
17+
setErrorMessage: React.Dispatch<React.SetStateAction<string>>;
18+
verifyCode: (code: string) => void;
19+
};
20+
21+
const FormVerifyTotp = ({
22+
codeLength,
23+
errorMessage,
24+
localizedInputGroupLabel,
25+
localizedSubmitButtonText,
26+
setErrorMessage,
27+
verifyCode,
28+
}: FormVerifyTotpProps) => {
29+
const inputRefs = useRef(
30+
Array.from({ length: codeLength }, () =>
31+
React.createRef<HTMLInputElement>()
32+
)
33+
);
34+
35+
const [codeArray, setCodeArray] = useState<CodeArray>(new Array(codeLength));
36+
const [isSubmitting, setIsSubmitting] = useState(false);
37+
38+
const stringifiedCode = codeArray.join('');
39+
const handleSubmit = async (e: React.FormEvent) => {
40+
e.preventDefault();
41+
42+
setIsSubmitting(true);
43+
await verifyCode(stringifiedCode);
44+
setIsSubmitting(false);
45+
};
46+
47+
return (
48+
<>
49+
{errorMessage && <Banner type={BannerType.error}>{errorMessage}</Banner>}
50+
<form
51+
noValidate
52+
className="flex flex-col gap-4 my-6"
53+
onSubmit={handleSubmit}
54+
>
55+
<TotpInputGroup
56+
{...{
57+
codeArray,
58+
codeLength,
59+
inputRefs,
60+
localizedInputGroupLabel,
61+
setCodeArray,
62+
setErrorMessage,
63+
errorMessage,
64+
}}
65+
/>
66+
<FtlMsg
67+
id="form-verify-code-submit-button"
68+
attrs={{ ariaLabel: true }}
69+
vars={{ codeValue: stringifiedCode }}
70+
>
71+
<button
72+
type="submit"
73+
className="cta-primary cta-xl"
74+
disabled={isSubmitting || stringifiedCode.length < codeLength}
75+
aria-label={`Submit ${stringifiedCode}`}
76+
>
77+
{localizedSubmitButtonText}
78+
</button>
79+
</FtlMsg>
80+
</form>
81+
</>
82+
);
83+
};
84+
85+
export default FormVerifyTotp;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import React, { useCallback, useState } from 'react';
6+
import AppLayout from '../AppLayout';
7+
import FormVerifyTotp, { FormVerifyTotpProps } from '.';
8+
9+
export const Subject = ({
10+
codeLength = 6,
11+
success = true,
12+
initialErrorMessage = '',
13+
}: Partial<FormVerifyTotpProps> & {
14+
success?: Boolean;
15+
initialErrorMessage?: string;
16+
}) => {
17+
const localizedInputGroupLabel = `Enter ${codeLength.toString()}-digit code`;
18+
const localizedSubmitButtonText = 'Submit';
19+
const [errorMessage, setErrorMessage] = useState(initialErrorMessage);
20+
21+
const mockVerifyCodeSuccess = useCallback(
22+
(code: string) => alert(`Mock code submission with code ${code}`),
23+
[]
24+
);
25+
26+
const mockVerifyCodeFail = useCallback(
27+
(code: string) => setErrorMessage('Something went wrong'),
28+
[]
29+
);
30+
31+
const verifyCode = success ? mockVerifyCodeSuccess : mockVerifyCodeFail;
32+
33+
return (
34+
<AppLayout>
35+
<FormVerifyTotp
36+
{...{
37+
codeLength,
38+
errorMessage,
39+
localizedInputGroupLabel,
40+
localizedSubmitButtonText,
41+
setErrorMessage,
42+
verifyCode,
43+
}}
44+
/>
45+
</AppLayout>
46+
);
47+
};
48+
49+
export default Subject;

packages/fxa-settings/src/components/Settings/ModalVerifySession/index.stories.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { ModalVerifySession } from '.';
1010
import { AppContext } from 'fxa-settings/src/models';
1111
import { mockSession, MOCK_ACCOUNT } from 'fxa-settings/src/models/mocks';
1212
import { LocationProvider } from '@reach/router';
13-
import { AuthUiErrors } from 'fxa-settings/src/lib/auth-errors/auth-errors';
1413

1514
export default {
1615
title: 'Components/Settings/ModalVerifySession',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
## TotpInputGroup component
2+
## This component is composed of 6 or 8 single digit inputs for verification codes
3+
4+
# Screen reader only label for each single-digit input, e.g., Code digit 1 of 6
5+
# $inputNumber is a number from 1 to 8
6+
# $codeLength is a number, it represents the total length of the code
7+
single-char-input-label = Digit { $inputNumber } of { $codeLength }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import React from 'react';
6+
import { Meta } from '@storybook/react';
7+
import TotpInputGroup from '.';
8+
import { withLocalization } from 'fxa-react/lib/storybooks';
9+
import Subject from './mocks';
10+
11+
export default {
12+
title: 'Components/TotpInputGroup',
13+
component: TotpInputGroup,
14+
decorators: [withLocalization],
15+
} as Meta;
16+
17+
export const With6Digits = () => <Subject />;
18+
19+
export const With8Digits = () => <Subject codeLength={8} />;
20+
21+
export const WithError = () => (
22+
<Subject initialErrorMessage="Sample error message." />
23+
);

0 commit comments

Comments
 (0)