diff --git a/.changeset/cold-clowns-fix.md b/.changeset/cold-clowns-fix.md new file mode 100644 index 00000000000..98658a7e261 --- /dev/null +++ b/.changeset/cold-clowns-fix.md @@ -0,0 +1,9 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/nextjs': minor +'@clerk/clerk-react': minor +'@clerk/shared': minor +--- + +Introduces MFA setup session task for handling require MFA after sign-in and sign-up diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 844b91663d6..0629ca25514 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -157,6 +157,12 @@ 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); + const withBillingJwtV2 = base .clone() .setId('withBillingJwtV2') @@ -210,6 +216,7 @@ export const envs = { withReverification, withSessionTasks, withSessionTasksResetPassword, + withSessionTasksSetupMfa, withSignInOrUpEmailLinksFlow, withSignInOrUpFlow, withSignInOrUpwithRestrictedModeFlow, diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index a5acc533fc6..f3d9a8739a5 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -32,6 +32,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 }, /** 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..1bdb3c1e8f7 --- /dev/null +++ b/integration/tests/session-tasks-setup-mfa.test.ts @@ -0,0 +1,207 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; +import { stringPhoneNumber } from '../testUtils/phoneUtils'; +import { fakerPhoneNumber } from '../testUtils/usersService'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasksSetupMfa] })( + 'session tasks setup-mfa flow @nextjs', + ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + test.afterAll(async () => { + 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(); + + const testPhoneNumber = fakerPhoneNumber(); + await u.po.signIn.getPhoneNumberInput().fill(testPhoneNumber); + await u.page.getByRole('button', { name: /continue/i }).click(); + + await u.po.signIn.enterTestOtpCode(); + + await u.page.getByText(/save these backup codes/i).waitFor({ state: 'visible', timeout: 10000 }); + + await u.po.signIn.continue(); + + 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.getByText(/save these backup codes/i).waitFor({ state: 'visible', timeout: 10000 }); + + await u.po.signIn.continue(); + + 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(); + + const invalidPhoneNumber = '123091293193091311'; + await u.po.signIn.getPhoneNumberInput().fill(invalidPhoneNumber); + await u.po.signIn.continue(); + // we need to improve this error message + await expect(u.page.getByTestId('form-feedback-error')).toBeVisible(); + + const validPhoneNumber = fakerPhoneNumber(); + await u.po.signIn.getPhoneNumberInput().fill(validPhoneNumber); + await u.po.signIn.continue(); + + await u.po.signIn.enterTestOtpCode(); + + await u.page.getByText(/save these backup codes/i).waitFor({ state: 'visible', timeout: 10000 }); + + await u.po.signIn.continue(); + + await u.page.waitForAppUrl('/page-protected'); + await u.po.expect.toBeSignedIn(); + + 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(); + + const testPhoneNumber = fakerPhoneNumber(); + await u.po.signIn.getPhoneNumberInput().fill(testPhoneNumber); + await u.po.signIn.continue(); + + await u.po.signIn.enterOtpCode('111111', { + awaitPrepare: true, + awaitAttempt: true, + }); + + await expect(u.page.getByTestId('form-feedback-error')).toBeVisible(); + + 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('button', { name: /cancel/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/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index a055cc6954f..b4910821584 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "928KB" }, + { "path": "./dist/clerk.js", "maxSize": "929KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "87KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "129KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "66KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index f6c18b85eee..100dc42d266 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -87,6 +87,7 @@ import type { SignUpResource, TaskChooseOrganizationProps, TaskResetPasswordProps, + TaskSetupMFAProps, TasksRedirectOptions, UnsubscribeCallback, UserAvatarProps, @@ -1447,6 +1448,26 @@ export class Clerk implements ClerkInterface { void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node })); }; + public mountTaskSetupMfa = (node: HTMLDivElement, props?: TaskSetupMFAProps) => { + this.assertComponentsReady(this.#componentControls); + + void this.#componentControls.ensureMounted({ preloadHint: 'TaskSetupMFA' }).then(controls => + controls.mountComponent({ + name: 'TaskSetupMFA', + appearanceKey: 'taskSetupMfa', + node, + props, + }), + ); + + this.telemetry?.record(eventPrebuiltComponentMounted('TaskSetupMfa', props)); + }; + + public unmountTaskSetupMfa = (node: HTMLDivElement) => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node })); + }; + /** * `setActive` can be used to set the active session and/or organization. */ diff --git a/packages/clerk-js/src/core/resources/UserSettings.ts b/packages/clerk-js/src/core/resources/UserSettings.ts index ed5951b7037..2c60f7e3445 100644 --- a/packages/clerk-js/src/core/resources/UserSettings.ts +++ b/packages/clerk-js/src/core/resources/UserSettings.ts @@ -127,6 +127,9 @@ export class UserSettings extends BaseResource implements UserSettingsResource { legal_consent_enabled: false, mode: 'public', progressive: true, + mfa: { + required: false, + }, }; social: OAuthProviders = {} as OAuthProviders; usernameSettings: UsernameSettingsData = {} as UsernameSettingsData; diff --git a/packages/clerk-js/src/core/sessionTasks.ts b/packages/clerk-js/src/core/sessionTasks.ts index 7c775022840..effa11659ef 100644 --- a/packages/clerk-js/src/core/sessionTasks.ts +++ b/packages/clerk-js/src/core/sessionTasks.ts @@ -9,6 +9,7 @@ import { buildURL, forwardClerkQueryParams } from '../utils'; 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/clerk-js/src/test/fixture-helpers.ts b/packages/clerk-js/src/test/fixture-helpers.ts index c22a879cb3b..133a63e5e40 100644 --- a/packages/clerk-js/src/test/fixture-helpers.ts +++ b/packages/clerk-js/src/test/fixture-helpers.ts @@ -586,6 +586,10 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { us.sign_up.mode = SIGN_UP_MODES.WAITLIST; }; + const withMfaRequired = (required: boolean = true) => { + us.sign_up.mfa = { required }; + }; + // TODO: Add the rest, consult pkg/generate/auth_config.go return { @@ -606,5 +610,6 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { withRestrictedMode, withLegalConsent, withWaitlistMode, + withMfaRequired, }; }; diff --git a/packages/clerk-js/src/test/fixtures.ts b/packages/clerk-js/src/test/fixtures.ts index ada0f9775cd..49df934b769 100644 --- a/packages/clerk-js/src/test/fixtures.ts +++ b/packages/clerk-js/src/test/fixtures.ts @@ -208,6 +208,9 @@ const createBaseUserSettings = (): UserSettingsJSON => { captcha_enabled: false, disable_hibp: false, mode: 'public', + mfa: { + required: false, + }, }, restrictions: { allowlist: { diff --git a/packages/clerk-js/src/ui/common/Wizard.tsx b/packages/clerk-js/src/ui/common/Wizard.tsx index 77bd735452a..aee4cd4d8a4 100644 --- a/packages/clerk-js/src/ui/common/Wizard.tsx +++ b/packages/clerk-js/src/ui/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/clerk-js/src/ui/components/SessionTasks/index.tsx b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx index dc728a60c26..ba27873fcf0 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx +++ b/packages/clerk-js/src/ui/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,37 @@ const SessionTasksStart = () => { function SessionTasksRoutes(): JSX.Element { const ctx = useSessionTasksContext(); + const clerk = useClerk(); + const { navigate, currentPath } = useRouter(); + + // 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.redirectOnActiveSession?.current) { + void navigate(ctx.redirectUrlComplete); + } + return; + } + + clerk.telemetry?.record(eventComponentMounted('SessionTask', { task: task.key })); + }, [clerk, currentPath, navigate, ctx.redirectUrlComplete, ctx.redirectOnActiveSession]); + + if (!clerk.session?.currentTask && ctx.redirectOnActiveSession?.current) { + return ( + + ({ flex: 1 })}> + + + + + ); + } return ( @@ -68,6 +101,13 @@ function SessionTasksRoutes(): JSX.Element { + + + + + @@ -84,44 +124,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 redirectOnActiveSessionRef = useRef(true); return ( - + ); diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskSetupMfa/SetupMfaStartScreen.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskSetupMfa/SetupMfaStartScreen.tsx new file mode 100644 index 00000000000..b133b027c51 --- /dev/null +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskSetupMfa/SetupMfaStartScreen.tsx @@ -0,0 +1,109 @@ +import type { VerificationStrategy } from '@clerk/shared/types'; + +import { descriptors, Flex, Icon, type LocalizationKey, localizationKeys, Text } from '@/ui/customizables'; +import { Actions } from '@/ui/elements/Actions'; +import { Card } from '@/ui/elements/Card'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; +import { Header } from '@/ui/elements/Header'; +import { PreviewButton } from '@/ui/elements/PreviewButton'; +import { AuthApp, Mobile } from '@/ui/icons'; + +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 = withCardStateProvider((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 && ( + ({ paddingInline: 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 as keyof typeof METHOD_CONFIG] ?? 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/clerk-js/src/ui/components/SessionTasks/tasks/TaskSetupMfa/SmsCodeFlowScreen.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskSetupMfa/SmsCodeFlowScreen.tsx new file mode 100644 index 00000000000..25c09f08fd4 --- /dev/null +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/TaskSetupMfa/SmsCodeFlowScreen.tsx @@ -0,0 +1,435 @@ +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 '@/ui/common'; +import { MfaBackupCodeList } from '@/ui/components/UserProfile/MfaBackupCodeList'; +import { Button, Col, descriptors, Flex, Flow, localizationKeys, Text } from '@/ui/customizables'; +import { Action, Actions } from '@/ui/elements/Actions'; +import { Card } from '@/ui/elements/Card'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; +import { Form } from '@/ui/elements/Form'; +import { FormButtonContainer } from '@/ui/elements/FormButtons'; +import { Header } from '@/ui/elements/Header'; +import { PreviewButton } from '@/ui/elements/PreviewButton'; +import { SuccessPage } from '@/ui/elements/SuccessPage'; +import { type VerificationCodeCardProps, VerificationCodeContent } from '@/ui/elements/VerificationCodeCard'; +import { Add } from '@/ui/icons'; +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(phoneNumber => { + const hasOtherIdentifications = + user?.primaryEmailAddress !== null || + user?.primaryWeb3Wallet !== null || + user?.passkeys.length > 0 || + user?.externalAccounts.length > 0 || + user?.enterpriseAccounts.length > 0 || + user?.username !== null; + + if (phoneNumber.id === user?.primaryPhoneNumber?.id && !hasOtherIdentifications) { + return false; + } + return !phoneNumber.reservedForSecondFactor; + }) || [] + ); +}; + +const MFAVerifyPhoneForSessionTasks = withCardStateProvider((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(); + await enableMfa(); + }) + .catch(reject); + }; + + return ( + + void prepare()} + onIdentityPreviewEditClicked={() => onReset()} + onBackLinkClicked={() => onReset()} + backLinkLabel={localizationKeys('taskSetupMfa.smsCode.cancel')} + /> + + ); +}); + +type AddPhoneForSessionTasksProps = { + resourceRef: React.MutableRefObject; + onSuccess: () => void; + onReset: () => void; +}; + +const AddPhoneForSessionTasks = withCardStateProvider((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; + } + await 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} + + + + + ({ + flexDirection: 'column', + gap: theme.space.$4, + })} + > + + + + + + ); +}); + +type SuccessScreenProps = { + resourceRef: React.MutableRefObject; + onFinish: () => void; +}; + +const SuccessScreen = withCardStateProvider((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 = withCardStateProvider((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, + })} + > + + + + {card.error && ( + ({ 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, + })} + > +