diff --git a/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts b/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts index edc6ca18..976b09e7 100644 --- a/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts +++ b/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts @@ -45,6 +45,16 @@ describe('executeEmbeddedSignInFlow', (): void => { expect(captureRequestBody()).toMatchObject({verbose: true}); }); + it('sends a top-level locale field through to the request body', async (): Promise => { + await executeEmbeddedSignInFlow({ + payload: {action: 'submit', executionId: 'exec-abc', inputs: {username: 'user'}, locale: 'es-AR'}, + url: URL, + }); + + const body = captureRequestBody(); + expect(body).toMatchObject({locale: 'es-AR', inputs: {username: 'user'}}); + }); + it('injects verbose:true for a new flow start that also includes scopes', async (): Promise => { await executeEmbeddedSignInFlow({ payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION', scopes: ['openid', 'profile']}, diff --git a/packages/javascript/src/api/__tests__/executeEmbeddedSignUpFlow.test.ts b/packages/javascript/src/api/__tests__/executeEmbeddedSignUpFlow.test.ts index 7258a14f..84fbdac4 100644 --- a/packages/javascript/src/api/__tests__/executeEmbeddedSignUpFlow.test.ts +++ b/packages/javascript/src/api/__tests__/executeEmbeddedSignUpFlow.test.ts @@ -38,6 +38,16 @@ describe('executeEmbeddedSignUpFlow', (): void => { expect(captureRequestBody()).toMatchObject({verbose: true}); }); + it('sends a top-level locale field through to the request body', async (): Promise => { + await executeEmbeddedSignUpFlow({ + payload: {action: 'submit', executionId: 'exec-abc', inputs: {username: 'user'}, locale: 'es-AR'}, + url: URL, + }); + + const body = captureRequestBody(); + expect(body).toMatchObject({locale: 'es-AR', inputs: {username: 'user'}}); + }); + it('injects verbose:true for a new flow start that also includes scopes', async (): Promise => { await executeEmbeddedSignUpFlow({ payload: {applicationId: 'app-1', flowType: 'REGISTRATION', scopes: ['openid', 'profile']}, diff --git a/packages/javascript/src/models/embedded-recovery-flow.ts b/packages/javascript/src/models/embedded-recovery-flow.ts index 06019655..aca8019b 100644 --- a/packages/javascript/src/models/embedded-recovery-flow.ts +++ b/packages/javascript/src/models/embedded-recovery-flow.ts @@ -117,6 +117,7 @@ export interface EmbeddedRecoveryFlowRequest extends Partial; + locale?: string; } /** diff --git a/packages/javascript/src/models/embedded-signin-flow.ts b/packages/javascript/src/models/embedded-signin-flow.ts index bff79add..41406f3b 100644 --- a/packages/javascript/src/models/embedded-signin-flow.ts +++ b/packages/javascript/src/models/embedded-signin-flow.ts @@ -359,4 +359,7 @@ export interface EmbeddedSignInFlowRequest extends Partial; + + /** Current UI language (BCP 47) so the flow localizes notifications; validated server-side. */ + locale?: string; } diff --git a/packages/javascript/src/models/embedded-signup-flow.ts b/packages/javascript/src/models/embedded-signup-flow.ts index 6e26dde6..eb3bf247 100644 --- a/packages/javascript/src/models/embedded-signup-flow.ts +++ b/packages/javascript/src/models/embedded-signup-flow.ts @@ -211,6 +211,7 @@ export interface EmbeddedSignUpFlowRequest extends Partial; + locale?: string; } /** diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/AcceptInvite.tsx b/packages/react/src/components/presentation/auth/AcceptInvite/AcceptInvite.tsx index 3382d0d9..00e68621 100644 --- a/packages/react/src/components/presentation/auth/AcceptInvite/AcceptInvite.tsx +++ b/packages/react/src/components/presentation/auth/AcceptInvite/AcceptInvite.tsx @@ -3,6 +3,7 @@ import {ThunderIDAPIError} from '@thunderid/browser'; import {FC, ReactElement, ReactNode, useMemo} from 'react'; +import useTranslation from '../../../../hooks/useTranslation'; import BaseAcceptInvite, {BaseAcceptInviteRenderProps, AcceptInviteFlowResponse} from './BaseAcceptInvite'; /** @@ -153,6 +154,7 @@ const AcceptInvite: FC = ({ showSubtitle = true, }: AcceptInviteProps): ReactElement => { // Extract from URL if not provided as props + const {currentLanguage} = useTranslation(); const {executionId: urlExecutionId, inviteToken: urlInviteToken} = useMemo(() => getUrlParams(), []); const executionId: any = executionIdProp || urlExecutionId; @@ -179,6 +181,7 @@ const AcceptInvite: FC = ({ body: JSON.stringify({ ...payload, verbose: true, + locale: currentLanguage, }), headers: { Accept: 'application/json', diff --git a/packages/react/src/components/presentation/auth/Recovery/Recovery.tsx b/packages/react/src/components/presentation/auth/Recovery/Recovery.tsx index 82054100..1e794e6e 100644 --- a/packages/react/src/components/presentation/auth/Recovery/Recovery.tsx +++ b/packages/react/src/components/presentation/auth/Recovery/Recovery.tsx @@ -5,6 +5,7 @@ import {EmbeddedRecoveryFlowRequest, EmbeddedRecoveryFlowResponse, EmbeddedFlowT import {FC, PropsWithChildren, ReactElement, useCallback} from 'react'; import BaseRecovery, {BaseRecoveryProps} from './BaseRecovery'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; +import useTranslation from '../../../../hooks/useTranslation'; export type RecoveryProps = PropsWithChildren< BaseRecoveryProps & { @@ -54,6 +55,7 @@ const Recovery: FC = ({ ...rest }: RecoveryProps): ReactElement => { const {recover, isInitialized, applicationId} = useThunderID(); + const {currentLanguage} = useTranslation(); const handleInitialize: (payload?: EmbeddedRecoveryFlowRequest) => Promise = useCallback( @@ -71,7 +73,7 @@ const Recovery: FC = ({ executionId, inputs: {[tokenUrlParam]: tokenValue}, }; - return (await recover(resumePayload)) as EmbeddedRecoveryFlowResponse; + return (await recover({...resumePayload, locale: currentLanguage})) as EmbeddedRecoveryFlowResponse; } } @@ -80,15 +82,15 @@ const Recovery: FC = ({ ...(effectiveApplicationId && {applicationId: effectiveApplicationId}), }; - return (await recover(initialPayload)) as EmbeddedRecoveryFlowResponse; + return (await recover({...initialPayload, locale: currentLanguage})) as EmbeddedRecoveryFlowResponse; }, - [applicationId, tokenUrlParam, recover], + [applicationId, tokenUrlParam, recover, currentLanguage], ); const handleOnSubmit: (payload: EmbeddedRecoveryFlowRequest) => Promise = useCallback( async (payload: EmbeddedRecoveryFlowRequest): Promise => - (await recover(payload)) as EmbeddedRecoveryFlowResponse, - [recover], + (await recover({...payload, locale: currentLanguage})) as EmbeddedRecoveryFlowResponse, + [recover, currentLanguage], ); const handleComplete: (response: EmbeddedRecoveryFlowResponse) => void = useCallback( diff --git a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx index 566ea98d..ab678eb4 100644 --- a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx @@ -250,7 +250,7 @@ const SignIn: FC = ({ }: SignInProps): ReactElement => { const {applicationId, afterSignInUrl, signIn, isInitialized, isLoading, meta, getStorageManager, scopes, vendor} = useThunderID(); - const {t} = useTranslation(preferences?.i18n); + const {t, currentLanguage} = useTranslation(preferences?.i18n); // State management for the flow const [components, setComponents] = useState([]); @@ -587,6 +587,7 @@ const SignIn: FC = ({ response = (await signIn({ executionId: resumeExecutionId, ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), + locale: currentLanguage, })) as EmbeddedSignInFlowResponse; } catch (resumeError) { // Only treat a stale/expired session (HTTP 400 from the server, meaning the stored @@ -620,6 +621,7 @@ const SignIn: FC = ({ applicationId: effectiveApplicationId, flowType: EmbeddedFlowType.Authentication, ...(scopes && {scopes}), + locale: currentLanguage, })) as EmbeddedSignInFlowResponse; } else { throw resumeError; @@ -630,6 +632,7 @@ const SignIn: FC = ({ applicationId: effectiveApplicationId, flowType: EmbeddedFlowType.Authentication, ...(scopes && {scopes}), + locale: currentLanguage, })) as EmbeddedSignInFlowResponse; } @@ -825,6 +828,7 @@ const SignIn: FC = ({ ...payload, inputs: processedInputs, ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), + locale: currentLanguage, })) as EmbeddedSignInFlowResponse; if (await handleRedirection(response)) { diff --git a/packages/react/src/components/presentation/auth/SignUp/SignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/SignUp.tsx index 89a24243..fef47505 100644 --- a/packages/react/src/components/presentation/auth/SignUp/SignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/SignUp.tsx @@ -11,6 +11,7 @@ import {FC, ReactElement, ReactNode} from 'react'; // eslint-disable-next-line import/no-named-as-default import BaseSignUp, {BaseSignUpProps, BaseSignUpRenderProps} from './BaseSignUp'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; +import useTranslation from '../../../../hooks/useTranslation'; /** * Render props function parameters (re-exported from BaseSignUp for convenience) @@ -42,6 +43,7 @@ const SignUp: FC = ({ ...rest }: SignUpProps): ReactElement => { const {signUp, isInitialized, applicationId, scopes} = useThunderID(); + const {currentLanguage} = useTranslation(); /** * Initialize the sign-up flow. @@ -72,14 +74,14 @@ const SignUp: FC = ({ initialPayload = payload; } - return (await signUp(initialPayload)) as EmbeddedSignUpFlowResponse; + return (await signUp({...initialPayload, locale: currentLanguage})) as EmbeddedSignUpFlowResponse; }; /** * Handle sign-up steps. */ const handleOnSubmit = async (payload: EmbeddedSignUpFlowRequest): Promise => - (await signUp(payload)) as EmbeddedSignUpFlowResponse; + (await signUp({...payload, locale: currentLanguage})) as EmbeddedSignUpFlowResponse; /** * Handle successful sign-up and redirect. diff --git a/packages/react/src/components/presentation/auth/SignUp/__tests__/SignUp.test.tsx b/packages/react/src/components/presentation/auth/SignUp/__tests__/SignUp.test.tsx index 19cbbeab..6dc0303f 100644 --- a/packages/react/src/components/presentation/auth/SignUp/__tests__/SignUp.test.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/__tests__/SignUp.test.tsx @@ -62,6 +62,31 @@ describe('SignUp', () => { window.history.replaceState({}, '', '/signup'); }); + it('attaches the current UI language as locale on the initiation request', async () => { + mockSignUp.mockResolvedValueOnce({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}); + + renderSignUp(); + + await waitFor(() => { + expect(mockSignUp).toHaveBeenCalledTimes(1); + }); + expect(mockSignUp).toHaveBeenCalledWith(expect.objectContaining({locale: expect.any(String)})); + }); + + it('attaches the current UI language as locale on a step submission', async () => { + mockSignUp.mockResolvedValueOnce({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}); + const captured = renderSignUp(); + await waitFor(() => { + expect(mockSignUp).toHaveBeenCalledTimes(1); + }); + + mockSignUp.mockResolvedValueOnce({executionId: 'exec-1', flowStatus: 'INCOMPLETE'}); + await act(async () => { + await captured.submit?.({id: 'continue'}, {username: 'user'}, true); + }); + expect(mockSignUp).toHaveBeenLastCalledWith(expect.objectContaining({locale: expect.any(String)})); + }); + it('redirects to afterSignUpUrl when the completion carries an assertion', async () => { // A URL differing from the current one only by its fragment is a same-document navigation: // location updates without the page reloading, so the redirect can be observed from inside the