Skip to content

Commit 4a5d267

Browse files
authored
Merge pull request #19509 from mozilla/FXA-12303
feat(mfa): add Glean metrics for MFA
2 parents 39d6e65 + 0b09d81 commit 4a5d267

27 files changed

Lines changed: 248 additions & 42 deletions

File tree

packages/fxa-settings/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,10 @@ Example: Guarding a page at render time with a scoped JWT
222222
```tsx
223223
import { MfaGuard } from './MfaGuard';
224224
import PageMfaGuardTestWithAuthClient from './components/Settings/PageMfaGuardTest';
225+
import { MfaReason } from '../../../lib/types';
225226

226227
export const Page = () => (
227-
<MfaGuard requiredScope="test">
228+
<MfaGuard requiredScope="test" reason={MfaReason.test}>
228229
<PageMfaGuardTestWithAuthClient path="/mfa_guard/test/auth_client" />
229230
</MfaGuard>
230231
);

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AppContext } from '../../../models';
1010
import { mockAppContext } from '../../../models/mocks';
1111
import { MfaGuard } from './index';
1212
import { JwtTokenCache } from '../../../lib/cache';
13+
import { MfaReason } from '../../../lib/types';
1314

1415
const scope: 'test' = 'test';
1516
const session = 'session-xyz';
@@ -59,7 +60,7 @@ export const JwtMissingShowsModal = () => {
5960

6061
return (
6162
<AppContext.Provider value={mockAppContext({ authClient } as any)}>
62-
<MfaGuard requiredScope={scope}>
63+
<MfaGuard requiredScope={scope} reason={MfaReason.test}>
6364
<div>Secured content</div>
6465
</MfaGuard>
6566
</AppContext.Provider>
@@ -72,7 +73,7 @@ export const JwtPresentRendersChildren = () => {
7273

7374
return (
7475
<AppContext.Provider value={mockAppContext({ authClient } as any)}>
75-
<MfaGuard requiredScope={scope}>
76+
<MfaGuard requiredScope={scope} reason={MfaReason.test}>
7677
<div>Secured content</div>
7778
</MfaGuard>
7879
</AppContext.Provider>

packages/fxa-settings/src/components/Settings/MfaGuard/index.test.tsx

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { MfaGuard } from './index';
1010
import { JwtTokenCache, MfaOtpRequestCache } from '../../../lib/cache';
1111
import { AuthUiErrors } from '../../../lib/auth-errors/auth-errors';
1212
import { AppContext } from '../../../models';
13+
import { MfaReason } from '../../../lib/types';
14+
import GleanMetrics from '../../../lib/glean';
1315

1416
const mockSessionToken = 'session-xyz';
1517
const mockOtp = '123456';
@@ -64,7 +66,7 @@ describe('MfaGuard', () => {
6466
it('requests OTP and shows modal when JWT missing', async () => {
6567
renderWithRouter(
6668
<AppContext.Provider value={mockAppContext()}>
67-
<MfaGuard requiredScope={mockScope}>
69+
<MfaGuard requiredScope={mockScope} reason={MfaReason.test}>
6870
<div>secured</div>
6971
</MfaGuard>
7072
</AppContext.Provider>
@@ -88,12 +90,32 @@ describe('MfaGuard', () => {
8890
);
8991
});
9092

93+
it('emits metrics on success', async () => {
94+
const submitSuccessSpy = jest.spyOn(
95+
GleanMetrics.accountPref,
96+
'mfaGuardSubmitSuccess'
97+
);
98+
renderWithRouter(
99+
<AppContext.Provider value={mockAppContext()}>
100+
<MfaGuard requiredScope={mockScope} reason={MfaReason.test}>
101+
<div>secured</div>
102+
</MfaGuard>
103+
</AppContext.Provider>
104+
);
105+
await submitCode();
106+
await waitFor(() => {
107+
expect(submitSuccessSpy).toHaveBeenCalledWith({
108+
event: { reason: MfaReason.test },
109+
});
110+
});
111+
});
112+
91113
it('renders children when JWT exists', () => {
92114
JwtTokenCache.setToken(mockSessionToken, mockScope, 'jwt-present');
93115

94116
renderWithRouter(
95117
<AppContext.Provider value={mockAppContext()}>
96-
<MfaGuard requiredScope={mockScope}>
118+
<MfaGuard requiredScope={mockScope} reason={MfaReason.test}>
97119
<div>secured</div>
98120
</MfaGuard>
99121
</AppContext.Provider>
@@ -117,7 +139,7 @@ describe('MfaGuard', () => {
117139

118140
renderWithRouter(
119141
<AppContext.Provider value={mockAppContext()}>
120-
<MfaGuard requiredScope={mockScope}>
142+
<MfaGuard requiredScope={mockScope} reason={MfaReason.test}>
121143
<div>secured</div>
122144
</MfaGuard>
123145
</AppContext.Provider>
@@ -132,7 +154,7 @@ describe('MfaGuard', () => {
132154
it('shows error banner on invalid OTP', async () => {
133155
renderWithRouter(
134156
<AppContext.Provider value={mockAppContext()}>
135-
<MfaGuard requiredScope={mockScope}>
157+
<MfaGuard requiredScope={mockScope} reason={MfaReason.test}>
136158
<div>secured</div>
137159
</MfaGuard>
138160
</AppContext.Provider>
@@ -149,7 +171,11 @@ describe('MfaGuard', () => {
149171
it('clears error banner on input change', async () => {
150172
renderWithRouter(
151173
<AppContext.Provider value={mockAppContext()}>
152-
<MfaGuard requiredScope={mockScope} debounceIntervalMs={0}>
174+
<MfaGuard
175+
requiredScope={mockScope}
176+
debounceIntervalMs={0}
177+
reason={MfaReason.test}
178+
>
153179
<div>secured</div>
154180
</MfaGuard>
155181
</AppContext.Provider>
@@ -171,7 +197,11 @@ describe('MfaGuard', () => {
171197
it('shows resend success banner and hides error banner on resend success', async () => {
172198
renderWithRouter(
173199
<AppContext.Provider value={mockAppContext()}>
174-
<MfaGuard requiredScope={mockScope} debounceIntervalMs={0}>
200+
<MfaGuard
201+
requiredScope={mockScope}
202+
debounceIntervalMs={0}
203+
reason={MfaReason.test}
204+
>
175205
<div>secured</div>
176206
</MfaGuard>
177207
</AppContext.Provider>
@@ -201,7 +231,11 @@ describe('MfaGuard', () => {
201231
it('shows error banner and hide success banner on resend error', async () => {
202232
renderWithRouter(
203233
<AppContext.Provider value={mockAppContext()}>
204-
<MfaGuard requiredScope={mockScope} debounceIntervalMs={0}>
234+
<MfaGuard
235+
requiredScope={mockScope}
236+
debounceIntervalMs={0}
237+
reason={MfaReason.test}
238+
>
205239
<div>secured</div>
206240
</MfaGuard>
207241
</AppContext.Provider>
@@ -231,7 +265,11 @@ describe('MfaGuard', () => {
231265

232266
renderWithRouter(
233267
<AppContext.Provider value={mockAppContext()}>
234-
<MfaGuard requiredScope={mockScope} debounceIntervalMs={0}>
268+
<MfaGuard
269+
requiredScope={mockScope}
270+
debounceIntervalMs={0}
271+
reason={MfaReason.test}
272+
>
235273
<div>secured</div>
236274
</MfaGuard>
237275
</AppContext.Provider>
@@ -252,6 +290,7 @@ describe('MfaGuard', () => {
252290
requiredScope={mockScope}
253291
onDismissCallback={mockOnDismiss}
254292
debounceIntervalMs={0}
293+
reason={MfaReason.test}
255294
>
256295
<div>secured</div>
257296
</MfaGuard>
@@ -266,7 +305,11 @@ describe('MfaGuard', () => {
266305
it('debounces OTP resend requests', async () => {
267306
renderWithRouter(
268307
<AppContext.Provider value={mockAppContext()}>
269-
<MfaGuard requiredScope={mockScope} debounceIntervalMs={100}>
308+
<MfaGuard
309+
requiredScope={mockScope}
310+
debounceIntervalMs={100}
311+
reason={MfaReason.test}
312+
>
270313
<div>secured</div>
271314
</MfaGuard>
272315
</AppContext.Provider>

packages/fxa-settings/src/components/Settings/MfaGuard/index.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ import {
2424
MfaOtpRequestCache,
2525
sessionToken as getSessionToken,
2626
} from '../../../lib/cache';
27-
import { MfaScope } from '../../../lib/types';
27+
import { MfaReason, MfaScope } from '../../../lib/types';
2828
import { useNavigate } from '@reach/router';
2929
import * as Sentry from '@sentry/react';
3030
import { MfaErrorBoundary } from './error-boundary';
3131
import { getLocalizedErrorMessage } from '../../../lib/error-utils';
32+
import GleanMetrics from '../../../lib/glean';
3233

3334
/**
3435
* This is a guard component designed to wrap around components that perform
@@ -40,11 +41,13 @@ export const MfaGuard = ({
4041
requiredScope,
4142
onDismissCallback = async () => {},
4243
debounceIntervalMs = 3000,
44+
reason,
4345
}: {
4446
children: ReactNode;
4547
requiredScope: MfaScope;
4648
onDismissCallback?: () => Promise<void>;
4749
debounceIntervalMs?: number;
50+
reason: MfaReason;
4851
}) => {
4952
// Let errors be handled by error boundaries in async contexts
5053
const handleError = useErrorHandler();
@@ -156,6 +159,9 @@ export const MfaGuard = ({
156159
code,
157160
requiredScope
158161
);
162+
GleanMetrics.accountPref.mfaGuardSubmitSuccess({
163+
event: { reason },
164+
});
159165
JwtTokenCache.setToken(sessionToken, requiredScope, result.accessToken);
160166
resetStates();
161167
} catch (err) {
@@ -213,6 +219,7 @@ export const MfaGuard = ({
213219
resendCodeLoading,
214220
showResendSuccessBanner,
215221
localizedErrorBannerMessage,
222+
reason,
216223
}}
217224
>
218225
<p>Re-verify Account!</p>

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useBooleanState } from 'fxa-react/lib/hooks';
99
import { ModalMfaProtected } from '.';
1010
import { action } from '@storybook/addon-actions';
1111
import { MOCK_EMAIL } from '../../../pages/mocks';
12+
import { MfaReason } from '../../../lib/types';
1213

1314
export default {
1415
title: 'Components/Settings/ModalMfaProtected',
@@ -44,6 +45,7 @@ export const DefaultWithValidCode123456 = () => {
4445
<ModalMfaProtected
4546
email={MOCK_EMAIL}
4647
expirationTime={5}
48+
reason={MfaReason.test}
4749
onSubmit={(code) => {
4850
action('Submitted')(code);
4951
if (code === '123456') {

packages/fxa-settings/src/components/Settings/ModalMfaProtected/index.test.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

55
import React from 'react';
6-
import { screen } from '@testing-library/react';
6+
import { screen, waitFor } from '@testing-library/react';
77
import userEvent from '@testing-library/user-event';
88
import { renderWithRouter } from '../../../models/mocks';
99
import { ModalMfaProtected } from '.';
1010
import { MOCK_EMAIL } from '../../../pages/mocks';
11+
import { MfaReason } from '../../../lib/types';
12+
import GleanMetrics from '../../../lib/glean';
1113

1214
const defaultProps = {
1315
email: MOCK_EMAIL,
@@ -18,6 +20,7 @@ const defaultProps = {
1820
clearErrorMessage: () => {},
1921
resendCodeLoading: false,
2022
showResendSuccessBanner: false,
23+
reason: MfaReason.test,
2124
};
2225

2326
describe('ModalMfaProtected', () => {
@@ -45,6 +48,24 @@ describe('ModalMfaProtected', () => {
4548
).toBeInTheDocument();
4649
});
4750

51+
it('has correct metrics', async () => {
52+
const viewSpy = jest.spyOn(GleanMetrics.accountPref, 'mfaGuardView');
53+
renderWithRouter(<ModalMfaProtected {...defaultProps} />);
54+
await waitFor(() => {
55+
expect(viewSpy).toHaveBeenCalledWith({
56+
event: { reason: MfaReason.test },
57+
});
58+
});
59+
expect(screen.getByRole('button', { name: 'Confirm' })).toHaveAttribute(
60+
'data-glean-id',
61+
'account_pref_mfa_guard_submit'
62+
);
63+
expect(screen.getByRole('button', { name: 'Confirm' })).toHaveAttribute(
64+
'data-glean-type',
65+
MfaReason.test
66+
);
67+
});
68+
4869
it('handles form submission', async () => {
4970
const onSubmit = jest.fn();
5071
renderWithRouter(

packages/fxa-settings/src/components/Settings/ModalMfaProtected/index.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5-
import React from 'react';
5+
import React, { useEffect } from 'react';
66
import { useForm } from 'react-hook-form';
77
import Modal from '../Modal';
88
import InputText from '../../InputText';
99
import { useFtlMsgResolver } from '../../../models';
1010
import { FtlMsg } from 'fxa-react/lib/utils';
1111
import { EmailCodeImage } from '../../images';
1212
import Banner, { ResendCodeSuccessBanner } from '../../Banner';
13+
import { MfaReason } from '../../../lib/types';
14+
import GleanMetrics from '../../../lib/glean';
1315

1416
type ModalProps = {
1517
email: string;
@@ -21,6 +23,7 @@ type ModalProps = {
2123
localizedErrorBannerMessage?: string;
2224
resendCodeLoading: boolean;
2325
showResendSuccessBanner: boolean;
26+
reason: MfaReason;
2427
};
2528

2629
type FormData = {
@@ -37,7 +40,13 @@ export const ModalMfaProtected = ({
3740
localizedErrorBannerMessage,
3841
resendCodeLoading,
3942
showResendSuccessBanner,
43+
reason,
4044
}: ModalProps) => {
45+
useEffect(() => {
46+
GleanMetrics.accountPref.mfaGuardView({
47+
event: { reason },
48+
});
49+
}, [reason]);
4150
const ftlMsgResolver = useFtlMsgResolver();
4251

4352
const { handleSubmit, register, formState } = useForm<FormData>({
@@ -144,6 +153,8 @@ export const ModalMfaProtected = ({
144153
type="submit"
145154
className="cta-primary cta-xl flex-1 w-1/2"
146155
disabled={buttonDisabled}
156+
data-glean-id="account_pref_mfa_guard_submit"
157+
data-glean-type={reason}
147158
>
148159
Confirm
149160
</button>

packages/fxa-settings/src/components/Settings/Page2faChange/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ import { useAccount, useAlertBar, useFtlMsgResolver } from '../../../models';
1515

1616
import FlowSetup2faApp from '../FlowSetup2faApp';
1717
import VerifiedSessionGuard from '../VerifiedSessionGuard';
18-
import { GleanClickEventType2FA } from '../../../lib/types';
18+
import { GleanClickEventType2FA, MfaReason } from '../../../lib/types';
1919
import { FtlMsg } from 'fxa-react/lib/utils';
2020
import { MfaGuard } from '../MfaGuard';
2121
import { isInvalidJwtError } from '../../../lib/mfa-guard-utils';
2222
import { useErrorHandler } from 'react-error-boundary';
2323

2424
export const MfaGuardedPage2faChange = (_: RouteComponentProps) => {
2525
return (
26-
<MfaGuard requiredScope="2fa">
26+
<MfaGuard requiredScope="2fa" reason={MfaReason.changeTotp}>
2727
<Page2faChange />
2828
</MfaGuard>
2929
);

packages/fxa-settings/src/components/Settings/Page2faReplaceBackupCodes/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
useFtlMsgResolver,
1515
useSession,
1616
} from '../../../models';
17-
import { GleanClickEventType2FA } from '../../../lib/types';
17+
import { GleanClickEventType2FA, MfaReason } from '../../../lib/types';
1818
import GleanMetrics from '../../../lib/glean';
1919
import { totpUtils } from '../../../lib/totp-utils';
2020
import VerifiedSessionGuard from '../VerifiedSessionGuard';
@@ -27,7 +27,7 @@ export const PageMfaGuard2faReplaceBackupCodes = (
2727
props: RouteComponentProps
2828
) => {
2929
return (
30-
<MfaGuard requiredScope="2fa">
30+
<MfaGuard requiredScope="2fa" reason={MfaReason.createBackupCodes}>
3131
<Page2faReplaceBackupCodes {...props} />
3232
</MfaGuard>
3333
);

packages/fxa-settings/src/components/Settings/PageChangePassword/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
getLocalizedErrorMessage,
2424
} from '../../../lib/error-utils';
2525
import VerifiedSessionGuard from '../VerifiedSessionGuard';
26+
import { MfaReason } from '../../../lib/types';
2627

2728
type FormData = {
2829
oldPassword: string;
@@ -145,7 +146,11 @@ export const PageChangePassword = ({}: RouteComponentProps) => {
145146
};
146147

147148
const MfaGuardedPageChangePassword = (_: RouteComponentProps) => {
148-
return <MfaGuard requiredScope="password"><PageChangePassword /></MfaGuard>;
149+
return (
150+
<MfaGuard requiredScope="password" reason={MfaReason.changePassword}>
151+
<PageChangePassword />
152+
</MfaGuard>
153+
);
149154
};
150155

151156
export default MfaGuardedPageChangePassword;

0 commit comments

Comments
 (0)