Skip to content

Commit 3356b39

Browse files
Merge pull request #19362 from mozilla/FXA-12302
feat(mfa): create new ModalMfaProtected component
2 parents 572ef40 + 2961ab2 commit 3356b39

5 files changed

Lines changed: 369 additions & 1 deletion

File tree

packages/fxa-react/lib/storybooks.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const withLocalization: Decorator = (Story) => (
2323
</AppLocalizationProvider>
2424
);
2525

26-
export const withLocation: (location: string | undefined) => Decorator =
26+
export const withLocation: (location?: string) => Decorator =
2727
(location) => (Story) => {
2828
if (location === undefined) {
2929
return (
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
## ModalMfaProtected
2+
3+
modal-mfa-protected-title = Enter confirmation code
4+
modal-mfa-protected-subtitle = Help us make sure it’s you changing your account info
5+
# This string is used to show a notification to the user for them to enter
6+
# email confirmation code to update their multi-factor-authentication-protected
7+
# account settings
8+
# Variables:
9+
# email (String) - the user's email
10+
# expirationTime (Number) - the expiration time in minutes
11+
modal-mfa-protected-instruction = { $expirationTime ->
12+
[one] Enter the code that was sent to <email>{ $email }</email> within { $expirationTime } minute.
13+
*[other] Enter the code that was sent to <email>{ $email }</email> within { $expirationTime } minutes.
14+
}
15+
modal-mfa-protected-input-label = Enter 6-digit code
16+
modal-mfa-protected-cancel-button = Cancel
17+
modal-mfa-protected-confirm-button = Confirm
18+
19+
modal-mfa-protected-code-expired = Code expired?
20+
# Link to resend a new code to the user's email.
21+
modal-mfa-protected-resend-code-link = Email new code.
22+
23+
##
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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, { useState } from 'react';
6+
import { Meta } from '@storybook/react';
7+
import { withLocalization, withLocation } from 'fxa-react/lib/storybooks';
8+
import { useBooleanState } from 'fxa-react/lib/hooks';
9+
import { ModalMfaProtected } from '.';
10+
import { action } from '@storybook/addon-actions';
11+
import { MOCK_EMAIL } from '../../../pages/mocks';
12+
13+
export default {
14+
title: 'Components/Settings/ModalMfaProtected',
15+
component: ModalMfaProtected,
16+
decorators: [withLocalization, withLocation()],
17+
} as Meta;
18+
19+
export const DefaultWithValidCode123456 = () => {
20+
const [modalRevealed, showModal, hideModal] = useBooleanState(true);
21+
const [localizedErrorTooltipMessage, setLocalizedErrorTooltipMessage] =
22+
useState<string | undefined>(undefined);
23+
const [showResendSuccessBanner, setShowResendSuccessBanner] =
24+
useState<boolean>(false);
25+
26+
const dismiss = () => {
27+
hideModal();
28+
setLocalizedErrorTooltipMessage(undefined);
29+
setShowResendSuccessBanner(false);
30+
};
31+
32+
return (
33+
<>
34+
<button
35+
className="cta-base-p cta-neutral"
36+
onClick={(event) => {
37+
event.stopPropagation();
38+
showModal();
39+
}}
40+
>
41+
Show modal
42+
</button>
43+
{modalRevealed && (
44+
<ModalMfaProtected
45+
email={MOCK_EMAIL}
46+
expirationTime={5}
47+
onSubmit={(code) => {
48+
action('Submitted')(code);
49+
if (code === '123456') {
50+
dismiss();
51+
} else {
52+
setLocalizedErrorTooltipMessage(
53+
'Invalid or expired confirmation code.'
54+
);
55+
setShowResendSuccessBanner(false);
56+
}
57+
}}
58+
{...{
59+
localizedErrorTooltipMessage,
60+
showResendSuccessBanner,
61+
}}
62+
clearErrorTooltip={() => {
63+
setLocalizedErrorTooltipMessage(undefined);
64+
}}
65+
onDismiss={dismiss}
66+
handleResendCode={() => {
67+
setShowResendSuccessBanner(true);
68+
}}
69+
resendCodeLoading={false}
70+
/>
71+
)}
72+
</>
73+
);
74+
};
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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 { screen } from '@testing-library/react';
7+
import userEvent from '@testing-library/user-event';
8+
import { renderWithRouter } from '../../../models/mocks';
9+
import { ModalMfaProtected } from '.';
10+
import { MOCK_EMAIL } from '../../../pages/mocks';
11+
12+
const defaultProps = {
13+
email: MOCK_EMAIL,
14+
expirationTime: 5,
15+
onSubmit: () => {},
16+
onDismiss: () => {},
17+
handleResendCode: () => {},
18+
clearErrorTooltip: () => {},
19+
resendCodeLoading: false,
20+
showResendSuccessBanner: false,
21+
};
22+
23+
describe('ModalMfaProtected', () => {
24+
it('renders correctly', () => {
25+
renderWithRouter(<ModalMfaProtected {...defaultProps} />);
26+
27+
expect(screen.getByText('Enter confirmation code')).toBeInTheDocument();
28+
expect(
29+
screen.getByText('Help us make sure it’s you changing your account info')
30+
).toBeInTheDocument();
31+
expect(
32+
screen.getByText(/Enter the code that was sent to/)
33+
).toHaveTextContent(
34+
`Enter the code that was sent to ${MOCK_EMAIL} within 5 minutes.`
35+
);
36+
expect(
37+
screen.getByRole('textbox', { name: 'Enter 6-digit code' })
38+
).toBeInTheDocument();
39+
expect(screen.getByTestId('modal-dismiss')).toBeInTheDocument();
40+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
41+
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
42+
expect(screen.getByText('Code expired?')).toBeInTheDocument();
43+
expect(
44+
screen.getByRole('button', { name: 'Email new code.' })
45+
).toBeInTheDocument();
46+
});
47+
48+
it('handles form submission', async () => {
49+
const onSubmit = jest.fn();
50+
renderWithRouter(
51+
<ModalMfaProtected {...defaultProps} onSubmit={onSubmit} />
52+
);
53+
54+
await userEvent.type(
55+
screen.getByRole('textbox', { name: 'Enter 6-digit code' }),
56+
'123456'
57+
);
58+
await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
59+
60+
expect(onSubmit).toHaveBeenCalledWith('123456');
61+
});
62+
63+
it('calls handleResendCode when Email new code button is clicked', async () => {
64+
const handleResendCode = jest.fn();
65+
renderWithRouter(
66+
<ModalMfaProtected
67+
{...defaultProps}
68+
handleResendCode={handleResendCode}
69+
/>
70+
);
71+
72+
await userEvent.click(
73+
screen.getByRole('button', { name: 'Email new code.' })
74+
);
75+
76+
expect(handleResendCode).toHaveBeenCalled();
77+
});
78+
79+
it('calls onDismiss when the close button is clicked', async () => {
80+
const onDismiss = jest.fn();
81+
renderWithRouter(
82+
<ModalMfaProtected {...defaultProps} onDismiss={onDismiss} />
83+
);
84+
85+
await userEvent.click(screen.getByTestId('modal-dismiss'));
86+
87+
expect(onDismiss).toHaveBeenCalled();
88+
});
89+
90+
it('calls onDismiss when the cancel button is clicked', async () => {
91+
const onDismiss = jest.fn();
92+
renderWithRouter(
93+
<ModalMfaProtected {...defaultProps} onDismiss={onDismiss} />
94+
);
95+
96+
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
97+
98+
expect(onDismiss).toHaveBeenCalled();
99+
});
100+
101+
it('displays banners and tooltips', () => {
102+
renderWithRouter(
103+
<ModalMfaProtected
104+
{...defaultProps}
105+
localizedErrorTooltipMessage="error tooltip"
106+
/>
107+
);
108+
expect(screen.getByText('error tooltip')).toBeInTheDocument();
109+
});
110+
111+
it('shows code resend success banner', () => {
112+
renderWithRouter(
113+
<ModalMfaProtected {...defaultProps} showResendSuccessBanner={true} />
114+
);
115+
expect(
116+
screen.getByText('A new code was sent to your email.')
117+
).toBeInTheDocument();
118+
expect(
119+
screen.getByText(
120+
'Add [email protected] to your contacts to ensure a smooth delivery.'
121+
)
122+
).toBeInTheDocument();
123+
});
124+
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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 { useForm } from 'react-hook-form';
7+
import Modal from '../Modal';
8+
import InputText from '../../InputText';
9+
import { useFtlMsgResolver } from '../../../models';
10+
import { FtlMsg } from 'fxa-react/lib/utils';
11+
import { EmailCodeImage } from '../../images';
12+
import { ResendCodeSuccessBanner } from '../../Banner';
13+
14+
type ModalProps = {
15+
email: string;
16+
expirationTime: number;
17+
onSubmit: (code: string) => void;
18+
onDismiss: () => void;
19+
handleResendCode: () => void;
20+
clearErrorTooltip: () => void;
21+
localizedErrorTooltipMessage?: string;
22+
resendCodeLoading: boolean;
23+
showResendSuccessBanner: boolean;
24+
};
25+
26+
type FormData = {
27+
confirmationCode: string;
28+
};
29+
30+
export const ModalMfaProtected = ({
31+
email,
32+
expirationTime,
33+
onSubmit,
34+
onDismiss,
35+
handleResendCode,
36+
clearErrorTooltip,
37+
localizedErrorTooltipMessage,
38+
resendCodeLoading,
39+
showResendSuccessBanner,
40+
}: ModalProps) => {
41+
const ftlMsgResolver = useFtlMsgResolver();
42+
43+
const { handleSubmit, register, formState } = useForm<FormData>({
44+
mode: 'all',
45+
defaultValues: {
46+
confirmationCode: '',
47+
},
48+
});
49+
50+
const { isDirty, isValid } = formState;
51+
const buttonDisabled = !isDirty || !isValid;
52+
53+
return (
54+
<Modal
55+
data-testid="modal-verify-session"
56+
descId="modal-mfa-protected-desc"
57+
headerId="modal-mfa-protected-title"
58+
hasButtons={false}
59+
onDismiss={onDismiss}
60+
>
61+
<form
62+
onSubmit={handleSubmit(({ confirmationCode }) => {
63+
onSubmit(confirmationCode.trim());
64+
})}
65+
>
66+
<FtlMsg id="modal-mfa-protected-title">
67+
<h2 id="modal-mfa-protected-title" className="font-bold text-xl">
68+
Enter confirmation code
69+
</h2>
70+
</FtlMsg>
71+
<FtlMsg id="modal-mfa-protected-subtitle">
72+
<p className="text-base mt-1">Help us make sure it’s you changing your account info</p>
73+
</FtlMsg>
74+
{showResendSuccessBanner && <ResendCodeSuccessBanner />}
75+
76+
<EmailCodeImage />
77+
78+
<FtlMsg
79+
id="modal-mfa-protected-instruction"
80+
vars={{ email, expirationTime }}
81+
elems={{
82+
email: <span className="font-bold">{email}</span>,
83+
}}
84+
>
85+
<p id="modal-mfa-protected-desc" className="my-6">
86+
Enter the code that was sent to <span className="font-bold">{email}</span> within {expirationTime === 1 ? '1 minute' : `${expirationTime} minutes`}.
87+
</p>
88+
</FtlMsg>
89+
90+
<div className="mt-4 mb-8">
91+
<InputText
92+
name="confirmationCode"
93+
label={ftlMsgResolver.getMsg(
94+
'modal-mfa-protected-input-label',
95+
'Enter 6-digit code'
96+
)}
97+
inputRef={register({
98+
required: true,
99+
pattern: /^\s*[0-9]{6}\s*$/,
100+
})}
101+
onChange={clearErrorTooltip}
102+
{...{
103+
errorText: localizedErrorTooltipMessage,
104+
}}
105+
/>
106+
</div>
107+
108+
<div className="flex justify-between gap-4 w-full">
109+
<FtlMsg id="modal-mfa-protected-cancel-button">
110+
<button
111+
type="button"
112+
className="cta-neutral cta-xl flex-1 w-1/2"
113+
onClick={onDismiss}
114+
>
115+
Cancel
116+
</button>
117+
</FtlMsg>
118+
<FtlMsg id="modal-mfa-protected-confirm-button">
119+
<button
120+
type="submit"
121+
className="cta-primary cta-xl flex-1 w-1/2"
122+
disabled={buttonDisabled}
123+
>
124+
Confirm
125+
</button>
126+
</FtlMsg>
127+
</div>
128+
</form>
129+
<div className="mt-7 text-grey-500 text-sm inline-flex gap-1">
130+
<FtlMsg id="modal-mfa-protected-code-expired">
131+
<p>Code expired?</p>
132+
</FtlMsg>
133+
<FtlMsg id="modal-mfa-protected-resend-code-link">
134+
<button
135+
className="link-blue"
136+
onClick={handleResendCode}
137+
disabled={resendCodeLoading}
138+
>
139+
Email new code.
140+
</button>
141+
</FtlMsg>
142+
</div>
143+
</Modal>
144+
);
145+
};
146+
147+
export default ModalMfaProtected;

0 commit comments

Comments
 (0)