Show display name in realm selector (#27259)

Solves #17735

Signed-off-by: Oliver Cremerius <antikalk@users.noreply.github.com>
This commit is contained in:
Oliver 2024-02-26 14:04:38 +01:00 committed by GitHub
parent 516d86cda6
commit bf89d53134
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 144 additions and 70 deletions

View file

@ -25,9 +25,9 @@ import { AuthWall } from "./root/AuthWall";
const AppContexts = ({ children }: PropsWithChildren) => ( const AppContexts = ({ children }: PropsWithChildren) => (
<ErrorBoundaryProvider> <ErrorBoundaryProvider>
<ServerInfoProvider> <ServerInfoProvider>
<RealmsProvider> <RealmContextProvider>
<RealmContextProvider> <WhoAmIContextProvider>
<WhoAmIContextProvider> <RealmsProvider>
<RecentRealmsProvider> <RecentRealmsProvider>
<AccessContextProvider> <AccessContextProvider>
<Help> <Help>
@ -37,9 +37,9 @@ const AppContexts = ({ children }: PropsWithChildren) => (
</Help> </Help>
</AccessContextProvider> </AccessContextProvider>
</RecentRealmsProvider> </RecentRealmsProvider>
</WhoAmIContextProvider> </RealmsProvider>
</RealmContextProvider> </WhoAmIContextProvider>
</RealmsProvider> </RealmContextProvider>
</ServerInfoProvider> </ServerInfoProvider>
</ErrorBoundaryProvider> </ErrorBoundaryProvider>
); );

View file

@ -11,11 +11,14 @@ import {
Spinner, Spinner,
Split, Split,
SplitItem, SplitItem,
Stack,
StackItem,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { CheckIcon } from "@patternfly/react-icons"; import { CheckIcon } from "@patternfly/react-icons";
import { Fragment, useState, useMemo } from "react"; import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, To, useHref } from "react-router-dom"; import { Link, To, useHref } from "react-router-dom";
import { label } from "ui-shared";
import { useRealm } from "../../context/realm-context/RealmContext"; import { useRealm } from "../../context/realm-context/RealmContext";
import { useRealms } from "../../context/RealmsContext"; import { useRealms } from "../../context/RealmsContext";
@ -47,16 +50,33 @@ const AddRealm = ({ onClick }: AddRealmProps) => {
}; };
type RealmTextProps = { type RealmTextProps = {
value: string; name: string;
displayName?: string;
showIsRecent?: boolean;
}; };
const RealmText = ({ value }: RealmTextProps) => { const RealmText = ({ name, displayName, showIsRecent }: RealmTextProps) => {
const { realm } = useRealm(); const { realm } = useRealm();
const { t } = useTranslation();
return ( return (
<Split className="keycloak__realm_selector__list-item-split"> <Split className="keycloak__realm_selector__list-item-split">
<SplitItem isFilled>{value}</SplitItem> <SplitItem isFilled>
<SplitItem>{value === realm && <CheckIcon />}</SplitItem> <Stack>
{displayName ? (
<StackItem className="pf-u-font-weight-bold" isFilled>
{label(t, displayName)}
</StackItem>
) : null}
<StackItem isFilled>{name}</StackItem>
</Stack>
</SplitItem>
<SplitItem>{name === realm && <CheckIcon />}</SplitItem>
{showIsRecent ? (
<SplitItem>
<Label>{t("recent")}</Label>
</SplitItem>
) : null}
</Split> </Split>
); );
}; };
@ -80,7 +100,7 @@ const ContextSelectorItemLink = ({
export const RealmSelector = () => { export const RealmSelector = () => {
const { realm } = useRealm(); const { realm } = useRealm();
const { realms, refresh } = useRealms(); const { realms } = useRealms();
const { whoAmI } = useWhoAmI(); const { whoAmI } = useWhoAmI();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@ -89,35 +109,50 @@ export const RealmSelector = () => {
const all = useMemo( const all = useMemo(
() => () =>
recentRealms realms
.filter((r) => r !== realm) .filter((r) => r.name !== realm)
.map((name) => { .map((realm) => {
return { name, used: true }; const used = recentRealms.some((name) => name === realm.name);
return { realm, used };
}) })
.concat( .sort((r1, r2) => {
realms if (r1.used == r2.used) return 0;
.filter((name) => !recentRealms.includes(name) || name === realm) if (r1.used) return -1;
.map((name) => ({ name, used: false })), if (r2.used) return 1;
), return 0;
}),
[recentRealms, realm, realms], [recentRealms, realm, realms],
); );
const filteredItems = useMemo( const filteredItems = useMemo(() => {
() => const normalizedSearch = search.trim().toLowerCase();
search.trim() === ""
? all if (normalizedSearch.length === 0) {
: all.filter((r) => return all;
r.name.toLowerCase().includes(search.toLowerCase()), }
),
[search, 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 ? ( return realms.length > 5 ? (
<ContextSelector <ContextSelector
data-testid="realmSelector" data-testid="realmSelector"
toggleText={realm} toggleText={label(t, realmDisplayName, realm)}
isOpen={open} isOpen={open}
screenReaderLabel={realm} screenReaderLabel={label(t, realmDisplayName, realm)}
onToggle={() => setOpen(!open)} onToggle={() => setOpen(!open)}
searchInputValue={search} searchInputValue={search}
onSearchInputChange={(value) => setSearch(value)} onSearchInputChange={(value) => setSearch(value)}
@ -132,12 +167,11 @@ export const RealmSelector = () => {
> >
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<ContextSelectorItemLink <ContextSelectorItemLink
key={item.name} key={item.realm.name}
to={toDashboard({ realm: item.name })} to={toDashboard({ realm: item.realm.name })}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
<RealmText value={item.name} />{" "} <RealmText {...item.realm} showIsRecent={item.used} />{" "}
{item.used && <Label>{t("recent")}</Label>}
</ContextSelectorItemLink> </ContextSelectorItemLink>
))} ))}
</ContextSelector> </ContextSelector>
@ -151,24 +185,23 @@ export const RealmSelector = () => {
<DropdownToggle <DropdownToggle
data-testid="realmSelectorToggle" data-testid="realmSelectorToggle"
onToggle={() => { onToggle={() => {
if (realms.length === 0) refresh();
setOpen(!open); setOpen(!open);
}} }}
className="keycloak__realm_selector_dropdown__toggle" className="keycloak__realm_selector_dropdown__toggle"
> >
{realm} {label(t, realmDisplayName, realm)}
</DropdownToggle> </DropdownToggle>
} }
dropdownItems={(realms.length !== 0 dropdownItems={(realms.length !== 0
? realms.map((name) => ( ? realms.map((realm) => (
<DropdownItem <DropdownItem
key={name} key={realm.name}
component={ component={
<Link <Link
to={toDashboard({ realm: name })} to={toDashboard({ realm: realm.name })}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
<RealmText value={name} /> <RealmText {...realm} />
</Link> </Link>
} }
/> />

View file

@ -1,40 +1,47 @@
import { NetworkError } from "@keycloak/keycloak-admin-client"; import { NetworkError } from "@keycloak/keycloak-admin-client";
import { PropsWithChildren, useCallback, useMemo, useState } from "react"; 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 { keycloak } from "../keycloak";
import { useFetch } from "../utils/useFetch"; import { useFetch } from "../utils/useFetch";
import { fetchAdminUI } from "./auth/admin-ui-endpoint"; import { fetchAdminUI } from "./auth/admin-ui-endpoint";
import useLocaleSort from "../utils/useLocaleSort";
import { useTranslation } from "react-i18next";
type RealmsContextProps = { type RealmsContextProps = {
/** A list of all the realms. */ /** A list of all the realms. */
realms: string[]; realms: RealmNameRepresentation[];
/** Refreshes the realms with the latest information. */ /** Refreshes the realms with the latest information. */
refresh: () => Promise<void>; refresh: () => Promise<void>;
}; };
export interface RealmNameRepresentation {
name: string;
displayName?: string;
}
export const RealmsContext = createNamedContext<RealmsContextProps | undefined>( export const RealmsContext = createNamedContext<RealmsContextProps | undefined>(
"RealmsContext", "RealmsContext",
undefined, undefined,
); );
export const RealmsProvider = ({ children }: PropsWithChildren) => { export const RealmsProvider = ({ children }: PropsWithChildren) => {
const [realms, setRealms] = useState<string[]>([]); const [realms, setRealms] = useState<RealmNameRepresentation[]>([]);
const [refreshCount, setRefreshCount] = useState(0); const [refreshCount, setRefreshCount] = useState(0);
const localeSort = useLocaleSort();
const { t } = useTranslation();
function updateRealms(realms: string[]) { function updateRealms(realms: RealmNameRepresentation[]) {
setRealms(realms.sort()); setRealms(localeSort(realms, (r) => label(t, r.displayName, r.name)));
} }
useFetch( useFetch(
async () => { 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 { try {
return await fetchAdminUI<string[]>("ui-ext/realms/names", {}); return await fetchAdminUI<RealmNameRepresentation[]>(
"ui-ext/realms/names",
{},
);
} catch (error) { } catch (error) {
if (error instanceof NetworkError && error.response.status < 500) { if (error instanceof NetworkError && error.response.status < 500) {
return []; return [];

View file

@ -6,7 +6,7 @@ import {
useStoredState, useStoredState,
} from "ui-shared"; } from "ui-shared";
import { useRealm } from "./realm-context/RealmContext"; import { useRealm } from "./realm-context/RealmContext";
import { useRealms } from "./RealmsContext"; import { RealmNameRepresentation, useRealms } from "./RealmsContext";
const MAX_REALMS = 4; const MAX_REALMS = 4;
@ -43,12 +43,17 @@ export const RecentRealmsProvider = ({ children }: PropsWithChildren) => {
export const useRecentRealms = () => useRequiredContext(RecentRealmsContext); 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 no realms have been set yet we can't filter out any non-existent realm names.
if (realms.length === 0) { if (realms.length === 0) {
return storedRealms; return storedRealms;
} }
// Only keep realm names that actually still exist. // 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);
});
} }

View file

@ -34,7 +34,7 @@ import FeatureRepresentation, {
} from "@keycloak/keycloak-admin-client/lib/defs/featureRepresentation"; } from "@keycloak/keycloak-admin-client/lib/defs/featureRepresentation";
import { useRealm } from "../context/realm-context/RealmContext"; import { useRealm } from "../context/realm-context/RealmContext";
import { useServerInfo } from "../context/server-info/ServerInfoProvider"; import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { HelpItem } from "ui-shared"; import { HelpItem, label } from "ui-shared";
import environment from "../environment"; import environment from "../environment";
import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner"; import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner";
import useLocaleSort, { mapByKey } from "../utils/useLocaleSort"; import useLocaleSort, { mapByKey } from "../utils/useLocaleSort";
@ -57,7 +57,7 @@ const EmptyDashboard = () => {
const [realmInfo, setRealmInfo] = useState<RealmRepresentation>(); const [realmInfo, setRealmInfo] = useState<RealmRepresentation>();
const brandImage = environment.logo ? environment.logo : "/icon.svg"; const brandImage = environment.logo ? environment.logo : "/icon.svg";
useFetch(() => adminClient.realms.findOne({ realm }), setRealmInfo, []); useFetch(() => adminClient.realms.findOne({ realm }), setRealmInfo, []);
const realmDisplayInfo = realmInfo?.displayName || realm; const realmDisplayInfo = label(t, realmInfo?.displayName, realm);
return ( return (
<PageSection variant="light"> <PageSection variant="light">
@ -133,7 +133,7 @@ const Dashboard = () => {
useFetch(() => adminClient.realms.findOne({ realm }), setRealmInfo, []); useFetch(() => adminClient.realms.findOne({ realm }), setRealmInfo, []);
const realmDisplayInfo = realmInfo?.displayName || realm; const realmDisplayInfo = label(t, realmInfo?.displayName, realm);
const welcomeTab = useTab("welcome"); const welcomeTab = useTab("welcome");
const infoTab = useTab("info"); const infoTab = useTab("info");

View file

@ -1,4 +1,5 @@
.keycloak__page_nav__nav { .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;
} }

View file

@ -30,7 +30,7 @@ export const unWrap = (key: string) => key.substring(2, key.length - 1);
export const label = ( export const label = (
t: TFunction, t: TFunction,
text: string | undefined, text: string | undefined,
fallback: string | undefined, fallback?: string,
) => (isBundleKey(text) ? t(unWrap(text!)) : text) || fallback; ) => (isBundleKey(text) ? t(unWrap(text!)) : text) || fallback;
export const labelAttribute = ( export const labelAttribute = (

View file

@ -2,7 +2,6 @@ package org.keycloak.admin.ui.rest;
import static org.keycloak.utils.StreamsUtil.throwIfEmpty; import static org.keycloak.utils.StreamsUtil.throwIfEmpty;
import java.util.Objects;
import java.util.stream.Stream; import java.util.stream.Stream;
import jakarta.ws.rs.GET; 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.media.Schema;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.jboss.resteasy.reactive.NoCache; import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.admin.ui.rest.model.RealmNameRepresentation;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.services.ForbiddenException; import org.keycloak.services.ForbiddenException;
@ -37,26 +37,32 @@ public class UIRealmsResource {
@NoCache @NoCache
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Operation( @Operation(
summary = "Lists only the names of the realms", summary = "Lists only the names and display names of the realms",
description = "Returns a list of realm names based on what the caller is allowed to view" description = "Returns a list of realms containing only their name and displayName" +
" based on what the caller is allowed to view"
) )
@APIResponse( @APIResponse(
responseCode = "200", responseCode = "200",
description = "", description = "",
content = {@Content( content = {@Content(
schema = @Schema( schema = @Schema(
implementation = String.class, implementation = RealmNameRepresentation.class,
type = SchemaType.ARRAY type = SchemaType.ARRAY
) )
)} )}
) )
public Stream<String> getRealmNames() { public Stream<RealmNameRepresentation> getRealms() {
Stream<String> realms = session.realms().getRealmsStream() Stream<RealmNameRepresentation> realms = session.realms().getRealmsStream()
.filter(realm -> { .filter(realm -> {
RealmsPermissionEvaluator eval = AdminPermissions.realms(session, auth.adminAuth()); RealmsPermissionEvaluator eval = AdminPermissions.realms(session, auth.adminAuth());
return eval.canView(realm) || eval.isAdmin(realm); return eval.canView(realm) || eval.isAdmin(realm);
}) })
.map(RealmModel::getName); .map((RealmModel realm) -> {
RealmNameRepresentation realmNameRep = new RealmNameRepresentation();
realmNameRep.setDisplayName(realm.getDisplayName());
realmNameRep.setName(realm.getName());
return realmNameRep;
});
return throwIfEmpty(realms, new ForbiddenException()); return throwIfEmpty(realms, new ForbiddenException());
} }
} }

View file

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