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
3 changes: 2 additions & 1 deletion packages/fxa-settings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,10 @@ Example: Guarding a page at render time with a scoped JWT
```tsx
import { MfaGuard } from './MfaGuard';
import PageMfaGuardTestWithAuthClient from './components/Settings/PageMfaGuardTest';
import { MfaReason } from '../../../lib/types';

export const Page = () => (
<MfaGuard requiredScope="test">
<MfaGuard requiredScope="test" reason={MfaReason.test}>
<PageMfaGuardTestWithAuthClient path="/mfa_guard/test/auth_client" />
</MfaGuard>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AppContext } from '../../../models';
import { mockAppContext } from '../../../models/mocks';
import { MfaGuard } from './index';
import { JwtTokenCache } from '../../../lib/cache';
import { MfaReason } from '../../../lib/types';

const scope: 'test' = 'test';
const session = 'session-xyz';
Expand Down Expand Up @@ -59,7 +60,7 @@ export const JwtMissingShowsModal = () => {

return (
<AppContext.Provider value={mockAppContext({ authClient } as any)}>
<MfaGuard requiredScope={scope}>
<MfaGuard requiredScope={scope} reason={MfaReason.test}>
<div>Secured content</div>
</MfaGuard>
</AppContext.Provider>
Expand All @@ -72,7 +73,7 @@ export const JwtPresentRendersChildren = () => {

return (
<AppContext.Provider value={mockAppContext({ authClient } as any)}>
<MfaGuard requiredScope={scope}>
<MfaGuard requiredScope={scope} reason={MfaReason.test}>
<div>Secured content</div>
</MfaGuard>
</AppContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { MfaGuard } from './index';
import { JwtTokenCache, MfaOtpRequestCache } from '../../../lib/cache';
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
import { AppContext } from '../../../models';
import { MfaReason } from '../../../lib/types';
import GleanMetrics from '../../../lib/glean';

const mockSessionToken = 'session-xyz';
const mockOtp = '123456';
Expand Down Expand Up @@ -64,7 +66,7 @@ describe('MfaGuard', () => {
it('requests OTP and shows modal when JWT missing', async () => {
renderWithRouter(
<AppContext.Provider value={mockAppContext()}>
<MfaGuard requiredScope={mockScope}>
<MfaGuard requiredScope={mockScope} reason={MfaReason.test}>
<div>secured</div>
</MfaGuard>
</AppContext.Provider>
Expand All @@ -88,12 +90,32 @@ describe('MfaGuard', () => {
);
});

it('emits metrics on success', async () => {
const submitSuccessSpy = jest.spyOn(
GleanMetrics.accountPref,
'mfaGuardSubmitSuccess'
);
renderWithRouter(
<AppContext.Provider value={mockAppContext()}>
<MfaGuard requiredScope={mockScope} reason={MfaReason.test}>
<div>secured</div>
</MfaGuard>
</AppContext.Provider>
);
await submitCode();
await waitFor(() => {
expect(submitSuccessSpy).toHaveBeenCalledWith({
event: { reason: MfaReason.test },
});
});
});

it('renders children when JWT exists', () => {
JwtTokenCache.setToken(mockSessionToken, mockScope, 'jwt-present');

renderWithRouter(
<AppContext.Provider value={mockAppContext()}>
<MfaGuard requiredScope={mockScope}>
<MfaGuard requiredScope={mockScope} reason={MfaReason.test}>
<div>secured</div>
</MfaGuard>
</AppContext.Provider>
Expand All @@ -117,7 +139,7 @@ describe('MfaGuard', () => {

renderWithRouter(
<AppContext.Provider value={mockAppContext()}>
<MfaGuard requiredScope={mockScope}>
<MfaGuard requiredScope={mockScope} reason={MfaReason.test}>
<div>secured</div>
</MfaGuard>
</AppContext.Provider>
Expand All @@ -132,7 +154,7 @@ describe('MfaGuard', () => {
it('shows error banner on invalid OTP', async () => {
renderWithRouter(
<AppContext.Provider value={mockAppContext()}>
<MfaGuard requiredScope={mockScope}>
<MfaGuard requiredScope={mockScope} reason={MfaReason.test}>
<div>secured</div>
</MfaGuard>
</AppContext.Provider>
Expand All @@ -149,7 +171,11 @@ describe('MfaGuard', () => {
it('clears error banner on input change', async () => {
renderWithRouter(
<AppContext.Provider value={mockAppContext()}>
<MfaGuard requiredScope={mockScope} debounceIntervalMs={0}>
<MfaGuard
requiredScope={mockScope}
debounceIntervalMs={0}
reason={MfaReason.test}
>
<div>secured</div>
</MfaGuard>
</AppContext.Provider>
Expand All @@ -171,7 +197,11 @@ describe('MfaGuard', () => {
it('shows resend success banner and hides error banner on resend success', async () => {
renderWithRouter(
<AppContext.Provider value={mockAppContext()}>
<MfaGuard requiredScope={mockScope} debounceIntervalMs={0}>
<MfaGuard
requiredScope={mockScope}
debounceIntervalMs={0}
reason={MfaReason.test}
>
<div>secured</div>
</MfaGuard>
</AppContext.Provider>
Expand Down Expand Up @@ -201,7 +231,11 @@ describe('MfaGuard', () => {
it('shows error banner and hide success banner on resend error', async () => {
renderWithRouter(
<AppContext.Provider value={mockAppContext()}>
<MfaGuard requiredScope={mockScope} debounceIntervalMs={0}>
<MfaGuard
requiredScope={mockScope}
debounceIntervalMs={0}
reason={MfaReason.test}
>
<div>secured</div>
</MfaGuard>
</AppContext.Provider>
Expand Down Expand Up @@ -231,7 +265,11 @@ describe('MfaGuard', () => {

renderWithRouter(
<AppContext.Provider value={mockAppContext()}>
<MfaGuard requiredScope={mockScope} debounceIntervalMs={0}>
<MfaGuard
requiredScope={mockScope}
debounceIntervalMs={0}
reason={MfaReason.test}
>
<div>secured</div>
</MfaGuard>
</AppContext.Provider>
Expand All @@ -252,6 +290,7 @@ describe('MfaGuard', () => {
requiredScope={mockScope}
onDismissCallback={mockOnDismiss}
debounceIntervalMs={0}
reason={MfaReason.test}
>
<div>secured</div>
</MfaGuard>
Expand All @@ -266,7 +305,11 @@ describe('MfaGuard', () => {
it('debounces OTP resend requests', async () => {
renderWithRouter(
<AppContext.Provider value={mockAppContext()}>
<MfaGuard requiredScope={mockScope} debounceIntervalMs={100}>
<MfaGuard
requiredScope={mockScope}
debounceIntervalMs={100}
reason={MfaReason.test}
>
<div>secured</div>
</MfaGuard>
</AppContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ import {
MfaOtpRequestCache,
sessionToken as getSessionToken,
} from '../../../lib/cache';
import { MfaScope } from '../../../lib/types';
import { MfaReason, MfaScope } from '../../../lib/types';
import { useNavigate } from '@reach/router';
import * as Sentry from '@sentry/react';
import { MfaErrorBoundary } from './error-boundary';
import { getLocalizedErrorMessage } from '../../../lib/error-utils';
import GleanMetrics from '../../../lib/glean';

/**
* This is a guard component designed to wrap around components that perform
Expand All @@ -40,11 +41,13 @@ export const MfaGuard = ({
requiredScope,
onDismissCallback = async () => {},
debounceIntervalMs = 3000,
reason,
}: {
children: ReactNode;
requiredScope: MfaScope;
onDismissCallback?: () => Promise<void>;
debounceIntervalMs?: number;
reason: MfaReason;
}) => {
// Let errors be handled by error boundaries in async contexts
const handleError = useErrorHandler();
Expand Down Expand Up @@ -156,6 +159,9 @@ export const MfaGuard = ({
code,
requiredScope
);
GleanMetrics.accountPref.mfaGuardSubmitSuccess({
event: { reason },
});
JwtTokenCache.setToken(sessionToken, requiredScope, result.accessToken);
resetStates();
} catch (err) {
Expand Down Expand Up @@ -213,6 +219,7 @@ export const MfaGuard = ({
resendCodeLoading,
showResendSuccessBanner,
localizedErrorBannerMessage,
reason,
}}
>
<p>Re-verify Account!</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useBooleanState } from 'fxa-react/lib/hooks';
import { ModalMfaProtected } from '.';
import { action } from '@storybook/addon-actions';
import { MOCK_EMAIL } from '../../../pages/mocks';
import { MfaReason } from '../../../lib/types';

export default {
title: 'Components/Settings/ModalMfaProtected',
Expand Down Expand Up @@ -44,6 +45,7 @@ export const DefaultWithValidCode123456 = () => {
<ModalMfaProtected
email={MOCK_EMAIL}
expirationTime={5}
reason={MfaReason.test}
onSubmit={(code) => {
action('Submitted')(code);
if (code === '123456') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import React from 'react';
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithRouter } from '../../../models/mocks';
import { ModalMfaProtected } from '.';
import { MOCK_EMAIL } from '../../../pages/mocks';
import { MfaReason } from '../../../lib/types';
import GleanMetrics from '../../../lib/glean';

const defaultProps = {
email: MOCK_EMAIL,
Expand All @@ -18,6 +20,7 @@ const defaultProps = {
clearErrorMessage: () => {},
resendCodeLoading: false,
showResendSuccessBanner: false,
reason: MfaReason.test,
};

describe('ModalMfaProtected', () => {
Expand Down Expand Up @@ -45,6 +48,24 @@ describe('ModalMfaProtected', () => {
).toBeInTheDocument();
});

it('has correct metrics', async () => {
const viewSpy = jest.spyOn(GleanMetrics.accountPref, 'mfaGuardView');
renderWithRouter(<ModalMfaProtected {...defaultProps} />);
await waitFor(() => {
expect(viewSpy).toHaveBeenCalledWith({
event: { reason: MfaReason.test },
});
});
expect(screen.getByRole('button', { name: 'Confirm' })).toHaveAttribute(
'data-glean-id',
'account_pref_mfa_guard_submit'
);
expect(screen.getByRole('button', { name: 'Confirm' })).toHaveAttribute(
'data-glean-type',
MfaReason.test
);
});

it('handles form submission', async () => {
const onSubmit = jest.fn();
renderWithRouter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
* 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 React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import Modal from '../Modal';
import InputText from '../../InputText';
import { useFtlMsgResolver } from '../../../models';
import { FtlMsg } from 'fxa-react/lib/utils';
import { EmailCodeImage } from '../../images';
import Banner, { ResendCodeSuccessBanner } from '../../Banner';
import { MfaReason } from '../../../lib/types';
import GleanMetrics from '../../../lib/glean';

type ModalProps = {
email: string;
Expand All @@ -21,6 +23,7 @@ type ModalProps = {
localizedErrorBannerMessage?: string;
resendCodeLoading: boolean;
showResendSuccessBanner: boolean;
reason: MfaReason;
};

type FormData = {
Expand All @@ -37,7 +40,13 @@ export const ModalMfaProtected = ({
localizedErrorBannerMessage,
resendCodeLoading,
showResendSuccessBanner,
reason,
}: ModalProps) => {
useEffect(() => {
GleanMetrics.accountPref.mfaGuardView({
event: { reason },
});
}, [reason]);
const ftlMsgResolver = useFtlMsgResolver();

const { handleSubmit, register, formState } = useForm<FormData>({
Expand Down Expand Up @@ -144,6 +153,8 @@ export const ModalMfaProtected = ({
type="submit"
className="cta-primary cta-xl flex-1 w-1/2"
disabled={buttonDisabled}
data-glean-id="account_pref_mfa_guard_submit"
data-glean-type={reason}
>
Confirm
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ import { useAccount, useAlertBar, useFtlMsgResolver } from '../../../models';

import FlowSetup2faApp from '../FlowSetup2faApp';
import VerifiedSessionGuard from '../VerifiedSessionGuard';
import { GleanClickEventType2FA } from '../../../lib/types';
import { GleanClickEventType2FA, MfaReason } from '../../../lib/types';
import { FtlMsg } from 'fxa-react/lib/utils';
import { MfaGuard } from '../MfaGuard';

export const MfaGuardedPage2faChange = (_: RouteComponentProps) => {
return (
<MfaGuard requiredScope="2fa">
<MfaGuard requiredScope="2fa" reason={MfaReason.changeTotp}>
<Page2faChange />
</MfaGuard>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
useFtlMsgResolver,
useSession,
} from '../../../models';
import { GleanClickEventType2FA } from '../../../lib/types';
import { GleanClickEventType2FA, MfaReason } from '../../../lib/types';
import GleanMetrics from '../../../lib/glean';
import { totpUtils } from '../../../lib/totp-utils';
import VerifiedSessionGuard from '../VerifiedSessionGuard';
Expand All @@ -27,7 +27,7 @@ export const PageMfaGuard2faReplaceBackupCodes = (
props: RouteComponentProps
) => {
return (
<MfaGuard requiredScope="2fa">
<MfaGuard requiredScope="2fa" reason={MfaReason.createBackupCodes}>
<Page2faReplaceBackupCodes {...props} />
</MfaGuard>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getLocalizedErrorMessage,
} from '../../../lib/error-utils';
import VerifiedSessionGuard from '../VerifiedSessionGuard';
import { MfaReason } from '../../../lib/types';

type FormData = {
oldPassword: string;
Expand Down Expand Up @@ -145,7 +146,11 @@ export const PageChangePassword = ({}: RouteComponentProps) => {
};

const MfaGuardedPageChangePassword = (_: RouteComponentProps) => {
return <MfaGuard requiredScope="password"><PageChangePassword /></MfaGuard>;
return (
<MfaGuard requiredScope="password" reason={MfaReason.changePassword}>
<PageChangePassword />
</MfaGuard>
);
};

export default MfaGuardedPageChangePassword;
Loading