-
Notifications
You must be signed in to change notification settings - Fork 227
feat(settings): new FlowSetup2faBackupCodeDownload component #18941
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| ## The backup codes download step of the setup 2 factor authentication flow | ||
|
|
||
| flow-setup-2fa-backup-code-dl-heading = Save backup authentication codes | ||
|
|
||
| flow-setup-2fa-backup-code-dl-save-these-codes = Keep these in a place you’ll remember. If you don’t have access to your authenticator app you’ll need to enter one to sign in. | ||
|
|
||
| flow-setup-2fa-backup-code-dl-button-continue = Continue | ||
|
|
||
| ## | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| /* 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 { withLocalization } from 'fxa-react/lib/storybooks'; | ||
| import SettingsLayout from '../SettingsLayout'; | ||
| import { action } from '@storybook/addon-actions'; | ||
| import { FlowSetup2faBackupCodeDownload } from '.'; | ||
|
|
||
| export default { | ||
| title: 'Components/Settings/FlowSetup2faBackupCodeDownload', | ||
| component: FlowSetup2faBackupCodeDownload, | ||
| decorators: [withLocalization], | ||
| } as Meta; | ||
|
|
||
| const navigateBackward = async () => { | ||
| action('navigateBackward')(); | ||
| }; | ||
|
|
||
| const onContinue = () => { | ||
MagentaManifold marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| action('onContinue')(); | ||
| }; | ||
|
|
||
| const dummyRecoveryCodes = [ | ||
| 'code111111', | ||
| 'code222222', | ||
| 'code333333', | ||
| 'code444444', | ||
| 'code555555', | ||
| 'code666666', | ||
| 'code777777', | ||
| 'code888888', | ||
| ]; | ||
|
|
||
| export const Default = () => ( | ||
| <SettingsLayout> | ||
| <FlowSetup2faBackupCodeDownload | ||
| currentStep={2} | ||
| numberOfSteps={3} | ||
| localizedFlowTitle="Two-step authentication" | ||
| onBackButtonClick={navigateBackward} | ||
| showProgressBar | ||
| email="mock@example.com" | ||
| recoveryCodes={dummyRecoveryCodes} | ||
| onContinue={onContinue} | ||
| /> | ||
| </SettingsLayout> | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| /* 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 } from '@testing-library/react'; | ||
| import userEvent from '@testing-library/user-event'; | ||
|
|
||
| import { FlowSetup2faBackupCodeDownload } from '.'; | ||
| import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; | ||
| import GleanMetrics from '../../../lib/glean'; | ||
| import { GleanClickEventType2FA } from '../../../lib/types'; | ||
|
|
||
| const recoveryCodes = ['3594s0tbsq', '0zrg82sdzm', 'wx88yxenfc']; | ||
|
|
||
| const renderFlowSetup2faBackupCodeDownload = () => { | ||
| const onBackButtonClick = jest.fn(); | ||
| const onContinue = jest.fn(); | ||
| return { | ||
| onBackButtonClick, | ||
| onContinue, | ||
| ...renderWithLocalizationProvider( | ||
| <FlowSetup2faBackupCodeDownload | ||
| currentStep={1} | ||
| numberOfSteps={3} | ||
| localizedFlowTitle="Two-step authentication" | ||
| email="ibicking@mozilla.com" | ||
| showProgressBar | ||
| {...{ recoveryCodes, onBackButtonClick, onContinue }} | ||
| /> | ||
| ), | ||
| }; | ||
| }; | ||
|
|
||
| describe('FlowSetup2faBackupCodeDownload', () => { | ||
| beforeEach(() => { | ||
| window.URL.createObjectURL = jest.fn(() => 'blob:mock-url'); | ||
| // set UA to a desktop browser as the default for the tests | ||
| // using a hack to work around userAgent being read-only | ||
| Object.defineProperty(window.navigator, 'userAgent', { | ||
| value: | ||
| 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0', | ||
| configurable: true, | ||
| }); | ||
| }); | ||
| it('renders correctly', () => { | ||
| renderFlowSetup2faBackupCodeDownload(); | ||
| expect(screen.getByRole('progressbar')).toBeVisible(); | ||
| screen | ||
| .getByTestId('datablock') | ||
| .querySelectorAll('li') | ||
| .forEach((li, i) => expect(li).toHaveTextContent(recoveryCodes[i])); | ||
| expect(screen.getByRole('link', { name: 'Download' })).toHaveAttribute( | ||
| 'download', | ||
| 'ibicking@mozilla.com Backup authentication codes.txt' | ||
| ); | ||
| }); | ||
|
|
||
| it('sets up Glean metrics correctly', () => { | ||
| const gleanSpy = jest.spyOn( | ||
| GleanMetrics.accountPref, | ||
| 'twoStepAuthCodesView' | ||
| ); | ||
| renderFlowSetup2faBackupCodeDownload(); | ||
| expect(gleanSpy).toBeCalled(); | ||
|
|
||
| const downloadButton = screen.getByRole('link', { name: 'Download' }); | ||
| const copyButton = screen.getByRole('button', { name: 'Copy' }); | ||
| const printButton = screen.getByRole('button', { name: 'Print' }); | ||
|
|
||
| expect(downloadButton).toHaveAttribute( | ||
| 'data-glean-id', | ||
| 'two_step_auth_codes_download' | ||
| ); | ||
| expect(copyButton).toHaveAttribute( | ||
| 'data-glean-id', | ||
| 'two_step_auth_codes_copy' | ||
| ); | ||
| expect(printButton).toHaveAttribute( | ||
| 'data-glean-id', | ||
| 'two_step_auth_codes_print' | ||
| ); | ||
| for (const button of [downloadButton, copyButton, printButton]) { | ||
| expect(button).toHaveAttribute( | ||
| 'data-glean-type', | ||
| GleanClickEventType2FA.setup | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| it('does not display the download button or the print button on mobile', () => { | ||
| // Set the user agent to a mobile browser for this test case | ||
| Object.defineProperty(window.navigator, 'userAgent', { | ||
| value: | ||
| 'Mozilla/5.0 (Android 15; Mobile; rv:139.0) Gecko/139.0 Firefox/139.0', | ||
| configurable: true, | ||
| }); | ||
| renderFlowSetup2faBackupCodeDownload(); | ||
| expect( | ||
| screen.queryByRole('link', { name: 'Download' }) | ||
| ).not.toBeInTheDocument(); | ||
| expect( | ||
| screen.queryByRole('button', { name: 'Print' }) | ||
| ).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('calls onBackButtonClick when the back button is clicked', async () => { | ||
| const { onBackButtonClick } = renderFlowSetup2faBackupCodeDownload(); | ||
| const cancelButton = screen.getByRole('button', { name: 'Back' }); | ||
| await userEvent.click(cancelButton); | ||
| expect(onBackButtonClick).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('calls onContinue when the Continue button is clicked', async () => { | ||
| const { onContinue } = renderFlowSetup2faBackupCodeDownload(); | ||
| const continueButton = screen.getByRole('button', { name: 'Continue' }); | ||
| await userEvent.click(continueButton); | ||
| expect(onContinue).toHaveBeenCalled(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| /* 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, { useEffect } from 'react'; | ||
| import { FtlMsg } from 'fxa-react/lib/utils'; | ||
| import FlowContainer from '../FlowContainer'; | ||
| import ProgressBar from '../ProgressBar'; | ||
| import { GleanClickEventType2FA } from '../../../lib/types'; | ||
| import DataBlock from '../../DataBlock'; | ||
| import GleanMetrics from '../../../lib/glean'; | ||
| import { UAParser } from 'ua-parser-js'; | ||
|
|
||
| type FlowSetup2faBackupCodeDownloadProps = { | ||
| currentStep?: number; | ||
| numberOfSteps?: number; | ||
| hideBackButton?: boolean; | ||
| localizedFlowTitle: string; | ||
| onBackButtonClick?: () => void; | ||
| showProgressBar?: boolean; | ||
| recoveryCodes: string[]; | ||
| email: string; | ||
| onContinue: () => void; | ||
| reason?: GleanClickEventType2FA; | ||
| }; | ||
|
|
||
| export const FlowSetup2faBackupCodeDownload = ({ | ||
| currentStep, | ||
| numberOfSteps, | ||
| hideBackButton = false, | ||
| localizedFlowTitle, | ||
| onBackButtonClick, | ||
| showProgressBar = true, | ||
| recoveryCodes, | ||
| email, | ||
| onContinue, | ||
| reason = GleanClickEventType2FA.setup, | ||
| }: FlowSetup2faBackupCodeDownloadProps) => { | ||
| const [isMobile, setIsMobile] = React.useState(false); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why set as state? This should only need to be computed once, since it is static (vs checking based on viewport sizing which could change). Perhaps If updating this - it might also be good to update the comment with a tad more detail about what types will be reported as 'mobile' here vs having no known "type" (≈ desktop)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checking UA is impure, so Will update in future PRs if better solution exists |
||
| useEffect(() => { | ||
| // undefined means desktop | ||
| setIsMobile(new UAParser().getDevice().type !== undefined); | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| GleanMetrics.accountPref.twoStepAuthCodesView({ | ||
| event: { reason }, | ||
| }); | ||
| }, [reason]); | ||
|
|
||
| return ( | ||
| <FlowContainer | ||
| title={localizedFlowTitle} | ||
| {...{ hideBackButton, onBackButtonClick }} | ||
| > | ||
| {showProgressBar && currentStep != null && numberOfSteps != null && ( | ||
| <ProgressBar {...{ currentStep, numberOfSteps }} /> | ||
| )} | ||
| <FtlMsg id="flow-setup-2fa-backup-code-dl-heading"> | ||
| <h2 className="font-bold text-xl my-2"> | ||
| Save backup authentication codes | ||
| </h2> | ||
| </FtlMsg> | ||
|
|
||
| <div className="my-2" data-testid="2fa-recovery-codes"> | ||
| <FtlMsg id="flow-setup-2fa-backup-code-dl-save-these-codes"> | ||
| Keep these in a place you’ll remember. If you don’t have access to | ||
| your authenticator app you’ll need to enter one to sign in. | ||
| </FtlMsg> | ||
| <div className="mt-6 flex flex-col items-center justify-between"> | ||
| <DataBlock | ||
MagentaManifold marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| value={recoveryCodes} | ||
| contentType="Backup authentication codes" | ||
| email={email} | ||
| isMobile={isMobile} | ||
| gleanDataAttrs={{ | ||
| download: { | ||
| id: 'two_step_auth_codes_download', | ||
| type: reason, | ||
| }, | ||
| copy: { | ||
| id: 'two_step_auth_codes_copy', | ||
| type: reason, | ||
| }, | ||
| print: { | ||
| id: 'two_step_auth_codes_print', | ||
| type: reason, | ||
| }, | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
| <FtlMsg id="flow-setup-2fa-backup-code-dl-button-continue"> | ||
| <button | ||
| type="submit" | ||
| className="cta-primary cta-xl mt-3" | ||
| onClick={onContinue} | ||
| data-glean-id="two_step_auth_codes_submit" | ||
| > | ||
| Continue | ||
| </button> | ||
| </FtlMsg> | ||
| </FlowContainer> | ||
| ); | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.