Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
11 changes: 11 additions & 0 deletions .changeset/signin-field-labels-i18n.md
Original file line number Diff line number Diff line change
@@ -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.<field>.label` / `elements.fields.<field>.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 `<SignIn />` component now accepts the `preferences` prop, so texts can be overridden per component as with `<SignUp />` and the React SDK.
1 change: 1 addition & 0 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export type {
Config,
Preferences,
ThemePreferences,
I18nBundleOverride,
I18nPreferences,
I18nStorageStrategy,
WithPreferences,
Expand Down
16 changes: 13 additions & 3 deletions packages/javascript/src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<I18nMetadata>;
translations: Partial<I18nTranslations> | Record<string, string>;
}

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.
Expand Down
26 changes: 26 additions & 0 deletions packages/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,32 @@ 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
`<AsgardeoProvider>` or per component through the `preferences` prop of `<SignIn />` and `<SignUp />`. 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
<AsgardeoProvider
preferences={{
i18n: {
bundles: {
'en-US': {
translations: {
'elements.fields.username.label': 'Email',
'elements.fields.username.placeholder': 'Enter your email',
},
},
},
},
}}
>
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<BaseSignInProps, 'className' | 'onSuccess' | 'onError' | 'variant' | 'size'>;
export type SignInProps = Pick<
BaseSignInProps,
'className' | 'onSuccess' | 'onError' | 'variant' | 'size' | 'preferences'
>;

/**
* A SignIn component for Next.js that provides native authentication flow.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -63,7 +64,7 @@ const isPasskeyAuthenticator = (authenticator: EmbeddedSignInFlowAuthenticator):
/**
* Props for the BaseSignIn component.
*/
export interface BaseSignInProps {
export interface BaseSignInProps extends WithPreferences {
afterSignInUrl?: string;

/**
Expand Down Expand Up @@ -182,6 +183,7 @@ const BaseSignInContent: FC<BaseSignInProps> = ({
variant = 'outlined',
showTitle = true,
showSubtitle = true,
preferences,
}: BaseSignInProps): ReactElement => {
const {theme} = useTheme();
const {t} = useTranslation();
Expand Down Expand Up @@ -1067,6 +1069,7 @@ const BaseSignInContent: FC<BaseSignInProps> = ({
buttonClassName: buttonClasses,
error,
inputClassName: inputClasses,
preferences,
},
)}
</form>
Expand All @@ -1092,6 +1095,7 @@ const BaseSignInContent: FC<BaseSignInProps> = ({
buttonClassName: buttonClasses,
error,
inputClassName: inputClasses,
preferences,
},
)}
</div>
Expand Down Expand Up @@ -1222,6 +1226,7 @@ const BaseSignInContent: FC<BaseSignInProps> = ({
buttonClassName: buttonClasses,
error,
inputClassName: inputClasses,
preferences,
},
)}
</form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,39 @@ vi.mock('../../../../../../contexts/Theme/useTheme', () => ({
}),
}));

const translations: Record<string, string> = {
'elements.fields.username.label': 'Email',
'elements.fields.username.placeholder': 'Enter your email',
};

vi.mock('../../../../../../contexts/Flow/useFlow', () => ({
default: () => ({setSubtitle: vi.fn(), setTitle: vi.fn()}),
}));

vi.mock('../../../../../../hooks/useTranslation', () => ({
default: () => ({
t: (key: string) => key,
t: (key: string) => translations[key] ?? key,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
currentLanguage: 'en',
setLanguage: vi.fn(),
availableLanguages: ['en'],
}),
}));

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,
Expand Down Expand Up @@ -181,6 +205,22 @@ 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(), {}),
);

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 is used.
expect(container.textContent).toContain('Password');
expect(password.getAttribute('placeholder')).toBe('elements.fields.generic.placeholder');
});

it('does not leak form-state props onto a social button rendered by the sign-up factory', () => {
const googleSignUpButton: any = {
components: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
EmbeddedSignInFlowAuthenticator,
EmbeddedSignInFlowAuthenticatorKnownIdPType,
ApplicationNativeAuthenticationConstants,
Preferences,
WithPreferences,
} from '@asgardeo/browser';
import {ReactElement} from 'react';
Expand Down Expand Up @@ -249,6 +250,7 @@ export const createSignInOptionFromAuthenticator = (
buttonClassName?: string;
error?: string | null;
inputClassName?: string;
preferences?: Preferences;
},
): ReactElement =>
createSignInOption({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,33 @@ const UsernamePassword: FC<BaseSignInOptionProps> = ({
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) => (
<div key={param.param}>
{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:
Expand Down
13 changes: 10 additions & 3 deletions packages/react/src/contexts/I18n/I18nProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof createPackageComponentLogger> = createPackageComponentLogger(
'@asgardeo/react',
Expand Down Expand Up @@ -226,7 +233,7 @@ const I18nProvider: FC<PropsWithChildren<I18nProviderProps>> = ({

// 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<string, string | Record<string, string>>,
);
Expand All @@ -237,7 +244,7 @@ const I18nProvider: FC<PropsWithChildren<I18nProviderProps>> = ({
translations: deepMerge(merged[key].translations, normalizedTranslations),
};
} else {
merged[key] = {...userBundle, translations: normalizedTranslations};
merged[key] = bundleFromOverride(key, userBundle, normalizedTranslations);
}
});
}
Expand Down
9 changes: 5 additions & 4 deletions packages/react/src/hooks/useTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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<string, string | Record<string, string>>,
);
Expand All @@ -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);
}
});

Expand Down
46 changes: 46 additions & 0 deletions packages/react/src/utils/bundleFromOverride.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* 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, I18nTranslations} from '@asgardeo/i18n';

/**
* 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: 'ltr',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
displayName: locale,
languageCode,
localeCode: locale,
...(override.metadata ?? {}),
} as I18nMetadata,
translations,
};
};

export default bundleFromOverride;
Loading