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:
parent
92bec0214f
commit
339619816a
8 changed files with 294 additions and 163 deletions
|
@ -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>
|
||||
) : (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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[] = [];
|
||||
|
|
|
@ -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"));
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue