From bf89d5313427e6e4b9ec3e48e0e5fa600618837e Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 26 Feb 2024 14:04:38 +0100 Subject: [PATCH] Show display name in realm selector (#27259) Solves #17735 Signed-off-by: Oliver Cremerius --- js/apps/admin-ui/src/App.tsx | 12 +-- .../realm-selector/RealmSelector.tsx | 101 ++++++++++++------ .../admin-ui/src/context/RealmsContext.tsx | 29 +++-- js/apps/admin-ui/src/context/RecentRealms.tsx | 11 +- js/apps/admin-ui/src/dashboard/Dashboard.tsx | 6 +- js/apps/admin-ui/src/page-nav.css | 3 +- js/libs/ui-shared/src/user-profile/utils.ts | 2 +- .../admin/ui/rest/UIRealmsResource.java | 28 +++-- .../rest/model/RealmNameRepresentation.java | 22 ++++ 9 files changed, 144 insertions(+), 70 deletions(-) create mode 100644 rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/RealmNameRepresentation.java diff --git a/js/apps/admin-ui/src/App.tsx b/js/apps/admin-ui/src/App.tsx index af0202c3fa..62840fdacb 100644 --- a/js/apps/admin-ui/src/App.tsx +++ b/js/apps/admin-ui/src/App.tsx @@ -25,9 +25,9 @@ import { AuthWall } from "./root/AuthWall"; const AppContexts = ({ children }: PropsWithChildren) => ( - - - + + + @@ -37,9 +37,9 @@ const AppContexts = ({ children }: PropsWithChildren) => ( - - - + + + ); diff --git a/js/apps/admin-ui/src/components/realm-selector/RealmSelector.tsx b/js/apps/admin-ui/src/components/realm-selector/RealmSelector.tsx index 545e314eb6..63314df76c 100644 --- a/js/apps/admin-ui/src/components/realm-selector/RealmSelector.tsx +++ b/js/apps/admin-ui/src/components/realm-selector/RealmSelector.tsx @@ -11,11 +11,14 @@ import { Spinner, Split, SplitItem, + Stack, + StackItem, } from "@patternfly/react-core"; import { CheckIcon } from "@patternfly/react-icons"; import { Fragment, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Link, To, useHref } from "react-router-dom"; +import { label } from "ui-shared"; import { useRealm } from "../../context/realm-context/RealmContext"; import { useRealms } from "../../context/RealmsContext"; @@ -47,16 +50,33 @@ const AddRealm = ({ onClick }: AddRealmProps) => { }; type RealmTextProps = { - value: string; + name: string; + displayName?: string; + showIsRecent?: boolean; }; -const RealmText = ({ value }: RealmTextProps) => { +const RealmText = ({ name, displayName, showIsRecent }: RealmTextProps) => { const { realm } = useRealm(); + const { t } = useTranslation(); return ( - {value} - {value === realm && } + + + {displayName ? ( + + {label(t, displayName)} + + ) : null} + {name} + + + {name === realm && } + {showIsRecent ? ( + + + + ) : null} ); }; @@ -80,7 +100,7 @@ const ContextSelectorItemLink = ({ export const RealmSelector = () => { const { realm } = useRealm(); - const { realms, refresh } = useRealms(); + const { realms } = useRealms(); const { whoAmI } = useWhoAmI(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -89,35 +109,50 @@ export const RealmSelector = () => { const all = useMemo( () => - recentRealms - .filter((r) => r !== realm) - .map((name) => { - return { name, used: true }; + realms + .filter((r) => r.name !== realm) + .map((realm) => { + const used = recentRealms.some((name) => name === realm.name); + return { realm, used }; }) - .concat( - realms - .filter((name) => !recentRealms.includes(name) || name === realm) - .map((name) => ({ name, used: false })), - ), + .sort((r1, r2) => { + if (r1.used == r2.used) return 0; + if (r1.used) return -1; + if (r2.used) return 1; + return 0; + }), [recentRealms, realm, realms], ); - const filteredItems = useMemo( - () => - search.trim() === "" - ? all - : all.filter((r) => - r.name.toLowerCase().includes(search.toLowerCase()), - ), - [search, all], + const filteredItems = useMemo(() => { + const normalizedSearch = search.trim().toLowerCase(); + + if (normalizedSearch.length === 0) { + return all; + } + + return search.trim() === "" + ? all + : all.filter( + (r) => + r.realm.name.toLowerCase().includes(normalizedSearch) || + label(t, r.realm.displayName) + ?.toLowerCase() + .includes(normalizedSearch), + ); + }, [search, all]); + + const realmDisplayName = useMemo( + () => realms.find((r) => r.name === realm)?.displayName, + [realm, realms], ); return realms.length > 5 ? ( setOpen(!open)} searchInputValue={search} onSearchInputChange={(value) => setSearch(value)} @@ -132,12 +167,11 @@ export const RealmSelector = () => { > {filteredItems.map((item) => ( setOpen(false)} > - {" "} - {item.used && } + {" "} ))} @@ -151,24 +185,23 @@ export const RealmSelector = () => { { - if (realms.length === 0) refresh(); setOpen(!open); }} className="keycloak__realm_selector_dropdown__toggle" > - {realm} + {label(t, realmDisplayName, realm)} } dropdownItems={(realms.length !== 0 - ? realms.map((name) => ( + ? realms.map((realm) => ( setOpen(false)} > - + } /> diff --git a/js/apps/admin-ui/src/context/RealmsContext.tsx b/js/apps/admin-ui/src/context/RealmsContext.tsx index b9ac9e3c40..ed84f25c27 100644 --- a/js/apps/admin-ui/src/context/RealmsContext.tsx +++ b/js/apps/admin-ui/src/context/RealmsContext.tsx @@ -1,40 +1,47 @@ import { NetworkError } from "@keycloak/keycloak-admin-client"; import { PropsWithChildren, useCallback, useMemo, useState } from "react"; -import { createNamedContext, useRequiredContext } from "ui-shared"; +import { createNamedContext, useRequiredContext, label } from "ui-shared"; import { keycloak } from "../keycloak"; import { useFetch } from "../utils/useFetch"; import { fetchAdminUI } from "./auth/admin-ui-endpoint"; +import useLocaleSort from "../utils/useLocaleSort"; +import { useTranslation } from "react-i18next"; type RealmsContextProps = { /** A list of all the realms. */ - realms: string[]; + realms: RealmNameRepresentation[]; /** Refreshes the realms with the latest information. */ refresh: () => Promise; }; +export interface RealmNameRepresentation { + name: string; + displayName?: string; +} + export const RealmsContext = createNamedContext( "RealmsContext", undefined, ); export const RealmsProvider = ({ children }: PropsWithChildren) => { - const [realms, setRealms] = useState([]); + const [realms, setRealms] = useState([]); const [refreshCount, setRefreshCount] = useState(0); + const localeSort = useLocaleSort(); + const { t } = useTranslation(); - function updateRealms(realms: string[]) { - setRealms(realms.sort()); + function updateRealms(realms: RealmNameRepresentation[]) { + setRealms(localeSort(realms, (r) => label(t, r.displayName, r.name))); } useFetch( async () => { - // We don't want to fetch until the user has requested it, so let's ignore the initial mount. - if (refreshCount === 0) { - return []; - } - try { - return await fetchAdminUI("ui-ext/realms/names", {}); + return await fetchAdminUI( + "ui-ext/realms/names", + {}, + ); } catch (error) { if (error instanceof NetworkError && error.response.status < 500) { return []; diff --git a/js/apps/admin-ui/src/context/RecentRealms.tsx b/js/apps/admin-ui/src/context/RecentRealms.tsx index 9d929405ad..1cde5d8894 100644 --- a/js/apps/admin-ui/src/context/RecentRealms.tsx +++ b/js/apps/admin-ui/src/context/RecentRealms.tsx @@ -6,7 +6,7 @@ import { useStoredState, } from "ui-shared"; import { useRealm } from "./realm-context/RealmContext"; -import { useRealms } from "./RealmsContext"; +import { RealmNameRepresentation, useRealms } from "./RealmsContext"; const MAX_REALMS = 4; @@ -43,12 +43,17 @@ export const RecentRealmsProvider = ({ children }: PropsWithChildren) => { export const useRecentRealms = () => useRequiredContext(RecentRealmsContext); -function filterRealmNames(realms: string[], storedRealms: string[]) { +function filterRealmNames( + realms: RealmNameRepresentation[], + storedRealms: string[], +) { // If no realms have been set yet we can't filter out any non-existent realm names. if (realms.length === 0) { return storedRealms; } // Only keep realm names that actually still exist. - return storedRealms.filter((realm) => realms.includes(realm)); + return storedRealms.filter((realm) => { + return realms.some((r) => r.name === realm); + }); } diff --git a/js/apps/admin-ui/src/dashboard/Dashboard.tsx b/js/apps/admin-ui/src/dashboard/Dashboard.tsx index 5a6e0d7221..7767d16bd8 100644 --- a/js/apps/admin-ui/src/dashboard/Dashboard.tsx +++ b/js/apps/admin-ui/src/dashboard/Dashboard.tsx @@ -34,7 +34,7 @@ import FeatureRepresentation, { } from "@keycloak/keycloak-admin-client/lib/defs/featureRepresentation"; import { useRealm } from "../context/realm-context/RealmContext"; import { useServerInfo } from "../context/server-info/ServerInfoProvider"; -import { HelpItem } from "ui-shared"; +import { HelpItem, label } from "ui-shared"; import environment from "../environment"; import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; import useLocaleSort, { mapByKey } from "../utils/useLocaleSort"; @@ -57,7 +57,7 @@ const EmptyDashboard = () => { const [realmInfo, setRealmInfo] = useState(); const brandImage = environment.logo ? environment.logo : "/icon.svg"; useFetch(() => adminClient.realms.findOne({ realm }), setRealmInfo, []); - const realmDisplayInfo = realmInfo?.displayName || realm; + const realmDisplayInfo = label(t, realmInfo?.displayName, realm); return ( @@ -133,7 +133,7 @@ const Dashboard = () => { useFetch(() => adminClient.realms.findOne({ realm }), setRealmInfo, []); - const realmDisplayInfo = realmInfo?.displayName || realm; + const realmDisplayInfo = label(t, realmInfo?.displayName, realm); const welcomeTab = useTab("welcome"); const infoTab = useTab("info"); diff --git a/js/apps/admin-ui/src/page-nav.css b/js/apps/admin-ui/src/page-nav.css index 676ae04f25..bd9d20827c 100644 --- a/js/apps/admin-ui/src/page-nav.css +++ b/js/apps/admin-ui/src/page-nav.css @@ -1,4 +1,5 @@ .keycloak__page_nav__nav { - --pf-c-page__sidebar--Transition: all 50ms cubic-bezier(.42, 0, .58, 1) + --pf-c-page__sidebar--Transition: all 50ms cubic-bezier(.42, 0, .58, 1); + overflow: inherit; } \ No newline at end of file diff --git a/js/libs/ui-shared/src/user-profile/utils.ts b/js/libs/ui-shared/src/user-profile/utils.ts index b979dc7c82..329bab7fbc 100644 --- a/js/libs/ui-shared/src/user-profile/utils.ts +++ b/js/libs/ui-shared/src/user-profile/utils.ts @@ -30,7 +30,7 @@ export const unWrap = (key: string) => key.substring(2, key.length - 1); export const label = ( t: TFunction, text: string | undefined, - fallback: string | undefined, + fallback?: string, ) => (isBundleKey(text) ? t(unWrap(text!)) : text) || fallback; export const labelAttribute = ( diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmsResource.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmsResource.java index 991421bd93..a0bd21f072 100644 --- a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmsResource.java +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/UIRealmsResource.java @@ -2,7 +2,6 @@ package org.keycloak.admin.ui.rest; import static org.keycloak.utils.StreamsUtil.throwIfEmpty; -import java.util.Objects; import java.util.stream.Stream; import jakarta.ws.rs.GET; @@ -15,6 +14,7 @@ import org.eclipse.microprofile.openapi.annotations.media.Content; import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.jboss.resteasy.reactive.NoCache; +import org.keycloak.admin.ui.rest.model.RealmNameRepresentation; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.services.ForbiddenException; @@ -37,26 +37,32 @@ public class UIRealmsResource { @NoCache @Produces(MediaType.APPLICATION_JSON) @Operation( - summary = "Lists only the names of the realms", - description = "Returns a list of realm names based on what the caller is allowed to view" + summary = "Lists only the names and display names of the realms", + description = "Returns a list of realms containing only their name and displayName" + + " based on what the caller is allowed to view" ) @APIResponse( responseCode = "200", description = "", content = {@Content( schema = @Schema( - implementation = String.class, + implementation = RealmNameRepresentation.class, type = SchemaType.ARRAY ) )} ) - public Stream getRealmNames() { - Stream realms = session.realms().getRealmsStream() - .filter(realm -> { - RealmsPermissionEvaluator eval = AdminPermissions.realms(session, auth.adminAuth()); - return eval.canView(realm) || eval.isAdmin(realm); - }) - .map(RealmModel::getName); + public Stream getRealms() { + Stream realms = session.realms().getRealmsStream() + .filter(realm -> { + RealmsPermissionEvaluator eval = AdminPermissions.realms(session, auth.adminAuth()); + return eval.canView(realm) || eval.isAdmin(realm); + }) + .map((RealmModel realm) -> { + RealmNameRepresentation realmNameRep = new RealmNameRepresentation(); + realmNameRep.setDisplayName(realm.getDisplayName()); + realmNameRep.setName(realm.getName()); + return realmNameRep; + }); return throwIfEmpty(realms, new ForbiddenException()); } } diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/RealmNameRepresentation.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/RealmNameRepresentation.java new file mode 100644 index 0000000000..06d5787a58 --- /dev/null +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/model/RealmNameRepresentation.java @@ -0,0 +1,22 @@ +package org.keycloak.admin.ui.rest.model; + +public class RealmNameRepresentation { + private String name; + private String displayName; + + public String getName() { + return this.name; + } + + public String getDisplayName() { + return this.displayName; + } + + public void setName(String name) { + this.name = name; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } +}