From 04ab84800312a705e3ee623f64fbcc2e2abf078b Mon Sep 17 00:00:00 2001 From: Jon Koops Date: Thu, 11 May 2023 20:23:10 +0200 Subject: [PATCH] Rework merging of message bundles for localization of Admin Console (#20183) Closes #20182 --- .../release_notes/topics/22_0_0.adoc | 8 +- .../admin-ui/src/context/whoami/WhoAmI.tsx | 2 +- .../context/whoami/__tests__/WhoAmI.test.ts | 26 --- .../context/whoami/__tests__/mock-whoami.json | 53 ------ js/apps/admin-ui/src/i18n.ts | 84 --------- js/apps/admin-ui/src/i18n/OverridesBackend.ts | 161 ++++++++++++++++++ js/apps/admin-ui/src/i18n/i18n.ts | 53 ++++++ js/apps/admin-ui/src/main.tsx | 4 +- .../src/realm-settings/LocalizationTab.tsx | 2 +- 9 files changed, 225 insertions(+), 168 deletions(-) delete mode 100644 js/apps/admin-ui/src/context/whoami/__tests__/WhoAmI.test.ts delete mode 100644 js/apps/admin-ui/src/context/whoami/__tests__/mock-whoami.json delete mode 100644 js/apps/admin-ui/src/i18n.ts create mode 100644 js/apps/admin-ui/src/i18n/OverridesBackend.ts create mode 100644 js/apps/admin-ui/src/i18n/i18n.ts diff --git a/docs/documentation/release_notes/topics/22_0_0.adoc b/docs/documentation/release_notes/topics/22_0_0.adoc index 23da6caeed..2cfbb62331 100644 --- a/docs/documentation/release_notes/topics/22_0_0.adoc +++ b/docs/documentation/release_notes/topics/22_0_0.adoc @@ -18,4 +18,10 @@ See the migration guide for more details. After the upgrade to Jakarta EE, artifacts for Keycloak Admin clients were renamed to more descriptive names with consideration for long-term maintainability. We still provide two separate Keycloak Admin clients, one with Jakarta EE and the other with Java EE support. -See the migration guide for more details. \ No newline at end of file +See the migration guide for more details. + += Changes to custom Admin Console messages + +The Admin Console (and soon also the new Account Console) works slightly different than the rest of Keycloak in regards to how keys for internationalized messages are parsed. This is due to the fact that it uses the https://www.i18next.com/[i18next] library for internationalization. Therefore when defining custom messages for the Admin Console under "Realm Settings" ➡ "Localization" best practices for i18next must be taken into account. Specifically, when defining a message for the Admin Console it is it important to specify a https://www.i18next.com/principles/namespaces[namespace] in the key of your message. + +For example, let's assume we want to overwrite the https://github.com/keycloak/keycloak/blob/025778fe9c745316f80b53fe3052aeb314e868ef/js/apps/admin-ui/public/locales/en/dashboard.json#L3[`welcome`] message shown to the user when a new realm has been created. This message is located in the `dashboard` namespace, same as the name of the original file that holds the messages (`dashboard.json`). If we wanted to overwrite this message we'll have to use the namespace as a prefix followed by the key of the message separated by a colon, in this case it would become `dashboard:welcome`. \ No newline at end of file diff --git a/js/apps/admin-ui/src/context/whoami/WhoAmI.tsx b/js/apps/admin-ui/src/context/whoami/WhoAmI.tsx index 00d04b4021..45995d51cd 100644 --- a/js/apps/admin-ui/src/context/whoami/WhoAmI.tsx +++ b/js/apps/admin-ui/src/context/whoami/WhoAmI.tsx @@ -5,7 +5,7 @@ import { createNamedContext, useRequiredContext } from "ui-shared"; import { adminClient } from "../../admin-client"; import environment from "../../environment"; -import i18n, { DEFAULT_LOCALE } from "../../i18n"; +import { DEFAULT_LOCALE, i18n } from "../../i18n/i18n"; import { useFetch } from "../../utils/useFetch"; export class WhoAmI { diff --git a/js/apps/admin-ui/src/context/whoami/__tests__/WhoAmI.test.ts b/js/apps/admin-ui/src/context/whoami/__tests__/WhoAmI.test.ts deleted file mode 100644 index 6bbdf32a59..0000000000 --- a/js/apps/admin-ui/src/context/whoami/__tests__/WhoAmI.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import type WhoAmIRepresentation from "@keycloak/keycloak-admin-client/lib/defs/whoAmIRepresentation"; -import { expect, test } from "vitest"; -import { WhoAmI } from "../WhoAmI"; -import whoamiMock from "./mock-whoami.json"; - -test("returns display name", () => { - const whoami = new WhoAmI(whoamiMock as WhoAmIRepresentation); - expect(whoami.getDisplayName()).toEqual("Stan Silvert"); -}); - -test("can not create realm", () => { - const whoami = new WhoAmI(whoamiMock as WhoAmIRepresentation); - expect(whoami.canCreateRealm()).toEqual(false); -}); - -test("getRealmAccess", () => { - const whoami = new WhoAmI(whoamiMock as WhoAmIRepresentation); - expect(Object.keys(whoami.getRealmAccess()).length).toEqual(3); - expect(whoami.getRealmAccess()["master"].length).toEqual(18); -}); - -//TODO: When we have easy access to i18n, create test for setting locale. -// Tested manually and it does work. diff --git a/js/apps/admin-ui/src/context/whoami/__tests__/mock-whoami.json b/js/apps/admin-ui/src/context/whoami/__tests__/mock-whoami.json deleted file mode 100644 index d65e8809d3..0000000000 --- a/js/apps/admin-ui/src/context/whoami/__tests__/mock-whoami.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "userId": "1b635073-7ac8-49db-8eb9-f9fa9cd15bf5", - "realm": "master", - "displayName": "Stan Silvert", - "locale": "en", - "createRealm": false, - "realm_access": { - "test": [ - "query-clients", - "view-clients" - ], - "aaa": [ - "view-identity-providers", - "view-realm", - "manage-identity-providers", - "impersonation", - "create-client", - "manage-users", - "query-realms", - "view-authorization", - "query-clients", - "query-users", - "manage-events", - "manage-realm", - "view-events", - "view-users", - "view-clients", - "manage-authorization", - "manage-clients", - "query-groups" - ], - "master": [ - "view-realm", - "view-identity-providers", - "manage-identity-providers", - "impersonation", - "create-client", - "manage-users", - "query-realms", - "view-authorization", - "query-clients", - "query-users", - "manage-events", - "manage-realm", - "view-events", - "view-users", - "view-clients", - "manage-authorization", - "manage-clients", - "query-groups" - ] - } -} diff --git a/js/apps/admin-ui/src/i18n.ts b/js/apps/admin-ui/src/i18n.ts deleted file mode 100644 index 20df08410e..0000000000 --- a/js/apps/admin-ui/src/i18n.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { InitOptions, TOptions, init, use } from "i18next"; -import HttpBackend, { LoadPathOption } from "i18next-http-backend"; -import { initReactI18next } from "react-i18next"; - -import { adminClient } from "./admin-client"; -import environment from "./environment"; -import { addTrailingSlash } from "./util"; -import { getAuthorizationHeaders } from "./utils/getAuthorizationHeaders"; - -export const DEFAULT_LOCALE = "en"; - -export async function initI18n() { - const options = await initOptions(); - await init(options); -} - -const initOptions = async (): Promise => { - const constructLoadPath: LoadPathOption = (_, namespaces) => { - if (namespaces[0] === "overrides") { - return `${addTrailingSlash(adminClient.baseUrl)}admin/realms/${ - adminClient.realmName - }/localization/{{lng}}?useRealmDefaultLocaleFallback=true`; - } else { - return `${environment.resourceUrl}/locales/{{lng}}/{{ns}}.json`; - } - }; - - return { - returnNull: false, - defaultNS: "common", - fallbackLng: DEFAULT_LOCALE, - preload: [DEFAULT_LOCALE], - ns: [ - "common", - "common-help", - "dashboard", - "clients", - "clients-help", - "client-scopes", - "client-scopes-help", - "groups", - "realm", - "roles", - "users", - "users-help", - "sessions", - "events", - "realm-settings", - "realm-settings-help", - "authentication", - "authentication-help", - "user-federation", - "user-federation-help", - "identity-providers", - "identity-providers-help", - "dynamic", - "overrides", - ], - interpolation: { - escapeValue: false, - }, - postProcess: ["overrideProcessor"], - backend: { - loadPath: constructLoadPath, - customHeaders: getAuthorizationHeaders( - await adminClient.getAccessToken() - ), - }, - }; -}; - -const configuredI18n = use({ - type: "postProcessor", - name: "overrideProcessor", - process: function (value: string, key: string, _: TOptions, translator: any) { - const override: string = - translator.resourceStore.data[translator.language].overrides?.[key]; - return override || value; - }, -}) - .use(initReactI18next) - .use(HttpBackend); - -export default configuredI18n; diff --git a/js/apps/admin-ui/src/i18n/OverridesBackend.ts b/js/apps/admin-ui/src/i18n/OverridesBackend.ts new file mode 100644 index 0000000000..7a28873a82 --- /dev/null +++ b/js/apps/admin-ui/src/i18n/OverridesBackend.ts @@ -0,0 +1,161 @@ +import { CallbackError, ReadCallback, ResourceKey } from "i18next"; +import HttpBackend from "i18next-http-backend"; + +import { adminClient } from "../admin-client"; +import { DEFAULT_LOCALE, KEY_SEPARATOR, NAMESPACE_SEPARATOR } from "./i18n"; + +type ParsedOverrides = { [namespace: string]: { [key: string]: string } }; + +/** A custom backend that merges the overrides the static labels with those defined by the user in the console. */ +export class OverridesBackend extends HttpBackend { + #overridesCache = new Map>(); + + async loadUrl( + url: string, + callback: ReadCallback, + languages?: string | string[], + namespaces?: string | string[] + ) { + try { + const [data, overrides] = await Promise.all([ + this.#loadUrlPromisified(url, languages, namespaces), + this.#loadOverrides(languages), + ]); + + const namespace = this.#determineNamespace(namespaces); + + // Bail out on applying overrides if the namespace could not be determined. + if (!namespace) { + return callback(null, data); + } + + callback(null, this.#applyOverrides(namespace, data, overrides)); + } catch (error) { + callback(error as CallbackError, null); + } + } + + #applyOverrides( + namespace: string, + data: ResourceKey, + overrides: ParsedOverrides + ) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (typeof data === "string" || !overrides[namespace]) { + return data; + } + + // Ensure we are operating on a cloned data structure to prevent in place mutations. + const target = structuredClone(data); + + for (const [path, value] of Object.entries(overrides[namespace])) { + this.#applyOverride(target, path, value); + } + + return target; + } + + /** Applies an override by converting path segments denoted with a key separator as nested objects and merging the result. */ + #applyOverride(target: Record, path: string, value: string) { + const trail = path.split(KEY_SEPARATOR); + let pointer = target; + + trail.forEach((segment, index) => { + const isLast = index === trail.length - 1; + pointer = pointer[segment] = isLast ? value : pointer[segment] ?? {}; + }); + } + + #loadOverrides(languages?: string | string[]) { + const locale = this.#determineLocale(languages); + const cachedOverrides = this.#overridesCache.get(locale); + + if (cachedOverrides) { + return cachedOverrides; + } + + const overrides = adminClient.realms + .getRealmLocalizationTexts({ + realm: adminClient.realmName, + selectedLocale: locale, + }) + .then((data) => this.#parseOverrides(data)); + + this.#overridesCache.set(locale, overrides); + + // Evict cached request on failure. + overrides.catch((error) => { + this.#overridesCache.delete(locale); + return Promise.reject(error); + }); + + return overrides; + } + + #parseOverrides(data: Record) { + const parsed: ParsedOverrides = {}; + + for (const [path, value] of Object.entries(data)) { + const parts = path.split(NAMESPACE_SEPARATOR); + + // Omit entry if no namespace has been provided. + if (parts.length !== 2) { + continue; + } + + const [namespace, key] = parts; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!parsed[namespace]) { + parsed[namespace] = {}; + } + + parsed[namespace][key] = value; + } + + return parsed; + } + + #determineLocale(languages?: string | string[]) { + if (typeof languages === "string") { + return languages; + } + + return languages?.[0] ?? DEFAULT_LOCALE; + } + + #determineNamespace(namespaces?: string | string[]) { + if (typeof namespaces === "string") { + return namespaces; + } + + return namespaces?.[0]; + } + + #loadUrlPromisified( + url: string, + languages?: string | string[], + namespaces?: string | string[] + ) { + return new Promise((resolve, reject) => { + const callback: ReadCallback = (error, data) => { + if (error) { + return reject(error); + } + + if (typeof data !== "object" || data === null) { + return reject( + new Error( + "Unable to load URL, data returned is of an unsupported type.", + { cause: error } + ) + ); + } + + resolve(data); + }; + + super.loadUrl(url, callback, languages, namespaces); + }); + } +} diff --git a/js/apps/admin-ui/src/i18n/i18n.ts b/js/apps/admin-ui/src/i18n/i18n.ts new file mode 100644 index 0000000000..62302f7aaa --- /dev/null +++ b/js/apps/admin-ui/src/i18n/i18n.ts @@ -0,0 +1,53 @@ +import { createInstance } from "i18next"; +import { initReactI18next } from "react-i18next"; + +import environment from "../environment"; +import { joinPath } from "../utils/joinPath"; +import { OverridesBackend } from "./OverridesBackend"; + +export const DEFAULT_LOCALE = "en"; +export const DEFAULT_NAMESPACE = "common"; +export const NAMESPACE_SEPARATOR = ":"; +export const KEY_SEPARATOR = "."; + +export const i18n = createInstance({ + returnNull: false, + fallbackLng: DEFAULT_LOCALE, + defaultNS: DEFAULT_NAMESPACE, + nsSeparator: NAMESPACE_SEPARATOR, + keySeparator: KEY_SEPARATOR, + ns: [ + DEFAULT_NAMESPACE, + "common-help", + "dashboard", + "clients", + "clients-help", + "client-scopes", + "client-scopes-help", + "groups", + "realm", + "roles", + "users", + "users-help", + "sessions", + "events", + "realm-settings", + "realm-settings-help", + "authentication", + "authentication-help", + "user-federation", + "user-federation-help", + "identity-providers", + "identity-providers-help", + "dynamic", + ], + interpolation: { + escapeValue: false, + }, + backend: { + loadPath: joinPath(environment.resourceUrl, "locales/{{lng}}/{{ns}}.json"), + }, +}); + +i18n.use(OverridesBackend); +i18n.use(initReactI18next); diff --git a/js/apps/admin-ui/src/main.tsx b/js/apps/admin-ui/src/main.tsx index 8a933539a9..385c9238e1 100644 --- a/js/apps/admin-ui/src/main.tsx +++ b/js/apps/admin-ui/src/main.tsx @@ -5,7 +5,7 @@ import { StrictMode } from "react"; import { render } from "react-dom"; import { createHashRouter, RouterProvider } from "react-router-dom"; -import { initI18n } from "./i18n"; +import { i18n } from "./i18n/i18n"; import { initKeycloak } from "./keycloak"; import { RootRoute } from "./routes"; @@ -13,7 +13,7 @@ import "./index.css"; // Initialize required components before rendering app. await initKeycloak(); -await initI18n(); +await i18n.init(); const router = createHashRouter([RootRoute]); const container = document.getElementById("app"); diff --git a/js/apps/admin-ui/src/realm-settings/LocalizationTab.tsx b/js/apps/admin-ui/src/realm-settings/LocalizationTab.tsx index 4be5fe2dd4..ba9459e230 100644 --- a/js/apps/admin-ui/src/realm-settings/LocalizationTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/LocalizationTab.tsx @@ -46,7 +46,7 @@ import { PaginatingTableToolbar } from "../components/table-toolbar/PaginatingTa import { useRealm } from "../context/realm-context/RealmContext"; import { useServerInfo } from "../context/server-info/ServerInfoProvider"; import { useWhoAmI } from "../context/whoami/WhoAmI"; -import { DEFAULT_LOCALE } from "../i18n"; +import { DEFAULT_LOCALE } from "../i18n/i18n"; import { convertToFormValues } from "../util"; import { useFetch } from "../utils/useFetch"; import { AddMessageBundleModal } from "./AddMessageBundleModal";