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 { SearchInput, ToolbarItem } from "@patternfly/react-core";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; 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 { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { useAccess } from "../context/access/Access"; import { useAccess } from "../context/access/Access";
import { fetchAdminUI } from "../context/auth/admin-ui-endpoint"; import { fetchAdminUI } from "../context/auth/admin-ui-endpoint";
import { useRealm } from "../context/realm-context/RealmContext";
import useToggle from "../utils/useToggle"; import useToggle from "../utils/useToggle";
import { GroupsModal } from "./GroupsModal"; import { GroupsModal } from "./GroupsModal";
import { useSubGroups } from "./SubGroupsContext"; import { useSubGroups } from "./SubGroupsContext";
@ -17,7 +15,6 @@ import { DeleteGroup } from "./components/DeleteGroup";
import { GroupToolbar } from "./components/GroupToolbar"; import { GroupToolbar } from "./components/GroupToolbar";
import { MoveDialog } from "./components/MoveDialog"; import { MoveDialog } from "./components/MoveDialog";
import { getLastId } from "./groupIdUtils"; import { getLastId } from "./groupIdUtils";
import { toGroups } from "./routes/Groups";
type GroupTableProps = { type GroupTableProps = {
refresh: () => void; refresh: () => void;
@ -30,7 +27,6 @@ export const GroupTable = ({
}: GroupTableProps) => { }: GroupTableProps) => {
const { t } = useTranslation("groups"); const { t } = useTranslation("groups");
const { realm } = useRealm();
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]); const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
const [rename, setRename] = useState<GroupRepresentation>(); const [rename, setRename] = useState<GroupRepresentation>();
@ -44,7 +40,6 @@ export const GroupTable = ({
const refresh = () => setKey(key + 1); const refresh = () => setKey(key + 1);
const [search, setSearch] = useState<string>(); const [search, setSearch] = useState<string>();
const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const id = getLastId(location.pathname); const id = getLastId(location.pathname);
@ -60,14 +55,10 @@ export const GroupTable = ({
let groupsData = undefined; let groupsData = undefined;
if (id) { if (id) {
const group = await adminClient.groups.findOne({ id }); groupsData = await fetchAdminUI<GroupRepresentation[]>(
if (!group) { "ui-ext/groups/subgroup",
throw new Error(t("common:notFound")); { ...params, id },
} );
groupsData = !search
? group.subGroups
: group.subGroups?.filter((g) => g.name?.includes(search));
} else { } else {
groupsData = await fetchAdminUI<GroupRepresentation[]>("ui-ext/groups", { groupsData = await fetchAdminUI<GroupRepresentation[]>("ui-ext/groups", {
...params, ...params,
@ -75,11 +66,7 @@ export const GroupTable = ({
}); });
} }
if (!groupsData) { return groupsData;
navigate(toGroups({ realm }));
}
return groupsData || [];
}; };
return ( return (
@ -204,11 +191,7 @@ export const GroupTable = ({
displayKey: "groups:groupName", displayKey: "groups:groupName",
cellRenderer: (group) => cellRenderer: (group) =>
canViewDetails ? ( canViewDetails ? (
<Link <Link key={group.id} to={`${location.pathname}/${group.id}`}>
key={group.id}
to={`${location.pathname}/${group.id}`}
onClick={() => navigate(toGroups({ realm, id: group.id }))}
>
{group.name} {group.name}
</Link> </Link>
) : ( ) : (

View file

@ -1,5 +1,6 @@
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import { import {
Button,
Drawer, Drawer,
DrawerContent, DrawerContent,
DrawerContentBody, DrawerContentBody,
@ -11,16 +12,18 @@ import {
Tab, Tab,
TabTitleText, TabTitleText,
Tabs, Tabs,
Tooltip,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { AngleLeftIcon, TreeIcon } from "@patternfly/react-icons";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { adminClient } from "../admin-client";
import { GroupBreadCrumbs } from "../components/bread-crumb/GroupBreadCrumbs"; import { GroupBreadCrumbs } from "../components/bread-crumb/GroupBreadCrumbs";
import { PermissionsTab } from "../components/permission-tab/PermissionTab"; import { PermissionsTab } from "../components/permission-tab/PermissionTab";
import { ViewHeader } from "../components/view-header/ViewHeader"; import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAccess } from "../context/access/Access"; import { useAccess } from "../context/access/Access";
import { fetchAdminUI } from "../context/auth/admin-ui-endpoint";
import { useRealm } from "../context/realm-context/RealmContext"; import { useRealm } from "../context/realm-context/RealmContext";
import helpUrls from "../help-urls"; import helpUrls from "../help-urls";
import { useFetch } from "../utils/useFetch"; import { useFetch } from "../utils/useFetch";
@ -53,6 +56,7 @@ export default function GroupsSection() {
const location = useLocation(); const location = useLocation();
const id = getLastId(location.pathname); const id = getLastId(location.pathname);
const [open, toggle] = useToggle(true);
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
const refresh = () => setKey(key + 1); const refresh = () => setKey(key + 1);
@ -82,7 +86,9 @@ export default function GroupsSection() {
for (const i of ids!) { for (const i of ids!) {
const group = const group =
i !== "search" i !== "search"
? await adminClient.groups.findOne({ id: i }) ? await fetchAdminUI<GroupRepresentation | undefined>(
"ui-ext/groups/" + i,
)
: { name: t("searchGroups"), id: "search" }; : { name: t("searchGroups"), id: "search" };
if (group) { if (group) {
groups.push(group); groups.push(group);
@ -123,11 +129,28 @@ export default function GroupsSection() {
/> />
)} )}
<PageSection variant={PageSectionVariants.light} className="pf-u-p-0"> <PageSection variant={PageSectionVariants.light} className="pf-u-p-0">
<Drawer isInline isExpanded key={key}> <Drawer isInline isExpanded={open} key={key} position="left">
<DrawerContent <DrawerContent
panelContent={ panelContent={
<DrawerPanelContent isResizable defaultSize="80%" minSize="500px"> <DrawerPanelContent isResizable>
<DrawerHead> <DrawerHead>
<GroupTree
refresh={refresh}
canViewDetails={canViewDetails}
/>
</DrawerHead>
</DrawerPanelContent>
}
>
<DrawerContentBody>
<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 /> <GroupBreadCrumbs />
<ViewHeader <ViewHeader
titleKey={!id ? "groups:groups" : currentGroup()?.name!} titleKey={!id ? "groups:groups" : currentGroup()?.name!}
@ -201,14 +224,9 @@ export default function GroupsSection() {
<Tab <Tab
eventKey={3} eventKey={3}
data-testid="role-mapping-tab" data-testid="role-mapping-tab"
title={ title={<TabTitleText>{t("roleMapping")}</TabTitleText>}
<TabTitleText>{t("roleMapping")}</TabTitleText>
}
> >
<GroupRoleMapping <GroupRoleMapping id={id!} name={currentGroup()?.name!} />
id={id!}
name={currentGroup()?.name!}
/>
</Tab> </Tab>
)} )}
{canViewPermissions && ( {canViewPermissions && (
@ -216,9 +234,7 @@ export default function GroupsSection() {
eventKey={4} eventKey={4}
data-testid="permissionsTab" data-testid="permissionsTab"
title={ title={
<TabTitleText> <TabTitleText>{t("common:permissions")}</TabTitleText>
{t("common:permissions")}
</TabTitleText>
} }
> >
<PermissionsTab id={id} type="groups" /> <PermissionsTab id={id} type="groups" />
@ -227,17 +243,8 @@ export default function GroupsSection() {
</Tabs> </Tabs>
)} )}
{subGroups.length === 0 && ( {subGroups.length === 0 && (
<GroupTable <GroupTable refresh={refresh} canViewDetails={canViewDetails} />
refresh={refresh}
canViewDetails={canViewDetails}
/>
)} )}
</DrawerHead>
</DrawerPanelContent>
}
>
<DrawerContentBody>
<GroupTree refresh={refresh} canViewDetails={canViewDetails} />
</DrawerContentBody> </DrawerContentBody>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import type { Path } from "react-router-dom";
import { generatePath } from "react-router-dom"; import { generatePath } from "react-router-dom";
import type { AppRouteObject } from "../../routes"; 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")); const GroupsSection = lazy(() => import("../GroupsSection"));

View file

@ -1,10 +1,13 @@
package org.keycloak.admin.ui.rest; package org.keycloak.admin.ui.rest;
import java.util.Objects;
import java.util.stream.Stream; import java.util.stream.Stream;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam; 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.GroupModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator; import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import org.keycloak.services.resources.admin.permissions.GroupPermissionEvaluator; import org.keycloak.services.resources.admin.permissions.GroupPermissionEvaluator;
import org.keycloak.utils.GroupUtils; import org.keycloak.utils.GroupUtils;
import static org.keycloak.models.utils.ModelToRepresentation.toRepresentation;
public class GroupsResource { public class GroupsResource {
private final KeycloakSession session; private final KeycloakSession session;
private final RealmModel realm; private final RealmModel realm;
@ -64,6 +70,66 @@ public class GroupsResource {
boolean canViewGlobal = groupsEvaluator.canView(); boolean canViewGlobal = groupsEvaluator.canView();
return stream.filter(group -> canViewGlobal || groupsEvaluator.canView(group)) 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(); boolean canViewGlobal = groupsEvaluator.canView();
return stream.filter(group -> canViewGlobal || groupsEvaluator.canView(group)) 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; package org.keycloak.utils;
import java.util.Collections;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
@ -10,12 +11,13 @@ import org.keycloak.services.resources.admin.permissions.GroupPermissionEvaluato
public class GroupUtils { public class GroupUtils {
// Moved out from org.keycloak.admin.ui.rest.GroupsResource // Moved out from org.keycloak.admin.ui.rest.GroupsResource
public static GroupRepresentation toGroupHierarchy(GroupPermissionEvaluator groupsEvaluator, GroupModel group, final String search, boolean exact) { public static GroupRepresentation toGroupHierarchy(GroupPermissionEvaluator groupsEvaluator, GroupModel group, final String search, boolean exact, boolean lazy) {
return toGroupHierarchy(groupsEvaluator, group, search, exact, true); 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); GroupRepresentation rep = ModelToRepresentation.toRepresentation(group, full);
if (!lazy) {
rep.setSubGroups(group.getSubGroupsStream().filter(g -> rep.setSubGroups(group.getSubGroupsStream().filter(g ->
groupMatchesSearchOrIsPathElement( groupMatchesSearchOrIsPathElement(
g, search g, search
@ -26,6 +28,9 @@ public class GroupUtils {
) )
).collect(Collectors.toList())); ).collect(Collectors.toList()));
} else {
rep.setSubGroups(Collections.emptyList());
}
if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)) { if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)) {
setAccess(groupsEvaluator, group, rep); setAccess(groupsEvaluator, group, rep);