From 768231d95088bdf925c62de1e4c603cf19a6a461 Mon Sep 17 00:00:00 2001 From: agagancarczyk <4890675+agagancarczyk@users.noreply.github.com> Date: Mon, 8 Jan 2024 14:03:26 +0000 Subject: [PATCH] Localization tabs (#25532) * Add new localization tabs to Administration Console Closes #23057 Signed-off-by: Agnieszka Signed-off-by: Jon Koops * css cleanup Signed-off-by: Agnieszka Gancarczyk * css cleanup Signed-off-by: Agnieszka Gancarczyk --------- Signed-off-by: Agnieszka Signed-off-by: Jon Koops Signed-off-by: Agnieszka Gancarczyk Co-authored-by: Jon Koops Co-authored-by: Agnieszka Gancarczyk --- .../e2e/realm_settings_events_test.spec.ts | 46 +- .../e2e/realm_settings_tabs_test.spec.ts | 127 +++- .../realm_settings/RealmSettingsPage.ts | 21 +- .../admin/messages/messages_en.properties | 24 +- .../src/realm-settings/LocalizationTab.tsx | 12 +- .../src/realm-settings/RealmSettingsTabs.tsx | 9 +- .../localization/EffectiveMessageBundles.tsx | 505 ++++++++++++++++ .../localization/LocalizationTab.tsx | 275 +++++++++ .../localization/RealmOverrides.tsx | 571 ++++++++++++++++++ js/apps/admin-ui/src/util.ts | 10 + .../effectiveMessageBundleRepresentation.ts | 7 +- .../src/resources/serverInfo.ts | 22 +- .../services/resources/ThemeResource.java | 16 +- 13 files changed, 1579 insertions(+), 66 deletions(-) create mode 100644 js/apps/admin-ui/src/realm-settings/localization/EffectiveMessageBundles.tsx create mode 100644 js/apps/admin-ui/src/realm-settings/localization/LocalizationTab.tsx create mode 100644 js/apps/admin-ui/src/realm-settings/localization/RealmOverrides.tsx diff --git a/js/apps/admin-ui/cypress/e2e/realm_settings_events_test.spec.ts b/js/apps/admin-ui/cypress/e2e/realm_settings_events_test.spec.ts index 25de676ba4..42becc1500 100644 --- a/js/apps/admin-ui/cypress/e2e/realm_settings_events_test.spec.ts +++ b/js/apps/admin-ui/cypress/e2e/realm_settings_events_test.spec.ts @@ -89,12 +89,6 @@ describe("Realm settings events tab tests", () => { return this; }; - const addBundle = () => { - realmSettingsPage.addKeyValuePair("key_" + uuid(), "value_" + uuid()); - - return this; - }; - it("Enable user events", () => { cy.intercept("GET", `/admin/realms/${realmName}/events/config`).as("load"); sidebarPage.goToRealmSettings(); @@ -240,33 +234,31 @@ describe("Realm settings events tab tests", () => { realmSettingsPage.checkKeyPublic(); }); - it("add locale", () => { + it("Realm header settings", () => { sidebarPage.goToRealmSettings(); + cy.findByTestId("rs-security-defenses-tab").click(); + cy.findByTestId("headers-form-tab-save").should("be.disabled"); + cy.get("#xFrameOptions").clear().type("DENY"); + cy.findByTestId("headers-form-tab-save").should("be.enabled").click(); - cy.findByTestId("rs-localization-tab").click(); - cy.findByTestId("internationalization-disabled").click({ force: true }); + masthead.checkNotificationMessage("Realm successfully updated"); + }); - cy.get(realmSettingsPage.supportedLocalesTypeahead) - .click() - .get(".pf-c-select__menu-item") - .contains("Danish") - .click(); - cy.get("#kc-l-supported-locales").click(); + it("Brute force detection", () => { + sidebarPage.goToRealmSettings(); + cy.findAllByTestId("rs-security-defenses-tab").click(); + cy.get("#pf-tab-20-bruteForce").click(); - cy.intercept("GET", `/admin/realms/${realmName}/localization/en*`).as( - "load", - ); + cy.findByTestId("brute-force-tab-save").should("be.disabled"); - cy.findByTestId("localization-tab-save").click(); - cy.wait("@load"); + cy.get("#bruteForceProtected").click({ force: true }); + cy.findByTestId("waitIncrementSeconds").type("1"); + cy.findByTestId("maxFailureWaitSeconds").type("1"); + cy.findByTestId("maxDeltaTimeSeconds").type("1"); + cy.findByTestId("minimumQuickLoginWaitSeconds").type("1"); - addBundle(); - - masthead.checkNotificationMessage( - "Success! The message bundle has been added.", - ); - realmSettingsPage.setDefaultLocale("Danish"); - cy.findByTestId("localization-tab-save").click(); + cy.findByTestId("brute-force-tab-save").should("be.enabled").click(); + masthead.checkNotificationMessage("Realm successfully updated"); }); it("add session data", () => { diff --git a/js/apps/admin-ui/cypress/e2e/realm_settings_tabs_test.spec.ts b/js/apps/admin-ui/cypress/e2e/realm_settings_tabs_test.spec.ts index 7f1e125bd9..25ad351f37 100644 --- a/js/apps/admin-ui/cypress/e2e/realm_settings_tabs_test.spec.ts +++ b/js/apps/admin-ui/cypress/e2e/realm_settings_tabs_test.spec.ts @@ -34,6 +34,12 @@ describe("Realm settings tabs tests", () => { await adminClient.deleteRealm(realmName); }); + const addBundle = () => { + realmSettingsPage.addKeyValuePair("123", "abc"); + + return this; + }; + it("shows the 'user profile' tab if enabled", () => { sidebarPage.goToRealmSettings(); cy.findByTestId(realmSettingsPage.userProfileTab).should("not.exist"); @@ -167,6 +173,109 @@ describe("Realm settings tabs tests", () => { }); }); + describe("Go to localization tab", () => { + it("Locales tab - Add locale", () => { + sidebarPage.goToRealmSettings(); + realmSettingsPage.goToLocalizationTab(); + realmSettingsPage.goToLocalizationLocalesSubTab(); + + cy.findByTestId("internationalization-disabled").click({ force: true }); + + cy.get(realmSettingsPage.supportedLocalesTypeahead) + .click() + .get(".pf-c-select__menu-item") + .contains("Danish") + .click(); + cy.get("#kc-l-supported-locales").click(); + + cy.intercept("GET", `/admin/realms/${realmName}/localization/en*`).as( + "load", + ); + + cy.findByTestId("localization-tab-save").click(); + cy.wait("@load"); + + masthead.checkNotificationMessage("Realm successfully updated"); + }); + + it("Realm Overrides - Add and delete bundle", () => { + sidebarPage.goToRealmSettings(); + realmSettingsPage.goToLocalizationTab(); + realmSettingsPage.goToLocalizationRealmOverridesSubTab(); + + addBundle(); + + masthead.checkNotificationMessage( + "Success! The message bundle has been added.", + ); + + cy.findByTestId("editable-rows-table") + .contains("td", "123") + .should("be.visible"); + + cy.findByTestId("realmOverrides-deleteKebabToggle").click(); + cy.findByTestId("confirm").click(); + masthead.checkNotificationMessage( + "Successfully removed message(s) from the bundle.", + ); + }); + + it("Realm Overrides - Search for and delete bundle", () => { + sidebarPage.goToRealmSettings(); + realmSettingsPage.goToLocalizationTab(); + realmSettingsPage.goToLocalizationRealmOverridesSubTab(); + + addBundle(); + + cy.get('input[aria-label="Search"]').type("123"); + + cy.findByTestId("editable-rows-table") + .contains("td", "123") + .should("be.visible"); + + cy.findByTestId("selectAll").click(); + cy.get('[data-testid="toolbar-deleteBtn"] button').click(); + cy.findByTestId("delete-selected-bundleBtn").click(); + cy.findByTestId("confirm").click(); + masthead.checkNotificationMessage( + "Successfully removed message(s) from the bundle.", + ); + }); + + it("Realm Overrides - Edit and cancel edit message bundle", () => { + sidebarPage.goToRealmSettings(); + realmSettingsPage.goToLocalizationTab(); + realmSettingsPage.goToLocalizationRealmOverridesSubTab(); + + addBundle(); + + cy.findByTestId("editUserLabelBtn-0").click(); + cy.findByTestId("editUserLabelCancelBtn-0").click(); + + cy.findByTestId("editUserLabelBtn-0").click(); + cy.findByTestId("editUserLabelInput-0").click().clear().type("def"); + cy.findByTestId("editUserLabelAcceptBtn-0").click(); + + cy.findByTestId("editable-rows-table") + .contains("td", "def") + .should("be.visible"); + + cy.findByTestId("realmOverrides-deleteKebabToggle").click(); + cy.findByTestId("confirm").click(); + + masthead.checkNotificationMessage( + "Successfully removed message(s) from the bundle.", + ); + }); + + it("Effective Message Bundles - Check before search message", () => { + sidebarPage.goToRealmSettings(); + realmSettingsPage.goToLocalizationTab(); + realmSettingsPage.goToLocalizationEffectiveMessageBundlesSubTab(); + cy.contains("h1", "Search for effective messages"); + }); + }); + describe("Accessibility tests for realm settings", () => { beforeEach(() => { loginPage.logIn(); @@ -226,18 +335,32 @@ describe("Realm settings tabs tests", () => { cy.checkA11y(); }); - it("Check a11y violations on localization tab", () => { + it("Check a11y violations on localization locales sub tab", () => { realmSettingsPage.goToLocalizationTab(); + realmSettingsPage.goToLocalizationLocalesSubTab(); cy.checkA11y(); }); - it("Check a11y violations on localization tab/ adding message bundle", () => { + it("Check a11y violations on localization realm overrides sub tab", () => { realmSettingsPage.goToLocalizationTab(); + realmSettingsPage.goToLocalizationRealmOverridesSubTab(); + cy.checkA11y(); + }); + + it("Check a11y violations on localization realm overrides sub tab/ adding message bundle", () => { + realmSettingsPage.goToLocalizationTab(); + realmSettingsPage.goToLocalizationRealmOverridesSubTab(); cy.findByTestId("add-bundle-button").click(); cy.checkA11y(); modalUtils.cancelModal(); }); + it("Check a11y violations on localization effective message bundles sub tab", () => { + realmSettingsPage.goToLocalizationTab(); + realmSettingsPage.goToLocalizationEffectiveMessageBundlesSubTab(); + cy.checkA11y(); + }); + it("Check a11y violations on security defenses tab", () => { realmSettingsPage.goToSecurityDefensesTab(); cy.checkA11y(); diff --git a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/realm_settings/RealmSettingsPage.ts b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/realm_settings/RealmSettingsPage.ts index c5e7e52574..12185cd86a 100644 --- a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/realm_settings/RealmSettingsPage.ts +++ b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/realm_settings/RealmSettingsPage.ts @@ -238,6 +238,10 @@ export default class RealmSettingsPage extends CommonPage { #port = "#kc-port"; #publicKeyBtn = ".kc-keys-list > tbody > tr > td > .button-wrapper > button"; + #localizationLocalesSubTab = "rs-localization-locales-tab"; + #localizationRealmOverridesSubTab = "rs-localization-realm-overrides-tab"; + #localizationEffectiveMessageBundlesSubTab = + "rs-localization-effective-message-bundles-tab"; #realmSettingsEventsTab = new RealmSettingsEventsTab(); #realmId = 'input[aria-label="Copyable input"]'; #securityDefensesHeadersSaveBtn = "headers-form-tab-save"; @@ -456,7 +460,7 @@ export default class RealmSettingsPage extends CommonPage { cy.findByTestId(this.keyInput).type(key); cy.findByTestId(this.valueInput).type(value); - cy.findByTestId(this.confirmAddBundle).click(); + cy.findByTestId(this.confirmAddBundle).click({ force: true }); return this; } @@ -1260,6 +1264,21 @@ export default class RealmSettingsPage extends CommonPage { return this; } + goToLocalizationLocalesSubTab() { + cy.findByTestId(this.#localizationLocalesSubTab).click(); + return this; + } + + goToLocalizationRealmOverridesSubTab() { + cy.findByTestId(this.#localizationRealmOverridesSubTab).click(); + return this; + } + + goToLocalizationEffectiveMessageBundlesSubTab() { + cy.findByTestId(this.#localizationEffectiveMessageBundlesSubTab).click(); + return this; + } + goToSecurityDefensesTab() { cy.findByTestId(this.securityDefensesTab).click(); return this; diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index b21ee24694..5d9af57f06 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -2404,7 +2404,7 @@ id=ID join=Join clientUpdaterSourceGroupsHelp=The condition checks the group of the entity who tries to create/update the client to determine whether the policy is applied. idTokenEncryptionContentEncryptionAlgorithmHelp=JWA Algorithm used for content encryption in encrypting ID tokens. This option is needed just if you want encrypted ID tokens. If left empty, ID Tokens are just signed, but not encrypted. -messageBundleDescription=You can edit the supported locales. If you haven't selected supported locales yet, you can only edit the English locale. +messageBundleDescription=You can only edit the supported locales. If you haven't selected supported locales yet, you can only edit the English locale. saveEventListenersError=Error saving event listener\: {{error}} scopesHelp=The scopes to be sent when asking for authorization. It can be a space-separated list of scopes. Defaults to 'openid'. multivalued.tooltip=Indicates if attribute supports multiple values. If true, the list of all values of this attribute will be set as claim. If false, just first value will be set as claim @@ -2941,6 +2941,28 @@ invalidEmailMessage='{{0}}': Invalid email address. missingLastNameMessage='{{0}}': Please specify last name. missingEmailMessage='{{0}}': Please specify email. missingPasswordMessage='{{0}}': Please specify password. +locales=Locales +realmOverrides=Realm overrides +realmOverridesHelp=You can only edit the supported locales. If you haven't selected supported locale yet, you only can edit English locale. +effectiveMessageBundles=Effective message bundles +effectiveMessageBundlesHelp=You can search for effective message bundles based on themes, features, language, and free text. +deleteMessageBundle=Delete message bundle {{key}} +deleteAllMessagesBundleSuccess=Successfully removed message(s) from the bundle. +deleteAllMessagesBundleError=Error removing message(s) from the bundle, {{error}} +noRealmOverridesSearchResultsInstructions=Click on the search bar above to search for realm overrides +emptyEffectiveMessageBundles=Search for effective messages +emptyEffectiveMessageBundlesInstructions=You can search for the effective messages you want by theme, feature and language in the search box above. +searchForEffectiveMessageBundles=Search for message bundle +selectAll=Select all +theme=Theme +themeType=Theme Type +language=Language +hasWords=Has words +deleteConfirmMessageBundle=Delete message bundle? +messageBundleDeleteConfirmDialog=Are you sure you want to permanently delete {{count}} selected message bundles +selectTheme=Select theme +selectThemeType=Select theme type +selectLanguage=Select language referral=Referral referralHelp=Specifies if LDAP referrals should be followed or ignored. Please note that enabling referrals can slow down authentication as it allows the LDAP server to decide which other LDAP servers to use. This could potentially include untrusted servers. authenticatorRefConfig.value.help=Add a custom reference name for the authenticator. When this authenticator is successfully completed during an authentication flow, the Authentication Method Reference (AMR) protocol mapper will use this value to populate the amr claim of the generated tokens. Note, the AMR protocol must be configured for the given client to populate the AMR claim diff --git a/js/apps/admin-ui/src/realm-settings/LocalizationTab.tsx b/js/apps/admin-ui/src/realm-settings/LocalizationTab.tsx index 39ba660e55..5229ce5925 100644 --- a/js/apps/admin-ui/src/realm-settings/LocalizationTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/LocalizationTab.tsx @@ -45,7 +45,7 @@ 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/i18n"; -import { convertToFormValues } from "../util"; +import { convertToFormValues, localeToDisplayName } from "../util"; import { useFetch } from "../utils/useFetch"; import useLocaleSort, { mapByKey } from "../utils/useLocaleSort"; import { AddMessageBundleModal } from "./AddMessageBundleModal"; @@ -74,16 +74,6 @@ export type BundleForm = { messageBundle: KeyValueType; }; -const localeToDisplayName = (locale: string, displayLocale: string) => { - try { - return new Intl.DisplayNames([displayLocale], { type: "language" }).of( - locale, - ); - } catch (error) { - return locale; - } -}; - export const LocalizationTab = ({ save, realm }: LocalizationTabProps) => { const { t } = useTranslation(); const [addMessageBundleModalOpen, setAddMessageBundleModalOpen] = diff --git a/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx b/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx index 43dbc0bcaf..764c52d098 100644 --- a/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx +++ b/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx @@ -33,7 +33,7 @@ import { convertFormValuesToObject, convertToFormValues } from "../util"; import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled"; import { RealmSettingsEmailTab } from "./EmailTab"; import { RealmSettingsGeneralTab } from "./GeneralTab"; -import { LocalizationTab } from "./LocalizationTab"; +import { LocalizationTab } from "./localization/LocalizationTab"; import { RealmSettingsLoginTab } from "./LoginTab"; import { PartialExportDialog } from "./PartialExport"; import { PartialImportDialog } from "./PartialImport"; @@ -340,12 +340,7 @@ export const RealmSettingsTabs = ({ data-testid="rs-localization-tab" {...localizationTab} > - + {t("securityDefences")}} diff --git a/js/apps/admin-ui/src/realm-settings/localization/EffectiveMessageBundles.tsx b/js/apps/admin-ui/src/realm-settings/localization/EffectiveMessageBundles.tsx new file mode 100644 index 0000000000..ba93f56c69 --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/localization/EffectiveMessageBundles.tsx @@ -0,0 +1,505 @@ +import { + ActionGroup, + Button, + Chip, + ChipGroup, + Divider, + Dropdown, + DropdownToggle, + Flex, + FlexItem, + Form, + FormGroup, + Select, + SelectOption, + SelectVariant, +} from "@patternfly/react-core"; +import { pickBy } from "lodash-es"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { adminClient } from "../../admin-client"; +import { KeycloakTextInput } from "../../components/keycloak-text-input/KeycloakTextInput"; +import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState"; +import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable"; +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/i18n"; +import { localeToDisplayName } from "../../util"; + +type EffectiveMessageBundlesProps = { + defaultSupportedLocales: string[]; +}; + +type EffectiveMessageBundlesSearchForm = { + theme: string; + themeType: string; + locale: string; + hasWords: string[]; +}; + +const defaultValues: EffectiveMessageBundlesSearchForm = { + theme: "", + themeType: "", + locale: "", + hasWords: [], +}; + +export const EffectiveMessageBundles = ({ + defaultSupportedLocales, +}: EffectiveMessageBundlesProps) => { + const { t } = useTranslation(); + const { realm } = useRealm(); + const serverInfo = useServerInfo(); + const { whoAmI } = useWhoAmI(); + const [searchDropdownOpen, setSearchDropdownOpen] = useState(false); + const [searchPerformed, setSearchPerformed] = useState(false); + const [selectThemesOpen, setSelectThemesOpen] = useState(false); + const [selectThemeTypeOpen, setSelectThemeTypeOpen] = useState(false); + const [selectLanguageOpen, setSelectLanguageOpen] = useState(false); + const [activeFilters, setActiveFilters] = useState< + Partial + >({}); + const themes = serverInfo.themes; + const themeKeys = themes + ? Object.keys(themes).sort((a, b) => a.localeCompare(b)) + : []; + const themeTypes = Object.values(themes!) + .flatMap((theme) => theme.map((item) => item.name)) + .filter((value, index, self) => self.indexOf(value) === index) + .sort((a, b) => a.localeCompare(b)); + const [key, setKey] = useState(0); + + const filterLabels: Record = + { + theme: t("theme"), + themeType: t("themeType"), + locale: t("language"), + hasWords: t("hasWords"), + }; + + const { + getValues, + reset, + formState: { isDirty }, + control, + } = useForm({ + mode: "onChange", + defaultValues, + }); + + const loader = async () => { + try { + const filter = getValues(); + const messages = await adminClient.serverInfo.findEffectiveMessageBundles( + { + realm, + ...filter, + locale: filter.locale || DEFAULT_LOCALE, + source: true, + }, + ); + + return filter.hasWords.length > 0 + ? messages.filter((m) => + filter.hasWords.some((f) => m.value.includes(f)), + ) + : messages; + } catch (error) { + return []; + } + }; + + function submitSearch() { + setSearchDropdownOpen(false); + commitFilters(); + } + + function resetSearch() { + reset(); + commitFilters(); + } + + function removeFilter(key: keyof EffectiveMessageBundlesSearchForm) { + const formValues: EffectiveMessageBundlesSearchForm = { ...getValues() }; + delete formValues[key]; + + reset({ ...defaultValues, ...formValues }); + commitFilters(); + } + + function removeFilterValue( + key: keyof EffectiveMessageBundlesSearchForm, + valueToRemove: string, + ) { + const formValues = getValues(); + const fieldValue = formValues[key]; + const newFieldValue = Array.isArray(fieldValue) + ? fieldValue.filter((val) => val !== valueToRemove) + : fieldValue; + + reset({ ...formValues, [key]: newFieldValue }); + commitFilters(); + } + + function commitFilters() { + const newFilters: Partial = pickBy( + getValues(), + (value) => value !== "" || (Array.isArray(value) && value.length > 0), + ); + + setActiveFilters(newFilters); + setKey(key + 1); + } + + const effectiveMessageBunldesSearchFormDisplay = () => { + return ( + + + setSearchDropdownOpen(isOpen)} + > + {t("searchForEffectiveMessageBundles")} + + } + isOpen={searchDropdownOpen} + > +
+ + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + ( +
+ { + const target = e.target as HTMLInputElement; + const words = target.value + .split(" ") + .map((word) => word.trim()); + field.onChange(words); + }} + /> + + {field.value.map((word, index) => ( + { + e.stopPropagation(); + const newWords = field.value.filter( + (_, i) => i !== index, + ); + field.onChange(newWords); + }} + > + {word} + + ))} + +
+ )} + /> +
+ + + + +
+
+
+ + {Object.entries(activeFilters).length > 0 && ( +
+ {Object.entries(activeFilters).map((filter) => { + const [key, value] = filter as [ + keyof EffectiveMessageBundlesSearchForm, + string | string[], + ]; + + return ( + removeFilter(key)} + > + {typeof value === "string" ? ( + + {key === "locale" + ? localeToDisplayName(value, whoAmI.getLocale()) + : value} + + ) : ( + value.map((entry) => ( + removeFilterValue(key, entry)} + > + {entry} + + )) + )} + + ); + })} +
+ )} +
+
+ ); + }; + + if (!searchPerformed) { + return ( + <> +
+ {effectiveMessageBunldesSearchFormDisplay()} +
+ + + + ); + } + + return ( + + } + isSearching={Object.keys(activeFilters).length > 0} + /> + ); +}; diff --git a/js/apps/admin-ui/src/realm-settings/localization/LocalizationTab.tsx b/js/apps/admin-ui/src/realm-settings/localization/LocalizationTab.tsx new file mode 100644 index 0000000000..58a8c1b258 --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/localization/LocalizationTab.tsx @@ -0,0 +1,275 @@ +import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; +import { + ActionGroup, + Button, + FormGroup, + Select, + SelectOption, + SelectVariant, + Switch, + Tab, + TabTitleText, + Tabs, +} from "@patternfly/react-core"; +import { useEffect, useMemo, useState } from "react"; +import { Controller, useForm, useWatch } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { HelpItem } from "ui-shared"; +import { FormAccess } from "../../components/form/FormAccess"; +import { useServerInfo } from "../../context/server-info/ServerInfoProvider"; +import { useWhoAmI } from "../../context/whoami/WhoAmI"; +import { DEFAULT_LOCALE } from "../../i18n/i18n"; +import { convertToFormValues, localeToDisplayName } from "../../util"; +import { EffectiveMessageBundles } from "./EffectiveMessageBundles"; +import { RealmOverrides } from "./RealmOverrides"; + +type LocalizationTabProps = { + save: (realm: RealmRepresentation) => void; + realm: RealmRepresentation; +}; + +export const LocalizationTab = ({ save, realm }: LocalizationTabProps) => { + const { t } = useTranslation(); + const { whoAmI } = useWhoAmI(); + + const [activeTab, setActiveTab] = useState(0); + const { setValue, control, handleSubmit, formState } = useForm(); + const [supportedLocalesOpen, setSupportedLocalesOpen] = useState(false); + const [defaultLocaleOpen, setDefaultLocaleOpen] = useState(false); + + const defaultSupportedLocales = realm.supportedLocales?.length + ? realm.supportedLocales + : [DEFAULT_LOCALE]; + + const themeTypes = useServerInfo().themes!; + const allLocales = useMemo(() => { + const locales = Object.values(themeTypes).flatMap((theme) => + theme.flatMap(({ locales }) => (locales ? locales : [])), + ); + return Array.from(new Set(locales)); + }, [themeTypes]); + + const setupForm = () => { + convertToFormValues(realm, setValue); + setValue("supportedLocales", defaultSupportedLocales); + }; + + useEffect(setupForm, []); + + const watchSupportedLocales: string[] = useWatch({ + control, + name: "supportedLocales", + defaultValue: defaultSupportedLocales, + }); + + const internationalizationEnabled = useWatch({ + control, + name: "internationalizationEnabled", + defaultValue: realm.internationalizationEnabled, + }); + + return ( + setActiveTab(key as number)} + > + {t("locales")}} + data-testid="rs-localization-locales-tab" + > + + + } + > + ( + + )} + /> + + {internationalizationEnabled && ( + <> + + ( + + )} + /> + + + ( + + )} + /> + + + )} + + + + + + + + {t("realmOverrides")}{" "} + + + } + data-testid="rs-localization-realm-overrides-tab" + > + + + + {t("effectiveMessageBundles")} + + + } + data-testid="rs-localization-effective-message-bundles-tab" + > + + + + ); +}; diff --git a/js/apps/admin-ui/src/realm-settings/localization/RealmOverrides.tsx b/js/apps/admin-ui/src/realm-settings/localization/RealmOverrides.tsx new file mode 100644 index 0000000000..2796901e62 --- /dev/null +++ b/js/apps/admin-ui/src/realm-settings/localization/RealmOverrides.tsx @@ -0,0 +1,571 @@ +import { + AlertVariant, + Button, + ButtonVariant, + Divider, + Dropdown, + DropdownItem, + Form, + FormGroup, + KebabToggle, + Select, + SelectGroup, + SelectOption, + SelectVariant, + ToolbarItem, +} from "@patternfly/react-core"; +import { + CheckIcon, + PencilAltIcon, + SearchIcon, + TimesIcon, +} from "@patternfly/react-icons"; +import { + IRow, + IRowCell, + Table, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; +import RealmRepresentation from "libs/keycloak-admin-client/lib/defs/realmRepresentation"; +import { cloneDeep, isEqual, uniqWith } from "lodash-es"; +import { ChangeEvent, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { adminClient } from "../../admin-client"; +import { useAlerts } from "../../components/alert/Alerts"; +import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog"; +import { KeyValueType } from "../../components/key-value-form/key-value-convert"; +import { KeycloakTextInput } from "../../components/keycloak-text-input/KeycloakTextInput"; +import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState"; +import { PaginatingTableToolbar } from "../../components/table-toolbar/PaginatingTableToolbar"; +import { useRealm } from "../../context/realm-context/RealmContext"; +import { useWhoAmI } from "../../context/whoami/WhoAmI"; +import { DEFAULT_LOCALE } from "../../i18n/i18n"; +import { localeToDisplayName } from "../../util"; +import { AddMessageBundleModal } from "../AddMessageBundleModal"; + +type RealmOverridesProps = { + internationalizationEnabled: boolean; + watchSupportedLocales: string[]; + realm: RealmRepresentation; +}; + +type EditStatesType = { [key: number]: boolean }; + +export type BundleForm = { + key: string; + value: string; + messageBundle: KeyValueType; +}; + +export enum RowEditAction { + Save = "save", + Cancel = "cancel", + Edit = "edit", + Delete = "delete", +} + +export const RealmOverrides = ({ + internationalizationEnabled, + watchSupportedLocales, + realm, +}: RealmOverridesProps) => { + const { t } = useTranslation(); + const [addMessageBundleModalOpen, setAddMessageBundleModalOpen] = + useState(false); + const [filterDropdownOpen, setFilterDropdownOpen] = useState(false); + const [messageBundles, setMessageBundles] = useState<[string, string][]>([]); + const [selectMenuLocale, setSelectMenuLocale] = useState(DEFAULT_LOCALE); + const [kebabOpen, setKebabOpen] = useState(false); + const { getValues, handleSubmit } = useForm(); + const [selectMenuValueSelected, setSelectMenuValueSelected] = useState(false); + const [tableRows, setTableRows] = useState([]); + const [tableKey, setTableKey] = useState(0); + const [max, setMax] = useState(10); + const [first, setFirst] = useState(0); + const [filter, setFilter] = useState(""); + const bundleForm = useForm({ mode: "onChange" }); + const { addAlert, addError } = useAlerts(); + const { realm: currentRealm } = useRealm(); + const { whoAmI } = useWhoAmI(); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [areAllRowsSelected, setAreAllRowsSelected] = useState(false); + const [editStates, setEditStates] = useState({}); + const [formValue, setFormValue] = useState(""); + const refreshTable = () => { + setTableKey(tableKey + 1); + }; + + useEffect(() => { + const fetchLocalizationTexts = async () => { + try { + let result = await adminClient.realms.getRealmLocalizationTexts({ + first, + max, + realm: realm.realm!, + selectedLocale: + selectMenuLocale || + getValues("defaultLocale") || + whoAmI.getLocale(), + }); + + if (filter) { + const searchInBundles = (idx: number) => { + return Object.entries(result).filter((i) => + i[idx].includes(filter), + ); + }; + + const filtered = uniqWith( + searchInBundles(0).concat(searchInBundles(1)), + isEqual, + ); + + result = Object.fromEntries(filtered); + } + + return Object.entries(result).slice(first, first + max); + } catch (error) { + return []; + } + }; + + fetchLocalizationTexts().then((bundles) => { + setMessageBundles(bundles); + + const updatedRows: IRow[] = bundles.map( + (messageBundle): IRow => ({ + rowEditBtnAriaLabel: () => + t("rowEditBtnAriaLabel", { + messageBundle: messageBundle[1], + }), + rowSaveBtnAriaLabel: () => + t("rowSaveBtnAriaLabel", { + messageBundle: messageBundle[1], + }), + rowCancelBtnAriaLabel: () => + t("rowCancelBtnAriaLabel", { + messageBundle: messageBundle[1], + }), + cells: [ + { + title: messageBundle[0], + props: { + value: messageBundle[0], + }, + }, + { + title: messageBundle[1], + props: { + value: messageBundle[1], + }, + }, + ], + }), + ); + + setTableRows(updatedRows); + }); + }, [tableKey, first, max, filter]); + + const handleModalToggle = () => { + setAddMessageBundleModalOpen(!addMessageBundleModalOpen); + }; + + const options = [ + + + {localeToDisplayName(DEFAULT_LOCALE, whoAmI.getDisplayName())} + + , + , + + {watchSupportedLocales.map((locale) => ( + + {localeToDisplayName(locale, whoAmI.getLocale())} + + ))} + , + ]; + + const addKeyValue = async (pair: KeyValueType): Promise => { + try { + await adminClient.realms.addLocalization( + { + realm: currentRealm!, + selectedLocale: + selectMenuLocale || getValues("defaultLocale") || DEFAULT_LOCALE, + key: pair.key, + }, + pair.value, + ); + + adminClient.setConfig({ + realmName: currentRealm!, + }); + refreshTable(); + bundleForm.setValue("key", ""); + bundleForm.setValue("value", ""); + addAlert(t("addMessageBundleSuccess"), AlertVariant.success); + } catch (error) { + addError(t("addMessageBundleError"), error); + } + }; + + const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({ + titleKey: "deleteConfirmMessageBundle", + messageKey: t("messageBundleDeleteConfirmDialog", { + count: selectedRowKeys.length, + }), + continueButtonLabel: "delete", + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + try { + for (const key of selectedRowKeys) { + await adminClient.realms.deleteRealmLocalizationTexts({ + realm: currentRealm!, + selectedLocale: selectMenuLocale, + key: key, + }); + } + setAreAllRowsSelected(false); + setSelectedRowKeys([]); + refreshTable(); + addAlert(t("deleteAllMessagesBundleSuccess"), AlertVariant.success); + } catch (error) { + addError("deleteAllMessagesBundleError", error); + } + }, + }); + + const handleRowSelect = ( + event: ChangeEvent, + rowIndex: number, + ) => { + const selectedKey = (tableRows[rowIndex].cells?.[0] as IRowCell).props + .value; + if (event.target.checked) { + setSelectedRowKeys((prevSelected) => [...prevSelected, selectedKey]); + } else { + setSelectedRowKeys((prevSelected) => + prevSelected.filter((key) => key !== selectedKey), + ); + } + + setAreAllRowsSelected( + tableRows.length === + selectedRowKeys.length + (event.target.checked ? 1 : -1), + ); + }; + + const toggleSelectAllRows = () => { + if (areAllRowsSelected) { + setSelectedRowKeys([]); + } else { + setSelectedRowKeys( + tableRows.map((row) => (row.cells?.[0] as IRowCell).props.value), + ); + } + setAreAllRowsSelected(!areAllRowsSelected); + }; + + const isRowSelected = (key: any) => { + return selectedRowKeys.includes(key); + }; + + const onSubmit = async (inputValue: string, rowIndex: number) => { + const newRows = cloneDeep(tableRows); + + const newRow = cloneDeep(newRows[rowIndex]); + (newRow.cells?.[1] as IRowCell).props.value = inputValue; + newRows[rowIndex] = newRow; + + try { + const key = (newRow.cells?.[0] as IRowCell).props.value; + const value = (newRow.cells?.[1] as IRowCell).props.value; + + await adminClient.realms.addLocalization( + { + realm: realm.realm!, + selectedLocale: + selectMenuLocale || getValues("defaultLocale") || DEFAULT_LOCALE, + key, + }, + value, + ); + + addAlert(t("updateMessageBundleSuccess"), AlertVariant.success); + setTableRows(newRows); + } catch (error) { + addAlert(t("updateMessageBundleError"), AlertVariant.danger); + } + + setEditStates((prevEditStates) => ({ + ...prevEditStates, + [rowIndex]: false, + })); + }; + + return ( + <> + + {addMessageBundleModalOpen && ( + { + addKeyValue(pair); + handleModalToggle(); + }} + form={bundleForm} + /> + )} + { + setFirst(first); + setMax(max); + }} + inputGroupName={"search"} + inputGroupOnEnter={(search) => { + setFilter(search); + setFirst(0); + setMax(10); + }} + inputGroupPlaceholder={t("searchForMessageBundle")} + toolbarItem={ + <> + + + setKebabOpen(!kebabOpen)} /> + } + isOpen={kebabOpen} + isPlain + data-testid="toolbar-deleteBtn" + dropdownItems={[ + { + toggleDeleteDialog(); + setKebabOpen(false); + }} + > + {t("delete")} + , + ]} + /> + + + } + searchTypeComponent={ + + + + } + > + {messageBundles.length === 0 && !filter && ( + + )} + {messageBundles.length === 0 && filter && ( + + )} + {messageBundles.length !== 0 && ( + + + + + + + + + + {tableRows.map((row, rowIndex) => ( + + + + + + ))} + +
+ + {t("key")}{t("value")}
+ handleRowSelect( + event as ChangeEvent, + rowIndex, + ), + isSelected: isRowSelected( + (row.cells?.[0] as IRowCell).props.value, + ), + }} + /> + + {(row.cells?.[0] as IRowCell).props.value} + +
{ + onSubmit(formValue, rowIndex); + })} + > + + {editStates[rowIndex] ? ( + <> + , + ) => { + setFormValue(event.target.value); + }} + key={`edit-input-${rowIndex}`} + /> + + + )} + +
+
+ + } + onClick={() => { + setSelectedRowKeys([ + (row.cells?.[0] as IRowCell).props.value, + ]); + toggleDeleteDialog(); + setKebabOpen(false); + }} + /> +
+ )} +
+ + ); +}; diff --git a/js/apps/admin-ui/src/util.ts b/js/apps/admin-ui/src/util.ts index 8defa9741f..64611b04fe 100644 --- a/js/apps/admin-ui/src/util.ts +++ b/js/apps/admin-ui/src/util.ts @@ -169,3 +169,13 @@ export const addTrailingSlash = (url: string) => url.endsWith("/") ? url : url + "/"; export const generateId = () => Math.floor(Math.random() * 1000); + +export const localeToDisplayName = (locale: string, displayLocale: string) => { + try { + return new Intl.DisplayNames([displayLocale], { type: "language" }).of( + locale, + ); + } catch (error) { + return locale; + } +}; diff --git a/js/libs/keycloak-admin-client/src/defs/effectiveMessageBundleRepresentation.ts b/js/libs/keycloak-admin-client/src/defs/effectiveMessageBundleRepresentation.ts index 7e3172389b..296cb84409 100644 --- a/js/libs/keycloak-admin-client/src/defs/effectiveMessageBundleRepresentation.ts +++ b/js/libs/keycloak-admin-client/src/defs/effectiveMessageBundleRepresentation.ts @@ -1,6 +1,5 @@ export default interface EffectiveMessageBundleRepresentation { - theme?: string; - themeType?: string; - locale?: string; - source?: boolean; + key: string; + value: string; + source: "THEME" | "REALM"; } diff --git a/js/libs/keycloak-admin-client/src/resources/serverInfo.ts b/js/libs/keycloak-admin-client/src/resources/serverInfo.ts index cbc4dee4cc..7b6752ff2a 100644 --- a/js/libs/keycloak-admin-client/src/resources/serverInfo.ts +++ b/js/libs/keycloak-admin-client/src/resources/serverInfo.ts @@ -3,6 +3,14 @@ import type { ServerInfoRepresentation } from "../defs/serverInfoRepesentation.j import type KeycloakAdminClient from "../index.js"; import type EffectiveMessageBundleRepresentation from "../defs/effectiveMessageBundleRepresentation.js"; +export interface MessageBundleQuery { + realm: string; + theme?: string; + themeType?: string; + locale?: string; + source?: boolean; +} + export class ServerInfo extends Resource { constructor(client: KeycloakAdminClient) { super(client, { @@ -17,18 +25,12 @@ export class ServerInfo extends Resource { }); public findEffectiveMessageBundles = this.makeRequest< - { - realm: string; - theme?: string; - themeType?: string; - locale?: string; - source?: boolean; - }, + MessageBundleQuery, EffectiveMessageBundleRepresentation[] >({ method: "GET", - path: "/resources/{realm}/{themeType}/{locale}", - urlParamKeys: ["realm", "themeType", "locale"], - queryParamKeys: ["theme", "source"], + path: "/resources/{realm}/{theme}/{locale}", + urlParamKeys: ["realm", "theme", "locale"], + queryParamKeys: ["themeType", "source"], }); } diff --git a/services/src/main/java/org/keycloak/services/resources/ThemeResource.java b/services/src/main/java/org/keycloak/services/resources/ThemeResource.java index d06407c1ec..39bbb96995 100644 --- a/services/src/main/java/org/keycloak/services/resources/ThemeResource.java +++ b/services/src/main/java/org/keycloak/services/resources/ThemeResource.java @@ -16,6 +16,10 @@ */ package org.keycloak.services.resources; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.OPTIONS; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; @@ -30,9 +34,6 @@ import org.keycloak.services.util.CacheControlUtil; import org.keycloak.services.util.LocaleUtil; import org.keycloak.theme.Theme; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Response; @@ -102,6 +103,12 @@ public class ThemeResource { } } + @Path("/{realm}/{themeType}/{locale}") + @OPTIONS + public Response localizationTextPreflight() { + return Cors.add(session.getContext().getHttpRequest(), Response.ok()).auth().preflight().build(); + } + @GET @Path("/{realm}/{themeType}/{locale}") @Produces(MediaType.APPLICATION_JSON) @@ -109,6 +116,9 @@ public class ThemeResource { @PathParam("locale") String localeString, @PathParam("themeType") String themeType, @QueryParam("source") boolean showSource) throws IOException { final RealmModel realm = session.realms().getRealmByName(realmName); + if (realm == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } session.getContext().setRealm(realm); List result;