From ef0f8ea532098dfe014552ea06a6dcfd65b5d205 Mon Sep 17 00:00:00 2001 From: Jon Koops Date: Wed, 23 Aug 2023 17:42:06 +0200 Subject: [PATCH] lazy populate the treeview for groups (#21520) (#22656) Fixes: #19954 --- js/apps/admin-ui/src/groups/GroupTable.tsx | 31 +-- js/apps/admin-ui/src/groups/GroupsSection.tsx | 223 +++++++++--------- js/apps/admin-ui/src/groups/Members.tsx | 19 +- .../src/groups/components/GroupTree.tsx | 83 ++++++- js/apps/admin-ui/src/groups/routes/Groups.tsx | 2 +- .../admin/ui/rest/GroupsResource.java | 68 +++++- .../resources/admin/GroupsResource.java | 2 +- .../java/org/keycloak/utils/GroupUtils.java | 29 ++- 8 files changed, 294 insertions(+), 163 deletions(-) diff --git a/js/apps/admin-ui/src/groups/GroupTable.tsx b/js/apps/admin-ui/src/groups/GroupTable.tsx index e600f34e1e6..ee129f0dd8b 100644 --- a/js/apps/admin-ui/src/groups/GroupTable.tsx +++ b/js/apps/admin-ui/src/groups/GroupTable.tsx @@ -2,14 +2,12 @@ import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/g import { SearchInput, ToolbarItem } from "@patternfly/react-core"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link, useLocation, useNavigate } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; -import { adminClient } from "../admin-client"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { useAccess } from "../context/access/Access"; import { fetchAdminUI } from "../context/auth/admin-ui-endpoint"; -import { useRealm } from "../context/realm-context/RealmContext"; import useToggle from "../utils/useToggle"; import { GroupsModal } from "./GroupsModal"; import { useSubGroups } from "./SubGroupsContext"; @@ -17,7 +15,6 @@ import { DeleteGroup } from "./components/DeleteGroup"; import { GroupToolbar } from "./components/GroupToolbar"; import { MoveDialog } from "./components/MoveDialog"; import { getLastId } from "./groupIdUtils"; -import { toGroups } from "./routes/Groups"; type GroupTableProps = { refresh: () => void; @@ -30,7 +27,6 @@ export const GroupTable = ({ }: GroupTableProps) => { const { t } = useTranslation("groups"); - const { realm } = useRealm(); const [selectedRows, setSelectedRows] = useState([]); const [rename, setRename] = useState(); @@ -44,7 +40,6 @@ export const GroupTable = ({ const refresh = () => setKey(key + 1); const [search, setSearch] = useState(); - const navigate = useNavigate(); const location = useLocation(); const id = getLastId(location.pathname); @@ -60,14 +55,10 @@ export const GroupTable = ({ let groupsData = undefined; if (id) { - const group = await adminClient.groups.findOne({ id }); - if (!group) { - throw new Error(t("common:notFound")); - } - - groupsData = !search - ? group.subGroups - : group.subGroups?.filter((g) => g.name?.includes(search)); + groupsData = await fetchAdminUI( + "ui-ext/groups/subgroup", + { ...params, id }, + ); } else { groupsData = await fetchAdminUI("ui-ext/groups", { ...params, @@ -75,11 +66,7 @@ export const GroupTable = ({ }); } - if (!groupsData) { - navigate(toGroups({ realm })); - } - - return groupsData || []; + return groupsData; }; return ( @@ -204,11 +191,7 @@ export const GroupTable = ({ displayKey: "groups:groupName", cellRenderer: (group) => canViewDetails ? ( - navigate(toGroups({ realm, id: group.id }))} - > + {group.name} ) : ( diff --git a/js/apps/admin-ui/src/groups/GroupsSection.tsx b/js/apps/admin-ui/src/groups/GroupsSection.tsx index 19b35ec3292..765a71135ed 100644 --- a/js/apps/admin-ui/src/groups/GroupsSection.tsx +++ b/js/apps/admin-ui/src/groups/GroupsSection.tsx @@ -1,5 +1,6 @@ import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; import { + Button, Drawer, DrawerContent, DrawerContentBody, @@ -11,16 +12,18 @@ import { Tab, TabTitleText, Tabs, + Tooltip, } from "@patternfly/react-core"; +import { AngleLeftIcon, TreeIcon } from "@patternfly/react-icons"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; -import { adminClient } from "../admin-client"; import { GroupBreadCrumbs } from "../components/bread-crumb/GroupBreadCrumbs"; import { PermissionsTab } from "../components/permission-tab/PermissionTab"; import { ViewHeader } from "../components/view-header/ViewHeader"; import { useAccess } from "../context/access/Access"; +import { fetchAdminUI } from "../context/auth/admin-ui-endpoint"; import { useRealm } from "../context/realm-context/RealmContext"; import helpUrls from "../help-urls"; import { useFetch } from "../utils/useFetch"; @@ -53,6 +56,7 @@ export default function GroupsSection() { const location = useLocation(); const id = getLastId(location.pathname); + const [open, toggle] = useToggle(true); const [key, setKey] = useState(0); const refresh = () => setKey(key + 1); @@ -82,7 +86,9 @@ export default function GroupsSection() { for (const i of ids!) { const group = i !== "search" - ? await adminClient.groups.findOne({ id: i }) + ? await fetchAdminUI( + "ui-ext/groups/" + i, + ) : { name: t("searchGroups"), id: "search" }; if (group) { groups.push(group); @@ -123,121 +129,122 @@ export default function GroupsSection() { /> )} - + + - - setRename(currentGroup())} - > - {t("renameGroup")} - , - - {t("deleteGroup")} - , - ] - : undefined - } + - {subGroups.length > 0 && ( - setActiveTab(key as number)} - isBox - mountOnEnter - unmountOnExit - > - {t("childGroups")}} - > - - - {canViewMembers && ( - {t("members")}} - > - - - )} - {t("common:attributes")} - } - > - - - {canManageRoles && ( - {t("roleMapping")} - } - > - - - )} - {canViewPermissions && ( - - {t("common:permissions")} - - } - > - - - )} - - )} - {subGroups.length === 0 && ( - - )} } > - + + + ), + }, + ] + : []), + ]; + } setGroups(groups); - setData(groups.map((g) => mapGroup(g, [], refresh))); + if (search) { + setData(groups.map((g) => mapGroup(g, refresh))); + } else { + setData( + unionBy( + data, + groups.map((g) => mapGroup(g, refresh)), + "id", + ), + ); + } setCount(count); }, - [key, first, max, search, exact], + [key, first, firstSub, max, search, exact, activeItem], ); const findGroup = ( - groups: GroupRepresentation[], + groups: GroupRepresentation[] | TreeViewDataItem[], id: string, - path: GroupRepresentation[], - found: GroupRepresentation[], + path: (GroupRepresentation | TreeViewDataItem)[], + found: (GroupRepresentation | TreeViewDataItem)[], ) => { return groups.map((group) => { if (found.length > 0) return; - if (group.subGroups && group.subGroups.length > 0) + if ("subGroups" in group && group.subGroups?.length) { findGroup(group.subGroups, id, [...path, group], found); + } + + if ("children" in group && group.children) { + findGroup(group.children, id, [...path, group], found); + } if (group.id === id) { found.push(...path, group); @@ -241,6 +297,7 @@ export const GroupTree = ({ hasSelectableNodes className="keycloak_groups_treeview" onSelect={(_, item) => { + if (item.id === "next") return; setActiveItem(item); const id = item.id?.substring(item.id.lastIndexOf("/") + 1); const subGroups: GroupRepresentation[] = []; diff --git a/js/apps/admin-ui/src/groups/routes/Groups.tsx b/js/apps/admin-ui/src/groups/routes/Groups.tsx index cac06784bc6..a98b5f6e4af 100644 --- a/js/apps/admin-ui/src/groups/routes/Groups.tsx +++ b/js/apps/admin-ui/src/groups/routes/Groups.tsx @@ -3,7 +3,7 @@ import type { Path } from "react-router-dom"; import { generatePath } from "react-router-dom"; import type { AppRouteObject } from "../../routes"; -export type GroupsParams = { realm: string; id?: string }; +export type GroupsParams = { realm: string; id?: string; lazy?: string }; const GroupsSection = lazy(() => import("../GroupsSection")); diff --git a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/GroupsResource.java b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/GroupsResource.java index af542542cb6..c7882a6b958 100644 --- a/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/GroupsResource.java +++ b/rest/admin-ui-ext/src/main/java/org/keycloak/admin/ui/rest/GroupsResource.java @@ -1,10 +1,13 @@ package org.keycloak.admin.ui.rest; +import java.util.Objects; import java.util.stream.Stream; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; @@ -16,11 +19,14 @@ import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.keycloak.models.GroupModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.services.resources.admin.permissions.GroupPermissionEvaluator; import org.keycloak.utils.GroupUtils; +import static org.keycloak.models.utils.ModelToRepresentation.toRepresentation; + public class GroupsResource { private final KeycloakSession session; private final RealmModel realm; @@ -64,6 +70,66 @@ public class GroupsResource { boolean canViewGlobal = groupsEvaluator.canView(); return stream.filter(group -> canViewGlobal || groupsEvaluator.canView(group)) - .map(group -> GroupUtils.toGroupHierarchy(groupsEvaluator, group, search, exact)); + .map(group -> GroupUtils.toGroupHierarchy(groupsEvaluator, group, search, exact, "".equals(search))); + } + + @GET + @Path("/subgroup") + @Consumes({"application/json"}) + @Produces({"application/json"}) + @Operation( + summary = "List all sub groups with fine grained authorisation and pagination", + description = "This endpoint returns a list of groups with fine grained authorisation" + ) + @APIResponse( + responseCode = "200", + description = "", + content = {@Content( + schema = @Schema( + implementation = GroupRepresentation.class, + type = SchemaType.ARRAY + ) + )} + ) + public final Stream subgroups(@QueryParam("id") final String groupId, @QueryParam("search") + @DefaultValue("") final String search, @QueryParam("first") @DefaultValue("0") int first, @QueryParam("max") @DefaultValue("10") int max) { + GroupPermissionEvaluator groupsEvaluator = auth.groups(); + groupsEvaluator.requireList(); + GroupModel group = realm.getGroupById(groupId); + if (group == null) { + return Stream.empty(); + } + + return group.getSubGroupsStream().filter(g -> g.getName().contains(search)) + .map(g -> GroupUtils.toGroupHierarchy(groupsEvaluator, g, search, false, true)).skip(first).limit(max); + } + + @GET + @Path("{id}") + @Consumes({"application/json"}) + @Produces({"application/json"}) + @Operation( + summary = "Find a specific group with no subgroups", + description = "This endpoint returns a group by id with no subgroups" + ) + @APIResponse( + responseCode = "200", + description = "", + content = {@Content( + schema = @Schema( + implementation = GroupRepresentation.class, + type = SchemaType.OBJECT + ) + )} + ) + public GroupRepresentation findGroupById(@PathParam("id") String id) { + GroupModel group = realm.getGroupById(id); + this.auth.groups().requireView(group); + + GroupRepresentation rep = toRepresentation(group, true); + + rep.setAccess(auth.groups().getAccess(group)); + + return rep; } } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/GroupsResource.java b/services/src/main/java/org/keycloak/services/resources/admin/GroupsResource.java index beb5b62b6f0..74bf445e687 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/GroupsResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/GroupsResource.java @@ -108,7 +108,7 @@ public class GroupsResource { boolean canViewGlobal = groupsEvaluator.canView(); return stream.filter(group -> canViewGlobal || groupsEvaluator.canView(group)) - .map(group -> GroupUtils.toGroupHierarchy(groupsEvaluator, group, search, exact, !briefRepresentation)); + .map(group -> GroupUtils.toGroupHierarchy(groupsEvaluator, group, search, exact, !briefRepresentation, false)); } /** diff --git a/services/src/main/java/org/keycloak/utils/GroupUtils.java b/services/src/main/java/org/keycloak/utils/GroupUtils.java index be46a79d0cd..bc1c51e5641 100644 --- a/services/src/main/java/org/keycloak/utils/GroupUtils.java +++ b/services/src/main/java/org/keycloak/utils/GroupUtils.java @@ -1,5 +1,6 @@ package org.keycloak.utils; +import java.util.Collections; import java.util.stream.Collectors; import org.keycloak.common.Profile; @@ -10,22 +11,26 @@ import org.keycloak.services.resources.admin.permissions.GroupPermissionEvaluato public class GroupUtils { // Moved out from org.keycloak.admin.ui.rest.GroupsResource - public static GroupRepresentation toGroupHierarchy(GroupPermissionEvaluator groupsEvaluator, GroupModel group, final String search, boolean exact) { - return toGroupHierarchy(groupsEvaluator, group, search, exact, true); + public static GroupRepresentation toGroupHierarchy(GroupPermissionEvaluator groupsEvaluator, GroupModel group, final String search, boolean exact, boolean lazy) { + return toGroupHierarchy(groupsEvaluator, group, search, exact, true, lazy); } - public static GroupRepresentation toGroupHierarchy(GroupPermissionEvaluator groupsEvaluator, GroupModel group, final String search, boolean exact, boolean full) { + public static GroupRepresentation toGroupHierarchy(GroupPermissionEvaluator groupsEvaluator, GroupModel group, final String search, boolean exact, boolean full, boolean lazy) { GroupRepresentation rep = ModelToRepresentation.toRepresentation(group, full); - rep.setSubGroups(group.getSubGroupsStream().filter(g -> - groupMatchesSearchOrIsPathElement( - g, search - ) - ).map(subGroup -> - ModelToRepresentation.toGroupHierarchy( - subGroup, full, search, exact - ) + if (!lazy) { + rep.setSubGroups(group.getSubGroupsStream().filter(g -> + groupMatchesSearchOrIsPathElement( + g, search + ) + ).map(subGroup -> + ModelToRepresentation.toGroupHierarchy( + subGroup, full, search, exact + ) - ).collect(Collectors.toList())); + ).collect(Collectors.toList())); + } else { + rep.setSubGroups(Collections.emptyList()); + } if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)) { setAccess(groupsEvaluator, group, rep);