Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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<void> => {
await executeEmbeddedSignInFlow({
payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION', scopes: ['openid', 'profile']},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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<void> => {
await executeEmbeddedSignUpFlow({
payload: {applicationId: 'app-1', flowType: 'REGISTRATION', scopes: ['openid', 'profile']},
Expand Down
1 change: 1 addition & 0 deletions packages/javascript/src/models/embedded-recovery-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export interface EmbeddedRecoveryFlowRequest extends Partial<EmbeddedRecoveryFlo
challengeToken?: string;
executionId?: string;
inputs?: Record<string, any>;
locale?: string;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions packages/javascript/src/models/embedded-signin-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,7 @@ export interface EmbeddedSignInFlowRequest extends Partial<EmbeddedSignInFlowIni
* ```
*/
inputs?: Record<string, any>;

/** Current UI language (BCP 47) so the flow localizes notifications; validated server-side. */
locale?: string;
}
1 change: 1 addition & 0 deletions packages/javascript/src/models/embedded-signup-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export interface EmbeddedSignUpFlowRequest extends Partial<EmbeddedSignUpFlowIni
challengeToken?: string;
executionId?: string;
inputs?: Record<string, any>;
locale?: string;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -153,6 +154,7 @@ const AcceptInvite: FC<AcceptInviteProps> = ({
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;
Expand All @@ -179,6 +181,7 @@ const AcceptInvite: FC<AcceptInviteProps> = ({
body: JSON.stringify({
...payload,
verbose: true,
locale: currentLanguage,
}),
headers: {
Accept: 'application/json',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -54,6 +55,7 @@ const Recovery: FC<RecoveryProps> = ({
...rest
}: RecoveryProps): ReactElement => {
const {recover, isInitialized, applicationId} = useThunderID();
const {currentLanguage} = useTranslation();

const handleInitialize: (payload?: EmbeddedRecoveryFlowRequest) => Promise<EmbeddedRecoveryFlowResponse> =
useCallback(
Expand All @@ -71,7 +73,7 @@ const Recovery: FC<RecoveryProps> = ({
executionId,
inputs: {[tokenUrlParam]: tokenValue},
};
return (await recover(resumePayload)) as EmbeddedRecoveryFlowResponse;
return (await recover({...resumePayload, locale: currentLanguage})) as EmbeddedRecoveryFlowResponse;
}
}

Expand All @@ -80,15 +82,15 @@ const Recovery: FC<RecoveryProps> = ({
...(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<EmbeddedRecoveryFlowResponse> = useCallback(
async (payload: EmbeddedRecoveryFlowRequest): Promise<EmbeddedRecoveryFlowResponse> =>
(await recover(payload)) as EmbeddedRecoveryFlowResponse,
[recover],
(await recover({...payload, locale: currentLanguage})) as EmbeddedRecoveryFlowResponse,
[recover, currentLanguage],
);

const handleComplete: (response: EmbeddedRecoveryFlowResponse) => void = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ const SignIn: FC<SignInProps> = ({
}: 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<EmbeddedFlowComponent[]>([]);
Expand Down Expand Up @@ -587,6 +587,7 @@ const SignIn: FC<SignInProps> = ({
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
Expand Down Expand Up @@ -620,6 +621,7 @@ const SignIn: FC<SignInProps> = ({
applicationId: effectiveApplicationId,
flowType: EmbeddedFlowType.Authentication,
...(scopes && {scopes}),
locale: currentLanguage,
})) as EmbeddedSignInFlowResponse;
} else {
throw resumeError;
Expand All @@ -630,6 +632,7 @@ const SignIn: FC<SignInProps> = ({
applicationId: effectiveApplicationId,
flowType: EmbeddedFlowType.Authentication,
...(scopes && {scopes}),
locale: currentLanguage,
})) as EmbeddedSignInFlowResponse;
}

Expand Down Expand Up @@ -825,6 +828,7 @@ const SignIn: FC<SignInProps> = ({
...payload,
inputs: processedInputs,
...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}),
locale: currentLanguage,
})) as EmbeddedSignInFlowResponse;

if (await handleRedirection(response)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -42,6 +43,7 @@ const SignUp: FC<SignUpProps> = ({
...rest
}: SignUpProps): ReactElement => {
const {signUp, isInitialized, applicationId, scopes} = useThunderID();
const {currentLanguage} = useTranslation();

/**
* Initialize the sign-up flow.
Expand Down Expand Up @@ -72,14 +74,14 @@ const SignUp: FC<SignUpProps> = ({
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<EmbeddedSignUpFlowResponse> =>
(await signUp(payload)) as EmbeddedSignUpFlowResponse;
(await signUp({...payload, locale: currentLanguage})) as EmbeddedSignUpFlowResponse;

/**
* Handle successful sign-up and redirect.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading