Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default {

export const SingleCode = () => <Subject />;

export const SingleCodeOnIOS = () => <Subject isIOS />;
export const SingleCodeOnIOS = () => <Subject isMobile />;

export const MultipleCodes = () => (
<Subject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ it('can render multiple values', () => {
});

it('displays only Copy icon in iOS', () => {
renderWithLocalizationProvider(<Subject value={multiValue} isIOS />);
renderWithLocalizationProvider(<Subject value={multiValue} isMobile />);

expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument();
expect(
Expand Down
12 changes: 6 additions & 6 deletions packages/fxa-settings/src/components/DataBlock/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type DataBlockProps = {
prefixDataTestId?: string;
onCopy?: (event: React.ClipboardEvent<HTMLElement>) => void;
onAction?: actionFn;
isIOS?: boolean;
isMobile?: boolean;
email: string;
gleanDataAttrs: {
copy?: GetDataTrioGleanData;
Expand All @@ -42,7 +42,7 @@ export const DataBlock = ({
prefixDataTestId,
onCopy,
onAction = () => {},
isIOS = false,
isMobile = false,
email,
gleanDataAttrs,
}: DataBlockProps) => {
Expand All @@ -61,10 +61,10 @@ export const DataBlock = ({
};

return (
<div className="w-full flex flex-col gap-3 items-center bg-white rounded-xl border-2 border-grey-100 px-5 pt-5 pb-3">
<div className="w-full flex flex-col items-center bg-white rounded-xl border-2 border-grey-100 p-5">
<ul
className={classNames(
'relative gap-2 w-full text-black text-sm font-mono font-bold',
'relative gap-2 mobileLandscape:gap-4 w-full mb-5 text-black text-sm font-mono font-bold',
valueIsArray ? 'grid grid-cols-2 max-w-sm' : 'flex flex-col max-w-lg'
)}
{...{ onCopy }}
Expand All @@ -88,13 +88,13 @@ export const DataBlock = ({
prefixDataTestId={`datablock-${performedAction}`}
message={actionTypeToNotification[performedAction]}
anchorPosition="middle"
position="bottom"
position="top"
className="mt-1"
></Tooltip>
</FtlMsg>
)}
</ul>
{isIOS ? (
{isMobile ? (
<GetDataCopySingleton
{...{
value,
Expand Down
10 changes: 5 additions & 5 deletions packages/fxa-settings/src/components/GetDataTrio/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export type GetDataTrioProps = {
};

const trioButtonClassName =
'w-24 h-20 shrink p-1 relative text-grey-600 text-sm rounded-xl flex flex-col items-center justify-center hover:text-blue-600 active:text-blue-500 focus-visible-default outline-offset-2 hover:bg-gradient-to-tr hover:from-blue-600/10 hover:to-purple-500/10 active:bg-gradient-to-tr active:from-blue-600/10 active:to-purple-500/10 focus-visible:bg-gradient-to-tr focus-visible:from-blue-600/10 focus-visible:to-purple-500/10';
'w-12 h-12 p-1 relative text-grey-600 text-sm rounded flex flex-col items-center justify-center hover:text-blue-600 active:text-blue-500 focus-visible-default outline-offset-2 hover:bg-gradient-to-tr hover:from-blue-600/10 hover:to-purple-500/10 active:bg-gradient-to-tr active:from-blue-600/10 active:to-purple-500/10 focus-visible:bg-gradient-to-tr focus-visible:from-blue-600/10 focus-visible:to-purple-500/10';

export const GetDataCopySingleton = ({
value,
Expand Down Expand Up @@ -76,7 +76,7 @@ export const GetDataCopySingleton = ({
data-glean-id={gleanDataAttrs.copy?.id}
data-glean-type={gleanDataAttrs.copy?.type}
>
<CopyIcon aria-hidden className="w-10 h-10 fill-current" />
<CopyIcon aria-hidden className="w-8 h-8 fill-current" />
</button>
</FtlMsg>
);
Expand Down Expand Up @@ -169,7 +169,7 @@ export const GetDataTrio = ({
}, [value, pageTitle]);

return (
<div className="flex justify-center w-full">
<div className="flex justify-between max-w-52 w-4/5">
<FtlMsg
id="get-data-trio-download-2"
attrs={{ title: true, 'aria-label': true }}
Expand All @@ -195,7 +195,7 @@ export const GetDataTrio = ({
data-glean-id={gleanDataAttrs.download?.id}
data-glean-type={gleanDataAttrs.download?.type}
>
<DownloadIcon aria-hidden className="w-10 h-10 fill-current" />
<DownloadIcon aria-hidden className="w-8 h-8 fill-current" />
</a>
</FtlMsg>

Expand Down Expand Up @@ -225,7 +225,7 @@ export const GetDataTrio = ({
data-glean-id={gleanDataAttrs.print?.id}
data-glean-type={gleanDataAttrs.print?.type}
>
<PrintIcon aria-hidden className="w-10 h-10 fill-current" />
<PrintIcon aria-hidden className="w-7 h-7 fill-current" />
</button>
</FtlMsg>
</div>
Expand Down
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 = () => {
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);
Copy link
Contributor

Choose a reason for hiding this comment

The 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 useMemo or a plain const would be sufficient?

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)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking UA is impure, so useMemo is not correct here. Using a plain const will cause the UA check to run every re-render. So I am using a combination of useState and useEffect, so that the check is only run once (on mount).

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
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>
);
};