diff --git a/js/apps/admin-ui/cypress/e2e/group_test.spec.ts b/js/apps/admin-ui/cypress/e2e/group_test.spec.ts index 2c5033f97d..2241f2efad 100644 --- a/js/apps/admin-ui/cypress/e2e/group_test.spec.ts +++ b/js/apps/admin-ui/cypress/e2e/group_test.spec.ts @@ -394,6 +394,16 @@ describe("Group test", () => { .assertNotificationUserLeftTheGroup(1) .assertNoUsersFoundEmptyStateMessageExist(true); }); + + it("Show memberships from item bar", () => { + sidebarPage.goToGroups(); + groupPage.goToGroupChildGroupsTab(predefinedGroups[0]); + childGroupsTab.goToMembersTab(); + membersTab + .showGroupMembershipsItem(users[3].username) + .assertGroupItemExist(predefinedGroups[0], true) + .cancelShowGroupMembershipsModal(); + }); }); describe("Breadcrumbs", () => { diff --git a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/groups/group_details/tabs/MembersTab.ts b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/groups/group_details/tabs/MembersTab.ts index 70caecc60c..e8cec122cb 100644 --- a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/groups/group_details/tabs/MembersTab.ts +++ b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/groups/group_details/tabs/MembersTab.ts @@ -54,6 +54,17 @@ export default class MembersTab extends GroupDetailPage { return this; } + public showGroupMembershipsItem(username: string) { + listingPage.clickRowDetails(username); + listingPage.clickDetailMenu("Show memberships"); + return this; + } + + public cancelShowGroupMembershipsModal() { + modalUtils.cancelModal(); + return this; + } + public clickCheckboxIncludeSubGroupUsers() { cy.findByTestId(this.#includeSubGroupsCheck).click(); return this; diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 1f97634391..2a7ceee077 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3273,3 +3273,7 @@ groupDuplicated=Group duplicated duplicateAGroup=Duplicate group couldNotFetchClientRoleMappings=Could not fetch client role mappings\: {{error}} duplicateGroupWarning=Duplication of groups with a large number of subgroups is not supported. Please ensure that the group you are duplicating does not have a large number of subgroups. +showMemberships=Show memberships +showMembershipsTitle={{username}} Group Memberships +noGroupMembershipsText=This user is not a member of any groups. +noGroupMemberships=No memberships diff --git a/js/apps/admin-ui/src/groups/Members.tsx b/js/apps/admin-ui/src/groups/Members.tsx index 6eb72dd95a..6c387b2e6f 100644 --- a/js/apps/admin-ui/src/groups/Members.tsx +++ b/js/apps/admin-ui/src/groups/Members.tsx @@ -32,6 +32,8 @@ import { emptyFormatter } from "../util"; import { MemberModal } from "./MembersModal"; import { useSubGroups } from "./SubGroupsContext"; import { getLastId } from "./groupIdUtils"; +import { MembershipsModal } from "./MembershipsModal"; +import useToggle from "../utils/useToggle"; const UserDetailLink = (user: UserRepresentation) => { const { realm } = useRealm(); @@ -50,9 +52,7 @@ const UserDetailLink = (user: UserRepresentation) => { export const Members = () => { const { adminClient } = useAdminClient(); - const { t } = useTranslation(); - const { addAlert, addError } = useAlerts(); const location = useLocation(); const id = getLastId(location.pathname); @@ -62,6 +62,8 @@ export const Members = () => { const [addMembers, setAddMembers] = useState(false); const [isKebabOpen, setIsKebabOpen] = useState(false); const [selectedRows, setSelectedRows] = useState([]); + const [selectedUser, setSelectedUser] = useState(); + const [showMemberships, toggleShowMemberships] = useToggle(); const { hasAccess } = useAccess(); useFetch( @@ -162,6 +164,14 @@ export const Members = () => { }} /> )} + {showMemberships && ( + { + toggleShowMemberships(); + }} + user={selectedUser!} + /> + )} { ) } - actions={ - isManager + actions={[ + ...(isManager ? [ { title: t("leave"), @@ -257,13 +267,19 @@ export const Members = () => { } catch (error) { addError("usersLeftError", error); } - return true; }, } as Action, ] - : [] - } + : []), + { + title: t("showMemberships"), + onRowClick: (user) => { + setSelectedUser(user); + toggleShowMemberships(); + }, + } as Action, + ]} columns={[ { name: "username", diff --git a/js/apps/admin-ui/src/groups/MembershipsModal.tsx b/js/apps/admin-ui/src/groups/MembershipsModal.tsx new file mode 100644 index 0000000000..829394d174 --- /dev/null +++ b/js/apps/admin-ui/src/groups/MembershipsModal.tsx @@ -0,0 +1,157 @@ +import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; +import UserRepresentation from "js/libs/keycloak-admin-client/lib/defs/userRepresentation"; +import { Modal, ModalVariant } from "@patternfly/react-core"; +import { + Button, + ButtonVariant, + Checkbox, + Popover, +} from "@patternfly/react-core"; +import { QuestionCircleIcon } from "@patternfly/react-icons"; +import { cellWidth } from "@patternfly/react-table"; +import { useHelp } from "@keycloak/keycloak-ui-shared"; +import { ListEmptyState } from "@keycloak/keycloak-ui-shared"; +import { KeycloakDataTable } from "@keycloak/keycloak-ui-shared"; +import { sortBy, uniqBy } from "lodash-es"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAdminClient } from "../admin-client"; +import { GroupPath } from "../components/group/GroupPath"; + +type CredentialDataDialogProps = { + user: UserRepresentation; + onClose: () => void; +}; + +export const MembershipsModal = ({ + user, + onClose, +}: CredentialDataDialogProps) => { + const { t } = useTranslation(); + const { adminClient } = useAdminClient(); + const [key, setKey] = useState(0); + const refresh = () => setKey(key + 1); + const [isDirectMembership, setDirectMembership] = useState(true); + const { enabled } = useHelp(); + const alphabetize = (groupsList: GroupRepresentation[]) => { + return sortBy(groupsList, (group) => group.path?.toUpperCase()); + }; + + const loader = async (first?: number, max?: number, search?: string) => { + const params: { [name: string]: string | number } = { + first: first!, + max: max!, + }; + + const searchParam = search || ""; + if (searchParam) { + params.search = searchParam; + } + + const joinedUserGroups = await adminClient.users.listGroups({ + ...params, + id: user.id!, + }); + + const indirect: GroupRepresentation[] = []; + if (!isDirectMembership) + joinedUserGroups.forEach((g) => { + const paths = ( + g.path?.substring(1).match(/((~\/)|[^/])+/g) || [] + ).slice(0, -1); + + indirect.push( + ...paths.map((p) => ({ + name: p, + path: g.path?.substring(0, g.path.indexOf(p) + p.length), + })), + ); + }); + + return alphabetize(uniqBy([...joinedUserGroups, ...indirect], "path")); + }; + + return ( + + {t("cancel")} + , + ]} + > + + { + setDirectMembership(!isDirectMembership); + refresh(); + }} + isChecked={isDirectMembership} + className="pf-v5-u-mt-sm" + /> + {enabled && ( + {t("whoWillAppearPopoverTextUsers")}} + > + + + )} + + } + columns={[ + { + name: "groupMembership", + displayKey: "groupMembership", + cellRenderer: (group: GroupRepresentation) => group.name || "-", + transforms: [cellWidth(40)], + }, + { + name: "path", + displayKey: "path", + cellRenderer: (group: GroupRepresentation) => ( + + ), + transforms: [cellWidth(45)], + }, + ]} + emptyState={ + + } + /> + + ); +}; diff --git a/js/apps/admin-ui/src/groups/components/GroupTree.tsx b/js/apps/admin-ui/src/groups/components/GroupTree.tsx index f87eeefc5e..2301874795 100644 --- a/js/apps/admin-ui/src/groups/components/GroupTree.tsx +++ b/js/apps/admin-ui/src/groups/components/GroupTree.tsx @@ -363,6 +363,7 @@ export const GroupTree = ({ name="exact" isChecked={exact} onChange={(_event, value) => setExact(value)} + className="pf-v5-u-mr-xs" /> diff --git a/js/apps/admin-ui/src/user/UserGroups.tsx b/js/apps/admin-ui/src/user/UserGroups.tsx index cb7a40783c..f4ec2aa64a 100644 --- a/js/apps/admin-ui/src/user/UserGroups.tsx +++ b/js/apps/admin-ui/src/user/UserGroups.tsx @@ -205,13 +205,14 @@ export const UserGroups = ({ user }: UserGroupsProps) => { refresh(); }} isChecked={isDirectMembership} - className="direct-membership-check" + className="pf-v5-u-mt-sm" /> diff --git a/js/apps/admin-ui/src/user/user-section.css b/js/apps/admin-ui/src/user/user-section.css index e363c98e1c..8845a2ff1f 100644 --- a/js/apps/admin-ui/src/user/user-section.css +++ b/js/apps/admin-ui/src/user/user-section.css @@ -26,7 +26,7 @@ button#kc-join-groups-button { } .kc-who-will-appear-button { - margin-left: var(--pf-v5-global--spacer--md); + margin-left: var(--pf-v5-global--spacer--sm); } .pf-v5-c-chip.kc-consents-chip::before {