Added Memberships Modal (#33433)

* added MembershipsModal and fixed minor css issues

Signed-off-by: Agnieszka Gancarczyk <agagancarczyk@gmail.com>

* added test

Signed-off-by: Agnieszka Gancarczyk <agagancarczyk@gmail.com>

* improved test

Signed-off-by: Agnieszka Gancarczyk <agagancarczyk@gmail.com>

---------

Signed-off-by: Agnieszka Gancarczyk <agagancarczyk@gmail.com>
This commit is contained in:
Agnieszka Gancarczyk 2024-10-29 12:27:09 +00:00 committed by GitHub
parent 9681bce5fa
commit 09e3784f84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 209 additions and 9 deletions

View file

@ -394,6 +394,16 @@ describe("Group test", () => {
.assertNotificationUserLeftTheGroup(1) .assertNotificationUserLeftTheGroup(1)
.assertNoUsersFoundEmptyStateMessageExist(true); .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", () => { describe("Breadcrumbs", () => {

View file

@ -54,6 +54,17 @@ export default class MembersTab extends GroupDetailPage {
return this; return this;
} }
public showGroupMembershipsItem(username: string) {
listingPage.clickRowDetails(username);
listingPage.clickDetailMenu("Show memberships");
return this;
}
public cancelShowGroupMembershipsModal() {
modalUtils.cancelModal();
return this;
}
public clickCheckboxIncludeSubGroupUsers() { public clickCheckboxIncludeSubGroupUsers() {
cy.findByTestId(this.#includeSubGroupsCheck).click(); cy.findByTestId(this.#includeSubGroupsCheck).click();
return this; return this;

View file

@ -3273,3 +3273,7 @@ groupDuplicated=Group duplicated
duplicateAGroup=Duplicate group duplicateAGroup=Duplicate group
couldNotFetchClientRoleMappings=Could not fetch client role mappings\: {{error}} 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. 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

View file

@ -32,6 +32,8 @@ import { emptyFormatter } from "../util";
import { MemberModal } from "./MembersModal"; import { MemberModal } from "./MembersModal";
import { useSubGroups } from "./SubGroupsContext"; import { useSubGroups } from "./SubGroupsContext";
import { getLastId } from "./groupIdUtils"; import { getLastId } from "./groupIdUtils";
import { MembershipsModal } from "./MembershipsModal";
import useToggle from "../utils/useToggle";
const UserDetailLink = (user: UserRepresentation) => { const UserDetailLink = (user: UserRepresentation) => {
const { realm } = useRealm(); const { realm } = useRealm();
@ -50,9 +52,7 @@ const UserDetailLink = (user: UserRepresentation) => {
export const Members = () => { export const Members = () => {
const { adminClient } = useAdminClient(); const { adminClient } = useAdminClient();
const { t } = useTranslation(); const { t } = useTranslation();
const { addAlert, addError } = useAlerts(); const { addAlert, addError } = useAlerts();
const location = useLocation(); const location = useLocation();
const id = getLastId(location.pathname); const id = getLastId(location.pathname);
@ -62,6 +62,8 @@ export const Members = () => {
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 [selectedUser, setSelectedUser] = useState<UserRepresentation>();
const [showMemberships, toggleShowMemberships] = useToggle();
const { hasAccess } = useAccess(); const { hasAccess } = useAccess();
useFetch( useFetch(
@ -162,6 +164,14 @@ export const Members = () => {
}} }}
/> />
)} )}
{showMemberships && (
<MembershipsModal
onClose={() => {
toggleShowMemberships();
}}
user={selectedUser!}
/>
)}
<KeycloakDataTable <KeycloakDataTable
data-testid="members-table" data-testid="members-table"
key={`${id}${key}${includeSubGroup}`} key={`${id}${key}${includeSubGroup}`}
@ -242,8 +252,8 @@ export const Members = () => {
</> </>
) )
} }
actions={ actions={[
isManager ...(isManager
? [ ? [
{ {
title: t("leave"), title: t("leave"),
@ -257,13 +267,19 @@ export const Members = () => {
} catch (error) { } catch (error) {
addError("usersLeftError", error); addError("usersLeftError", error);
} }
return true; return true;
}, },
} as Action<UserRepresentation>, } as Action<UserRepresentation>,
] ]
: [] : []),
} {
title: t("showMemberships"),
onRowClick: (user) => {
setSelectedUser(user);
toggleShowMemberships();
},
} as Action<UserRepresentation>,
]}
columns={[ columns={[
{ {
name: "username", name: "username",

View file

@ -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 (
<Modal
variant={ModalVariant.large}
title={t("showMembershipsTitle", { username: user.username })}
data-testid="showMembershipsDialog"
isOpen
onClose={onClose}
actions={[
<Button
id="modal-cancel"
data-testid="cancel"
key="cancel"
variant={ButtonVariant.primary}
onClick={onClose}
>
{t("cancel")}
</Button>,
]}
>
<KeycloakDataTable
key={key}
loader={loader}
className="keycloak_user-section_groups-table"
isPaginated
ariaLabelKey="roleList"
searchPlaceholderKey="searchGroup"
toolbarItem={
<>
<Checkbox
label={t("directMembership")}
key="direct-membership-check"
id="kc-direct-membership-checkbox"
onChange={() => {
setDirectMembership(!isDirectMembership);
refresh();
}}
isChecked={isDirectMembership}
className="pf-v5-u-mt-sm"
/>
{enabled && (
<Popover
aria-label="Basic popover"
position="bottom"
bodyContent={<div>{t("whoWillAppearPopoverTextUsers")}</div>}
>
<Button
variant="link"
className="kc-who-will-appear-button"
key="who-will-appear-button"
icon={<QuestionCircleIcon />}
>
{t("whoWillAppearLinkTextUsers")}
</Button>
</Popover>
)}
</>
}
columns={[
{
name: "groupMembership",
displayKey: "groupMembership",
cellRenderer: (group: GroupRepresentation) => group.name || "-",
transforms: [cellWidth(40)],
},
{
name: "path",
displayKey: "path",
cellRenderer: (group: GroupRepresentation) => (
<GroupPath group={group} />
),
transforms: [cellWidth(45)],
},
]}
emptyState={
<ListEmptyState
hasIcon
message={t("noGroupMemberships")}
instructions={t("noGroupMembershipsText")}
/>
}
/>
</Modal>
);
};

View file

@ -363,6 +363,7 @@ export const GroupTree = ({
name="exact" name="exact"
isChecked={exact} isChecked={exact}
onChange={(_event, value) => setExact(value)} onChange={(_event, value) => setExact(value)}
className="pf-v5-u-mr-xs"
/> />
</InputGroupItem> </InputGroupItem>
<InputGroupItem> <InputGroupItem>

View file

@ -205,13 +205,14 @@ export const UserGroups = ({ user }: UserGroupsProps) => {
refresh(); refresh();
}} }}
isChecked={isDirectMembership} isChecked={isDirectMembership}
className="direct-membership-check" className="pf-v5-u-mt-sm"
/> />
<Button <Button
onClick={() => leave(selectedGroups)} onClick={() => leave(selectedGroups)}
data-testid="leave-group-button" data-testid="leave-group-button"
variant="link" variant="link"
isDisabled={selectedGroups.length === 0} isDisabled={selectedGroups.length === 0}
className="pf-v5-u-ml-md"
> >
{t("leave")} {t("leave")}
</Button> </Button>

View file

@ -26,7 +26,7 @@ button#kc-join-groups-button {
} }
.kc-who-will-appear-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 { .pf-v5-c-chip.kc-consents-chip::before {