Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/young-tires-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@asgardeo/javascript': patch
'@asgardeo/react': patch
'@asgardeo/i18n': patch
---

Introduce `<LanguageSwitcher />` & improve i18n support
2 changes: 2 additions & 0 deletions packages/i18n/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@ export * from './translations';

// Utils
export {default as getDefaultI18nBundles} from './utils/getDefaultI18nBundles';
export {default as normalizeTranslations} from './utils/normalizeTranslations';
export {default as TranslationBundleConstants} from './constants/TranslationBundleConstants';
68 changes: 68 additions & 0 deletions packages/i18n/src/utils/normalizeTranslations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* 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 {I18nTranslations} from '../models/i18n';

/**
* Accepts translations in either flat or namespaced format and normalizes them
* to the flat format required by the SDK.
*
* Flat format (already correct):
* ```ts
* { "signin.heading": "Sign In" }
* ```
*
* Namespaced format (auto-converted):
* ```ts
* { signin: { heading: "Sign In" } }
* ```
*
* Both formats can be mixed within the same object — a top-level string value
* is kept as-is, while a top-level object value is flattened one level deep
* using `"namespace.key"` concatenation.
*
* @param translations - Translations in flat or namespaced format.
* @returns Normalized flat translations compatible with `I18nTranslations`.
*/
const normalizeTranslations = (
translations: Record<string, string | Record<string, string>> | null | undefined,
): I18nTranslations => {
if (!translations || typeof translations !== 'object') {
return {} as unknown as I18nTranslations;
}

const result: Record<string, string> = {};

Object.entries(translations).forEach(([topKey, value]: [string, string | Record<string, string>]) => {
if (typeof value === 'string') {
// Already flat — keep as-is (e.g., "signin.heading": "Sign In")
result[topKey] = value;
} else if (value !== null && typeof value === 'object') {
// Namespaced — flatten one level (e.g., signin: { heading: "Sign In" } → "signin.heading": "Sign In")
Object.entries(value).forEach(([subKey, subValue]: [string, string]) => {
if (typeof subValue === 'string') {
result[`${topKey}.${subKey}`] = subValue;
}
});
}
});

return result as unknown as I18nTranslations;
};

export default normalizeTranslations;
4 changes: 4 additions & 0 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export {
Preferences,
ThemePreferences,
I18nPreferences,
I18nStorageStrategy,
WithPreferences,
SignInOptions,
SignOutOptions,
Expand Down Expand Up @@ -210,6 +211,9 @@ export {default as resolveFieldType} from './utils/resolveFieldType';
export {default as resolveFieldName} from './utils/resolveFieldName';
export {default as resolveMeta} from './utils/v2/resolveMeta';
export {default as resolveVars} from './utils/v2/resolveVars';
export {default as countryCodeToFlagEmoji} from './utils/v2/countryCodeToFlagEmoji';
export {default as resolveLocaleDisplayName} from './utils/v2/resolveLocaleDisplayName';
export {default as resolveLocaleEmoji} from './utils/v2/resolveLocaleEmoji';
export {default as processOpenIDScopes} from './utils/processOpenIDScopes';
export {default as withVendorCSSClassPrefix} from './utils/withVendorCSSClassPrefix';
export {default as transformBrandingPreferenceToTheme} from './utils/transformBrandingPreferenceToTheme';
Expand Down
43 changes: 42 additions & 1 deletion packages/javascript/src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,23 +265,64 @@ export interface ThemePreferences {
overrides?: RecursivePartial<ThemeConfig>;
}

/**
* The storage strategy to use for persisting the user's language selection.
*
* - `'cookie'` — persists in `document.cookie` as a domain cookie (default).
* Useful for cross-subdomain scenarios where the auth portal and
* the application share a root domain.
* - `'localStorage'` — persists in `window.localStorage`.
* - `'none'` — no persistence; the resolved language is held in React state only.
*/
export type I18nStorageStrategy = 'cookie' | 'localStorage' | 'none';

export interface I18nPreferences {
/**
* Custom translations to override default ones.
*/
bundles?: {
[key: string]: I18nBundle;
};
/**
* The domain to use when setting the language cookie.
* Only applies when `storageStrategy` is `'cookie'`.
* Defaults to the root domain derived from `window.location.hostname`
* (e.g. `'app.example.com'` → `'example.com'`).
* Override this for eTLD+1 domains like `.co.uk` or custom cookie scoping.
*/
cookieDomain?: string;
/**
* The fallback language to use if translations are not available in the specified language.
* Defaults to 'en-US'.
*/
fallbackLanguage?: string;
/**
* The language to use for translations.
* Defaults to the browser's default language.
* When set, acts as a hard override and bypasses all other detection sources
* (URL param, stored preference, browser language).
*/
language?: string;
/**
* The key used when reading/writing the language to the chosen storage.
* For `localStorage` this is the key name; for `cookie` this is the cookie name.
* @default 'asgardeo-i18n-language'
*/
storageKey?: string;
/**
* The storage strategy to use for persisting the user's language selection.
* @default 'cookie'
*/
storageStrategy?: I18nStorageStrategy;
/**
* The URL query-parameter name to inspect for a language override.
* Set to `false` to disable URL-parameter detection entirely.
* When a URL param is detected its value is immediately persisted to storage.
* @default 'lang'
* @example
* // With urlParam: 'locale', the URL ?locale=fr-FR will select French.
* // With urlParam: false, URL parameters are ignored.
*/
urlParam?: string | false;
}

export interface Preferences {
Expand Down
32 changes: 32 additions & 0 deletions packages/javascript/src/utils/v2/countryCodeToFlagEmoji.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* 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.
*/

/**
* Converts a two-letter ISO 3166-1 alpha-2 country code to a flag emoji using
* Unicode Regional Indicator Symbols (U+1F1E6–U+1F1FF).
*
* @param countryCode - Two-letter uppercase country code (e.g. "US", "GB")
* @returns Flag emoji string (e.g. "🇺🇸", "🇬🇧")
*/
export default function countryCodeToFlagEmoji(countryCode: string): string {
return countryCode
.toUpperCase()
.split('')
.map((char: string) => String.fromCodePoint(0x1f1e6 - 65 + char.charCodeAt(0)))
.join('');
}
37 changes: 37 additions & 0 deletions packages/javascript/src/utils/v2/resolveLocaleDisplayName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* 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.
*/

/**
* Resolves a BCP 47 locale tag to a human-readable display name using the
* `Intl.DisplayNames` API.
*
* Falls back to the raw locale code if the runtime does not support
* `Intl.DisplayNames` or if resolution returns `undefined`.
*
* @param locale - BCP 47 locale tag to resolve (e.g. "en", "fr", "zh-Hant")
* @param displayLocale - Locale used for the display name language (defaults to "en")
* @returns Human-readable language name (e.g. "English", "French")
*/
export default function resolveLocaleDisplayName(locale: string, displayLocale: string): string {
try {
const displayNames: Intl.DisplayNames = new Intl.DisplayNames([displayLocale], {type: 'language'});
return displayNames.of(locale) ?? locale;
} catch {
return locale;
}
}
90 changes: 90 additions & 0 deletions packages/javascript/src/utils/v2/resolveLocaleEmoji.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* 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 countryCodeToFlagEmoji from './countryCodeToFlagEmoji';

/**
* Maps BCP 47 language subtags to ISO 3166-1 alpha-2 country codes used for
* flag emoji resolution when no country subtag is present in the locale.
*/
const LANGUAGE_TO_COUNTRY: Readonly<Record<string, string>> = {
am: 'ET',
ar: 'SA',
bn: 'BD',
cs: 'CZ',
da: 'DK',
de: 'DE',
el: 'GR',
en: 'GB',
es: 'ES',
fa: 'IR',
fi: 'FI',
fr: 'FR',
he: 'IL',
hi: 'IN',
hu: 'HU',
id: 'ID',
it: 'IT',
ja: 'JP',
ko: 'KR',
ml: 'IN',
ms: 'MY',
nl: 'NL',
no: 'NO',
pl: 'PL',
pt: 'PT',
ro: 'RO',
ru: 'RU',
si: 'LK',
sk: 'SK',
sv: 'SE',
sw: 'KE',
ta: 'IN',
th: 'TH',
tr: 'TR',
uk: 'UA',
ur: 'PK',
vi: 'VN',
zh: 'CN',
};

/**
* Resolves a BCP 47 locale tag to a flag emoji.
*
* Resolution order:
* 1. Country subtag when present (e.g. `"en-US"` → 🇺🇸)
* 2. Language-to-country fallback map (e.g. `"en"` → 🇬🇧)
* 3. Globe emoji 🌐 for unrecognised codes
*
* @param locale - BCP 47 locale tag (e.g. "en", "en-US", "fr-CA")
* @returns Flag or globe emoji string
*/
function resolveLocaleEmoji(locale: string): string {
const parts: string[] = locale.split('-');
const languageCode: string = parts[0].toLowerCase();
const countrySubtag: string | undefined = parts.length > 1 ? parts[parts.length - 1].toUpperCase() : undefined;

const countryCode: string | undefined = countrySubtag ?? LANGUAGE_TO_COUNTRY[languageCode];

if (!countryCode || countryCode.length !== 2) {
return '\u{1F310}'; // 🌐
}

return countryCodeToFlagEmoji(countryCode);
}
export default resolveLocaleEmoji;
7 changes: 7 additions & 0 deletions packages/react/src/AsgardeoReactClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,13 @@ class AsgardeoReactClient<T extends AsgardeoReactConfig = AsgardeoReactConfig> e
// Tracker: https://github.com/asgardeo/javascript/issues/212#issuecomment-3435713699
if (config.platform === Platform.AsgardeoV2) {
this.asgardeo.clearSession();

if (config.signInUrl) {
navigate(config.signInUrl);
} else {
this.signIn(config.signInOptions);
}

args[1]?.(config.afterSignOutUrl || '');

return Promise.resolve(config.afterSignOutUrl || '');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* under the License.
*/

import {CreateOrganizationPayload, createPackageComponentLogger} from '@asgardeo/browser';
import {CreateOrganizationPayload, createPackageComponentLogger, Preferences} from '@asgardeo/browser';
import {cx} from '@emotion/css';
import {ChangeEvent, CSSProperties, FC, FormEvent, ReactElement, ReactNode, useState} from 'react';
import useStyles from './BaseCreateOrganization.styles';
Expand Down Expand Up @@ -59,8 +59,15 @@ export interface BaseCreateOrganizationProps {
onSubmit?: (payload: CreateOrganizationPayload) => void | Promise<void>;
onSuccess?: (organization: any) => void;
open?: boolean;
/**
* Component-level preferences to override global i18n and theme settings.
* Preferences are deep-merged with global ones, with component preferences
* taking precedence. Affects this component and all its descendants.
*/
preferences?: Preferences;
renderAdditionalFields?: () => ReactNode;
style?: CSSProperties;

title?: string;
}

Expand Down Expand Up @@ -95,13 +102,14 @@ export const BaseCreateOrganization: FC<BaseCreateOrganizationProps> = ({
onSubmit,
onSuccess,
open = false,
preferences,
renderAdditionalFields,
style,
title = 'Create Organization',
}: BaseCreateOrganizationProps): ReactElement => {
const {theme, colorScheme} = useTheme();
const styles: ReturnType<typeof useStyles> = useStyles(theme, colorScheme);
const {t} = useTranslation();
const {t} = useTranslation(preferences?.i18n);
const [formData, setFormData] = useState<OrganizationFormData>({
description: '',
handle: '',
Expand Down
Loading
Loading