added pagination to realm selector (#30219)

* added pagination to realm selector

fixes: #29978
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* fix display name for recent and refresh on open

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

---------

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
This commit is contained in:
Erik Jan de Wit 2024-06-13 11:29:57 +02:00 committed by GitHub
parent d96967682b
commit 08ead04c43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 137 additions and 196 deletions

View file

@ -2024,6 +2024,7 @@ eventTypes.REGISTER_ERROR.description=Register error
infoDisabledFeatures=Shows all disabled features.
userSession.modelNote.label=User Session Note
next=Next
previous=Previous
userLabel=User label
pagination=Pagination
changeAuthenticatorConfirm=If you change authenticator to {{clientAuthenticatorType}}, the Keycloak database will be updated and you may need to download a new adapter configuration for this client.

View file

@ -18,7 +18,6 @@ import {
ErrorBoundaryFallback,
ErrorBoundaryProvider,
} from "./context/ErrorBoundary";
import { RealmsProvider } from "./context/RealmsContext";
import { RecentRealmsProvider } from "./context/RecentRealms";
import { AccessContextProvider } from "./context/access/Access";
import { RealmContextProvider } from "./context/realm-context/RealmContext";
@ -33,15 +32,13 @@ const AppContexts = ({ children }: PropsWithChildren) => (
<ServerInfoProvider>
<RealmContextProvider>
<WhoAmIContextProvider>
<RealmsProvider>
<RecentRealmsProvider>
<AccessContextProvider>
<AlertProvider>
<SubGroups>{children}</SubGroups>
</AlertProvider>
</AccessContextProvider>
</RecentRealmsProvider>
</RealmsProvider>
<RecentRealmsProvider>
<AccessContextProvider>
<AlertProvider>
<SubGroups>{children}</SubGroups>
</AlertProvider>
</AccessContextProvider>
</RecentRealmsProvider>
</WhoAmIContextProvider>
</RealmContextProvider>
</ServerInfoProvider>

View file

@ -1,3 +1,4 @@
import { NetworkError } from "@keycloak/keycloak-admin-client";
import { label } from "@keycloak/keycloak-ui-shared";
import {
Button,
@ -15,19 +16,28 @@ import {
Stack,
StackItem,
} from "@patternfly/react-core";
import { CheckIcon } from "@patternfly/react-icons";
import { Fragment, useMemo, useState } from "react";
import {
AngleLeftIcon,
AngleRightIcon,
CheckIcon,
} from "@patternfly/react-icons";
import { debounce } from "lodash-es";
import { Fragment, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useRealms } from "../../context/RealmsContext";
import { useAdminClient } from "../../admin-client";
import { useRecentRealms } from "../../context/RecentRealms";
import { fetchAdminUI } from "../../context/auth/admin-ui-endpoint";
import { useRealm } from "../../context/realm-context/RealmContext";
import { useWhoAmI } from "../../context/whoami/WhoAmI";
import { toDashboard } from "../../dashboard/routes/Dashboard";
import { toAddRealm } from "../../realm/routes/AddRealm";
import { useFetch } from "../../utils/useFetch";
import "./realm-selector.css";
const MAX_RESULTS = 10;
type AddRealmProps = {
onClick: () => void;
};
@ -80,49 +90,67 @@ const RealmText = ({ name, displayName, showIsRecent }: RealmTextProps) => {
);
};
type RealmNameRepresentation = {
name: string;
displayName?: string;
};
export const RealmSelector = () => {
const { realm } = useRealm();
const { realms } = useRealms();
const { adminClient } = useAdminClient();
const { whoAmI } = useWhoAmI();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [realms, setRealms] = useState<RealmNameRepresentation[]>([]);
const { t } = useTranslation();
const recentRealms = useRecentRealms();
const navigate = useNavigate();
const all = useMemo(
() =>
realms
.map((realm) => {
const used = recentRealms.some((name) => name === realm.name);
return { realm, used };
})
.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 [search, setSearch] = useState("");
const [first, setFirst] = useState(0);
const debounceFn = useCallback(
debounce((value: string) => {
setFirst(0);
setSearch(value);
}, 1000),
[],
);
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),
useFetch(
async () => {
try {
return await fetchAdminUI<RealmNameRepresentation[]>(
adminClient,
"ui-ext/realms/names",
{ first: `${first}`, max: `${MAX_RESULTS + 1}`, search },
);
}, [search, all]);
} catch (error) {
if (error instanceof NetworkError && error.response.status < 500) {
return [];
}
throw error;
}
},
setRealms,
[open, first, search],
);
const sortedRealms = useMemo(
() => [
...(first === 0 && !search
? recentRealms.reduce((acc, name) => {
const realm = realms.find((r) => r.name === name);
if (realm) {
acc.push(realm);
}
return acc;
}, [] as RealmNameRepresentation[])
: []),
...realms.filter((r) => !recentRealms.includes(r.name)),
],
[recentRealms, realms, first, search],
);
const realmDisplayName = useMemo(
() => realms.find((r) => r.name === realm)?.displayName,
@ -148,13 +176,13 @@ export const RealmSelector = () => {
)}
>
<DropdownList>
{realms.length > 5 && (
{(realms.length > 5 || search || first !== 0) && (
<>
<DropdownGroup>
<DropdownList>
<SearchInput
value={search}
onChange={(_, value) => setSearch(value)}
onChange={(_, value) => debounceFn(value)}
onClear={() => setSearch("")}
/>
</DropdownList>
@ -163,25 +191,51 @@ export const RealmSelector = () => {
</>
)}
{(realms.length !== 0
? filteredItems.map((i) => (
<DropdownItem
key={i.realm.name}
onClick={() => {
navigate(toDashboard({ realm: i.realm.name }));
setOpen(false);
}}
>
<RealmText
{...i.realm}
showIsRecent={realms.length > 5 && i.used}
/>
</DropdownItem>
))
: [
<DropdownItem key="loader">
<Spinner size="sm" /> {t("loadingRealms")}
</DropdownItem>,
? [
first !== 0 ? (
<DropdownItem onClick={() => setFirst(first - MAX_RESULTS)}>
<AngleLeftIcon /> {t("previous")}
</DropdownItem>
) : (
[]
),
...sortedRealms.map((realm) => (
<DropdownItem
key={realm.name}
onClick={() => {
navigate(toDashboard({ realm: realm.name }));
setOpen(false);
setSearch("");
}}
>
<RealmText
{...realm}
showIsRecent={
realms.length > 5 && recentRealms.includes(realm.name)
}
/>
</DropdownItem>
)),
realms.length > MAX_RESULTS ? (
<DropdownItem onClick={() => setFirst(first + MAX_RESULTS)}>
<AngleRightIcon />
{t("next")}
</DropdownItem>
) : (
[]
),
]
: !search
? [
<DropdownItem key="loader">
<Spinner size="sm" /> {t("loadingRealms")}
</DropdownItem>,
]
: [
<DropdownItem key="no-results">
{t("noResultsFound")}
</DropdownItem>,
]
).concat([
<Fragment key="add-realm">
{whoAmI.canCreateRealm() && (

View file

@ -1,82 +0,0 @@
import { NetworkError } from "@keycloak/keycloak-admin-client";
import {
createNamedContext,
label,
useEnvironment,
useRequiredContext,
} from "@keycloak/keycloak-ui-shared";
import { PropsWithChildren, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../admin-client";
import { useFetch } from "../utils/useFetch";
import useLocaleSort from "../utils/useLocaleSort";
import { fetchAdminUI } from "./auth/admin-ui-endpoint";
type RealmsContextProps = {
/** A list of all the realms. */
realms: RealmNameRepresentation[];
/** Refreshes the realms with the latest information. */
refresh: () => Promise<void>;
};
export interface RealmNameRepresentation {
name: string;
displayName?: string;
}
export const RealmsContext = createNamedContext<RealmsContextProps | undefined>(
"RealmsContext",
undefined,
);
export const RealmsProvider = ({ children }: PropsWithChildren) => {
const { keycloak } = useEnvironment();
const { adminClient } = useAdminClient();
const [realms, setRealms] = useState<RealmNameRepresentation[]>([]);
const [refreshCount, setRefreshCount] = useState(0);
const localeSort = useLocaleSort();
const { t } = useTranslation();
function updateRealms(realms: RealmNameRepresentation[]) {
setRealms(localeSort(realms, (r) => label(t, r.displayName, r.name)));
}
useFetch(
async () => {
try {
return await fetchAdminUI<RealmNameRepresentation[]>(
adminClient,
"ui-ext/realms/names",
{},
);
} catch (error) {
if (error instanceof NetworkError && error.response.status < 500) {
return [];
}
throw error;
}
},
(realms) => updateRealms(realms),
[refreshCount],
);
const refresh = useCallback(async () => {
//this is needed otherwise the realm find function will not return
//new or renamed realms because of the cached realms in the token (perhaps?)
await keycloak.updateToken(Number.MAX_VALUE);
setRefreshCount((count) => count + 1);
}, []);
const value = useMemo<RealmsContextProps>(
() => ({ realms, refresh }),
[realms, refresh],
);
return (
<RealmsContext.Provider value={value}>{children}</RealmsContext.Provider>
);
};
export const useRealms = () => useRequiredContext(RealmsContext);

View file

@ -1,4 +1,4 @@
import { PropsWithChildren, useEffect, useMemo } from "react";
import { PropsWithChildren, useEffect } from "react";
import {
createNamedContext,
@ -6,7 +6,6 @@ import {
useStoredState,
} from "@keycloak/keycloak-ui-shared";
import { useRealm } from "./realm-context/RealmContext";
import { RealmNameRepresentation, useRealms } from "./RealmsContext";
const MAX_REALMS = 4;
@ -16,7 +15,6 @@ export const RecentRealmsContext = createNamedContext<string[] | undefined>(
);
export const RecentRealmsProvider = ({ children }: PropsWithChildren) => {
const { realms } = useRealms();
const { realm } = useRealm();
const [storedRealms, setStoredRealms] = useStoredState(
localStorage,
@ -24,36 +22,16 @@ export const RecentRealmsProvider = ({ children }: PropsWithChildren) => {
[realm],
);
const recentRealms = useMemo(
() => filterRealmNames(realms, storedRealms),
[realms, storedRealms],
);
useEffect(() => {
const newRealms = [...new Set([realm, ...recentRealms])];
const newRealms = [...new Set([realm, ...storedRealms])];
setStoredRealms(newRealms.slice(0, MAX_REALMS));
}, [realm]);
return (
<RecentRealmsContext.Provider value={recentRealms}>
<RecentRealmsContext.Provider value={storedRealms}>
{children}
</RecentRealmsContext.Provider>
);
};
export const useRecentRealms = () => useRequiredContext(RecentRealmsContext);
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) => {
return realms.some((r) => r.name === realm);
});
}

View file

@ -26,7 +26,6 @@ import {
useRoutableTab,
} from "../components/routable-tabs/RoutableTabs";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useRealms } from "../context/RealmsContext";
import { useAccess } from "../context/access/Access";
import { useRealm } from "../context/realm-context/RealmContext";
import { toDashboard } from "../dashboard/routes/Dashboard";
@ -78,7 +77,6 @@ const RealmSettingsHeader = ({
const { adminClient } = useAdminClient();
const { environment } = useEnvironment<Environment>();
const { t } = useTranslation();
const { refresh: refreshRealms } = useRealms();
const { addAlert, addError } = useAlerts();
const navigate = useNavigate();
const [partialImportOpen, setPartialImportOpen] = useState(false);
@ -105,7 +103,6 @@ const RealmSettingsHeader = ({
try {
await adminClient.realms.del({ realm: realmName });
addAlert(t("deletedSuccessRealmSetting"), AlertVariant.success);
await refreshRealms();
navigate(toDashboard({ realm: environment.masterRealm }));
refresh();
} catch (error) {
@ -179,7 +176,6 @@ export const RealmSettingsTabs = () => {
const { t } = useTranslation();
const { addAlert, addError } = useAlerts();
const { realm: realmName, realmRepresentation: realm, refresh } = useRealm();
const { refresh: refreshRealms } = useRealms();
const combinedLocales = useLocale();
const navigate = useNavigate();
const isFeatureEnabled = useIsFeatureEnabled();
@ -271,7 +267,6 @@ export const RealmSettingsTabs = () => {
const isRealmRenamed = realmName !== (r.realm || realm?.realm);
if (isRealmRenamed) {
await refreshRealms();
navigate(toRealmSettings({ realm: r.realm!, tab: "general" }));
}
refresh();

View file

@ -11,7 +11,6 @@ import { useAlerts } from "../../components/alert/Alerts";
import { FormAccess } from "../../components/form/FormAccess";
import { JsonFileUpload } from "../../components/json-file-upload/JsonFileUpload";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { useRealms } from "../../context/RealmsContext";
import { useWhoAmI } from "../../context/whoami/WhoAmI";
import { toDashboard } from "../../dashboard/routes/Dashboard";
import { convertFormValuesToObject, convertToFormValues } from "../../util";
@ -22,7 +21,6 @@ export default function NewRealmForm() {
const { t } = useTranslation();
const navigate = useNavigate();
const { refresh, whoAmI } = useWhoAmI();
const { refresh: refreshRealms } = useRealms();
const { addAlert, addError } = useAlerts();
const [realm, setRealm] = useState<RealmRepresentation>();
@ -47,7 +45,6 @@ export default function NewRealmForm() {
addAlert(t("saveRealmSuccess"));
refresh();
await refreshRealms();
navigate(toDashboard({ realm: fields.realm }));
} catch (error) {
addError("saveRealmError", error);

View file

@ -1,13 +1,12 @@
package org.keycloak.admin.ui.rest;
import static org.keycloak.utils.StreamsUtil.throwIfEmpty;
import java.util.stream.Stream;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
@ -51,19 +50,16 @@ public class UIRealmsResource {
)
)}
)
public Stream<RealmNameRepresentation> getRealms() {
public Stream<RealmNameRepresentation> getRealms(@QueryParam("first") @DefaultValue("0") int first,
@QueryParam("max") @DefaultValue("10") int max,
@QueryParam("search") @DefaultValue("") String search) {
final RealmsPermissionEvaluator eval = AdminPermissions.realms(session, auth.adminAuth());
Stream<RealmNameRepresentation> realms = session.realms().getRealmsStream()
.filter(realm -> {
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());
return session.realms().getRealmsStream()
.filter(realm -> eval.canView(realm) || eval.isAdmin(realm))
.filter(realm -> search.isEmpty() || realm.getName().toLowerCase().contains(search.trim().toLowerCase()))
.skip(first)
.limit(max)
.map((RealmModel realm) -> new RealmNameRepresentation(realm.getName(), realm.getDisplayName()));
}
}

View file

@ -4,6 +4,11 @@ public class RealmNameRepresentation {
private String name;
private String displayName;
public RealmNameRepresentation(String name, String displayName) {
this.name = name;
this.displayName = displayName;
}
public String getName() {
return this.name;
}