Skip to content

Commit e1ad3ae

Browse files
committed
feat(clerk-js): Implement MFA setup task and related UI components
- Created new UI components for MFA setup, including screens for SMS and TOTP code flows. - Integrated MFA setup into session tasks routing and context management. - Added tests for the MFA setup task to ensure functionality and user experience. - Updated user settings to include MFA requirements and adjusted related helper functions.
1 parent d7135bd commit e1ad3ae

44 files changed

Lines changed: 2941 additions & 190 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

integration/presets/envs.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ const withSessionTasksResetPassword = base
157157
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks-reset-password').sk)
158158
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks-reset-password').pk);
159159

160+
const withSessionTasksSetupMfa = base
161+
.clone()
162+
.setId('withSessionTasksSetupMfa')
163+
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks-setup-mfa').sk)
164+
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks-setup-mfa').pk);
165+
160166
const withBillingJwtV2 = base
161167
.clone()
162168
.setId('withBillingJwtV2')
@@ -210,6 +216,7 @@ export const envs = {
210216
withReverification,
211217
withSessionTasks,
212218
withSessionTasksResetPassword,
219+
withSessionTasksSetupMfa,
213220
withSignInOrUpEmailLinksFlow,
214221
withSignInOrUpFlow,
215222
withSignInOrUpwithRestrictedModeFlow,

integration/presets/longRunningApps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const createLongRunningApps = () => {
3232
{ id: 'next.appRouter.withSignInOrUpEmailLinksFlow', config: next.appRouter, env: envs.withSignInOrUpEmailLinksFlow },
3333
{ id: 'next.appRouter.withSessionTasks', config: next.appRouter, env: envs.withSessionTasks },
3434
{ id: 'next.appRouter.withSessionTasksResetPassword', config: next.appRouter, env: envs.withSessionTasksResetPassword },
35+
{ id: 'next.appRouter.withSessionTasksSetupMfa', config: next.appRouter, env: envs.withSessionTasksSetupMfa },
3536
{ id: 'next.appRouter.withLegalConsent', config: next.appRouter, env: envs.withLegalConsent },
3637

3738
/**
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { appConfigs } from '../presets';
4+
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
5+
import { stringPhoneNumber } from '../testUtils/phoneUtils';
6+
import { fakerPhoneNumber } from '../testUtils/usersService';
7+
8+
testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasksSetupMfa] })(
9+
'session tasks setup-mfa flow @nextjs',
10+
({ app }) => {
11+
test.describe.configure({ mode: 'parallel' });
12+
13+
test.afterAll(async () => {
14+
await app.teardown();
15+
});
16+
17+
test.afterEach(async ({ page, context }) => {
18+
const u = createTestUtils({ app, page, context });
19+
await u.page.signOut();
20+
await u.page.context().clearCookies();
21+
});
22+
23+
test('setup MFA with new phone number - happy path', async ({ page, context }) => {
24+
const u = createTestUtils({ app, page, context });
25+
const user = u.services.users.createFakeUser({
26+
fictionalEmail: true,
27+
withPassword: true,
28+
});
29+
await u.services.users.createBapiUser(user);
30+
31+
await u.po.signIn.goTo();
32+
await u.po.signIn.waitForMounted();
33+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
34+
await u.po.expect.toBeSignedIn();
35+
36+
await u.page.goToRelative('/page-protected');
37+
38+
await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });
39+
40+
await u.page.getByRole('button', { name: /sms code/i }).click();
41+
42+
const testPhoneNumber = fakerPhoneNumber();
43+
await u.po.signIn.getPhoneNumberInput().fill(testPhoneNumber);
44+
await u.page.getByRole('button', { name: /continue/i }).click();
45+
46+
await u.po.signIn.enterTestOtpCode();
47+
48+
await u.page.getByText(/save these backup codes/i).waitFor({ state: 'visible', timeout: 10000 });
49+
50+
await u.po.signIn.continue();
51+
52+
await u.page.waitForAppUrl('/page-protected');
53+
await u.po.expect.toBeSignedIn();
54+
55+
await user.deleteIfExists();
56+
});
57+
58+
test('setup MFA with existing phone number - happy path', async ({ page, context }) => {
59+
const u = createTestUtils({ app, page, context });
60+
const user = u.services.users.createFakeUser({
61+
fictionalEmail: true,
62+
withPhoneNumber: true,
63+
withPassword: true,
64+
});
65+
await u.services.users.createBapiUser(user);
66+
67+
await u.po.signIn.goTo();
68+
await u.po.signIn.waitForMounted();
69+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
70+
await u.po.expect.toBeSignedIn();
71+
72+
await u.page.goToRelative('/page-protected');
73+
74+
await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });
75+
76+
await u.page.getByRole('button', { name: /sms code/i }).click();
77+
78+
const formattedPhoneNumber = stringPhoneNumber(user.phoneNumber);
79+
await u.page
80+
.getByRole('button', {
81+
name: formattedPhoneNumber,
82+
})
83+
.click();
84+
85+
await u.page.getByText(/save these backup codes/i).waitFor({ state: 'visible', timeout: 10000 });
86+
87+
await u.po.signIn.continue();
88+
89+
await u.page.waitForAppUrl('/page-protected');
90+
await u.po.expect.toBeSignedIn();
91+
92+
await user.deleteIfExists();
93+
});
94+
95+
test('setup MFA with invalid phone number - error handling', async ({ page, context }) => {
96+
const u = createTestUtils({ app, page, context });
97+
const user = u.services.users.createFakeUser({
98+
fictionalEmail: true,
99+
withPassword: true,
100+
});
101+
await u.services.users.createBapiUser(user);
102+
103+
await u.po.signIn.goTo();
104+
await u.po.signIn.waitForMounted();
105+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
106+
await u.po.expect.toBeSignedIn();
107+
108+
await u.page.goToRelative('/page-protected');
109+
110+
await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });
111+
112+
await u.page.getByRole('button', { name: /sms code/i }).click();
113+
114+
const invalidPhoneNumber = '123091293193091311';
115+
await u.po.signIn.getPhoneNumberInput().fill(invalidPhoneNumber);
116+
await u.po.signIn.continue();
117+
// we need to improve this error message
118+
await expect(u.page.getByTestId('form-feedback-error')).toBeVisible();
119+
120+
const validPhoneNumber = fakerPhoneNumber();
121+
await u.po.signIn.getPhoneNumberInput().fill(validPhoneNumber);
122+
await u.po.signIn.continue();
123+
124+
await u.po.signIn.enterTestOtpCode();
125+
126+
await u.page.getByText(/save these backup codes/i).waitFor({ state: 'visible', timeout: 10000 });
127+
128+
await u.po.signIn.continue();
129+
130+
await u.page.waitForAppUrl('/page-protected');
131+
await u.po.expect.toBeSignedIn();
132+
133+
await user.deleteIfExists();
134+
});
135+
136+
test('setup MFA with invalid verification code - error handling', async ({ page, context }) => {
137+
const u = createTestUtils({ app, page, context });
138+
const user = u.services.users.createFakeUser({
139+
fictionalEmail: true,
140+
withPassword: true,
141+
});
142+
await u.services.users.createBapiUser(user);
143+
144+
await u.po.signIn.goTo();
145+
await u.po.signIn.waitForMounted();
146+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
147+
await u.po.expect.toBeSignedIn();
148+
149+
await u.page.goToRelative('/page-protected');
150+
151+
await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });
152+
153+
await u.page.getByRole('button', { name: /sms code/i }).click();
154+
155+
const testPhoneNumber = fakerPhoneNumber();
156+
await u.po.signIn.getPhoneNumberInput().fill(testPhoneNumber);
157+
await u.po.signIn.continue();
158+
159+
await u.po.signIn.enterOtpCode('111111', {
160+
awaitPrepare: true,
161+
awaitAttempt: true,
162+
});
163+
164+
await expect(u.page.getByTestId('form-feedback-error')).toBeVisible();
165+
166+
await user.deleteIfExists();
167+
});
168+
169+
test('can navigate back during MFA setup', async ({ page, context }) => {
170+
const u = createTestUtils({ app, page, context });
171+
const user = u.services.users.createFakeUser({
172+
fictionalEmail: true,
173+
withPhoneNumber: true,
174+
withPassword: true,
175+
});
176+
await u.services.users.createBapiUser(user);
177+
178+
await u.po.signIn.goTo();
179+
await u.po.signIn.waitForMounted();
180+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
181+
await u.po.expect.toBeSignedIn();
182+
183+
await u.page.goToRelative('/page-protected');
184+
185+
await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });
186+
187+
await u.page.getByRole('button', { name: /sms code/i }).click();
188+
189+
const formattedPhoneNumber = stringPhoneNumber(user.phoneNumber);
190+
await u.page
191+
.getByRole('button', {
192+
name: formattedPhoneNumber,
193+
})
194+
.waitFor({ state: 'visible' });
195+
196+
await u.page
197+
.getByRole('button', { name: /cancel/i })
198+
.first()
199+
.click();
200+
201+
await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });
202+
await u.page.getByRole('button', { name: /sms code/i }).waitFor({ state: 'visible' });
203+
204+
await user.deleteIfExists();
205+
});
206+
},
207+
);

packages/clerk-js/src/core/clerk.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import type {
8787
SignUpResource,
8888
TaskChooseOrganizationProps,
8989
TaskResetPasswordProps,
90+
TaskSetupMFAProps,
9091
TasksRedirectOptions,
9192
UnsubscribeCallback,
9293
UserAvatarProps,
@@ -1447,6 +1448,26 @@ export class Clerk implements ClerkInterface {
14471448
void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node }));
14481449
};
14491450

1451+
public mountTaskSetupMfa = (node: HTMLDivElement, props?: TaskSetupMFAProps) => {
1452+
this.assertComponentsReady(this.#componentControls);
1453+
1454+
void this.#componentControls.ensureMounted({ preloadHint: 'TaskSetupMFA' }).then(controls =>
1455+
controls.mountComponent({
1456+
name: 'TaskSetupMFA',
1457+
appearanceKey: 'taskSetupMfa',
1458+
node,
1459+
props,
1460+
}),
1461+
);
1462+
1463+
this.telemetry?.record(eventPrebuiltComponentMounted('TaskSetupMfa', props));
1464+
};
1465+
1466+
public unmountTaskSetupMfa = (node: HTMLDivElement) => {
1467+
this.assertComponentsReady(this.#componentControls);
1468+
void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node }));
1469+
};
1470+
14501471
/**
14511472
* `setActive` can be used to set the active session and/or organization.
14521473
*/

packages/clerk-js/src/core/resources/UserSettings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
127127
legal_consent_enabled: false,
128128
mode: 'public',
129129
progressive: true,
130+
mfa: {
131+
required: false,
132+
},
130133
};
131134
social: OAuthProviders = {} as OAuthProviders;
132135
usernameSettings: UsernameSettingsData = {} as UsernameSettingsData;

packages/clerk-js/src/core/sessionTasks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { buildURL, forwardClerkQueryParams } from '../utils';
99
export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = {
1010
'choose-organization': 'choose-organization',
1111
'reset-password': 'reset-password',
12+
'setup-mfa': 'setup-mfa',
1213
} as const;
1314

1415
/**

packages/clerk-js/src/test/fixture-helpers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,10 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => {
586586
us.sign_up.mode = SIGN_UP_MODES.WAITLIST;
587587
};
588588

589+
const withMfaRequired = (required: boolean = true) => {
590+
us.sign_up.mfa = { required };
591+
};
592+
589593
// TODO: Add the rest, consult pkg/generate/auth_config.go
590594

591595
return {
@@ -606,5 +610,6 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => {
606610
withRestrictedMode,
607611
withLegalConsent,
608612
withWaitlistMode,
613+
withMfaRequired,
609614
};
610615
};

packages/clerk-js/src/test/fixtures.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ const createBaseUserSettings = (): UserSettingsJSON => {
208208
captcha_enabled: false,
209209
disable_hibp: false,
210210
mode: 'public',
211+
mfa: {
212+
required: false,
213+
},
211214
},
212215
restrictions: {
213216
allowlist: {

packages/clerk-js/src/ui/common/Wizard.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Animated } from '../elements/Animated';
44

55
type WizardProps = React.PropsWithChildren<{
66
step: number;
7+
animate?: boolean;
78
}>;
89

910
type UseWizardProps = {
@@ -26,7 +27,11 @@ export const useWizard = (params: UseWizardProps = {}) => {
2627
};
2728

2829
export const Wizard = (props: WizardProps) => {
29-
const { step, children } = props;
30+
const { step, children, animate = true } = props;
31+
32+
if (!animate) {
33+
return React.Children.toArray(children)[step];
34+
}
3035

3136
return <Animated>{React.Children.toArray(children)[step]}</Animated>;
3237
};

0 commit comments

Comments
 (0)