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 { 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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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[] = [];
|
||||||
|
|
|
@ -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"));
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in a new issue