diff --git a/.changeset/signin-field-labels-i18n.md b/.changeset/signin-field-labels-i18n.md new file mode 100644 index 000000000..b95d2029a --- /dev/null +++ b/.changeset/signin-field-labels-i18n.md @@ -0,0 +1,11 @@ +--- +'@asgardeo/javascript': patch +'@asgardeo/react': patch +'@asgardeo/nextjs': patch +--- + +Let applications relabel the embedded sign-in fields through i18n. + +- The username and password fields of the embedded sign-in form now take their label and placeholder from the i18n bundle (`elements.fields..label` / `elements.fields..placeholder`) when a translation is provided, falling back to the text returned by the identity server. This lets applications whose users sign in with an email address relabel the identifier field without changing the login flow. +- `preferences.i18n.bundles` now accepts partial bundles (`I18nBundleOverride`): only the keys being changed need to be supplied, and the bundle metadata is optional. This was already the runtime behaviour but the types required a complete bundle. +- The Next.js `` component now accepts the `preferences` prop, so texts can be overridden per component as with `` and the React SDK. diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index ac913d7e7..5a574b088 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -179,6 +179,7 @@ export type { Config, Preferences, ThemePreferences, + I18nBundleOverride, I18nPreferences, I18nStorageStrategy, WithPreferences, diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 93ef170bf..c03aabe33 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -16,7 +16,7 @@ * under the License. */ -import {I18nBundle} from '@asgardeo/i18n'; +import {I18nMetadata, I18nTranslations} from '@asgardeo/i18n'; import {Platform} from './platforms'; import {TokenEndpointAuthMethod} from './token-endpoint-auth'; import {RecursivePartial} from './utility-types'; @@ -436,12 +436,22 @@ export interface ThemePreferences { */ export type I18nStorageStrategy = 'cookie' | 'localStorage' | 'none'; +/** + * A partial translation bundle supplied by the application to override built-in texts. + */ +export interface I18nBundleOverride { + metadata?: Partial; + translations: Partial | Record; +} + export interface I18nPreferences { /** - * Custom translations to override default ones. + * Custom translations to override default ones, keyed by locale code (e.g. `en-US`). + * Only the keys you want to change need to be present; everything else is taken from the + * built-in bundle for that locale. */ bundles?: { - [key: string]: I18nBundle; + [key: string]: I18nBundleOverride; }; /** * The domain to use when setting the language cookie. diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index 704b75b88..58d183e04 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -32,6 +32,34 @@ A missing entry surfaces as Google's `Error 400: redirect_uri_mismatch`. `afterSignOutUrl` (default: the app origin) is sent as the post-logout redirect URI and must be registered as well. +## Customising texts + +Every text the embedded components render can be overridden through `preferences.i18n.bundles`, either globally on +`` or per component through the `preferences` prop of `` and ``. Only the keys +you change need to be present; the rest come from the built-in bundle. For example, if your users sign in with an +email address, relabel the identifier field: + +```tsx + + {children} + +``` + +The available keys are listed in the `@asgardeo/i18n` package (`I18nTranslations`). + ## Logging The SDK logs at `error` level by default. Set `ASGARDEO_LOG_LEVEL` to `warn`, `info` or `debug` to see more, diff --git a/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx b/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx index 94789c8a4..cb4747b03 100644 --- a/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx +++ b/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx @@ -33,7 +33,10 @@ import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; * Props for the SignIn component. * Extends BaseSignInProps for full compatibility with the React BaseSignIn component */ -export type SignInProps = Pick; +export type SignInProps = Pick< + BaseSignInProps, + 'className' | 'onSuccess' | 'onError' | 'variant' | 'size' | 'preferences' +>; /** * A SignIn component for Next.js that provides native authentication flow. diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx index c364ff950..751b9af86 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx @@ -29,6 +29,7 @@ import { EmbeddedFlowExecuteRequestConfig, handleWebAuthnAuthentication, createPackageComponentLogger, + WithPreferences, } from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, FormEvent, RefObject, useEffect, useState, useCallback, useRef, ReactElement} from 'react'; @@ -63,7 +64,7 @@ const isPasskeyAuthenticator = (authenticator: EmbeddedSignInFlowAuthenticator): /** * Props for the BaseSignIn component. */ -export interface BaseSignInProps { +export interface BaseSignInProps extends WithPreferences { afterSignInUrl?: string; /** @@ -182,6 +183,7 @@ const BaseSignInContent: FC = ({ variant = 'outlined', showTitle = true, showSubtitle = true, + preferences, }: BaseSignInProps): ReactElement => { const {theme} = useTheme(); const {t} = useTranslation(); @@ -1067,6 +1069,7 @@ const BaseSignInContent: FC = ({ buttonClassName: buttonClasses, error, inputClassName: inputClasses, + preferences, }, )} @@ -1092,6 +1095,7 @@ const BaseSignInContent: FC = ({ buttonClassName: buttonClasses, error, inputClassName: inputClasses, + preferences, }, )} @@ -1222,6 +1226,7 @@ const BaseSignInContent: FC = ({ buttonClassName: buttonClasses, error, inputClassName: inputClasses, + preferences, }, )} diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.test.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.test.tsx index bdf588188..062edb4df 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.test.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.test.tsx @@ -130,15 +130,54 @@ vi.mock('../../../../../../contexts/Theme/useTheme', () => ({ }), })); +const signInPreferences: any = { + i18n: { + bundles: { + 'en-US': { + translations: { + 'elements.fields.username.label': 'Email', + 'elements.fields.username.placeholder': 'Enter your email', + }, + }, + }, + }, +}; + +vi.mock('../../../../../../contexts/Flow/useFlow', () => ({ + default: () => ({setSubtitle: vi.fn(), setTitle: vi.fn()}), +})); + +// Resolves keys from the i18n preferences handed to the hook, like the real hook does for +// component-level bundles, so the test fails if `preferences` is not forwarded. vi.mock('../../../../../../hooks/useTranslation', () => ({ - default: () => ({ - t: (key: string) => key, - currentLanguage: 'en', + default: (preferences?: any) => ({ + t: (key: string, params?: Record) => { + const override: string | undefined = preferences?.bundles?.['en-US']?.translations?.[key]; + if (override) return override; + if (key === 'elements.fields.generic.placeholder') return `Enter your ${params?.['field']}`; + return key; + }, + currentLanguage: 'en-US', setLanguage: vi.fn(), - availableLanguages: ['en'], + availableLanguages: ['en-US'], }), })); +const basicAuthenticator: any = { + authenticator: 'Username & Password', + authenticatorId: 'QmFzaWNBdXRoZW50aWNhdG9yOkxPQ0FM', + idp: 'LOCAL', + metadata: { + i18nKey: 'authenticator.basic', + params: [ + {confidential: false, displayName: 'Username', order: 0, param: 'username', type: 'STRING'}, + {confidential: true, displayName: 'Password', order: 1, param: 'password', type: 'STRING'}, + ], + promptType: 'USER_PROMPT', + }, + requiredParams: ['username', 'password'], +}; + const googleAuthenticator: any = { authenticator: 'Google', authenticatorId: ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Google, @@ -181,6 +220,24 @@ describe('createSignInOptionFromAuthenticator', () => { expect(domWarnings).toEqual([]); }); + it('lets the i18n bundle override username/password labels and falls back to the server text', () => { + const {container} = render( + createSignInOptionFromAuthenticator(basicAuthenticator, {}, {}, false, vi.fn(), vi.fn(), { + preferences: signInPreferences, + }), + ); + + const username = container.querySelector('input[name="username"]') as HTMLInputElement; + const password = container.querySelector('input[name="password"]') as HTMLInputElement; + + // Overridden through the bundle. + expect(container.textContent).toContain('Email'); + expect(username.getAttribute('placeholder')).toBe('Enter your email'); + // No translation for the password field: the identity server's displayName and the generic placeholder are used. + expect(container.textContent).toContain('Password'); + expect(password.getAttribute('placeholder')).toBe('Enter your password'); + }); + it('does not leak form-state props onto a social button rendered by the sign-up factory', () => { const googleSignUpButton: any = { components: [], diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.tsx index 7f1bd84da..1a63c2322 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.tsx @@ -20,6 +20,7 @@ import { EmbeddedSignInFlowAuthenticator, EmbeddedSignInFlowAuthenticatorKnownIdPType, ApplicationNativeAuthenticationConstants, + Preferences, WithPreferences, } from '@asgardeo/browser'; import {ReactElement} from 'react'; @@ -249,6 +250,7 @@ export const createSignInOptionFromAuthenticator = ( buttonClassName?: string; error?: string | null; inputClassName?: string; + preferences?: Preferences; }, ): ReactElement => createSignInOption({ diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/UsernamePassword.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/UsernamePassword.tsx index a6e7750b3..4db2c15db 100644 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/UsernamePassword.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/v1/options/UsernamePassword.tsx @@ -54,6 +54,17 @@ const UsernamePassword: FC = ({ setSubtitle(t('username.password.subheading')); }, [setTitle, setSubtitle, t]); + /** + * Field texts can be customised through the i18n bundle (e.g. label the identifier "Email" for + * organizations whose users sign in with an email address). Falls back to the text supplied by + * the identity server when no translation exists for the field. + */ + const resolveFieldText = (key: string, fallback: string): string => { + const translated: string = t(key); + + return translated && translated !== key ? translated : fallback; + }; + return ( <> {formFields.map((param: any) => ( @@ -61,12 +72,15 @@ const UsernamePassword: FC = ({ {createField({ className: inputClassName, disabled: isLoading, - label: param.displayName, + label: resolveFieldText(`elements.fields.${param.param}.label`, param.displayName), name: param.param, onChange: (value: any) => onInputChange(param.param, value), - placeholder: t(`elements.fields.generic.placeholder`, { - field: (param.displayName || param.param).toLowerCase(), - }), + placeholder: resolveFieldText( + `elements.fields.${param.param}.placeholder`, + t(`elements.fields.generic.placeholder`, { + field: (param.displayName || param.param).toLowerCase(), + }), + ), required: authenticator.requiredParams.includes(param.param), touched: touchedFields[param.param] || false, type: diff --git a/packages/react/src/contexts/I18n/I18nProvider.tsx b/packages/react/src/contexts/I18n/I18nProvider.tsx index 14147afa4..2f40de555 100644 --- a/packages/react/src/contexts/I18n/I18nProvider.tsx +++ b/packages/react/src/contexts/I18n/I18nProvider.tsx @@ -16,7 +16,13 @@ * under the License. */ -import {deepMerge, I18nPreferences, I18nStorageStrategy, createPackageComponentLogger} from '@asgardeo/browser'; +import { + deepMerge, + I18nBundleOverride, + I18nPreferences, + I18nStorageStrategy, + createPackageComponentLogger, +} from '@asgardeo/browser'; import { I18nBundle, I18nTranslations, @@ -26,6 +32,7 @@ import { } from '@asgardeo/i18n'; import {FC, PropsWithChildren, ReactElement, useCallback, useEffect, useMemo, useState} from 'react'; import I18nContext, {I18nContextValue} from './I18nContext'; +import bundleFromOverride from '../../utils/bundleFromOverride'; const logger: ReturnType = createPackageComponentLogger( '@asgardeo/react', @@ -226,7 +233,7 @@ const I18nProvider: FC> = ({ // 3. User-provided bundles (from props) — highest priority, override everything if (preferences?.bundles) { - Object.entries(preferences.bundles).forEach(([key, userBundle]: [string, I18nBundle]) => { + Object.entries(preferences.bundles).forEach(([key, userBundle]: [string, I18nBundleOverride]) => { const normalizedTranslations: I18nTranslations = normalizeTranslations( userBundle.translations as unknown as Record>, ); @@ -237,7 +244,7 @@ const I18nProvider: FC> = ({ translations: deepMerge(merged[key].translations, normalizedTranslations), }; } else { - merged[key] = {...userBundle, translations: normalizedTranslations}; + merged[key] = bundleFromOverride(key, userBundle, normalizedTranslations); } }); } diff --git a/packages/react/src/hooks/useTranslation.ts b/packages/react/src/hooks/useTranslation.ts index 826898af6..603a454b2 100644 --- a/packages/react/src/hooks/useTranslation.ts +++ b/packages/react/src/hooks/useTranslation.ts @@ -16,11 +16,12 @@ * under the License. */ -import {deepMerge, I18nPreferences, Preferences} from '@asgardeo/browser'; +import {deepMerge, I18nBundleOverride, I18nPreferences, Preferences} from '@asgardeo/browser'; import {I18nBundle, I18nTranslations, normalizeTranslations} from '@asgardeo/i18n'; import {useContext, useMemo} from 'react'; import ComponentPreferencesContext from '../contexts/I18n/ComponentPreferencesContext'; import I18nContext from '../contexts/I18n/I18nContext'; +import bundleFromOverride from '../utils/bundleFromOverride'; export interface UseTranslation { /** @@ -89,7 +90,7 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW }); // Merge component-level bundles using deepMerge for better merging - Object.entries(effectivePreferences.bundles).forEach(([key, componentBundle]: [string, I18nBundle]) => { + Object.entries(effectivePreferences.bundles).forEach(([key, componentBundle]: [string, I18nBundleOverride]) => { const normalizedTranslations: I18nTranslations = normalizeTranslations( componentBundle.translations as unknown as Record>, ); @@ -103,8 +104,8 @@ const useTranslation = (componentPreferences?: I18nPreferences): UseTranslationW translations: deepMerge(merged[key].translations, normalizedTranslations), }; } else { - // No global bundle for this language, use component bundle as-is - merged[key] = {...componentBundle, translations: normalizedTranslations}; + // No global bundle for this language, build one from the component bundle + merged[key] = bundleFromOverride(key, componentBundle, normalizedTranslations); } }); diff --git a/packages/react/src/utils/bundleFromOverride.test.ts b/packages/react/src/utils/bundleFromOverride.test.ts new file mode 100644 index 000000000..aeacad2d9 --- /dev/null +++ b/packages/react/src/utils/bundleFromOverride.test.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {I18nBundle} from '@asgardeo/i18n'; +import {describe, expect, it} from 'vitest'; +import bundleFromOverride, {deriveTextDirection} from './bundleFromOverride'; + +describe('bundleFromOverride', () => { + it('derives the metadata from the locale code', () => { + const bundle: I18nBundle = bundleFromOverride('fr-FR', {translations: {}}, {} as any); + + expect(bundle.metadata).toEqual({ + countryCode: 'FR', + direction: 'ltr', + displayName: 'fr-FR', + languageCode: 'fr', + localeCode: 'fr-FR', + }); + }); + + it('marks right-to-left languages as rtl', () => { + expect(bundleFromOverride('ar-AE', {translations: {}}, {} as any).metadata.direction).toBe('rtl'); + expect(deriveTextDirection('he')).toBe('rtl'); + expect(deriveTextDirection('en_US')).toBe('ltr'); + }); + + it('lets the override metadata win', () => { + const bundle: I18nBundle = bundleFromOverride( + 'ar-AE', + {metadata: {direction: 'ltr', displayName: 'Arabic'}, translations: {}}, + {} as any, + ); + + expect(bundle.metadata.direction).toBe('ltr'); + expect(bundle.metadata.displayName).toBe('Arabic'); + }); +}); diff --git a/packages/react/src/utils/bundleFromOverride.ts b/packages/react/src/utils/bundleFromOverride.ts new file mode 100644 index 000000000..099ca13a7 --- /dev/null +++ b/packages/react/src/utils/bundleFromOverride.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {I18nBundleOverride} from '@asgardeo/browser'; +import {I18nBundle, I18nMetadata, I18nTextDirection, I18nTranslations} from '@asgardeo/i18n'; + +/** + * Languages written right-to-left, by ISO 639-1 code. + */ +const RTL_LANGUAGES: Set = new Set(['ar', 'dv', 'fa', 'he', 'ks', 'ku', 'ps', 'sd', 'ug', 'ur', 'yi']); + +/** + * Derives the text direction for a locale from its language code. + */ +export const deriveTextDirection = (locale: string): I18nTextDirection => + RTL_LANGUAGES.has(locale.split(/[-_]/)[0].toLowerCase()) ? 'rtl' : 'ltr'; + +/** + * Builds a complete bundle from an application-supplied partial override for a locale that has + * no built-in bundle, deriving the metadata from the locale code where it is not provided. + */ +const bundleFromOverride = ( + locale: string, + override: I18nBundleOverride, + translations: I18nTranslations, +): I18nBundle => { + const [languageCode, countryCode = '']: string[] = locale.split(/[-_]/); + + return { + metadata: { + countryCode, + direction: deriveTextDirection(locale), + displayName: locale, + languageCode, + localeCode: locale, + ...(override.metadata ?? {}), + } as I18nMetadata, + translations, + }; +}; + +export default bundleFromOverride;