From 1d603179a347d414d091c7f91f93167393a7da0d Mon Sep 17 00:00:00 2001 From: wangsijie Date: Sun, 13 Sep 2026 02:47:28 +0000 Subject: [PATCH] feat(experience,phrases): add pinned password and primary code step-up screens Add the `/step-up/password` and `/step-up/verification-code/:type` pages, the pinned-user first factors of the step-up route tree. - `verifyStepUpPassword()` sends the password alone; `verifyStepUpVerificationCode()` sends the identifier type alone. Neither carries a raw identifier, and both complete through the existing `identifyAndSubmitInteraction({ verificationId })`. - The password form is the password field of the sign-in form and nothing else: no identifier switching, and no forgot-password link, since the mode rejects the event switch it would start. - The code page displays only the masked identifier from the server context, and its resend goes through the same pinned variant, renewing the stored verification ID. - Both pages read the authentication context the guard loaded, so a refresh recovers the same state. The identifier type rides in the path for the same reason. - Error handling composes the step-up handlers with the sign-in submission handlers, so a sign-in with requested ACR continues from the same pages. Sentinel lockout errors keep their existing handling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FFx5WPS3gkEZc5jWEbX7J1 --- packages/experience/src/App.tsx | 11 +- .../src/apis/experience/step-up.test.ts | 97 +++++- .../experience/src/apis/experience/step-up.ts | 41 +++ packages/experience/src/constants/step-up.ts | 17 +- .../StepUpCodeVerification/index.module.scss | 19 ++ .../StepUpCodeVerification/index.test.tsx | 289 ++++++++++++++++++ .../StepUpCodeVerification/index.tsx | 133 ++++++++ .../use-resend-step-up-verification-code.ts | 68 +++++ .../use-step-up-code-verification.ts | 78 +++++ .../StepUpMethodList/index.test.tsx | 10 +- .../use-select-step-up-method.ts | 4 +- .../pages/StepUp/Password/PasswordForm.tsx | 72 +++++ .../pages/StepUp/Password/index.module.scss | 19 ++ .../src/pages/StepUp/Password/index.test.tsx | 221 ++++++++++++++ .../src/pages/StepUp/Password/index.tsx | 34 +++ .../use-step-up-password-verification.ts | 69 +++++ .../StepUp/VerificationCode/index.test.tsx | 176 +++++++++++ .../pages/StepUp/VerificationCode/index.tsx | 75 +++++ .../src/locales/ar/step-up.ts | 2 + .../src/locales/cs/step-up.ts | 2 + .../src/locales/de/step-up.ts | 3 + .../src/locales/en/step-up.ts | 2 + .../src/locales/es-mx/step-up.ts | 3 + .../src/locales/es/step-up.ts | 3 + .../src/locales/fa-ir/step-up.ts | 2 + .../src/locales/fr/step-up.ts | 2 + .../src/locales/it/step-up.ts | 2 + .../src/locales/ja/step-up.ts | 2 + .../src/locales/ko/step-up.ts | 2 + .../src/locales/pl-pl/step-up.ts | 2 + .../src/locales/pt-br/step-up.ts | 3 + .../src/locales/pt-pt/step-up.ts | 3 + .../src/locales/ru/step-up.ts | 2 + .../src/locales/th/step-up.ts | 2 + .../src/locales/tr-tr/step-up.ts | 2 + .../src/locales/uk-ua/step-up.ts | 2 + .../src/locales/zh-cn/step-up.ts | 2 + .../src/locales/zh-hk/step-up.ts | 2 + .../src/locales/zh-tw/step-up.ts | 2 + 39 files changed, 1467 insertions(+), 13 deletions(-) create mode 100644 packages/experience/src/containers/StepUpCodeVerification/index.module.scss create mode 100644 packages/experience/src/containers/StepUpCodeVerification/index.test.tsx create mode 100644 packages/experience/src/containers/StepUpCodeVerification/index.tsx create mode 100644 packages/experience/src/containers/StepUpCodeVerification/use-resend-step-up-verification-code.ts create mode 100644 packages/experience/src/containers/StepUpCodeVerification/use-step-up-code-verification.ts create mode 100644 packages/experience/src/pages/StepUp/Password/PasswordForm.tsx create mode 100644 packages/experience/src/pages/StepUp/Password/index.module.scss create mode 100644 packages/experience/src/pages/StepUp/Password/index.test.tsx create mode 100644 packages/experience/src/pages/StepUp/Password/index.tsx create mode 100644 packages/experience/src/pages/StepUp/Password/use-step-up-password-verification.ts create mode 100644 packages/experience/src/pages/StepUp/VerificationCode/index.test.tsx create mode 100644 packages/experience/src/pages/StepUp/VerificationCode/index.tsx diff --git a/packages/experience/src/App.tsx b/packages/experience/src/App.tsx index 1ca2fc26f336..c96c7f06e3be 100644 --- a/packages/experience/src/App.tsx +++ b/packages/experience/src/App.tsx @@ -54,6 +54,8 @@ import SocialLanding from './pages/SocialLanding'; import SocialLinkAccount from './pages/SocialLinkAccount'; import SocialSignInWebCallback from './pages/SocialSignInWebCallback'; import StepUp from './pages/StepUp'; +import StepUpPassword from './pages/StepUp/Password'; +import StepUpVerificationCode from './pages/StepUp/VerificationCode'; import SwitchAccount from './pages/SwitchAccount'; import TrustedDevice from './pages/TrustedDevice'; import VerificationCode from './pages/VerificationCode'; @@ -182,13 +184,18 @@ const App = () => { {/* * Step-up: an authenticated session proves the missing assurance. The guard - * loads the server-driven context; the pinned-user first-factor pages are - * registered under this tree by the slices that implement them. Dev-only + * loads the server-driven context, the landing dispatches on it, and the + * pinned-user first-factor pages verify the pinned subject. Dev-only * feature: remove the flag when the flow is released. */} {isDevFeaturesEnabled && ( }> } /> + } /> + } + /> )} diff --git a/packages/experience/src/apis/experience/step-up.test.ts b/packages/experience/src/apis/experience/step-up.test.ts index 53b6a793082e..85e164d859b0 100644 --- a/packages/experience/src/apis/experience/step-up.test.ts +++ b/packages/experience/src/apis/experience/step-up.test.ts @@ -12,7 +12,19 @@ import { import api from '../api'; import { experienceApiRoutes } from './const'; -import { getStepUpContext, initStepUp, sendStepUpVerificationCode } from './step-up'; +import { identifyAndSubmitInteraction } from './interaction'; +import { + getStepUpContext, + initStepUp, + sendStepUpVerificationCode, + verifyStepUpPassword, + verifyStepUpVerificationCode, +} from './step-up'; + +jest.mock('./interaction', () => ({ + __esModule: true, + identifyAndSubmitInteraction: jest.fn(), +})); jest.mock('../api', () => ({ __esModule: true, @@ -26,6 +38,9 @@ jest.mock('../api', () => ({ const mockedApiPut = api.put as jest.MockedFunction; const mockedApiGet = api.get as jest.MockedFunction; const mockedApiPost = api.post as jest.MockedFunction; +const mockedIdentifyAndSubmitInteraction = identifyAndSubmitInteraction as jest.MockedFunction< + typeof identifyAndSubmitInteraction +>; /** The fields the guard requires; `selectedAcr` and `mode` are optional on the wire. */ const requiredContext = { @@ -52,6 +67,13 @@ const mockPutResponse = (status: number, body?: unknown) => { return json; }; +const mockPostResponse = (body: unknown) => { + const json = jest.fn().mockResolvedValue(body); + mockedApiPost.mockReturnValueOnce({ json } as unknown as ReturnType); + + return json; +}; + const mockGetInteraction = (body: unknown) => { const json = jest.fn().mockResolvedValue(body); mockedApiGet.mockReturnValueOnce({ json } as unknown as ReturnType); @@ -184,4 +206,77 @@ describe('step-up experience APIs', () => { } ); }); + describe('verifyStepUpPassword', () => { + it('sends the password alone and completes the interaction with the verified record', async () => { + const submitResult = { redirectTo: 'https://logto.io/callback' }; + mockPostResponse({ verificationId: 'password-verification-id' }); + mockedIdentifyAndSubmitInteraction.mockResolvedValueOnce(submitResult); + + await expect(verifyStepUpPassword('password')).resolves.toEqual(submitResult); + + expect(mockedApiPost).toBeCalledTimes(1); + const [url, options] = mockedApiPost.mock.calls[0] ?? []; + expect(url).toBe(`${experienceApiRoutes.verification}/password`); + // Pinned strictly: no identifier, raw or masked, rides along with the password. + expect(options).toStrictEqual({ json: { password: 'password' } }); + expect(mockedIdentifyAndSubmitInteraction).toBeCalledTimes(1); + expect(mockedIdentifyAndSubmitInteraction).toBeCalledWith({ + verificationId: 'password-verification-id', + }); + }); + + it('does not identify or submit when the password is rejected', async () => { + const error = new Error('Invalid credentials'); + const json = jest.fn().mockRejectedValue(error); + mockedApiPost.mockReturnValueOnce({ json } as unknown as ReturnType); + + await expect(verifyStepUpPassword('wrong')).rejects.toThrow(error); + + expect(mockedIdentifyAndSubmitInteraction).not.toBeCalled(); + }); + }); + + describe('verifyStepUpVerificationCode', () => { + it.each([SignInIdentifier.Email, SignInIdentifier.Phone] as const)( + 'verifies the %s code by identifier type only and completes the interaction', + async (type) => { + const submitResult = { redirectTo: 'https://logto.io/callback' }; + mockPostResponse({ verificationId: 'verified-verification-id' }); + mockedIdentifyAndSubmitInteraction.mockResolvedValueOnce(submitResult); + + await expect( + verifyStepUpVerificationCode({ type, code: '123456', verificationId: 'sent-id' }) + ).resolves.toEqual(submitResult); + + expect(mockedApiPost).toBeCalledTimes(1); + const [url, options] = mockedApiPost.mock.calls[0] ?? []; + expect(url).toBe(`${experienceApiRoutes.verification}/verification-code/verify`); + // The raw primary email / phone never leaves the server, on the verify call either. + expect(options).toStrictEqual({ + json: { identifier: { type }, code: '123456', verificationId: 'sent-id' }, + }); + // The record the verify call returns is the one that identifies the pinned subject. + expect(mockedIdentifyAndSubmitInteraction).toBeCalledTimes(1); + expect(mockedIdentifyAndSubmitInteraction).toBeCalledWith({ + verificationId: 'verified-verification-id', + }); + } + ); + + it('does not identify or submit when the code is rejected', async () => { + const error = new Error('Code mismatch'); + const json = jest.fn().mockRejectedValue(error); + mockedApiPost.mockReturnValueOnce({ json } as unknown as ReturnType); + + await expect( + verifyStepUpVerificationCode({ + type: SignInIdentifier.Email, + code: '000000', + verificationId: 'sent-id', + }) + ).rejects.toThrow(error); + + expect(mockedIdentifyAndSubmitInteraction).not.toBeCalled(); + }); + }); }); diff --git a/packages/experience/src/apis/experience/step-up.ts b/packages/experience/src/apis/experience/step-up.ts index 47ede1117222..aefa04faca37 100644 --- a/packages/experience/src/apis/experience/step-up.ts +++ b/packages/experience/src/apis/experience/step-up.ts @@ -9,6 +9,7 @@ import { type VerificationCodeIdentifier } from '@/types'; import api from '../api'; import { experienceApiRoutes, type VerificationResponse } from './const'; +import { identifyAndSubmitInteraction } from './interaction'; type StepUpInitResult = { /** @@ -62,3 +63,43 @@ export const sendStepUpVerificationCode = async (type: VerificationCodeIdentifie json: { interactionEvent: InteractionEvent.SignIn, identifier: { type } }, }) .json(); + +/** + * Verify the pinned subject's password and complete the interaction. + * + * Only the password is sent: Core verifies it against the pinned subject's credential, so no + * identifier — raw or masked — leaves the browser. The verified record then identifies that same + * subject through the existing identification and submission pair. + */ +export const verifyStepUpPassword = async (password: string) => { + const { verificationId } = await api + .post(`${experienceApiRoutes.verification}/password`, { json: { password } }) + .json(); + + return identifyAndSubmitInteraction({ verificationId }); +}; + +type StepUpVerificationCodePayload = { + type: VerificationCodeIdentifier; + code: string; + verificationId: string; +}; + +/** + * Verify a code sent to the pinned subject's primary email or phone, and complete the + * interaction. As when sending it, only the identifier type travels; Core reads the value it + * challenged from the verification record. + */ +export const verifyStepUpVerificationCode = async ({ + type, + code, + verificationId, +}: StepUpVerificationCodePayload) => { + const { verificationId: verifiedVerificationId } = await api + .post(`${experienceApiRoutes.verification}/verification-code/verify`, { + json: { identifier: { type }, code, verificationId }, + }) + .json(); + + return identifyAndSubmitInteraction({ verificationId: verifiedVerificationId }); +}; diff --git a/packages/experience/src/constants/step-up.ts b/packages/experience/src/constants/step-up.ts index bd72ef9f4344..bdfbe39440b3 100644 --- a/packages/experience/src/constants/step-up.ts +++ b/packages/experience/src/constants/step-up.ts @@ -1,10 +1,13 @@ import { type LogtoErrorCode } from '@logto/phrases'; import { experience } from '@logto/schemas'; +import { type VerificationCodeIdentifier } from '@/types'; + /** - * The pages of the `/step-up` route tree. The landing runs the server-driven dispatch; the - * pinned-user first-factor pages are registered by the slices that implement them, and they are - * also where `session.step_up.require_verification` sends a sign-in with requested ACR. + * The pages of the `/step-up` route tree. The landing runs the server-driven dispatch, and the + * pinned-user first-factor pages verify the pinned subject's password or primary email / phone + * code. A sign-in with requested ACR reaches the same pages through the landing, which + * `session.step_up.require_verification` navigates to. */ export const stepUpRoutes = Object.freeze({ landing: `/${experience.routes.stepUp}`, @@ -12,6 +15,14 @@ export const stepUpRoutes = Object.freeze({ verificationCode: `/${experience.routes.stepUp}/verification-code`, }); +/** + * The pinned-user code page of one identifier type. The type rides in the path rather than in + * `location.state`, so a refresh recovers the page the user is on; the code itself is addressed + * by the verification ID the send stored, and the raw identifier never appears here. + */ +export const getStepUpVerificationCodeRoute = (type: VerificationCodeIdentifier) => + `${stepUpRoutes.verificationCode}/${type}`; + /** The route that renders the invalid-session error page (registered in `App.tsx`). */ export const unknownSessionRoute = '/unknown-session'; diff --git a/packages/experience/src/containers/StepUpCodeVerification/index.module.scss b/packages/experience/src/containers/StepUpCodeVerification/index.module.scss new file mode 100644 index 000000000000..60452550998b --- /dev/null +++ b/packages/experience/src/containers/StepUpCodeVerification/index.module.scss @@ -0,0 +1,19 @@ +@use '@/shared/scss/underscore' as _; + +.codeInput { + margin-top: _.unit(4); +} + +.continueButton { + margin-top: _.unit(6); +} + +.message { + margin-top: _.unit(3); + font: var(--font-body-2); + color: var(--color-type-secondary); +} + +.link { + cursor: pointer; +} diff --git a/packages/experience/src/containers/StepUpCodeVerification/index.test.tsx b/packages/experience/src/containers/StepUpCodeVerification/index.test.tsx new file mode 100644 index 000000000000..464038f82755 --- /dev/null +++ b/packages/experience/src/containers/StepUpCodeVerification/index.test.tsx @@ -0,0 +1,289 @@ +import { SignInIdentifier, VerificationType } from '@logto/schemas'; +import { noop } from '@silverhand/essentials'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { cloneElement, type ReactElement, type ReactNode } from 'react'; + +import UserInteractionContext, { + type UserInteractionContextType, +} from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; +import { sendStepUpVerificationCode, verifyStepUpVerificationCode } from '@/apis/experience'; +import { type ErrorHandlers } from '@/hooks/use-error-handler'; +import { type VerificationCodeIdentifier } from '@/types'; + +import StepUpCodeVerification from '.'; + +const mockedHandleError = jest.fn, [unknown, ErrorHandlers?]>(); +const mockedRedirectTo = jest.fn(); +const mockedNavigate = jest.fn(); +const mockedSetToast = jest.fn(); +const mockedSetVerificationId = jest.fn(); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { dir: () => 'ltr' }, + }), + // Render the interpolated component, so the resend link keeps its click handler. + Trans: ({ + children, + components, + }: { + readonly children: ReactNode; + readonly components?: Record; + }) => (components?.a ? cloneElement(components.a, {}, children) : children), +})); + +jest.mock('@/hooks/use-navigate-with-preserved-search-params', () => ({ + ...jest.requireActual('@/hooks/use-navigate-with-preserved-search-params'), + __esModule: true, + default: () => mockedNavigate, +})); + +jest.mock('@/hooks/use-error-handler', () => ({ + __esModule: true, + default: () => mockedHandleError, +})); + +jest.mock('@/hooks/use-global-redirect-to', () => ({ + __esModule: true, + default: () => mockedRedirectTo, +})); + +jest.mock('@/hooks/use-toast', () => ({ + __esModule: true, + default: () => ({ setToast: mockedSetToast }), +})); + +/** + * The shared code input is exercised by its own tests; here it only has to report a code, so the + * container's own behavior — what it sends, and against which record — is what the tests read. + */ +jest.mock('@/shared/components/VerificationCode', () => ({ + __esModule: true, + defaultLength: 6, + default: ({ + error, + onChange, + }: { + readonly error?: string; + readonly onChange: (code: string[]) => void; + }) => ( + <> + + + {error &&
{error}
} + + ), +})); + +/** The resend countdown starts on mount; stop it so the resend link is always reachable. */ +jest.mock('react-timer-hook', () => ({ + useTimer: () => ({ seconds: 0, isRunning: false, restart: jest.fn() }), +})); + +jest.mock('@/apis/experience', () => ({ + ...jest.requireActual('@/apis/experience'), + sendStepUpVerificationCode: jest.fn(), + verifyStepUpVerificationCode: jest.fn(), +})); + +const mockedSendStepUpVerificationCode = sendStepUpVerificationCode as jest.MockedFunction< + typeof sendStepUpVerificationCode +>; +const mockedVerifyStepUpVerificationCode = verifyStepUpVerificationCode as jest.MockedFunction< + typeof verifyStepUpVerificationCode +>; + +/** The codes `useStepUpErrorHandler` contributes; every step-up call composes them. */ +const stepUpErrorCodes = [ + 'session.not_found', + 'session.interaction_not_found', + 'session.step_up.subject_not_found', + 'session.step_up.invalid_interaction_event', + 'session.identity_conflict', + 'session.step_up.forbidden_route', + 'session.step_up.acr_not_satisfied', +]; + +const userInteractionContext: UserInteractionContextType = { + availableSsoConnectorsMap: new Map(), + setSsoEmail: noop, + ssoConnectors: [], + setSsoConnectors: noop, + setIdentifierInputValue: noop, + setForgotPasswordIdentifierInputValue: noop, + setVerificationId: mockedSetVerificationId, + verificationIdsMap: {}, + hasBoundPasskey: false, + setHasBoundPasskey: noop, + clearInteractionContextSessionStorage: noop, +}; + +const renderContainer = (identifierType: VerificationCodeIdentifier = SignInIdentifier.Email) => + renderWithPageContext( + + + + ); + +const enterCode = async (label = 'Fill code') => { + await act(async () => { + fireEvent.click(screen.getByText(label)); + }); +}; + +describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('offers no switch to another identifier or to a password', () => { + renderContainer(); + + // The method list is where the user switches; this page is pinned to one identifier. + expect(screen.queryByText('action.sign_in_via_password')).toBeNull(); + expect(screen.queryByText('action.sign_in_via_passcode')).toBeNull(); + }); + + it.each([SignInIdentifier.Email, SignInIdentifier.Phone] as const)( + 'verifies the %s code by identifier type only and follows the redirect', + async (identifierType) => { + mockedVerifyStepUpVerificationCode.mockResolvedValueOnce({ + redirectTo: 'https://logto.io/callback', + }); + renderContainer(identifierType); + + await enterCode(); + + await waitFor(() => { + expect(mockedRedirectTo).toHaveBeenCalledWith('https://logto.io/callback'); + }); + expect(mockedVerifyStepUpVerificationCode).toHaveBeenCalledTimes(1); + // No raw identifier: the type and the verification ID are the whole payload. + expect(mockedVerifyStepUpVerificationCode).toHaveBeenCalledWith({ + type: identifierType, + code: '123456', + verificationId: 'sent-id', + }); + expect(mockedHandleError).not.toHaveBeenCalled(); + } + ); + + it('shows an input error and does not verify an incomplete code', async () => { + renderContainer(); + + await enterCode('Fill partial code'); + + await act(async () => { + fireEvent.click(screen.getByText('action.continue')); + }); + + expect(mockedVerifyStepUpVerificationCode).not.toHaveBeenCalled(); + expect(screen.getByText('error.invalid_passcode')).not.toBeNull(); + }); + + it('hands a failed verification to the error handler with the step-up handlers', async () => { + const error = new Error('Code mismatch'); + mockedVerifyStepUpVerificationCode.mockRejectedValueOnce(error); + renderContainer(); + + await enterCode(); + + await waitFor(() => { + expect(mockedHandleError).toHaveBeenCalledTimes(1); + }); + + const [handledError, errorHandlers] = mockedHandleError.mock.calls[0] ?? []; + expect(handledError).toBe(error); + expect(Object.keys(errorHandlers ?? {})).toEqual( + expect.arrayContaining([ + // A wrong or expired code is shown on the input rather than toasted. + 'verification_code.code_mismatch', + 'verification_code.expired', + ...stepUpErrorCodes, + // What a sign-in with requested ACR may still need after this factor. + 'session.mfa.require_mfa_verification', + 'user.missing_profile', + ]) + ); + expect(mockedRedirectTo).not.toHaveBeenCalled(); + }); + + it.each([SignInIdentifier.Email, SignInIdentifier.Phone] as const)( + 'resends the %s code by identifier type only and verifies against the new record', + async (identifierType) => { + mockedSendStepUpVerificationCode.mockResolvedValueOnce({ verificationId: 'resent-id' }); + mockedVerifyStepUpVerificationCode.mockResolvedValueOnce({ + redirectTo: 'https://logto.io/callback', + }); + renderContainer(identifierType); + + await act(async () => { + fireEvent.click(screen.getByText('description.resend_passcode')); + }); + + expect(mockedSendStepUpVerificationCode).toHaveBeenCalledTimes(1); + expect(mockedSendStepUpVerificationCode).toHaveBeenCalledWith(identifierType); + // The stored id is renewed, so a refresh verifies against the code that was sent last. + expect(mockedSetVerificationId).toHaveBeenCalledWith( + identifierType === SignInIdentifier.Email + ? VerificationType.EmailVerificationCode + : VerificationType.PhoneVerificationCode, + 'resent-id' + ); + expect(mockedSetToast).toHaveBeenCalledWith('description.passcode_sent'); + + await enterCode(); + + await waitFor(() => { + expect(mockedVerifyStepUpVerificationCode).toHaveBeenCalledWith({ + type: identifierType, + code: '123456', + verificationId: 'resent-id', + }); + }); + } + ); + + it('hands a failed resend to the error handler with the step-up handlers and keeps the old id', async () => { + const error = new Error('Failed to send the code'); + mockedSendStepUpVerificationCode.mockRejectedValueOnce(error); + renderContainer(); + + await act(async () => { + fireEvent.click(screen.getByText('description.resend_passcode')); + }); + + expect(mockedHandleError).toHaveBeenCalledTimes(1); + const [handledError, errorHandlers] = mockedHandleError.mock.calls[0] ?? []; + expect(handledError).toBe(error); + expect(new Set(Object.keys(errorHandlers ?? {}))).toEqual(new Set(stepUpErrorCodes)); + expect(mockedSetVerificationId).not.toHaveBeenCalled(); + + mockedVerifyStepUpVerificationCode.mockResolvedValueOnce({ redirectTo: 'https://logto.io' }); + await enterCode(); + + await waitFor(() => { + expect(mockedVerifyStepUpVerificationCode).toHaveBeenCalledWith({ + type: SignInIdentifier.Email, + code: '123456', + verificationId: 'sent-id', + }); + }); + }); +}); diff --git a/packages/experience/src/containers/StepUpCodeVerification/index.tsx b/packages/experience/src/containers/StepUpCodeVerification/index.tsx new file mode 100644 index 000000000000..baeb7ff9383d --- /dev/null +++ b/packages/experience/src/containers/StepUpCodeVerification/index.tsx @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import TextLink from '@/components/TextLink'; +import Button from '@/shared/components/Button'; +import VerificationCodeInput, { defaultLength } from '@/shared/components/VerificationCode'; +import { type VerificationCodeIdentifier } from '@/types'; + +import styles from './index.module.scss'; +import useResendStepUpVerificationCode from './use-resend-step-up-verification-code'; +import useStepUpCodeVerification from './use-step-up-code-verification'; + +const isCodeReady = (code: string[]) => code.length === defaultLength && code.every(Boolean); + +type Props = { + readonly identifierType: VerificationCodeIdentifier; + readonly verificationId: string; +}; + +/** + * The pinned-user code input, modeled on `MfaCodeVerification`: the code goes to an identifier + * the server resolved, so the container takes its type and nothing else. There is no link back to + * a password page or to another identifier — the method list is where the user switches. + */ +const StepUpCodeVerification = ({ identifierType, verificationId }: Props) => { + const { t } = useTranslation(); + const [codeInput, setCodeInput] = useState([]); + const [inputErrorMessage, setInputErrorMessage] = useState(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [currentVerificationId, setCurrentVerificationId] = useState(verificationId); + + useEffect(() => { + setCurrentVerificationId(verificationId); + }, [verificationId]); + + const errorCallback = useCallback(() => { + setCodeInput([]); + setInputErrorMessage(undefined); + }, []); + + const { errorMessage: submitErrorMessage, onSubmit } = useStepUpCodeVerification( + identifierType, + currentVerificationId, + errorCallback + ); + + const { seconds, isRunning, onResendVerificationCode } = + useResendStepUpVerificationCode(identifierType); + + const errorMessage = inputErrorMessage ?? submitErrorMessage; + + const handleSubmit = useCallback( + async (code: string[]) => { + if (isSubmitting) { + return; + } + + setInputErrorMessage(undefined); + setIsSubmitting(true); + + try { + await onSubmit(code.join('')); + } finally { + // Always reset, even if `onSubmit` throws, so the button does not spin forever. + setIsSubmitting(false); + } + }, + [isSubmitting, onSubmit] + ); + + return ( + <> + { + setCodeInput(code); + + if (isCodeReady(code)) { + void handleSubmit(code); + } + }} + /> +
+ {isRunning ? ( + }}> + {t('description.resend_after_seconds', { seconds })} + + ) : ( + { + setInputErrorMessage(undefined); + setCodeInput([]); + + const resentVerificationId = await onResendVerificationCode(); + + if (resentVerificationId) { + setCurrentVerificationId(resentVerificationId); + } + }} + /> + ), + }} + > + {t('description.resend_passcode')} + + )} +
+