Skip to content

Commit 5baa947

Browse files
authored
Merge pull request #2223 from develop
Release 2026.05.1
2 parents 620efce + a55f164 commit 5baa947

37 files changed

Lines changed: 1051 additions & 325 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ REACT_APP_DEVELOP_BUILD_VERSION=
44
REACT_APP_LAUNCHDARKLY_CLIENT_ID=65df9142fbf14e10954ac55d
55
REACT_APP_WEB_URI=http://localhost:5173
66

7+
# Mixpanel Analytics
8+
REACT_APP_MIXPANEL_TOKEN=
9+
REACT_APP_MIXPANEL_FORCE_ENABLE=false
10+
711
REACT_APP_DD_APP_ID=
812
REACT_APP_DD_CLIENT_TOKEN=
913
REACT_APP_DD_VERSION=local

.github/workflows/_build-deploy.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ jobs:
5858
- name: Configure aws credentials for secrets
5959
uses: aws-actions/configure-aws-credentials@v6
6060
with:
61-
role-to-assume: arn:aws:iam::917902836630:role/github-oidc-build-secrets2026031712184557570000000a
61+
role-to-assume: arn:aws:iam::917902836630:role/github-oidc-build-secrets
6262
role-session-name: OIDC-GHA-Build-Secrets
6363
aws-region: us-east-1
6464

@@ -165,7 +165,7 @@ jobs:
165165
- name: configure aws credentials
166166
uses: aws-actions/configure-aws-credentials@v6
167167
with:
168-
role-to-assume: arn:aws:iam::917902836630:role/github-oidc-ssm-version20260317121845461900000008
168+
role-to-assume: arn:aws:iam::917902836630:role/github-oidc-ssm-version
169169
role-session-name: OIDC-GHA-session-version
170170
aws-region: us-east-1
171171
- name: Store version in SSM

.github/workflows/_setup.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,22 @@ jobs:
3232
ENV_NAME="${{ inputs.env-name }}"
3333
case "$ENV_NAME" in
3434
"dev")
35-
echo 'role=arn:aws:iam::017925157769:role/github-oidc-deploy20260311132710794900000001' >> "$GITHUB_OUTPUT"
35+
echo 'role=arn:aws:iam::017925157769:role/github-oidc-deploy' >> "$GITHUB_OUTPUT"
3636
;;
3737
"test")
3838
echo 'role=arn:aws:iam::641513112151:role/cmiml-test-oidc-github-role' >> "$GITHUB_OUTPUT"
3939
;;
4040
"uat")
41-
echo 'role=arn:aws:iam::641513112151:role/github-oidc-deploy20260312130551192100000004' >> "$GITHUB_OUTPUT"
41+
echo 'role=arn:aws:iam::641513112151:role/github-oidc-deploy' >> "$GITHUB_OUTPUT"
4242
;;
4343
"stage")
4444
echo 'role=arn:aws:iam::641513112151:role/cmiml-stage-oidc-github-role' >> "$GITHUB_OUTPUT"
4545
;;
4646
"prod")
47-
echo 'role=arn:aws:iam::410431445687:role/github-oidc-deploy20260312143902067900000001' >> "$GITHUB_OUTPUT"
47+
echo 'role=arn:aws:iam::410431445687:role/github-oidc-deploy' >> "$GITHUB_OUTPUT"
4848
;;
4949
"prod-dr-us-west-2")
50-
echo 'role=arn:aws:iam::973422231492:role/github-oidc-deploy20260313122244970500000002' >> "$GITHUB_OUTPUT"
50+
echo 'role=arn:aws:iam::973422231492:role/github-oidc-deploy' >> "$GITHUB_OUTPUT"
5151
;;
5252
*)
5353
echo "Bad environment name"

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@
186186
"aria-query": "5.1.3",
187187
"defu": ">=6.1.5",
188188
"flatted": ">=3.4.2",
189+
"follow-redirects": ">=1.16.0",
189190
"js-yaml": ">=4.1.1",
190191
"lodash": ">=4.18.0",
191192
"picomatch": ">=4.0.4",

src/modules/Auth/features/Login/LoginForm/LoginForm.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { fireEvent, waitFor, screen } from '@testing-library/react';
2+
import { vi } from 'vitest';
23

34
import { inputAcceptsValue } from 'shared/tests/inputAcceptsValue';
45
import { mockedEmail, mockedPassword } from 'shared/mock';
6+
import { Mixpanel, MixpanelEventType, MixpanelProps } from 'shared/utils/mixpanel';
57
import { renderWithProviders } from 'shared/utils/renderWithProviders';
68
import { expectBanner } from 'shared/utils';
79
import { RootState } from 'redux/store';
@@ -46,4 +48,30 @@ describe('Login component tests', () => {
4648

4749
expect(screen.getByLabelText('Email')).toHaveValue(mockedEmail);
4850
});
51+
52+
describe('Mixpanel Tracking', () => {
53+
beforeEach(() => {
54+
vi.mocked(Mixpanel.track).mockClear();
55+
});
56+
57+
test('should have Mixpanel mock set up correctly', () => {
58+
// Verify the mock is configured properly for other tests
59+
expect(Mixpanel.track).toBeDefined();
60+
expect(vi.isMockFunction(Mixpanel.track)).toBe(true);
61+
});
62+
63+
test('should export correct MixpanelEventType values for LoginForm tracking', () => {
64+
// Verify the event types used by LoginForm are available
65+
expect(MixpanelEventType.MFAChallengePresented).toBe('MFA Challenge Presented');
66+
expect(MixpanelEventType.LoginSuccessful).toBe('Login Successful');
67+
expect(MixpanelEventType.LoginFailed).toBe('Login Failed');
68+
});
69+
70+
test('should export correct MixpanelProps for LoginForm tracking', () => {
71+
// Verify the props used by LoginForm are available
72+
expect(MixpanelProps.MFAUsed).toBe('MFA Used');
73+
expect(MixpanelProps.MFAMethodUsed).toBe('MFA Method Used');
74+
expect(MixpanelProps.FailureStage).toBe('Failure Stage');
75+
});
76+
});
4977
});

src/modules/Auth/features/Login/LoginForm/LoginForm.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { AUTH_BOX_WIDTH } from 'shared/consts';
1616
import { variables } from 'shared/styles';
1717
import { StyledErrorText, StyledHeadlineSmall } from 'shared/styles/styledComponents';
1818
import { LocationStateKeys } from 'shared/types';
19-
import { Mixpanel, MixpanelEventType } from 'shared/utils';
19+
import { Mixpanel, MixpanelEventType, MixpanelProps } from 'shared/utils';
2020

2121
import { loginFormSchema } from '../Login.schema';
2222
import {
@@ -61,6 +61,8 @@ export const LoginForm = () => {
6161

6262
// Check if MFA is required
6363
if (response?.result && 'mfaRequired' in response.result && response.result.mfaRequired) {
64+
// Track MFA challenge presented
65+
Mixpanel.track({ action: MixpanelEventType.MFAChallengePresented });
6466
// MFA session is set by the reducer, navigate to MFA verification page
6567
navigate(page.verifyMFA);
6668

@@ -82,13 +84,22 @@ export const LoginForm = () => {
8284
}
8385
}
8486

85-
Mixpanel.track({ action: MixpanelEventType.LoginSuccessful });
87+
Mixpanel.track({
88+
action: MixpanelEventType.LoginSuccessful,
89+
[MixpanelProps.MFAUsed]: false,
90+
[MixpanelProps.MFAMethodUsed]: null,
91+
});
8692

8793
clearSoftLock();
8894
}
8995

9096
if (signIn.rejected.match(result)) {
9197
setErrorMessage(result.payload as string);
98+
// Track login failed
99+
Mixpanel.track({
100+
action: MixpanelEventType.LoginFailed,
101+
[MixpanelProps.FailureStage]: 'Credentials',
102+
});
92103
}
93104
};
94105

@@ -145,7 +156,10 @@ export const LoginForm = () => {
145156
};
146157

147158
const handleLoginClick = () => {
148-
Mixpanel.track({ action: MixpanelEventType.LoginBtnClick });
159+
Mixpanel.track({
160+
action: MixpanelEventType.LoginBtnClick,
161+
[MixpanelProps.AuthMethod]: 'Password',
162+
});
149163
};
150164

151165
const handleResetPasswordClick = () => {

src/modules/Auth/features/Login/MFAForm/MFAForm.test.tsx

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { fireEvent, screen, waitFor } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33

4+
import { Mixpanel, MixpanelEventType } from 'shared/utils/mixpanel';
45
import { renderWithProviders } from 'shared/utils/renderWithProviders';
56

67
import { MFAForm } from './MFAForm';
@@ -16,13 +17,6 @@ vi.mock('react-router-dom', async () => {
1617
};
1718
});
1819

19-
vi.mock('shared/utils/mixpanel', () => ({
20-
Mixpanel: {
21-
track: vi.fn(),
22-
login: vi.fn(),
23-
},
24-
}));
25-
2620
describe('MFAForm', () => {
2721
// MFA session only contains backend-provided tokens
2822
// Session expiry and attempts are tracked by the backend
@@ -252,9 +246,39 @@ describe('MFAForm', () => {
252246
const recoveryLink = screen.getByText("I can't access my authenticator app");
253247
fireEvent.click(recoveryLink);
254248

249+
// Verify Mixpanel tracking
250+
expect(Mixpanel.track).toHaveBeenCalledWith({
251+
action: MixpanelEventType.CantAccessAuthAppClick,
252+
});
253+
// Verify callback
255254
expect(mockSwitchToRecovery).toHaveBeenCalled();
256255
});
257256

257+
describe('Mixpanel tracking', () => {
258+
it('tracks CantAccessAuthAppClick when recovery link is clicked', () => {
259+
renderMFAForm({}, vi.fn());
260+
261+
const recoveryLink = screen.getByText("I can't access my authenticator app");
262+
fireEvent.click(recoveryLink);
263+
264+
expect(Mixpanel.track).toHaveBeenCalledTimes(1);
265+
expect(Mixpanel.track).toHaveBeenCalledWith({
266+
action: MixpanelEventType.CantAccessAuthAppClick,
267+
});
268+
});
269+
270+
it('tracks recovery link click even when onSwitchToRecovery is not provided', () => {
271+
renderMFAForm({});
272+
273+
const recoveryLink = screen.getByText("I can't access my authenticator app");
274+
fireEvent.click(recoveryLink);
275+
276+
expect(Mixpanel.track).toHaveBeenCalledWith({
277+
action: MixpanelEventType.CantAccessAuthAppClick,
278+
});
279+
});
280+
});
281+
258282
it.skip('restricts input to 6 digits only', async () => {
259283
renderMFAForm();
260284
const input = screen.getByLabelText('Enter verification code') as HTMLInputElement;

src/modules/Auth/features/Login/MFAForm/MFAForm.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
55
import { Box, TextField } from '@mui/material';
66

77
import { StyledBodyMedium, StyledHeadlineSmall, StyledLinkBtn, variables } from 'shared/styles';
8+
import { Mixpanel, MixpanelEventType } from 'shared/utils';
89

910
import { mfaFormSchema } from './MFAForm.schema';
1011
import { StyledMFAContainer, StyledMFAForm, StyledMFAButton } from './MFAForm.styles';
@@ -67,6 +68,8 @@ const MFAFormComponent = ({ onSwitchToRecovery, onBackToLogin }: MFAFormProps) =
6768
};
6869

6970
const handleRecoveryClick = () => {
71+
// Track recovery link click
72+
Mixpanel.track({ action: MixpanelEventType.CantAccessAuthAppClick });
7073
if (onSwitchToRecovery) {
7174
onSwitchToRecovery();
7275
}

src/modules/Auth/features/Login/MFAForm/RecoveryCodeForm.test.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,6 @@ vi.mock('react-router-dom', async () => {
1616
};
1717
});
1818

19-
vi.mock('shared/utils/mixpanel', () => ({
20-
Mixpanel: {
21-
track: vi.fn(),
22-
},
23-
}));
24-
2519
describe('RecoveryCodeForm', () => {
2620
// MFA session only contains backend-provided tokens
2721
// Session expiry and attempts are tracked by the backend

src/modules/Auth/features/Login/MFAForm/useMFAVerification.test.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { verifyMFATOTPApi } from 'api';
77
import { auth } from 'modules/Auth/state';
88
import { navigateToLibrary } from 'modules/Auth/utils';
99
import { setupStore } from 'redux/store';
10+
import { Mixpanel, MixpanelEventType, MixpanelProps } from 'shared/utils/mixpanel';
1011
import type { MFAVerifyResponse } from 'modules/Auth/api/api.types';
1112

1213
import { useMFAVerification } from './useMFAVerification';
@@ -250,4 +251,100 @@ describe('useMFAVerification', () => {
250251
expect(result.current.isSessionExpired).toBe(true);
251252
expect(result.current.displayError).toBe('mfaSessionExpired');
252253
});
254+
255+
describe('Mixpanel Tracking', () => {
256+
beforeEach(() => {
257+
vi.mocked(Mixpanel.track).mockClear();
258+
});
259+
260+
it('should track LoginSuccessful with MFA via Authenticator App on successful TOTP verification', async () => {
261+
const mockDispatch = vi.spyOn(store, 'dispatch');
262+
mockDispatch.mockResolvedValueOnce({
263+
type: 'auth/verifyMFATOTP/fulfilled',
264+
payload: { result: { user: { id: 'user-123' } } },
265+
});
266+
267+
const { result } = renderHook(() => useMFAVerification('totp'), { wrapper });
268+
269+
await act(async () => {
270+
await result.current.verifyCode('123456');
271+
});
272+
273+
expect(Mixpanel.track).toHaveBeenCalledWith({
274+
action: MixpanelEventType.LoginSuccessful,
275+
[MixpanelProps.MFAUsed]: true,
276+
[MixpanelProps.MFAMethodUsed]: 'Authenticator App',
277+
});
278+
});
279+
280+
it('should track LoginSuccessful with MFA via Backup Codes on successful recovery code verification', async () => {
281+
const mockDispatch = vi.spyOn(store, 'dispatch');
282+
mockDispatch.mockResolvedValueOnce({
283+
type: 'auth/verifyMFARecoveryCode/fulfilled',
284+
payload: { result: { user: { id: 'user-123' } } },
285+
});
286+
287+
const { result } = renderHook(() => useMFAVerification('recovery'), { wrapper });
288+
289+
await act(async () => {
290+
await result.current.verifyCode('ABCDE-12345');
291+
});
292+
293+
expect(Mixpanel.track).toHaveBeenCalledWith({
294+
action: MixpanelEventType.LoginSuccessful,
295+
[MixpanelProps.MFAUsed]: true,
296+
[MixpanelProps.MFAMethodUsed]: 'Backup Codes',
297+
});
298+
});
299+
300+
it('should track LoginFailed with MFA stage and Authenticator App on TOTP failure', async () => {
301+
const mockResponse = {
302+
data: {
303+
result: {
304+
message: 'Invalid TOTP code',
305+
},
306+
},
307+
status: 400,
308+
statusText: 'Bad Request',
309+
headers: {} as AxiosHeaders,
310+
config: {
311+
headers: {} as AxiosHeaders,
312+
} as any,
313+
} satisfies AxiosResponse<MFAVerifyResponse>;
314+
315+
mockVerifyAPI.mockResolvedValueOnce(mockResponse);
316+
317+
const { result } = renderHook(() => useMFAVerification('totp'), { wrapper });
318+
319+
await act(async () => {
320+
await result.current.verifyCode('123456');
321+
});
322+
323+
expect(Mixpanel.track).toHaveBeenCalledWith({
324+
action: MixpanelEventType.LoginFailed,
325+
[MixpanelProps.FailureStage]: 'MFA',
326+
[MixpanelProps.MFAMethodUsed]: 'Authenticator App',
327+
});
328+
});
329+
330+
it('should track LoginFailed with MFA stage and Backup Codes on recovery code failure', async () => {
331+
const mockDispatch = vi.spyOn(store, 'dispatch');
332+
mockDispatch.mockResolvedValueOnce({
333+
type: 'auth/verifyMFARecoveryCode/rejected',
334+
payload: 'Invalid recovery code',
335+
});
336+
337+
const { result } = renderHook(() => useMFAVerification('recovery'), { wrapper });
338+
339+
await act(async () => {
340+
await result.current.verifyCode('INVALID-CODE');
341+
});
342+
343+
expect(Mixpanel.track).toHaveBeenCalledWith({
344+
action: MixpanelEventType.LoginFailed,
345+
[MixpanelProps.FailureStage]: 'MFA',
346+
[MixpanelProps.MFAMethodUsed]: 'Backup Codes',
347+
});
348+
});
349+
});
253350
});

0 commit comments

Comments
 (0)