diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 9dc71de2c77..fede99448b4 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -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') @@ -210,6 +217,7 @@ export const envs = { withReverification, withSessionTasks, withSessionTasksResetPassword, + withSessionTasksSetupMfa, withSignInOrUpEmailLinksFlow, withSignInOrUpFlow, withSignInOrUpwithRestrictedModeFlow, diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index 9e78875720b..6b69e53eb9b 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -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 }, diff --git a/integration/tests/session-tasks-setup-mfa.test.ts b/integration/tests/session-tasks-setup-mfa.test.ts new file mode 100644 index 00000000000..420bd702bac --- /dev/null +++ b/integration/tests/session-tasks-setup-mfa.test.ts @@ -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(); + }); + }, +); diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 315e0822f31..453af40d151 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -34,6 +34,7 @@ const AVAILABLE_COMPONENTS = [ 'oauthConsent', 'taskChooseOrganization', 'taskResetPassword', + 'taskSetupMFA', ] as const; type AvailableComponent = (typeof AVAILABLE_COMPONENTS)[number]; @@ -137,6 +138,7 @@ const componentControls: Record = { oauthConsent: buildComponentControls('oauthConsent'), taskChooseOrganization: buildComponentControls('taskChooseOrganization'), taskResetPassword: buildComponentControls('taskResetPassword'), + taskSetupMFA: buildComponentControls('taskSetupMFA'), }; declare global { @@ -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() ?? {}); }, diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index 924e159fcd2..5c08ca95a28 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -169,6 +169,14 @@ TaskChooseOrganization +
  • + + TaskSetupMFA + +
  • 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. */ diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 15256e3e1c2..a164c7f1b0c 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -898,6 +898,76 @@ export const enUS: LocalizationResource = { subtitle: 'Your account requires a new password before you can continue', title: 'Reset your password', }, + taskSetupMfa: { + badge: 'Two-step verification setup', + start: { + title: 'Set up two-step verification', + subtitle: 'Choose which method you prefer to protect your account with an extra layer of security', + methodSelection: { + totp: 'Authenticator application', + phoneCode: 'SMS code', + }, + }, + smsCode: { + title: 'Add sms code verification', + subtitle: 'Choose phone number you want to use for SMS code two-step verification', + addPhoneNumber: 'Add phone number', + cancel: 'Cancel', + verifyPhone: { + title: 'Verify your phone number', + subtitle: 'Enter the verification code sent to', + formTitle: 'Verification code', + resendButton: "Didn't receive a code? Resend", + formButtonPrimary: 'Continue', + }, + addPhone: { + infoText: + 'A text message containing a verification code will be sent to this phone number. Message and data rates may apply.', + formButtonPrimary: 'Continue', + }, + success: { + title: 'SMS code verification enabled', + message1: + 'Two-step verification is now enabled. When signing in, you will need to enter a verification code sent to this phone number as an additional step.', + message2: + 'Save these backup codes and store them somewhere safe. If you lose access to your authentication device, you can use backup codes to sign in.', + finishButton: 'Continue', + }, + }, + totpCode: { + title: 'Add authenticator application', + addAuthenticatorApp: { + infoText__ableToScan: + 'Set up a new sign-in method in your authenticator app and scan the following QR code to link it to your account.', + infoText__unableToScan: 'Set up a new sign-in method in your authenticator and enter the Key provided below.', + inputLabel__unableToScan1: + 'Make sure Time-based or One-time passwords is enabled, then finish linking your account.', + buttonUnableToScan__nonPrimary: "Can't scan QR code?", + buttonAbleToScan__nonPrimary: 'Scan QR code instead', + formButtonPrimary: 'Continue', + formButtonReset: 'Cancel', + }, + verifyTotp: { + title: 'Add authenticator application', + subtitle: 'Enter verification code generated by your authenticator', + formTitle: 'Verification code', + formButtonPrimary: 'Continue', + formButtonReset: 'Cancel', + }, + success: { + title: 'Authenticator application verification enabled', + message1: + 'Two-step verification is now enabled. When signing in, you will need to enter a verification code from this authenticator as an additional step.', + message2: + 'Save these backup codes and store them somewhere safe. If you lose access to your authentication device, you can use backup codes to sign in.', + finishButton: 'Continue', + }, + }, + signOut: { + actionText: 'Signed in as {{identifier}}', + actionLink: 'Sign out', + }, + }, unstable__errors: { already_a_member_in_organization: '{{email}} is already a member of the organization.', avatar_file_size_exceeded: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx index 6d032dc45c5..ca3efe37b4f 100644 --- a/packages/nextjs/src/client-boundary/uiComponents.tsx +++ b/packages/nextjs/src/client-boundary/uiComponents.tsx @@ -24,6 +24,7 @@ export { SignUpButton, TaskChooseOrganization, TaskResetPassword, + TaskSetupMFA, UserAvatar, UserButton, Waitlist, diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 09fb42c47a4..51f3a47a044 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -37,6 +37,7 @@ export { SignUpButton, TaskChooseOrganization, TaskResetPassword, + TaskSetupMFA, UserAvatar, UserButton, UserProfile, diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 87dc4845653..4ef85cfe27c 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -44,6 +44,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "SignUpButton", "TaskChooseOrganization", "TaskResetPassword", + "TaskSetupMFA", "UNSAFE_PortalProvider", "UserAvatar", "UserButton", diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index c200f386236..43763a2c25e 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -10,6 +10,7 @@ export { SignUp, TaskChooseOrganization, TaskResetPassword, + TaskSetupMFA, UserAvatar, UserButton, UserProfile, diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 5dd83c510b4..b7f830a6ff3 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -10,6 +10,7 @@ import type { SignUpProps, TaskChooseOrganizationProps, TaskResetPasswordProps, + TaskSetupMFAProps, UserAvatarProps, UserButtonProps, UserProfileProps, @@ -725,3 +726,31 @@ export const TaskResetPassword = withClerk( }, { component: 'TaskResetPassword', renderWhileLoading: true }, ); + +export const TaskSetupMFA = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'TaskSetupMFA', renderWhileLoading: true }, +); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index b39bf352b8c..082968efd83 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -50,6 +50,7 @@ import type { State, TaskChooseOrganizationProps, TaskResetPasswordProps, + TaskSetupMFAProps, TasksRedirectOptions, UnsubscribeCallback, UserAvatarProps, @@ -155,6 +156,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private premountOAuthConsentNodes = new Map(); private premountTaskChooseOrganizationNodes = new Map(); private premountTaskResetPasswordNodes = new Map(); + private premountTaskSetupMfaNodes = new Map(); // A separate Map of `addListener` method calls to handle multiple listeners. private premountAddListenerCalls = new Map< ListenerCallback, @@ -695,6 +697,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.mountTaskResetPassword(node, props); }); + this.premountTaskSetupMfaNodes.forEach((props, node) => { + clerkjs.mountTaskSetupMfa(node, props); + }); + /** * Only update status in case `clerk.status` is missing. In any other case, `clerk-js` should be the orchestrator. */ @@ -1255,6 +1261,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + mountTaskSetupMfa = (node: HTMLDivElement, props?: TaskSetupMFAProps): void => { + if (this.clerkjs && this.loaded) { + this.clerkjs.mountTaskSetupMfa(node, props); + } else { + this.premountTaskSetupMfaNodes.set(node, props); + } + }; + + unmountTaskSetupMfa = (node: HTMLDivElement): void => { + if (this.clerkjs && this.loaded) { + this.clerkjs.unmountTaskSetupMfa(node); + } else { + this.premountTaskSetupMfaNodes.delete(node); + } + }; + addListener = (listener: ListenerCallback): UnsubscribeCallback => { if (this.clerkjs) { return this.clerkjs.addListener(listener); diff --git a/packages/shared/src/internal/clerk-js/sessionTasks.ts b/packages/shared/src/internal/clerk-js/sessionTasks.ts index eb8a3f3ca99..e0d0fd1e0f8 100644 --- a/packages/shared/src/internal/clerk-js/sessionTasks.ts +++ b/packages/shared/src/internal/clerk-js/sessionTasks.ts @@ -9,6 +9,7 @@ import { buildURL } from './url'; export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record = { 'choose-organization': 'choose-organization', 'reset-password': 'reset-password', + 'setup-mfa': 'setup-mfa', } as const; /** diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 01927cfe4d6..cf1ab2602c1 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -672,6 +672,23 @@ export interface Clerk { */ unmountTaskResetPassword: (targetNode: HTMLDivElement) => void; + /** + * Mounts a TaskSetupMFA component at the target element. + * This component allows users to set up multi-factor authentication. + * + * @param targetNode - Target node to mount the TaskSetupMFA component. + * @param props - configuration parameters. + */ + mountTaskSetupMfa: (targetNode: HTMLDivElement, props?: TaskSetupMFAProps) => void; + + /** + * Unmount a TaskSetupMFA component from the target element. + * If there is no component mounted at the target node, results in a noop. + * + * @param targetNode - Target node to unmount the TaskSetupMFA component from. + */ + unmountTaskSetupMfa: (targetNode: HTMLDivElement) => void; + /** * @internal * Loads Stripe libraries for commerce functionality @@ -2302,6 +2319,14 @@ export type TaskResetPasswordProps = { appearance?: ClerkAppearanceTheme; }; +export type TaskSetupMFAProps = { + /** + * Full URL or path to navigate to after successfully resolving all tasks + */ + redirectUrlComplete: string; + appearance?: ClerkAppearanceTheme; +}; + export type CreateOrganizationInvitationParams = { emailAddress: string; role: OrganizationCustomRoleKey; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 7c3b5ae0fc0..d97301630d0 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1327,6 +1327,69 @@ export type __internal_LocalizationResource = { }; formButtonPrimary: LocalizationValue; }; + taskSetupMfa: { + badge: LocalizationValue; + start: { + title: LocalizationValue; + subtitle: LocalizationValue; + methodSelection: { + totp: LocalizationValue; + phoneCode: LocalizationValue; + }; + }; + smsCode: { + title: LocalizationValue; + subtitle: LocalizationValue; + addPhoneNumber: LocalizationValue; + cancel: LocalizationValue; + verifyPhone: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + resendButton: LocalizationValue; + formButtonPrimary: LocalizationValue; + }; + addPhone: { + infoText: LocalizationValue; + formButtonPrimary: LocalizationValue; + }; + success: { + title: LocalizationValue; + message1: LocalizationValue; + message2: LocalizationValue; + finishButton: LocalizationValue; + }; + }; + totpCode: { + title: LocalizationValue; + addAuthenticatorApp: { + infoText__ableToScan: LocalizationValue; + infoText__unableToScan: LocalizationValue; + inputLabel__unableToScan1: LocalizationValue; + buttonUnableToScan__nonPrimary: LocalizationValue; + buttonAbleToScan__nonPrimary: LocalizationValue; + formButtonPrimary: LocalizationValue; + formButtonReset: LocalizationValue; + }; + verifyTotp: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + formButtonPrimary: LocalizationValue; + formButtonReset: LocalizationValue; + }; + success: { + title: LocalizationValue; + message1: LocalizationValue; + message2: LocalizationValue; + finishButton: LocalizationValue; + }; + }; + signOut: { + actionText: LocalizationValue<'identifier'>; + actionLink: LocalizationValue; + }; + }; web3SolanaWalletButtons: { connect: LocalizationValue<'walletName'>; continue: LocalizationValue<'walletName'>; diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 727c044ad48..f35b1454c24 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -335,7 +335,7 @@ export interface SessionTask { /** * A unique identifier for the task */ - key: 'choose-organization' | 'reset-password'; + key: 'choose-organization' | 'reset-password' | 'setup-mfa'; } export type GetTokenOptions = { diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 6e0d04985b8..0588bf7480a 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -49,6 +49,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "SignUpButton", "TaskChooseOrganization", "TaskResetPassword", + "TaskSetupMFA", "UNSAFE_PortalProvider", "UserAvatar", "UserButton", diff --git a/packages/ui/src/common/Wizard.tsx b/packages/ui/src/common/Wizard.tsx index 77bd735452a..aee4cd4d8a4 100644 --- a/packages/ui/src/common/Wizard.tsx +++ b/packages/ui/src/common/Wizard.tsx @@ -4,6 +4,7 @@ import { Animated } from '../elements/Animated'; type WizardProps = React.PropsWithChildren<{ step: number; + animate?: boolean; }>; type UseWizardProps = { @@ -26,7 +27,11 @@ export const useWizard = (params: UseWizardProps = {}) => { }; export const Wizard = (props: WizardProps) => { - const { step, children } = props; + const { step, children, animate = true } = props; + + if (!animate) { + return React.Children.toArray(children)[step]; + } return {React.Children.toArray(children)[step]}; }; diff --git a/packages/ui/src/components/SessionTasks/index.tsx b/packages/ui/src/components/SessionTasks/index.tsx index 9cca5b9b9c1..3316a395c37 100644 --- a/packages/ui/src/components/SessionTasks/index.tsx +++ b/packages/ui/src/components/SessionTasks/index.tsx @@ -12,11 +12,13 @@ import { SessionTasksContext, TaskChooseOrganizationContext, TaskResetPasswordContext, + TaskSetupMFAContext, useSessionTasksContext, } from '../../contexts/components/SessionTasks'; import { Route, Switch, useRouter } from '../../router'; import { TaskChooseOrganization } from './tasks/TaskChooseOrganization'; import { TaskResetPassword } from './tasks/TaskResetPassword'; +import { TaskSetupMFA } from './tasks/TaskSetupMfa'; const SessionTasksStart = () => { const clerk = useClerk(); @@ -50,6 +52,43 @@ const SessionTasksStart = () => { function SessionTasksRoutes(): JSX.Element { const ctx = useSessionTasksContext(); + const clerk = useClerk(); + const { navigate, currentPath } = useRouter(); + + const currentTaskContainer = useRef(null); + + // If there are no pending tasks, navigate away from the tasks flow. + // This handles cases where a user with an active session returns to the tasks URL, + // for example by using browser back navigation. Since there are no pending tasks, + // we redirect them to their intended destination. + useEffect(() => { + // Tasks can only exist on pending sessions, but we check both conditions + // here to be defensive and ensure proper redirection + const task = clerk.session?.currentTask; + if (!task || clerk.session?.status === 'active') { + if (ctx.shouldAutoNavigateAway.current) { + void navigate(ctx.redirectUrlComplete); + } + return; + } + + clerk.telemetry?.record(eventComponentMounted('SessionTask', { task: task.key })); + }, [clerk, currentPath, navigate, ctx.redirectUrlComplete, ctx.shouldAutoNavigateAway]); + + if (!clerk.session?.currentTask && ctx.shouldAutoNavigateAway.current) { + return ( + ({ + minHeight: currentTaskContainer ? currentTaskContainer.current?.offsetHeight : undefined, + })} + > + ({ flex: 1 })}> + + + + + ); + } return ( @@ -68,6 +107,13 @@ function SessionTasksRoutes(): JSX.Element { + + + + + @@ -84,44 +130,9 @@ type SessionTasksProps = { * @internal */ export const SessionTasks = withCardStateProvider(({ redirectUrlComplete }: SessionTasksProps) => { - const clerk = useClerk(); - const { navigate } = useRouter(); - - const currentTaskContainer = useRef(null); - - // If there are no pending tasks, navigate away from the tasks flow. - // This handles cases where a user with an active session returns to the tasks URL, - // for example by using browser back navigation. Since there are no pending tasks, - // we redirect them to their intended destination. - useEffect(() => { - // Tasks can only exist on pending sessions, but we check both conditions - // here to be defensive and ensure proper redirection - const task = clerk.session?.currentTask; - if (!task || clerk.session?.status === 'active') { - void navigate(redirectUrlComplete); - return; - } - - clerk.telemetry?.record(eventComponentMounted('SessionTask', { task: task.key })); - }, [clerk, navigate, redirectUrlComplete]); - - if (!clerk.session?.currentTask) { - return ( - ({ - minHeight: currentTaskContainer ? currentTaskContainer.current?.offsetHeight : undefined, - })} - > - ({ flex: 1 })}> - - - - - ); - } - + const shouldAutoNavigateAwayRef = useRef(true); return ( - + ); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/SetupMfaStartScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/SetupMfaStartScreen.tsx new file mode 100644 index 00000000000..6a00e662087 --- /dev/null +++ b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/SetupMfaStartScreen.tsx @@ -0,0 +1,104 @@ +import type { VerificationStrategy } from '@clerk/shared/types'; + +import { Actions } from '@/elements/Actions'; +import { useCardState } from '@/elements/contexts'; +import { PreviewButton } from '@/elements/PreviewButton'; +import { AuthApp, Mobile } from '@/icons'; +import { descriptors, Flex, Icon, localizationKeys, Text, type LocalizationKey } from '@/ui/customizables'; +import { Card } from '@/ui/elements/Card'; +import { Header } from '@/ui/elements/Header'; + +import { MFA_METHODS_TO_STEP } from './constants'; +import { SharedFooterActionForSignOut } from './shared'; + +type SetupMfaStartScreenProps = { + availableMethods: VerificationStrategy[]; + goToStep: (step: number) => void; +}; + +const METHOD_CONFIG: Record<'totp' | 'phone_code', { icon: JSX.Element; label: LocalizationKey }> = { + totp: { + icon: , + label: localizationKeys('taskSetupMfa.start.methodSelection.totp'), + }, + phone_code: { + icon: , + label: localizationKeys('taskSetupMfa.start.methodSelection.phoneCode'), + }, +}; + +export const SetupMfaStartScreen = (props: SetupMfaStartScreenProps) => { + const { availableMethods, goToStep } = props; + const card = useCardState(); + + return ( + + ({ padding: t.space.$none })}> + ({ + paddingTop: t.space.$8, + paddingLeft: t.space.$8, + paddingRight: t.space.$8, + })} + > + + + + {card.error} + ({ + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + })} + > + {availableMethods.map(method => { + const methodConfig = METHOD_CONFIG[method] ?? null; + + if (!methodConfig) { + return null; + } + + return ( + { + goToStep(MFA_METHODS_TO_STEP[method as keyof typeof MFA_METHODS_TO_STEP]); + }} + > + ({ gap: t.space.$2, alignItems: 'center' })}> + ({ + borderRadius: t.radii.$circle, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$avatarBorder, + padding: t.space.$2, + backgroundColor: t.colors.$neutralAlpha50, + })} + > + {methodConfig.icon} + + + + + ); + })} + + + + + + + + ); +}; diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/SmsCodeFlowScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/SmsCodeFlowScreen.tsx new file mode 100644 index 00000000000..1a00a71876e --- /dev/null +++ b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/SmsCodeFlowScreen.tsx @@ -0,0 +1,415 @@ +import { useReverification, useUser } from '@clerk/shared/react'; +import type { PhoneNumberResource, UserResource } from '@clerk/shared/types'; +import React, { useMemo, useRef } from 'react'; + +import { useWizard, Wizard } from '@/common'; +import { MfaBackupCodeList } from '@/components/UserProfile/MfaBackupCodeList'; +import { Action, Actions } from '@/elements/Actions'; +import { useCardState } from '@/elements/contexts'; +import { Form } from '@/elements/Form'; +import { FormButtonContainer } from '@/elements/FormButtons'; +import { PreviewButton } from '@/elements/PreviewButton'; +import { type VerificationCodeCardProps, VerificationCodeContent } from '@/elements/VerificationCodeCard'; +import { Add } from '@/icons'; +import { Button, Col, descriptors, Flex, Flow, localizationKeys, Text } from '@/ui/customizables'; +import { Card } from '@/ui/elements/Card'; +import { Header } from '@/ui/elements/Header'; +import { SuccessPage } from '@/ui/elements/SuccessPage'; +import { handleError } from '@/ui/utils/errorHandler'; +import { getFlagEmojiFromCountryIso, parsePhoneString, stringToFormattedPhoneString } from '@/ui/utils/phoneUtils'; +import { useFormControl } from '@/ui/utils/useFormControl'; + +import { SharedFooterActionForSignOut } from './shared'; + +type MFAVerifyPhoneForSessionTasksProps = { + resourceRef: React.MutableRefObject; + onSuccess: () => void; + onReset: () => void; +}; + +export const getAvailablePhonesFromUser = (user: UserResource | undefined | null) => { + return user?.phoneNumbers.filter(p => !p.reservedForSecondFactor) || []; +}; + +const MFAVerifyPhoneForSessionTasks = (props: MFAVerifyPhoneForSessionTasksProps) => { + const { onSuccess, resourceRef, onReset } = props; + const card = useCardState(); + const phone = resourceRef.current; + const setReservedForSecondFactor = useReverification(() => phone?.setReservedForSecondFactor({ reserved: true })); + + const prepare = () => { + return resourceRef.current?.prepareVerification?.()?.catch(err => handleError(err, [], card.setError)); + }; + + const enableMfa = async () => { + card.setLoading(phone?.id); + try { + const result = await setReservedForSecondFactor(); + resourceRef.current = result; + onSuccess(); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + card.setIdle(); + } + }; + + React.useEffect(() => { + void prepare(); + }, []); + + const action: VerificationCodeCardProps['onCodeEntryFinishedAction'] = (code, resolve, reject) => { + void resourceRef.current + ?.attemptVerification({ code: code }) + .then(async () => { + await resolve(); + enableMfa(); + }) + .catch(reject); + }; + + return ( + + onReset()} + onBackLinkClicked={() => onReset()} + backLinkLabel={localizationKeys('taskSetupMfa.smsCode.cancel')} + /> + + ); +}; + +type AddPhoneForSessionTasksProps = { + resourceRef: React.MutableRefObject; + onSuccess: () => void; + onReset: () => void; + onUseExistingNumberClick: () => void; +}; + +const AddPhoneForSessionTasks = (props: AddPhoneForSessionTasksProps) => { + const { resourceRef, onSuccess, onReset } = props; + const card = useCardState(); + const { user } = useUser(); + const createPhoneNumber = useReverification( + (user: UserResource, opt: Parameters[0]) => user.createPhoneNumber(opt), + ); + + const phoneField = useFormControl('phoneNumber', '', { + type: 'tel', + label: localizationKeys('formFieldLabel__phoneNumber'), + isRequired: true, + }); + + const canSubmit = phoneField.value.length > 1 && user?.username !== phoneField.value; + + const addPhone = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user) { + return; + } + card.runAsync(async () => { + try { + const res = await createPhoneNumber(user, { phoneNumber: phoneField.value }); + resourceRef.current = res; + onSuccess(); + } catch (e) { + handleError(e as Error, [phoneField], card.setError); + } + }); + }; + + return ( + + + + + + {card.error} + void addPhone(e)}> + + + + ({ + flexDirection: 'column', + gap: theme.space.$4, + })} + > + + + + + + ); +}; + +type SuccessScreenProps = { + resourceRef: React.MutableRefObject; + onFinish: () => void; +}; + +const SuccessScreen = (props: SuccessScreenProps) => { + const { resourceRef, onFinish } = props; + + return ( + + + } + finishLabel={localizationKeys('taskSetupMfa.smsCode.success.finishButton')} + finishButtonProps={{ + block: true, + hasArrow: true, + }} + /> + + ); +}; + +type PhoneItemProps = { + phone: PhoneNumberResource; + onSuccess: () => void; + onUnverifiedPhoneClick: (phone: PhoneNumberResource) => void; + resourceRef: React.MutableRefObject; +}; + +const PhoneItem = ({ phone, onSuccess, onUnverifiedPhoneClick, resourceRef }: PhoneItemProps) => { + const card = useCardState(); + const setReservedForSecondFactor = useReverification(() => phone.setReservedForSecondFactor({ reserved: true })); + + const { iso } = parsePhoneString(phone.phoneNumber); + const flag = getFlagEmojiFromCountryIso(iso); + const formattedPhone = stringToFormattedPhoneString(phone.phoneNumber); + + const handleSelect = async () => { + if (phone.verification.status !== 'verified') { + return onUnverifiedPhoneClick(phone); + } + + card.setLoading(phone.id); + try { + const result = await setReservedForSecondFactor(); + resourceRef.current = result; + onSuccess(); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + card.setIdle(); + } + }; + + return ( + ({ + padding: `${t.space.$4} ${t.space.$6}`, + })} + onClick={() => void handleSelect()} + > + ({ gap: t.space.$4, alignItems: 'center' })}> + ({ fontSize: t.fontSizes.$lg })}>{flag} + {formattedPhone} + + + ); +}; + +type SmsCodeScreenProps = { + onSuccess: () => void; + onReset: () => void; + onAddPhoneClick: () => void; + onUnverifiedPhoneClick: (phone: PhoneNumberResource) => void; + resourceRef: React.MutableRefObject; + availablePhones: PhoneNumberResource[]; +}; + +const SmsCodeScreen = (props: SmsCodeScreenProps) => { + const { onSuccess, onReset, onAddPhoneClick, onUnverifiedPhoneClick, resourceRef } = props; + const { user } = useUser(); + const card = useCardState(); + + if (!user) { + return null; + } + + const availablePhones = getAvailablePhonesFromUser(user); + + return ( + + ({ padding: t.space.$none })}> + ({ + paddingTop: t.space.$8, + paddingInline: t.space.$8, + })} + > + + + + ({ paddingInline: t.space.$8 })}> + {card.error} + + + ({ + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + })} + > + {availablePhones?.map(phone => ( + + ))} + ({ + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + padding: `${t.space.$4} ${t.space.$4}`, + gap: t.space.$2, + })} + iconSx={t => ({ + width: t.sizes.$8, + height: t.sizes.$6, + })} + /> + + ({ + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$borderAlpha100, + padding: t.space.$4, + })} + > +