Localization tabs (#25532)

* Add new localization tabs to Administration Console

Closes #23057

Signed-off-by: Agnieszka <agancarc@redhat.com>
Signed-off-by: Jon Koops <jonkoops@gmail.com>

* css cleanup

Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>

* css cleanup

Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>

---------

Signed-off-by: Agnieszka <agancarc@redhat.com>
Signed-off-by: Jon Koops <jonkoops@gmail.com>
Signed-off-by: Agnieszka Gancarczyk <agancarc@redhat.com>
Co-authored-by: Jon Koops <jonkoops@gmail.com>
Co-authored-by: Agnieszka Gancarczyk <agancarc@redhat.com>
This commit is contained in:
agagancarczyk 2024-01-08 14:03:26 +00:00 committed by GitHub
parent ddcaa6dcbf
commit 768231d950
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 1579 additions and 66 deletions

View file

@ -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", () => {

View file

@ -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();

View file

@ -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;

View file

@ -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

View file

@ -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] =

View file

@ -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}
>
<LocalizationTab
key={key}
refresh={refresh}
save={save}
realm={realm}
/>
<LocalizationTab key={key} save={save} realm={realm} />
</Tab>
<Tab
title={<TabTitleText>{t("securityDefences")}</TabTitleText>}

View file

@ -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<EffectiveMessageBundlesSearchForm>
>({});
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<keyof EffectiveMessageBundlesSearchForm, string> =
{
theme: t("theme"),
themeType: t("themeType"),
locale: t("language"),
hasWords: t("hasWords"),
};
const {
getValues,
reset,
formState: { isDirty },
control,
} = useForm<EffectiveMessageBundlesSearchForm>({
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<EffectiveMessageBundlesSearchForm> = pickBy(
getValues(),
(value) => value !== "" || (Array.isArray(value) && value.length > 0),
);
setActiveFilters(newFilters);
setKey(key + 1);
}
const effectiveMessageBunldesSearchFormDisplay = () => {
return (
<Flex
direction={{ default: "column" }}
spaceItems={{ default: "spaceItemsNone" }}
>
<FlexItem>
<Dropdown
id="effective-message-bundles-search-select"
data-testid="EffectiveMessageBundlesSearchSelector"
toggle={
<DropdownToggle
data-testid="effectiveMessageBundlesSearchSelectorToggle"
onToggle={(isOpen) => setSearchDropdownOpen(isOpen)}
>
{t("searchForEffectiveMessageBundles")}
</DropdownToggle>
}
isOpen={searchDropdownOpen}
>
<Form
isHorizontal
className="pf-c-form pf-u-mx-lg pf-u-mb-lg"
data-testid="effectiveMessageBundlesSearchForm"
>
<FormGroup label={t("theme")} fieldId="kc-theme">
<Controller
name="theme"
control={control}
render={({ field }) => (
<Select
name="theme"
data-testid="effective_message_bundles-theme-searchField"
chipGroupProps={{
numChips: 1,
expandedText: t("hide"),
collapsedText: t("showRemaining"),
}}
variant={SelectVariant.single}
typeAheadAriaLabel="Select"
onToggle={(isOpen) => setSelectThemesOpen(isOpen)}
selections={field.value}
onSelect={(_, selectedValue) => {
field.onChange(selectedValue.toString());
}}
onClear={(theme) => {
theme.stopPropagation();
field.onChange("");
}}
isOpen={selectThemesOpen}
aria-labelledby={t("theme")}
chipGroupComponent={
<ChipGroup>
<Chip
key={field.value}
onClick={(theme) => {
theme.stopPropagation();
field.onChange("");
}}
>
{field.value}
</Chip>
</ChipGroup>
}
>
{[
<SelectOption
key="theme_placeholder"
value="Select theme"
label={t("selectTheme")}
className="kc__effective_message_bundles_search_theme__placeholder"
isDisabled
/>,
].concat(
themeKeys.map((option) => (
<SelectOption key={option} value={option} />
)),
)}
</Select>
)}
/>
</FormGroup>
<FormGroup label={t("themeType")} fieldId="kc-themeType">
<Controller
name="themeType"
control={control}
render={({ field }) => (
<Select
name="themeType"
data-testid="effective-message-bundles-feature-searchField"
chipGroupProps={{
numChips: 1,
expandedText: t("hide"),
collapsedText: t("showRemaining"),
}}
variant={SelectVariant.single}
typeAheadAriaLabel="Select"
onToggle={(isOpen) => setSelectThemeTypeOpen(isOpen)}
selections={field.value}
onSelect={(_, selectedValue) => {
field.onChange(selectedValue.toString());
}}
onClear={(themeType) => {
themeType.stopPropagation();
field.onChange("");
}}
isOpen={selectThemeTypeOpen}
aria-labelledby={t("themeType")}
chipGroupComponent={
<ChipGroup>
<Chip
key={field.value}
onClick={(themeType) => {
themeType.stopPropagation();
field.onChange("");
}}
>
{field.value}
</Chip>
</ChipGroup>
}
>
{[
<SelectOption
key="themeType_placeholder"
value="Select theme type"
label={t("selectThemeType")}
className="pf-m-plain"
isDisabled
/>,
].concat(
themeTypes.map((option) => (
<SelectOption key={option} value={option} />
)),
)}
</Select>
)}
/>
</FormGroup>
<FormGroup label={t("language")} fieldId="kc-language">
<Controller
name="locale"
control={control}
render={({ field }) => (
<Select
name="language"
data-testid="effective-message-bundles-language-searchField"
chipGroupProps={{
numChips: 1,
expandedText: t("hide"),
collapsedText: t("showRemaining"),
}}
variant={SelectVariant.single}
typeAheadAriaLabel="Select"
onToggle={(isOpen) => setSelectLanguageOpen(isOpen)}
selections={field.value}
onSelect={(_, selectedValue) => {
field.onChange(selectedValue.toString());
}}
onClear={(language) => {
language.stopPropagation();
field.onChange("");
}}
isOpen={selectLanguageOpen}
aria-labelledby="language"
chipGroupComponent={
<ChipGroup>
{field.value ? (
<Chip
key={field.value}
onClick={(language) => {
language.stopPropagation();
field.onChange("");
}}
>
{localeToDisplayName(
field.value,
whoAmI.getLocale(),
)}
</Chip>
) : null}
</ChipGroup>
}
>
{[
<SelectOption
key="language_placeholder"
value="Select language"
label={t("selectLanguage")}
className="pf-m-plain"
isDisabled
/>,
].concat(
defaultSupportedLocales.map((option) => (
<SelectOption key={option} value={option}>
{localeToDisplayName(option, whoAmI.getLocale())}
</SelectOption>
)),
)}
</Select>
)}
/>
</FormGroup>
<FormGroup label={t("hasWords")} fieldId="kc-hasWords">
<Controller
name="hasWords"
control={control}
render={({ field }) => (
<div>
<KeycloakTextInput
id="kc-hasWords"
data-testid="effective-message-bundles-hasWords-searchField"
value={field.value.join(" ")}
onChange={(e) => {
const target = e.target as HTMLInputElement;
const words = target.value
.split(" ")
.map((word) => word.trim());
field.onChange(words);
}}
/>
<ChipGroup>
{field.value.map((word, index) => (
<Chip
key={index}
onClick={(e) => {
e.stopPropagation();
const newWords = field.value.filter(
(_, i) => i !== index,
);
field.onChange(newWords);
}}
>
{word}
</Chip>
))}
</ChipGroup>
</div>
)}
/>
</FormGroup>
<ActionGroup>
<Button
variant={"primary"}
onClick={() => {
setSearchPerformed(true);
submitSearch();
}}
data-testid="search-effective-message-bundles-btn"
isDisabled={!isDirty}
>
{t("search")}
</Button>
<Button
variant="secondary"
onClick={resetSearch}
data-testid="reset-search-effective-message-bundles-btn"
isDisabled={!isDirty}
>
{t("reset")}
</Button>
</ActionGroup>
</Form>
</Dropdown>
</FlexItem>
<FlexItem>
{Object.entries(activeFilters).length > 0 && (
<div className="keycloak__searchChips pf-u-ml-md">
{Object.entries(activeFilters).map((filter) => {
const [key, value] = filter as [
keyof EffectiveMessageBundlesSearchForm,
string | string[],
];
return (
<ChipGroup
className="pf-u-mt-md pf-u-mr-md"
key={key}
categoryName={filterLabels[key]}
isClosable
onClick={() => removeFilter(key)}
>
{typeof value === "string" ? (
<Chip isReadOnly>
{key === "locale"
? localeToDisplayName(value, whoAmI.getLocale())
: value}
</Chip>
) : (
value.map((entry) => (
<Chip
key={entry}
onClick={() => removeFilterValue(key, entry)}
>
{entry}
</Chip>
))
)}
</ChipGroup>
);
})}
</div>
)}
</FlexItem>
</Flex>
);
};
if (!searchPerformed) {
return (
<>
<div className="pf-u-py-lg pf-u-pl-md">
{effectiveMessageBunldesSearchFormDisplay()}
</div>
<Divider />
<ListEmptyState
message={t("emptyEffectiveMessageBundles")}
instructions={t("emptyEffectiveMessageBundlesInstructions")}
isSearchVariant
/>
</>
);
}
return (
<KeycloakDataTable
key={key}
loader={loader}
ariaLabelKey="effectiveMessageBundles"
toolbarItem={effectiveMessageBunldesSearchFormDisplay()}
columns={[
{
name: "key",
displayKey: "key",
},
{
name: "value",
displayKey: "value",
},
]}
emptyState={
<ListEmptyState
message={t("noSearchResults")}
instructions={t("noSearchResultsInstructions")}
/>
}
isSearching={Object.keys(activeFilters).length > 0}
/>
);
};

View file

@ -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 (
<Tabs
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key as number)}
>
<Tab
id="locales"
eventKey={0}
title={<TabTitleText>{t("locales")}</TabTitleText>}
data-testid="rs-localization-locales-tab"
>
<FormAccess
isHorizontal
role="manage-realm"
className="pf-u-mt-lg pf-u-ml-md"
onSubmit={handleSubmit(save)}
>
<FormGroup
label={t("internationalization")}
fieldId="kc-internationalization"
labelIcon={
<HelpItem
helpText={t("internationalizationHelp")}
fieldLabelId="internationalization"
/>
}
>
<Controller
name="internationalizationEnabled"
control={control}
defaultValue={realm.internationalizationEnabled}
render={({ field }) => (
<Switch
id="kc-l-internationalization"
className="pf-u-mt-sm"
label={t("enabled")}
labelOff={t("disabled")}
isChecked={field.value}
data-testid={
field.value
? "internationalization-enabled"
: "internationalization-disabled"
}
onChange={field.onChange}
aria-label={t("internationalization")}
/>
)}
/>
</FormGroup>
{internationalizationEnabled && (
<>
<FormGroup
label={t("supportedLocales")}
fieldId="kc-l-supported-locales"
>
<Controller
name="supportedLocales"
control={control}
defaultValue={defaultSupportedLocales}
render={({ field }) => (
<Select
toggleId="kc-l-supported-locales"
onToggle={(open) => {
setSupportedLocalesOpen(open);
}}
onSelect={(_, v) => {
const option = v as string;
if (field.value.includes(option)) {
field.onChange(
field.value.filter(
(item: string) => item !== option,
),
);
} else {
field.onChange([...field.value, option]);
}
}}
onClear={() => {
field.onChange([]);
}}
selections={field.value}
variant={SelectVariant.typeaheadMulti}
aria-label={t("supportedLocales")}
isOpen={supportedLocalesOpen}
placeholderText={t("selectLocales")}
>
{allLocales.map((locale) => (
<SelectOption
selected={field.value.includes(locale)}
key={locale}
value={locale}
>
{localeToDisplayName(locale, whoAmI.getLocale())}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
<FormGroup
label={t("defaultLocale")}
fieldId="kc-l-default-locale"
>
<Controller
name="defaultLocale"
control={control}
defaultValue={DEFAULT_LOCALE}
render={({ field }) => (
<Select
toggleId="kc-default-locale"
onToggle={() => setDefaultLocaleOpen(!defaultLocaleOpen)}
onSelect={(_, value) => {
field.onChange(value as string);
setDefaultLocaleOpen(false);
}}
selections={
field.value
? localeToDisplayName(field.value, whoAmI.getLocale())
: realm.defaultLocale !== ""
? localeToDisplayName(
realm.defaultLocale || DEFAULT_LOCALE,
whoAmI.getLocale(),
)
: t("placeholderText")
}
variant={SelectVariant.single}
aria-label={t("defaultLocale")}
isOpen={defaultLocaleOpen}
placeholderText={t("placeholderText")}
data-testid="select-default-locale"
>
{watchSupportedLocales.map((locale, idx) => (
<SelectOption
key={`default-locale-${idx}`}
value={locale}
>
{localeToDisplayName(locale, whoAmI.getLocale())}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
</>
)}
<ActionGroup>
<Button
variant="primary"
isDisabled={!formState.isDirty}
type="submit"
data-testid="localization-tab-save"
>
{t("save")}
</Button>
<Button variant="link" onClick={setupForm}>
{t("revert")}
</Button>
</ActionGroup>
</FormAccess>
</Tab>
<Tab
id="realm-overrides"
eventKey={1}
title={
<TabTitleText>
{t("realmOverrides")}{" "}
<HelpItem
fieldLabelId="realm-overrides"
helpText={t("realmOverridesHelp")}
noVerticalAlign={false}
unWrap
/>
</TabTitleText>
}
data-testid="rs-localization-realm-overrides-tab"
>
<RealmOverrides
internationalizationEnabled={internationalizationEnabled}
watchSupportedLocales={watchSupportedLocales}
realm={realm}
/>
</Tab>
<Tab
id="effective-message-bundles"
eventKey={2}
title={
<TabTitleText>
{t("effectiveMessageBundles")}
<HelpItem
fieldLabelId="effective-message-bundles"
helpText={t("effectiveMessageBundlesHelp")}
noVerticalAlign={false}
unWrap
/>
</TabTitleText>
}
data-testid="rs-localization-effective-message-bundles-tab"
>
<EffectiveMessageBundles
defaultSupportedLocales={defaultSupportedLocales}
/>
</Tab>
</Tabs>
);
};

View file

@ -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<IRow[]>([]);
const [tableKey, setTableKey] = useState(0);
const [max, setMax] = useState(10);
const [first, setFirst] = useState(0);
const [filter, setFilter] = useState("");
const bundleForm = useForm<BundleForm>({ mode: "onChange" });
const { addAlert, addError } = useAlerts();
const { realm: currentRealm } = useRealm();
const { whoAmI } = useWhoAmI();
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [areAllRowsSelected, setAreAllRowsSelected] = useState(false);
const [editStates, setEditStates] = useState<EditStatesType>({});
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 = [
<SelectGroup label={t("defaultLocale")} key="group1">
<SelectOption key={DEFAULT_LOCALE} value={DEFAULT_LOCALE}>
{localeToDisplayName(DEFAULT_LOCALE, whoAmI.getDisplayName())}
</SelectOption>
</SelectGroup>,
<Divider key="divider" />,
<SelectGroup label={t("supportedLocales")} key="group2">
{watchSupportedLocales.map((locale) => (
<SelectOption key={locale} value={locale}>
{localeToDisplayName(locale, whoAmI.getLocale())}
</SelectOption>
))}
</SelectGroup>,
];
const addKeyValue = async (pair: KeyValueType): Promise<void> => {
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<HTMLInputElement>,
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 (
<>
<DeleteConfirm />
{addMessageBundleModalOpen && (
<AddMessageBundleModal
handleModalToggle={handleModalToggle}
save={(pair: any) => {
addKeyValue(pair);
handleModalToggle();
}}
form={bundleForm}
/>
)}
<PaginatingTableToolbar
count={messageBundles.length}
first={first}
max={max}
onNextClick={setFirst}
onPreviousClick={setFirst}
onPerPageSelect={(first, max) => {
setFirst(first);
setMax(max);
}}
inputGroupName={"search"}
inputGroupOnEnter={(search) => {
setFilter(search);
setFirst(0);
setMax(10);
}}
inputGroupPlaceholder={t("searchForMessageBundle")}
toolbarItem={
<>
<Button
data-testid="add-bundle-button"
onClick={() => setAddMessageBundleModalOpen(true)}
>
{t("addMessageBundle")}
</Button>
<ToolbarItem>
<Dropdown
toggle={
<KebabToggle onToggle={() => setKebabOpen(!kebabOpen)} />
}
isOpen={kebabOpen}
isPlain
data-testid="toolbar-deleteBtn"
dropdownItems={[
<DropdownItem
key="action"
component="button"
data-testid="delete-selected-bundleBtn"
isDisabled={
messageBundles.length === 0 ||
selectedRowKeys.length === 0
}
onClick={() => {
toggleDeleteDialog();
setKebabOpen(false);
}}
>
{t("delete")}
</DropdownItem>,
]}
/>
</ToolbarItem>
</>
}
searchTypeComponent={
<ToolbarItem>
<Select
width={180}
isOpen={filterDropdownOpen}
className="kc-filter-by-locale-select"
variant={SelectVariant.single}
isDisabled={!internationalizationEnabled}
onToggle={(isExpanded) => setFilterDropdownOpen(isExpanded)}
onSelect={(_, value) => {
setSelectMenuLocale(value.toString());
setSelectMenuValueSelected(true);
refreshTable();
setFilterDropdownOpen(false);
}}
selections={
selectMenuValueSelected
? localeToDisplayName(selectMenuLocale, whoAmI.getLocale())
: realm.defaultLocale !== ""
? localeToDisplayName(DEFAULT_LOCALE, whoAmI.getLocale())
: t("placeholderText")
}
>
{options}
</Select>
</ToolbarItem>
}
>
{messageBundles.length === 0 && !filter && (
<ListEmptyState
hasIcon
message={t("noMessageBundles")}
instructions={t("noMessageBundlesInstructions")}
onPrimaryAction={handleModalToggle}
/>
)}
{messageBundles.length === 0 && filter && (
<ListEmptyState
hasIcon
icon={SearchIcon}
isSearchVariant
message={t("noSearchResults")}
instructions={t("noRealmOverridesSearchResultsInstructions")}
/>
)}
{messageBundles.length !== 0 && (
<Table
aria-label={t("editableRowsTable")}
data-testid="editable-rows-table"
>
<Thead>
<Tr>
<Th className="pf-u-px-lg">
<input
type="checkbox"
aria-label={t("selectAll")}
checked={areAllRowsSelected}
onChange={toggleSelectAllRows}
data-testid="selectAll"
/>
</Th>
<Th className="pf-u-py-lg">{t("key")}</Th>
<Th className="pf-u-py-lg">{t("value")}</Th>
<Th aria-hidden="true" />
</Tr>
</Thead>
<Tbody>
{tableRows.map((row, rowIndex) => (
<Tr key={(row.cells?.[0] as IRowCell).props.value}>
<Td
className="pf-u-px-lg"
select={{
rowIndex,
onSelect: (event) =>
handleRowSelect(
event as ChangeEvent<HTMLInputElement>,
rowIndex,
),
isSelected: isRowSelected(
(row.cells?.[0] as IRowCell).props.value,
),
}}
/>
<Td className="pf-m-sm pf-u-px-sm" dataLabel={t("key")}>
{(row.cells?.[0] as IRowCell).props.value}
</Td>
<Td
className="pf-m-sm pf-u-px-sm"
dataLabel={t("value")}
key={rowIndex}
>
<Form
isHorizontal
className="kc-form-bundleValue"
onSubmit={handleSubmit(() => {
onSubmit(formValue, rowIndex);
})}
>
<FormGroup
fieldId="kc-bundleValue"
className="pf-u-display-inline-block"
>
{editStates[rowIndex] ? (
<>
<KeycloakTextInput
aria-label={t("editUserLabel")}
type="text"
className="pf-u-w-initial"
data-testid={`editUserLabelInput-${rowIndex}`}
value={formValue}
onChange={(
event: ChangeEvent<HTMLInputElement>,
) => {
setFormValue(event.target.value);
}}
key={`edit-input-${rowIndex}`}
/>
<Button
variant="link"
className="pf-m-plain"
data-testid={`editUserLabelAcceptBtn-${rowIndex}`}
type="submit"
icon={<CheckIcon />}
/>
<Button
variant="link"
className="pf-m-plain"
data-testid={`editUserLabelCancelBtn-${rowIndex}`}
icon={<TimesIcon />}
onClick={() => {
setEditStates((prevEditStates) => ({
...prevEditStates,
[rowIndex]: false,
}));
}}
/>
</>
) : (
<>
<span>
{(row.cells?.[1] as IRowCell).props.value}
</span>
<Button
onClick={() => {
const currentValue = (
tableRows[rowIndex].cells?.[1] as IRowCell
).props.value;
setFormValue(currentValue);
setEditStates((prevState) => ({
...prevState,
[rowIndex]: true,
}));
}}
key={`edit-button-${rowIndex}`}
aria-label={t("editUserLabel")}
variant="link"
className="pf-m-plain"
data-testid={`editUserLabelBtn-${rowIndex}`}
>
<PencilAltIcon />
</Button>
</>
)}
</FormGroup>
</Form>
</Td>
<Td isActionCell>
<Dropdown
toggle={
<KebabToggle
className="pf-m-plain"
data-testid="realmOverrides-deleteKebabToggle"
/>
}
onClick={() => {
setSelectedRowKeys([
(row.cells?.[0] as IRowCell).props.value,
]);
toggleDeleteDialog();
setKebabOpen(false);
}}
/>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</PaginatingTableToolbar>
</>
);
};

View file

@ -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;
}
};

View file

@ -1,6 +1,5 @@
export default interface EffectiveMessageBundleRepresentation {
theme?: string;
themeType?: string;
locale?: string;
source?: boolean;
key: string;
value: string;
source: "THEME" | "REALM";
}

View file

@ -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"],
});
}

View file

@ -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<KeySource> result;