diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 5ca8b1d9..87f979f9 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -170,6 +170,10 @@ export {default as extractEmojiFromUri} from './utils/extractEmojiFromUri'; export {default as set} from './utils/set'; export {default as get} from './utils/get'; export {default as startCase} from './utils/startCase'; +export { + default as substituteTranslationParams, + hasUnresolvedTranslationParams, +} from './utils/substituteTranslationParams'; export {default as removeTrailingSlash} from './utils/removeTrailingSlash'; export {default as resolveFieldName} from './utils/resolveFieldName'; export {default as resolveResourceEndpoint} from './utils/resolveResourceEndpoint'; diff --git a/packages/javascript/src/models/embedded-flow.ts b/packages/javascript/src/models/embedded-flow.ts index 8dea76ce..765cb1f1 100644 --- a/packages/javascript/src/models/embedded-flow.ts +++ b/packages/javascript/src/models/embedded-flow.ts @@ -34,11 +34,14 @@ export interface EmbeddedFlowExecuteRequestConfigBase extends Partial; } /** diff --git a/packages/javascript/src/utils/__tests__/substituteTranslationParams.test.ts b/packages/javascript/src/utils/__tests__/substituteTranslationParams.test.ts new file mode 100644 index 00000000..d4353f85 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/substituteTranslationParams.test.ts @@ -0,0 +1,65 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, it, expect} from 'vitest'; +import substituteTranslationParams, {hasUnresolvedTranslationParams} from '../substituteTranslationParams'; + +describe('substituteTranslationParams', () => { + it('substitutes the backend `{{param(name)}}` syntax', () => { + expect( + substituteTranslationParams('User already exists with the provided {{param(attribute)}}', {attribute: 'email'}), + ).toBe('User already exists with the provided email'); + }); + + it('tolerates whitespace inside the backend placeholder', () => { + expect(substituteTranslationParams('The provided {{ param( attribute ) }} is taken', {attribute: 'username'})).toBe( + 'The provided username is taken', + ); + }); + + it('substitutes the bundle `{name}` syntax', () => { + expect(substituteTranslationParams('Minimum length is {min} characters', {min: 8})).toBe( + 'Minimum length is 8 characters', + ); + }); + + it('substitutes every occurrence of every param', () => { + expect( + substituteTranslationParams('{{param(attribute)}} and {other} conflict with {{param(attribute)}}', { + attribute: 'email', + other: 'username', + }), + ).toBe('email and username conflict with email'); + }); + + it('leaves placeholders without a matching param untouched', () => { + expect(substituteTranslationParams('The provided {{param(attribute)}} is taken', {unrelated: 'x'})).toBe( + 'The provided {{param(attribute)}} is taken', + ); + }); + + it('returns the translation unchanged when no params are given', () => { + expect(substituteTranslationParams('The provided {{param(attribute)}} is taken')).toBe( + 'The provided {{param(attribute)}} is taken', + ); + expect(substituteTranslationParams('Plain message', {})).toBe('Plain message'); + expect(substituteTranslationParams('', {attribute: 'email'})).toBe(''); + }); + + it('treats param values as literals rather than replacement patterns', () => { + expect(substituteTranslationParams('Value: {{param(attribute)}}', {attribute: '$&'})).toBe('Value: $&'); + }); +}); + +describe('hasUnresolvedTranslationParams', () => { + it('detects a remaining backend placeholder', () => { + expect(hasUnresolvedTranslationParams('The provided {{param(attribute)}} is taken')).toBe(true); + expect(hasUnresolvedTranslationParams('The provided {{ param( attribute ) }} is taken')).toBe(true); + }); + + it('reports fully resolved strings as resolved', () => { + expect(hasUnresolvedTranslationParams('The provided email is taken')).toBe(false); + expect(hasUnresolvedTranslationParams('Minimum length is {min} characters')).toBe(false); + expect(hasUnresolvedTranslationParams('')).toBe(false); + }); +}); diff --git a/packages/javascript/src/utils/substituteTranslationParams.ts b/packages/javascript/src/utils/substituteTranslationParams.ts new file mode 100644 index 00000000..9f8c0d0e --- /dev/null +++ b/packages/javascript/src/utils/substituteTranslationParams.ts @@ -0,0 +1,55 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Matches the `{{param(name)}}` placeholder syntax used by backend messages. + */ +const BACKEND_PARAM_PATTERN = /\{\{\s*param\(\s*\w+\s*\)\s*\}\}/; + +/** + * Escapes characters that carry special meaning inside a regular expression. + * + * @param value - The literal string to escape. + * @returns The escaped string, safe to embed in a `RegExp`. + */ +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Checks whether a translation still contains an unsubstituted `{{param(name)}}` placeholder. + * + * Used to detect that a resolved translation is not presentable to the user, so the caller can + * fall back to a pre-substituted value instead. + * + * @param translation - The translation string to inspect. + * @returns `true` when at least one backend placeholder remains unsubstituted. + */ +export const hasUnresolvedTranslationParams = (translation: string): boolean => + Boolean(translation) && BACKEND_PARAM_PATTERN.test(translation); + +/** + * Substitutes named parameters into a translation string. + * + * Two placeholder syntaxes are supported, because messages reach the SDK from two sources: + * - `{{param(name)}}` is used by backend messages and the server-shipped `system` i18n bundle. + * - `{name}` is used by the SDK's own translation bundles. + * + * @param translation - The translation string, possibly containing placeholders. + * @param params - The parameter values to substitute, keyed by placeholder name. + * @returns The translation with every matching placeholder replaced. + */ +const substituteTranslationParams = (translation: string, params?: Record): string => { + if (!translation || !params || Object.keys(params).length === 0) { + return translation; + } + + return Object.entries(params).reduce((acc: string, [paramKey, paramValue]: [string, string | number]): string => { + const escapedKey: string = escapeRegExp(paramKey); + const value = String(paramValue); + + return acc + .replace(new RegExp(`\\{\\{\\s*param\\(\\s*${escapedKey}\\s*\\)\\s*\\}\\}`, 'g'), () => value) + .replace(new RegExp(`\\{${escapedKey}\\}`, 'g'), () => value); + }, translation); +}; + +export default substituteTranslationParams; diff --git a/packages/react/src/contexts/I18n/I18nProvider.tsx b/packages/react/src/contexts/I18n/I18nProvider.tsx index 11a88226..d192b353 100644 --- a/packages/react/src/contexts/I18n/I18nProvider.tsx +++ b/packages/react/src/contexts/I18n/I18nProvider.tsx @@ -12,6 +12,7 @@ import { getDefaultI18nBundles, normalizeTranslations, getVendorPrefix, + substituteTranslationParams, } from '@thunderid/browser'; import {FC, PropsWithChildren, ReactElement, useCallback, useEffect, useMemo, useState} from 'react'; import I18nContext, {I18nContextValue} from './I18nContext'; @@ -273,15 +274,7 @@ const I18nProvider: FC> = ({ } // Replace parameters if provided - if (params && Object.keys(params).length > 0) { - return Object.entries(params).reduce( - (acc: string, [paramKey, paramValue]: [string, string | number]) => - acc.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue)), - translation, - ); - } - - return translation; + return substituteTranslationParams(translation, params); }, [mergedBundles, currentLanguage, fallbackLanguage], ); diff --git a/packages/react/src/hooks/useTranslation.ts b/packages/react/src/hooks/useTranslation.ts index de49231b..9fd67331 100644 --- a/packages/react/src/hooks/useTranslation.ts +++ b/packages/react/src/hooks/useTranslation.ts @@ -8,6 +8,7 @@ import { I18nBundle, I18nTranslations, normalizeTranslations, + substituteTranslationParams, } from '@thunderid/browser'; import {useContext, useMemo} from 'react'; import ComponentPreferencesContext from '../contexts/I18n/ComponentPreferencesContext'; @@ -132,15 +133,7 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW } // Replace parameters if provided - if (params && Object.keys(params).length > 0) { - return Object.entries(params).reduce( - (acc: string, [paramKey, paramValue]: [string, string | number]) => - acc.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue)), - translation, - ); - } - - return translation; + return substituteTranslationParams(translation, params); }; }, [mergedBundles, currentLanguage, fallbackLanguage, globalT, effectivePreferences?.bundles]); diff --git a/packages/react/src/utils/__tests__/flowTransformer.test.ts b/packages/react/src/utils/__tests__/flowTransformer.test.ts new file mode 100644 index 00000000..b3a3675c --- /dev/null +++ b/packages/react/src/utils/__tests__/flowTransformer.test.ts @@ -0,0 +1,103 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, it, expect} from 'vitest'; +import {extractErrorMessage} from '../flowTransformer'; + +const UNIQUENESS_KEY = 'flows.executor.errors.attribute_not_unique'; + +/** + * Builds a `t` stub backed by a flat bundle, mirroring how `I18nProvider` resolves keys: + * a miss returns the key itself, and params are substituted into the resolved value. + */ +const createTranslator = + (bundle: Record = {}) => + (key: string, params?: Record): string => { + const translation: string = bundle[key] ?? key; + + if (!params) { + return translation; + } + + return Object.entries(params).reduce( + (acc: string, [paramKey, paramValue]: [string, string | number]): string => + acc + .replace(new RegExp(`\\{\\{\\s*param\\(\\s*${paramKey}\\s*\\)\\s*\\}\\}`, 'g'), String(paramValue)) + .replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue)), + translation, + ); + }; + +/** + * The attribute-uniqueness failure as the backend sends it: an INCOMPLETE step whose `error` + * carries the offending attribute in `params`, with `defaultValue` already substituted. + */ +const uniquenessResponse = (attribute: string) => ({ + error: { + code: 'FET-1061', + description: { + defaultValue: `The provided ${attribute} is already associated with another user and expects a unique value`, + key: `${UNIQUENESS_KEY}_desc`, + params: {attribute}, + }, + message: { + defaultValue: `User already exists with the provided ${attribute}`, + key: UNIQUENESS_KEY, + params: {attribute}, + }, + }, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', +}); + +describe('extractErrorMessage', () => { + it('substitutes the attribute name into the bundle translation', () => { + const t = createTranslator({ + [`system.${UNIQUENESS_KEY}`]: 'User already exists with the provided {{param(attribute)}}', + }); + + expect(extractErrorMessage(uniquenessResponse('email'), t)).toBe('User already exists with the provided email'); + expect(extractErrorMessage(uniquenessResponse('username'), t)).toBe( + 'User already exists with the provided username', + ); + }); + + it('substitutes params resolved from the unprefixed key too', () => { + const t = createTranslator({[UNIQUENESS_KEY]: 'The {{param(attribute)}} you entered is taken'}); + + expect(extractErrorMessage(uniquenessResponse('email'), t)).toBe('The email you entered is taken'); + }); + + it('falls back to defaultValue when the bundle template keeps an unresolved placeholder', () => { + const t = createTranslator({ + [`system.${UNIQUENESS_KEY}`]: 'User already exists with the provided {{param(attribute)}}', + }); + const response = uniquenessResponse('email'); + delete (response.error.message as {params?: Record}).params; + + expect(extractErrorMessage(response, t)).toBe('User already exists with the provided email'); + }); + + it('falls back to defaultValue when the key is not in any bundle', () => { + expect(extractErrorMessage(uniquenessResponse('email'), createTranslator())).toBe( + 'User already exists with the provided email', + ); + }); + + it('falls back to the description defaultValue when the message has none', () => { + const response = uniquenessResponse('email'); + delete (response.error.message as {defaultValue?: string}).defaultValue; + + expect(extractErrorMessage(response, createTranslator())).toBe( + 'The provided email is already associated with another user and expects a unique value', + ); + }); + + it('still supports the legacy failureReason, Error instances and the generic fallback', () => { + const t = createTranslator({'errors.flow.generic': 'Something went wrong'}); + + expect(extractErrorMessage({failureReason: 'Invalid credentials'}, t)).toBe('Invalid credentials'); + expect(extractErrorMessage(new Error('Network down'), t)).toBe('Network down'); + expect(extractErrorMessage(undefined, t)).toBe('Something went wrong'); + }); +}); diff --git a/packages/react/src/utils/flowTransformer.ts b/packages/react/src/utils/flowTransformer.ts index 75d99847..fb3ff8e6 100644 --- a/packages/react/src/utils/flowTransformer.ts +++ b/packages/react/src/utils/flowTransformer.ts @@ -27,7 +27,12 @@ * consistent response handling across all embedded flows. */ -import {EmbeddedFlowComponent, FlowMetadataResponse} from '@thunderid/browser'; +import { + EmbeddedFlowComponent, + FlowMetadataResponse, + I18nMessage, + hasUnresolvedTranslationParams, +} from '@thunderid/browser'; import resolveTranslationsInArray from './resolveTranslationsInArray'; import {UseTranslation} from '../hooks/useTranslation'; @@ -37,8 +42,8 @@ import {UseTranslation} from '../hooks/useTranslation'; export interface FlowErrorResponse { error?: { code: string; - description: {key: string; defaultValue?: string}; - message: {key: string; defaultValue?: string}; + description: I18nMessage; + message: I18nMessage; }; executionId: string; flowStatus: 'ERROR'; @@ -222,8 +227,10 @@ export const transformComponents = ( * Extract error message from flow error response. * * Resolution order: - * 1. Structured `error` object: try i18n lookup via `t(error.message.key)`. - * 2. Fallback to `defaultValue` from `message`, then `description`. + * 1. Structured `error` object: try i18n lookup via `t(error.message.key, error.message.params)`. + * Bundle entries keep the backend's `{{param(name)}}` placeholders, so `params` must be passed + * through; a resolved value that still holds an unsubstituted placeholder is treated as a miss. + * 2. Fallback to `defaultValue` from `message`, then `description` (the backend pre-substitutes it). * 3. Standard `Error.message`. * 4. Generic translated fallback via `defaultErrorKey`. */ @@ -237,15 +244,17 @@ export const extractErrorMessage = ( // 1. Try i18n lookup on message.key first (preferred for user-facing errors) if (flowError?.message?.key) { - const translated: string = t(flowError.message.key); - if (translated && translated !== flowError.message.key) { + const params: Record | undefined = flowError.message.params; + + const translated: string = t(flowError.message.key, params); + if (translated && translated !== flowError.message.key && !hasUnresolvedTranslationParams(translated)) { return translated; } // If the key resolved to itself, retry under the 'system:' namespace const systemKey = `system.${flowError.message.key}`; - const systemTranslated: string = t(systemKey); - if (systemTranslated && systemTranslated !== systemKey) { + const systemTranslated: string = t(systemKey, params); + if (systemTranslated && systemTranslated !== systemKey && !hasUnresolvedTranslationParams(systemTranslated)) { return systemTranslated; } } diff --git a/packages/vue/src/providers/I18nProvider.ts b/packages/vue/src/providers/I18nProvider.ts index 67cc8d8a..71589852 100644 --- a/packages/vue/src/providers/I18nProvider.ts +++ b/packages/vue/src/providers/I18nProvider.ts @@ -11,6 +11,7 @@ import { getDefaultI18nBundles, normalizeTranslations, getVendorPrefix, + substituteTranslationParams, } from '@thunderid/browser'; import { computed, @@ -278,15 +279,7 @@ const I18nProvider: Component = defineComponent({ translation = key; } - if (params && Object.keys(params).length > 0) { - return Object.entries(params).reduce( - (acc: string, [paramKey, paramValue]: [key: string, value: string | number]): string => - acc.replaceAll(`{${paramKey}}`, String(paramValue)), - translation, - ); - } - - return translation; + return substituteTranslationParams(translation, params); }; const setLanguage = (language: string): void => { diff --git a/packages/vue/src/utils/__tests__/flowTransformer.test.ts b/packages/vue/src/utils/__tests__/flowTransformer.test.ts new file mode 100644 index 00000000..b3a3675c --- /dev/null +++ b/packages/vue/src/utils/__tests__/flowTransformer.test.ts @@ -0,0 +1,103 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, it, expect} from 'vitest'; +import {extractErrorMessage} from '../flowTransformer'; + +const UNIQUENESS_KEY = 'flows.executor.errors.attribute_not_unique'; + +/** + * Builds a `t` stub backed by a flat bundle, mirroring how `I18nProvider` resolves keys: + * a miss returns the key itself, and params are substituted into the resolved value. + */ +const createTranslator = + (bundle: Record = {}) => + (key: string, params?: Record): string => { + const translation: string = bundle[key] ?? key; + + if (!params) { + return translation; + } + + return Object.entries(params).reduce( + (acc: string, [paramKey, paramValue]: [string, string | number]): string => + acc + .replace(new RegExp(`\\{\\{\\s*param\\(\\s*${paramKey}\\s*\\)\\s*\\}\\}`, 'g'), String(paramValue)) + .replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue)), + translation, + ); + }; + +/** + * The attribute-uniqueness failure as the backend sends it: an INCOMPLETE step whose `error` + * carries the offending attribute in `params`, with `defaultValue` already substituted. + */ +const uniquenessResponse = (attribute: string) => ({ + error: { + code: 'FET-1061', + description: { + defaultValue: `The provided ${attribute} is already associated with another user and expects a unique value`, + key: `${UNIQUENESS_KEY}_desc`, + params: {attribute}, + }, + message: { + defaultValue: `User already exists with the provided ${attribute}`, + key: UNIQUENESS_KEY, + params: {attribute}, + }, + }, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', +}); + +describe('extractErrorMessage', () => { + it('substitutes the attribute name into the bundle translation', () => { + const t = createTranslator({ + [`system.${UNIQUENESS_KEY}`]: 'User already exists with the provided {{param(attribute)}}', + }); + + expect(extractErrorMessage(uniquenessResponse('email'), t)).toBe('User already exists with the provided email'); + expect(extractErrorMessage(uniquenessResponse('username'), t)).toBe( + 'User already exists with the provided username', + ); + }); + + it('substitutes params resolved from the unprefixed key too', () => { + const t = createTranslator({[UNIQUENESS_KEY]: 'The {{param(attribute)}} you entered is taken'}); + + expect(extractErrorMessage(uniquenessResponse('email'), t)).toBe('The email you entered is taken'); + }); + + it('falls back to defaultValue when the bundle template keeps an unresolved placeholder', () => { + const t = createTranslator({ + [`system.${UNIQUENESS_KEY}`]: 'User already exists with the provided {{param(attribute)}}', + }); + const response = uniquenessResponse('email'); + delete (response.error.message as {params?: Record}).params; + + expect(extractErrorMessage(response, t)).toBe('User already exists with the provided email'); + }); + + it('falls back to defaultValue when the key is not in any bundle', () => { + expect(extractErrorMessage(uniquenessResponse('email'), createTranslator())).toBe( + 'User already exists with the provided email', + ); + }); + + it('falls back to the description defaultValue when the message has none', () => { + const response = uniquenessResponse('email'); + delete (response.error.message as {defaultValue?: string}).defaultValue; + + expect(extractErrorMessage(response, createTranslator())).toBe( + 'The provided email is already associated with another user and expects a unique value', + ); + }); + + it('still supports the legacy failureReason, Error instances and the generic fallback', () => { + const t = createTranslator({'errors.flow.generic': 'Something went wrong'}); + + expect(extractErrorMessage({failureReason: 'Invalid credentials'}, t)).toBe('Invalid credentials'); + expect(extractErrorMessage(new Error('Network down'), t)).toBe('Network down'); + expect(extractErrorMessage(undefined, t)).toBe('Something went wrong'); + }); +}); diff --git a/packages/vue/src/utils/flowTransformer.ts b/packages/vue/src/utils/flowTransformer.ts index 5c98220e..28172a9d 100644 --- a/packages/vue/src/utils/flowTransformer.ts +++ b/packages/vue/src/utils/flowTransformer.ts @@ -27,7 +27,12 @@ * consistent response handling across all embedded flows. */ -import {EmbeddedFlowComponent, FlowMetadataResponse} from '@thunderid/browser'; +import { + EmbeddedFlowComponent, + FlowMetadataResponse, + I18nMessage, + hasUnresolvedTranslationParams, +} from '@thunderid/browser'; import resolveTranslationsInArray from './resolveTranslationsInArray'; type TranslationFn = (key: string, params?: Record) => string; @@ -38,8 +43,8 @@ type TranslationFn = (key: string, params?: Record) => export interface FlowErrorResponse { error?: { code: string; - description: {key: string; defaultValue?: string}; - message: {key: string; defaultValue?: string}; + description: I18nMessage; + message: I18nMessage; }; executionId: string; flowStatus: 'ERROR'; @@ -184,8 +189,10 @@ export const transformComponents = ( * Extract error message from flow error response. * * Resolution order: - * 1. Structured `error` object: try i18n lookup via `t(error.message.key)`. - * 2. Fallback to `defaultValue` from `message`, then `description`. + * 1. Structured `error` object: try i18n lookup via `t(error.message.key, error.message.params)`. + * Bundle entries keep the backend's `{{param(name)}}` placeholders, so `params` must be passed + * through; a resolved value that still holds an unsubstituted placeholder is treated as a miss. + * 2. Fallback to `defaultValue` from `message`, then `description` (the backend pre-substitutes it). * 3. Standard `Error.message`. * 4. Generic translated fallback via `defaultErrorKey`. */ @@ -199,8 +206,10 @@ export const extractErrorMessage = ( // 1. Try i18n lookup on message.key first (preferred for user-facing errors) if (flowError?.message?.key) { - const translated: string = t(flowError.message.key); - if (translated && translated !== flowError.message.key) { + const params: Record | undefined = flowError.message.params; + + const translated: string = t(flowError.message.key, params); + if (translated && translated !== flowError.message.key && !hasUnresolvedTranslationParams(translated)) { return translated; } @@ -208,8 +217,8 @@ export const extractErrorMessage = ( // (e.g. system.${flowError.message.key}) because useI18n().t() performs // flat translations and FlowMetaProvider flattens namespaces as `${namespace}.${key}` const systemKey = `system.${flowError.message.key}`; - const systemTranslated: string = t(systemKey); - if (systemTranslated && systemTranslated !== systemKey) { + const systemTranslated: string = t(systemKey, params); + if (systemTranslated && systemTranslated !== systemKey && !hasUnresolvedTranslationParams(systemTranslated)) { return systemTranslated; } }