lazy populate the treeview for groups (#21520)

* added lazy parameter

fixes: #19954

* changed to only have the parameter

* fixed merge errors

* removed the `lazy` and now add subgroups on select

* lint

* fixed prettier

* fixed nullpointer

* fixed member tab
This commit is contained in:
Erik Jan de Wit 2023-08-04 22:19:34 +02:00 committed by GitHub
parent 92bec0214f
commit 339619816a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 294 additions and 163 deletions

View file

@ -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<GroupRepresentation[]>([]);
const [rename, setRename] = useState<GroupRepresentation>();
@ -44,7 +40,6 @@ export const GroupTable = ({
const refresh = () => setKey(key + 1);
const [search, setSearch] = useState<string>();
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<GroupRepresentation[]>(
"ui-ext/groups/subgroup",
{ ...params, id },
);
} else {
groupsData = await fetchAdminUI<GroupRepresentation[]>("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 ? (
<Link
key={group.id}
to={`${location.pathname}/${group.id}`}
onClick={() => navigate(toGroups({ realm, id: group.id }))}
>
<Link key={group.id} to={`${location.pathname}/${group.id}`}>
{group.name}
</Link>
) : (

View file

@ -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<GroupRepresentation | undefined>(
"ui-ext/groups/" + i,
)
: { name: t("searchGroups"), id: "search" };
if (group) {
groups.push(group);
@ -123,121 +129,122 @@ export default function GroupsSection() {
/>
)}
<PageSection variant={PageSectionVariants.light} className="pf-u-p-0">
<Drawer isInline isExpanded key={key}>
<Drawer isInline isExpanded={open} key={key} position="left">
<DrawerContent
panelContent={
<DrawerPanelContent isResizable defaultSize="80%" minSize="500px">
<DrawerPanelContent isResizable>
<DrawerHead>
<GroupBreadCrumbs />
<ViewHeader
titleKey={!id ? "groups:groups" : currentGroup()?.name!}
subKey={!id ? "groups:groupsDescription" : ""}
helpUrl={!id ? helpUrls.groupsUrl : ""}
divider={!id}
dropdownItems={
id && canManageGroup
? [
<DropdownItem
data-testid="renameGroupAction"
key="renameGroup"
onClick={() => setRename(currentGroup())}
>
{t("renameGroup")}
</DropdownItem>,
<DropdownItem
data-testid="deleteGroup"
key="deleteGroup"
onClick={toggleDeleteOpen}
>
{t("deleteGroup")}
</DropdownItem>,
]
: undefined
}
<GroupTree
refresh={refresh}
canViewDetails={canViewDetails}
/>
{subGroups.length > 0 && (
<Tabs
inset={{
default: "insetNone",
md: "insetSm",
xl: "insetLg",
"2xl": "inset2xl",
}}
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key as number)}
isBox
mountOnEnter
unmountOnExit
>
<Tab
data-testid="groups"
eventKey={0}
title={<TabTitleText>{t("childGroups")}</TabTitleText>}
>
<GroupTable
refresh={refresh}
canViewDetails={canViewDetails}
/>
</Tab>
{canViewMembers && (
<Tab
data-testid="members"
eventKey={1}
title={<TabTitleText>{t("members")}</TabTitleText>}
>
<Members />
</Tab>
)}
<Tab
data-testid="attributes"
eventKey={2}
title={
<TabTitleText>{t("common:attributes")}</TabTitleText>
}
>
<GroupAttributes />
</Tab>
{canManageRoles && (
<Tab
eventKey={3}
data-testid="role-mapping-tab"
title={
<TabTitleText>{t("roleMapping")}</TabTitleText>
}
>
<GroupRoleMapping
id={id!}
name={currentGroup()?.name!}
/>
</Tab>
)}
{canViewPermissions && (
<Tab
eventKey={4}
data-testid="permissionsTab"
title={
<TabTitleText>
{t("common:permissions")}
</TabTitleText>
}
>
<PermissionsTab id={id} type="groups" />
</Tab>
)}
</Tabs>
)}
{subGroups.length === 0 && (
<GroupTable
refresh={refresh}
canViewDetails={canViewDetails}
/>
)}
</DrawerHead>
</DrawerPanelContent>
}
>
<DrawerContentBody>
<GroupTree refresh={refresh} canViewDetails={canViewDetails} />
<Tooltip content={open ? t("common:hide") : t("common:show")}>
<Button
aria-label={open ? t("common:hide") : t("common:show")}
variant="plain"
icon={open ? <AngleLeftIcon /> : <TreeIcon />}
onClick={toggle}
/>
</Tooltip>
<GroupBreadCrumbs />
<ViewHeader
titleKey={!id ? "groups:groups" : currentGroup()?.name!}
subKey={!id ? "groups:groupsDescription" : ""}
helpUrl={!id ? helpUrls.groupsUrl : ""}
divider={!id}
dropdownItems={
id && canManageGroup
? [
<DropdownItem
data-testid="renameGroupAction"
key="renameGroup"
onClick={() => setRename(currentGroup())}
>
{t("renameGroup")}
</DropdownItem>,
<DropdownItem
data-testid="deleteGroup"
key="deleteGroup"
onClick={toggleDeleteOpen}
>
{t("deleteGroup")}
</DropdownItem>,
]
: undefined
}
/>
{subGroups.length > 0 && (
<Tabs
inset={{
default: "insetNone",
md: "insetSm",
xl: "insetLg",
"2xl": "inset2xl",
}}
activeKey={activeTab}
onSelect={(_, key) => setActiveTab(key as number)}
isBox
mountOnEnter
unmountOnExit
>
<Tab
data-testid="groups"
eventKey={0}
title={<TabTitleText>{t("childGroups")}</TabTitleText>}
>
<GroupTable
refresh={refresh}
canViewDetails={canViewDetails}
/>
</Tab>
{canViewMembers && (
<Tab
data-testid="members"
eventKey={1}
title={<TabTitleText>{t("members")}</TabTitleText>}
>
<Members />
</Tab>
)}
<Tab
data-testid="attributes"
eventKey={2}
title={
<TabTitleText>{t("common:attributes")}</TabTitleText>
}
>
<GroupAttributes />
</Tab>
{canManageRoles && (
<Tab
eventKey={3}
data-testid="role-mapping-tab"
title={<TabTitleText>{t("roleMapping")}</TabTitleText>}
>
<GroupRoleMapping id={id!} name={currentGroup()?.name!} />
</Tab>
)}
{canViewPermissions && (
<Tab
eventKey={4}
data-testid="permissionsTab"
title={
<TabTitleText>{t("common:permissions")}</TabTitleText>
}
>
<PermissionsTab id={id} type="groups" />
</Tab>
)}
</Tabs>
)}
{subGroups.length === 0 && (
<GroupTable refresh={refresh} canViewDetails={canViewDetails} />
)}
</DrawerContentBody>
</DrawerContent>
</Drawer>

View file

@ -17,6 +17,7 @@ import { Link, useLocation } from "react-router-dom";
import { adminClient } from "../admin-client";
import { useAlerts } from "../components/alert/Alerts";
import { GroupPath } from "../components/group/GroupPath";
import { KeycloakSpinner } from "../components/keycloak-spinner/KeycloakSpinner";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import {
Action,
@ -26,6 +27,7 @@ import { useAccess } from "../context/access/Access";
import { useRealm } from "../context/realm-context/RealmContext";
import { toUser } from "../user/routes/User";
import { emptyFormatter } from "../util";
import { useFetch } from "../utils/useFetch";
import { MemberModal } from "./MembersModal";
import { useSubGroups } from "./SubGroupsContext";
import { getLastId } from "./groupIdUtils";
@ -63,14 +65,21 @@ export const Members = () => {
const location = useLocation();
const id = getLastId(location.pathname);
const [includeSubGroup, setIncludeSubGroup] = useState(false);
const { currentGroup } = useSubGroups();
const { currentGroup: group } = useSubGroups();
const [currentGroup, setCurrentGroup] = useState<GroupRepresentation>();
const [addMembers, setAddMembers] = useState(false);
const [isKebabOpen, setIsKebabOpen] = useState(false);
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]);
const { hasAccess } = useAccess();
useFetch(
() => adminClient.groups.findOne({ id: group()!.id! }),
setCurrentGroup,
[],
);
const isManager =
hasAccess("manage-users") || currentGroup()!.access!.manageMembership;
hasAccess("manage-users") || currentGroup?.access!.manageMembership;
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
@ -96,7 +105,7 @@ export const Members = () => {
});
if (includeSubGroup) {
const subGroups = getSubGroups(currentGroup()?.subGroups!);
const subGroups = getSubGroups(currentGroup?.subGroups || []);
for (const group of subGroups) {
members = members.concat(
await adminClient.groups.listMembers({ id: group.id! }),
@ -113,6 +122,10 @@ export const Members = () => {
});
};
if (!currentGroup) {
return <KeycloakSpinner />;
}
return (
<>
{addMembers && (

View file

@ -1,6 +1,7 @@
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import {
AlertVariant,
Button,
Checkbox,
Dropdown,
DropdownItem,
@ -16,6 +17,8 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { AngleRightIcon } from "@patternfly/react-icons";
import { unionBy } from "lodash-es";
import { adminClient } from "../../admin-client";
import { useAlerts } from "../../components/alert/Alerts";
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
@ -23,7 +26,6 @@ import { PaginatingTableToolbar } from "../../components/table-toolbar/Paginatin
import { useAccess } from "../../context/access/Access";
import { fetchAdminUI } from "../../context/auth/admin-ui-endpoint";
import { useRealm } from "../../context/realm-context/RealmContext";
import { joinPath } from "../../utils/joinPath";
import { useFetch } from "../../utils/useFetch";
import useToggle from "../../utils/useToggle";
import { GroupsModal } from "../GroupsModal";
@ -109,6 +111,8 @@ type GroupTreeProps = {
canViewDetails: boolean;
};
const SUBGROUP_COUNT = 50;
export const GroupTree = ({
refresh: viewRefresh,
canViewDetails,
@ -130,6 +134,8 @@ export const GroupTree = ({
const [exact, setExact] = useState(false);
const [activeItem, setActiveItem] = useState<TreeViewDataItem>();
const [firstSub, setFirstSub] = useState(0);
const [key, setKey] = useState(0);
const refresh = () => {
setKey(key + 1);
@ -138,12 +144,10 @@ export const GroupTree = ({
const mapGroup = (
group: GroupRepresentation,
parents: GroupRepresentation[],
refresh: () => void,
): TreeViewDataItem => {
const groups = [...parents, group];
return {
id: joinPath(...groups.map((g) => g.id!)),
id: group.id,
name: (
<Tooltip content={group.name}>
<span>{group.name}</span>
@ -151,7 +155,7 @@ export const GroupTree = ({
),
children:
group.subGroups && group.subGroups.length > 0
? group.subGroups.map((g) => mapGroup(g, groups, refresh))
? group.subGroups.map((g) => mapGroup(g, refresh))
: undefined,
action: (hasAccess("manage-users") || group.access?.manage) && (
<GroupTreeContextMenu group={group} refresh={refresh} />
@ -169,33 +173,85 @@ export const GroupTree = ({
first: `${first}`,
max: `${max + 1}`,
exact: `${exact}`,
global: `${search !== ""}`,
},
search === "" ? null : { search },
),
);
const count = (await adminClient.groups.count({ search, top: true }))
.count;
return { groups, count };
let subGroups: GroupRepresentation[] = [];
if (activeItem) {
subGroups = await fetchAdminUI<GroupRepresentation[]>(
"ui-ext/groups/subgroup",
{
id: activeItem.id!,
first: `${firstSub}`,
max: `${SUBGROUP_COUNT}`,
},
);
}
return { groups, count, subGroups };
},
({ groups, count }) => {
({ groups, count, subGroups }) => {
const found: TreeViewDataItem[] = [];
if (activeItem) findGroup(data || [], activeItem.id!, [], found);
if (found.length && subGroups.length) {
const foundTreeItem = found.pop()!;
foundTreeItem.children = [
...(unionBy(foundTreeItem.children || []).splice(0, SUBGROUP_COUNT),
subGroups.map((g) => mapGroup(g, refresh), "id")),
...(subGroups.length === SUBGROUP_COUNT
? [
{
id: "next",
name: (
<Button
variant="plain"
onClick={() => setFirstSub(firstSub + SUBGROUP_COUNT)}
>
<AngleRightIcon />
</Button>
),
},
]
: []),
];
}
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[] = [];

View file

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

View file

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

View file

@ -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));
}
/**

View file

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