Skip to content

Commit f284c3d

Browse files
authored
feat(clerk-js,ui): Introduce setup MFA session task (#7626)
1 parent bf63b7b commit f284c3d

File tree

99 files changed

+6463
-815
lines changed

Some content is hidden

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

99 files changed

+6463
-815
lines changed

.changeset/great-bats-attack.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@clerk/tanstack-react-start': minor
3+
'@clerk/localizations': minor
4+
'@clerk/react-router': minor
5+
'@clerk/clerk-js': minor
6+
'@clerk/nextjs': minor
7+
'@clerk/shared': minor
8+
'@clerk/react': minor
9+
'@clerk/ui': minor
10+
---
11+
12+
Introducing `setup_mfa` session task

integration/presets/envs.ts

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

161+
const withSessionTasksSetupMfa = base
162+
.clone()
163+
.setId('withSessionTasksSetupMfa')
164+
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks-setup-mfa').sk)
165+
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks-setup-mfa').pk)
166+
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');
167+
161168
const withBillingJwtV2 = base
162169
.clone()
163170
.setId('withBillingJwtV2')
@@ -216,6 +223,7 @@ export const envs = {
216223
withSessionTasks,
217224
withSessionTasksResetPassword,
218225
withSharedUIVariant,
226+
withSessionTasksSetupMfa,
219227
withSignInOrUpEmailLinksFlow,
220228
withSignInOrUpFlow,
221229
withSignInOrUpwithRestrictedModeFlow,

integration/presets/longRunningApps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const createLongRunningApps = () => {
3131
{ id: 'next.appRouter.withSignInOrUpEmailLinksFlow', config: next.appRouter, env: envs.withSignInOrUpEmailLinksFlow },
3232
{ id: 'next.appRouter.withSessionTasks', config: next.appRouter, env: envs.withSessionTasks },
3333
{ id: 'next.appRouter.withSessionTasksResetPassword', config: next.appRouter, env: envs.withSessionTasksResetPassword },
34+
{ id: 'next.appRouter.withSessionTasksSetupMfa', config: next.appRouter, env: envs.withSessionTasksSetupMfa },
3435
{ id: 'next.appRouter.withLegalConsent', config: next.appRouter, env: envs.withLegalConsent },
3536
{ id: 'next.appRouter.withNeedsClientTrust', config: next.appRouter, env: envs.withNeedsClientTrust },
3637
{ id: 'next.appRouter.withSharedUIVariant', config: next.appRouter, env: envs.withSharedUIVariant },
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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: 'serial' });
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+
awaitRequests: false,
161+
});
162+
163+
await expect(u.page.getByTestId('form-feedback-error')).toBeVisible();
164+
165+
await user.deleteIfExists();
166+
});
167+
168+
test('can navigate back during MFA setup', async ({ page, context }) => {
169+
const u = createTestUtils({ app, page, context });
170+
const user = u.services.users.createFakeUser({
171+
fictionalEmail: true,
172+
withPhoneNumber: true,
173+
withPassword: true,
174+
});
175+
await u.services.users.createBapiUser(user);
176+
177+
await u.po.signIn.goTo();
178+
await u.po.signIn.waitForMounted();
179+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
180+
await u.po.expect.toBeSignedIn();
181+
182+
await u.page.goToRelative('/page-protected');
183+
184+
await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });
185+
186+
await u.page.getByRole('button', { name: /sms code/i }).click();
187+
188+
const formattedPhoneNumber = stringPhoneNumber(user.phoneNumber);
189+
await u.page
190+
.getByRole('button', {
191+
name: formattedPhoneNumber,
192+
})
193+
.waitFor({ state: 'visible' });
194+
195+
await u.page
196+
.getByRole('button', { name: /cancel/i })
197+
.first()
198+
.click();
199+
200+
await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });
201+
await u.page.getByRole('button', { name: /sms code/i }).waitFor({ state: 'visible' });
202+
203+
await user.deleteIfExists();
204+
});
205+
},
206+
);

packages/clerk-js/sandbox/app.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const AVAILABLE_COMPONENTS = [
3434
'oauthConsent',
3535
'taskChooseOrganization',
3636
'taskResetPassword',
37+
'taskSetupMFA',
3738
] as const;
3839
type AvailableComponent = (typeof AVAILABLE_COMPONENTS)[number];
3940

@@ -137,6 +138,7 @@ const componentControls: Record<AvailableComponent, ComponentPropsControl> = {
137138
oauthConsent: buildComponentControls('oauthConsent'),
138139
taskChooseOrganization: buildComponentControls('taskChooseOrganization'),
139140
taskResetPassword: buildComponentControls('taskResetPassword'),
141+
taskSetupMFA: buildComponentControls('taskSetupMFA'),
140142
};
141143

142144
declare global {
@@ -419,6 +421,14 @@ void (async () => {
419421
},
420422
);
421423
},
424+
'/task-setup-mfa': () => {
425+
Clerk.mountTaskSetupMfa(
426+
app,
427+
componentControls.taskSetupMFA.getProps() ?? {
428+
redirectUrlComplete: '/user-profile',
429+
},
430+
);
431+
},
422432
'/open-sign-in': () => {
423433
mountOpenSignInButton(app, componentControls.signIn.getProps() ?? {});
424434
},

packages/clerk-js/sandbox/template.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@
169169
TaskChooseOrganization
170170
</a>
171171
</li>
172+
<li class="relative">
173+
<a
174+
class="relative isolate flex w-full rounded-md border border-white px-2 py-[0.4375rem] text-sm hover:bg-gray-50 aria-[current]:bg-gray-50"
175+
href="/task-setup-mfa"
176+
>
177+
TaskSetupMFA
178+
</a>
179+
</li>
172180
<li class="relative">
173181
<a
174182
class="relative isolate flex w-full rounded-md border border-white px-2 py-[0.4375rem] text-sm hover:bg-gray-50 aria-[current]:bg-gray-50"

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import type {
113113
SignUpResource,
114114
TaskChooseOrganizationProps,
115115
TaskResetPasswordProps,
116+
TaskSetupMFAProps,
116117
TasksRedirectOptions,
117118
UnsubscribeCallback,
118119
UserAvatarProps,
@@ -1439,6 +1440,28 @@ export class Clerk implements ClerkInterface {
14391440
void this.#clerkUi?.then(ui => ui.ensureMounted()).then(controls => controls.unmountComponent({ node }));
14401441
};
14411442

1443+
public mountTaskSetupMfa = (node: HTMLDivElement, props?: TaskSetupMFAProps) => {
1444+
this.assertComponentsReady(this.#clerkUi);
1445+
1446+
const component = 'TaskSetupMFA';
1447+
void this.#clerkUi
1448+
.then(ui => ui.ensureMounted())
1449+
.then(controls =>
1450+
controls.mountComponent({
1451+
name: component,
1452+
appearanceKey: 'taskSetupMfa',
1453+
node,
1454+
props,
1455+
}),
1456+
);
1457+
1458+
this.telemetry?.record(eventPrebuiltComponentMounted('TaskSetupMfa', props));
1459+
};
1460+
1461+
public unmountTaskSetupMfa = (node: HTMLDivElement) => {
1462+
void this.#clerkUi?.then(ui => ui.ensureMounted()).then(controls => controls.unmountComponent({ node }));
1463+
};
1464+
14421465
/**
14431466
* `setActive` can be used to set the active session and/or organization.
14441467
*/

packages/localizations/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"dev": "tsup --watch",
6060
"format": "node ../../scripts/format-package.mjs",
6161
"format:check": "node ../../scripts/format-package.mjs --check",
62-
"generate": "tsc src/utils/generate.ts && node src/utils/generate.js && prettier --write src/*.ts",
62+
"generate": "tsc -p tsconfig.generate.json && node --experimental-vm-modules src/utils/generate.js && rimraf tmp && prettier --write src/*.ts",
6363
"lint": "eslint src",
6464
"lint:attw": "attw --pack . --profile node16"
6565
},

0 commit comments

Comments
 (0)