Skip to content
Draft
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
8 changes: 8 additions & 0 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ const withSessionTasksResetPassword = base
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks-reset-password').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks-reset-password').pk);

const withSessionTasksSetupMfa = base
.clone()
.setId('withSessionTasksSetupMfa')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks-setup-mfa').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks-setup-mfa').pk)
.setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY || 'a-key');

const withBillingJwtV2 = base
.clone()
.setId('withBillingJwtV2')
Expand Down Expand Up @@ -210,6 +217,7 @@ export const envs = {
withReverification,
withSessionTasks,
withSessionTasksResetPassword,
withSessionTasksSetupMfa,
withSignInOrUpEmailLinksFlow,
withSignInOrUpFlow,
withSignInOrUpwithRestrictedModeFlow,
Expand Down
1 change: 1 addition & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const createLongRunningApps = () => {
{ id: 'next.appRouter.withSignInOrUpEmailLinksFlow', config: next.appRouter, env: envs.withSignInOrUpEmailLinksFlow },
{ id: 'next.appRouter.withSessionTasks', config: next.appRouter, env: envs.withSessionTasks },
{ id: 'next.appRouter.withSessionTasksResetPassword', config: next.appRouter, env: envs.withSessionTasksResetPassword },
{ id: 'next.appRouter.withSessionTasksSetupMfa', config: next.appRouter, env: envs.withSessionTasksSetupMfa },
{ id: 'next.appRouter.withLegalConsent', config: next.appRouter, env: envs.withLegalConsent },
{ id: 'next.appRouter.withNeedsClientTrust', config: next.appRouter, env: envs.withNeedsClientTrust },

Expand Down
225 changes: 225 additions & 0 deletions integration/tests/session-tasks-setup-mfa.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { expect, test } from '@playwright/test';

import { appConfigs } from '../presets';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
import { stringPhoneNumber } from '../testUtils/phoneUtils';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasksSetupMfa] })(
'session tasks setup-mfa flow @nextjs',
({ app }) => {
test.describe.configure({ mode: 'parallel' });

test.afterAll(async () => {
const u = createTestUtils({ app });
await u.services.organizations.deleteAll();
await app.teardown();
});

test.afterEach(async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.signOut();
await u.page.context().clearCookies();
});

test('setup MFA with new phone number - happy path', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const user = u.services.users.createFakeUser({
fictionalEmail: true,
withPassword: true,
});
await u.services.users.createBapiUser(user);

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/page-protected');

await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /sms code/i }).click();

await u.page.getByRole('button', { name: /use a different phone number/i }).click();

const testPhoneNumber = '+15555550100';
await u.page.getByLabel(/phone number/i).fill(testPhoneNumber);
await u.page.getByRole('button', { name: /continue/i }).click();

await u.page.getByLabel(/enter code/i).waitFor({ state: 'visible' });
await u.page.getByLabel(/enter code/i).fill('424242');

await u.page.getByText(/backup codes/i).waitFor({ state: 'visible', timeout: 10000 });
await u.page.getByText(/save these codes/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /finish/i }).click();

await u.page.waitForAppUrl('/page-protected');
await u.po.expect.toBeSignedIn();

await user.deleteIfExists();
});

test('setup MFA with existing phone number - happy path', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const user = u.services.users.createFakeUser({
fictionalEmail: true,
withPhoneNumber: true,
withPassword: true,
});
await u.services.users.createBapiUser(user);

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/page-protected');

await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /sms code/i }).click();

const formattedPhoneNumber = stringPhoneNumber(user.phoneNumber);
await u.page
.getByRole('button', {
name: formattedPhoneNumber,
})
.click();

await u.page.getByLabel(/enter code/i).waitFor({ state: 'visible' });
await u.page.getByLabel(/enter code/i).fill('424242');

await u.page.getByText(/backup codes/i).waitFor({ state: 'visible', timeout: 10000 });
await u.page.getByText(/save these codes/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /finish/i }).click();

await u.page.waitForAppUrl('/page-protected');
await u.po.expect.toBeSignedIn();

await user.deleteIfExists();
});

test('setup MFA with invalid phone number - error handling', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const user = u.services.users.createFakeUser({
fictionalEmail: true,
withPassword: true,
});
await u.services.users.createBapiUser(user);

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/page-protected');

await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /sms code/i }).click();

await u.page.getByRole('button', { name: /use a different phone number/i }).click();

const invalidPhoneNumber = '123';
await u.page.getByLabel(/phone number/i).fill(invalidPhoneNumber);
await u.page.getByRole('button', { name: /continue/i }).click();

await expect(u.page.getByText(/is invalid/i)).toBeVisible();

const validPhoneNumber = '+15555550100';
await u.page.getByLabel(/phone number/i).fill(validPhoneNumber);
await u.page.getByRole('button', { name: /continue/i }).click();

await u.page.getByLabel(/enter code/i).waitFor({ state: 'visible' });
await u.page.getByLabel(/enter code/i).fill('424242');

await u.page.getByText(/backup codes/i).waitFor({ state: 'visible', timeout: 10000 });

await u.page.getByRole('button', { name: /finish/i }).click();

await u.page.waitForAppUrl('/page-protected');

await user.deleteIfExists();
});

test('setup MFA with invalid verification code - error handling', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const user = u.services.users.createFakeUser({
fictionalEmail: true,
withPassword: true,
});
await u.services.users.createBapiUser(user);

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/page-protected');

await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /sms code/i }).click();

await u.page.getByRole('button', { name: /use a different phone number/i }).click();

const testPhoneNumber = '+15555550100';
await u.page.getByLabel(/phone number/i).fill(testPhoneNumber);
await u.page.getByRole('button', { name: /continue/i }).click();

await u.page.getByLabel(/enter code/i).waitFor({ state: 'visible' });
await u.page.getByLabel(/enter code/i).fill('111111');

await expect(u.page.getByText(/incorrect/i)).toBeVisible();

await u.page.getByLabel(/enter code/i).fill('424242');

await u.page.getByText(/backup codes/i).waitFor({ state: 'visible', timeout: 10000 });
await u.page.getByText(/save these codes/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /finish/i }).click();

await u.page.waitForAppUrl('/page-protected');
await u.po.expect.toBeSignedIn();

await user.deleteIfExists();
});

test('can navigate back during MFA setup', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const user = u.services.users.createFakeUser({
fictionalEmail: true,
withPhoneNumber: true,
withPassword: true,
});
await u.services.users.createBapiUser(user);

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/page-protected');

await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /sms code/i }).click();

const formattedPhoneNumber = stringPhoneNumber(user.phoneNumber);
await u.page
.getByRole('button', {
name: formattedPhoneNumber,
})
.waitFor({ state: 'visible' });

await u.page.getByRole('link', { name: /back/i }).first().click();

await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });
await u.page.getByRole('button', { name: /sms code/i }).waitFor({ state: 'visible' });

await user.deleteIfExists();
});
},
);
10 changes: 10 additions & 0 deletions packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const AVAILABLE_COMPONENTS = [
'oauthConsent',
'taskChooseOrganization',
'taskResetPassword',
'taskSetupMFA',
] as const;
type AvailableComponent = (typeof AVAILABLE_COMPONENTS)[number];

Expand Down Expand Up @@ -137,6 +138,7 @@ const componentControls: Record<AvailableComponent, ComponentPropsControl> = {
oauthConsent: buildComponentControls('oauthConsent'),
taskChooseOrganization: buildComponentControls('taskChooseOrganization'),
taskResetPassword: buildComponentControls('taskResetPassword'),
taskSetupMFA: buildComponentControls('taskSetupMFA'),
};

declare global {
Expand Down Expand Up @@ -419,6 +421,14 @@ void (async () => {
},
);
},
'/task-setup-mfa': () => {
Clerk.mountTaskSetupMfa(
app,
componentControls.taskSetupMFA.getProps() ?? {
redirectUrlComplete: '/user-profile',
},
);
},
'/open-sign-in': () => {
mountOpenSignInButton(app, componentControls.signIn.getProps() ?? {});
},
Expand Down
8 changes: 8 additions & 0 deletions packages/clerk-js/sandbox/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@
TaskChooseOrganization
</a>
</li>
<li class="relative">
<a
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"
href="/task-setup-mfa"
>
TaskSetupMFA
</a>
</li>
<li class="relative">
<a
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"
Expand Down
23 changes: 23 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import type {
SignUpResource,
TaskChooseOrganizationProps,
TaskResetPasswordProps,
TaskSetupMFAProps,
TasksRedirectOptions,
UnsubscribeCallback,
UserAvatarProps,
Expand Down Expand Up @@ -1440,6 +1441,28 @@ export class Clerk implements ClerkInterface {
void this.#clerkUi?.then(ui => ui.ensureMounted()).then(controls => controls.unmountComponent({ node }));
};

public mountTaskSetupMfa = (node: HTMLDivElement, props?: TaskSetupMFAProps) => {
this.assertComponentsReady(this.#clerkUi);

const component = 'TaskSetupMFA';
void this.#clerkUi
.then(ui => ui.ensureMounted())
.then(controls =>
controls.mountComponent({
name: component,
appearanceKey: 'taskSetupMfa',
node,
props,
}),
);

this.telemetry?.record(eventPrebuiltComponentMounted('TaskSetupMfa', props));
};

public unmountTaskSetupMfa = (node: HTMLDivElement) => {
void this.#clerkUi?.then(ui => ui.ensureMounted()).then(controls => controls.unmountComponent({ node }));
};

/**
* `setActive` can be used to set the active session and/or organization.
*/
Expand Down
Loading