Skip to content

Commit 09e3784

Browse files
Added Memberships Modal (#33433)
* added MembershipsModal and fixed minor css issues Signed-off-by: Agnieszka Gancarczyk <[email protected]> * added test Signed-off-by: Agnieszka Gancarczyk <[email protected]> * improved test Signed-off-by: Agnieszka Gancarczyk <[email protected]> --------- Signed-off-by: Agnieszka Gancarczyk <[email protected]>
1 parent 9681bce commit 09e3784

File tree

8 files changed

+209
-9
lines changed

8 files changed

+209
-9
lines changed

js/apps/admin-ui/cypress/e2e/group_test.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,16 @@ describe("Group test", () => {
394394
.assertNotificationUserLeftTheGroup(1)
395395
.assertNoUsersFoundEmptyStateMessageExist(true);
396396
});
397+
398+
it("Show memberships from item bar", () => {
399+
sidebarPage.goToGroups();
400+
groupPage.goToGroupChildGroupsTab(predefinedGroups[0]);
401+
childGroupsTab.goToMembersTab();
402+
membersTab
403+
.showGroupMembershipsItem(users[3].username)
404+
.assertGroupItemExist(predefinedGroups[0], true)
405+
.cancelShowGroupMembershipsModal();
406+
});
397407
});
398408

399409
describe("Breadcrumbs", () => {

js/apps/admin-ui/cypress/support/pages/admin-ui/manage/groups/group_details/tabs/MembersTab.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,17 @@ export default class MembersTab extends GroupDetailPage {
5454
return this;
5555
}
5656

57+
public showGroupMembershipsItem(username: string) {
58+
listingPage.clickRowDetails(username);
59+
listingPage.clickDetailMenu("Show memberships");
60+
return this;
61+
}
62+
63+
public cancelShowGroupMembershipsModal() {
64+
modalUtils.cancelModal();
65+
return this;
66+
}
67+
5768
public clickCheckboxIncludeSubGroupUsers() {
5869
cy.findByTestId(this.#includeSubGroupsCheck).click();
5970
return this;

js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3273,3 +3273,7 @@ groupDuplicated=Group duplicated
32733273
duplicateAGroup=Duplicate group
32743274
couldNotFetchClientRoleMappings=Could not fetch client role mappings\: {{error}}
32753275
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.
3276+
showMemberships=Show memberships
3277+
showMembershipsTitle={{username}} Group Memberships
3278+
noGroupMembershipsText=This user is not a member of any groups.
3279+
noGroupMemberships=No memberships

js/apps/admin-ui/src/groups/Members.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import { emptyFormatter } from "../util";
3232
import { MemberModal } from "./MembersModal";
3333
import { useSubGroups } from "./SubGroupsContext";
3434
import { getLastId } from "./groupIdUtils";
35+
import { MembershipsModal } from "./MembershipsModal";
36+
import useToggle from "../utils/useToggle";
3537

3638
const UserDetailLink = (user: UserRepresentation) => {
3739
const { realm } = useRealm();
@@ -50,9 +52,7 @@ const UserDetailLink = (user: UserRepresentation) => {
5052

5153
export const Members = () => {
5254
const { adminClient } = useAdminClient();
53-
5455
const { t } = useTranslation();
55-
5656
const { addAlert, addError } = useAlerts();
5757
const location = useLocation();
5858
const id = getLastId(location.pathname);
@@ -62,6 +62,8 @@ export const Members = () => {
6262
const [addMembers, setAddMembers] = useState(false);
6363
const [isKebabOpen, setIsKebabOpen] = useState(false);
6464
const [selectedRows, setSelectedRows] = useState<UserRepresentation[]>([]);
65+
const [selectedUser, setSelectedUser] = useState<UserRepresentation>();
66+
const [showMemberships, toggleShowMemberships] = useToggle();
6567
const { hasAccess } = useAccess();
6668

6769
useFetch(
@@ -162,6 +164,14 @@ export const Members = () => {
162164
}}
163165
/>
164166
)}
167+
{showMemberships && (
168+
<MembershipsModal
169+
onClose={() => {
170+
toggleShowMemberships();
171+
}}
172+
user={selectedUser!}
173+
/>
174+
)}
165175
<KeycloakDataTable
166176
data-testid="members-table"
167177
key={`${id}${key}${includeSubGroup}`}
@@ -242,8 +252,8 @@ export const Members = () => {
242252
</>
243253
)
244254
}
245-
actions={
246-
isManager
255+
actions={[
256+
...(isManager
247257
? [
248258
{
249259
title: t("leave"),
@@ -257,13 +267,19 @@ export const Members = () => {
257267
} catch (error) {
258268
addError("usersLeftError", error);
259269
}
260-
261270
return true;
262271
},
263272
} as Action<UserRepresentation>,
264273
]
265-
: []
266-
}
274+
: []),
275+
{
276+
title: t("showMemberships"),
277+
onRowClick: (user) => {
278+
setSelectedUser(user);
279+
toggleShowMemberships();
280+
},
281+
} as Action<UserRepresentation>,
282+
]}
267283
columns={[
268284
{
269285
name: "username",
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
2+
import UserRepresentation from "js/libs/keycloak-admin-client/lib/defs/userRepresentation";
3+
import { Modal, ModalVariant } from "@patternfly/react-core";
4+
import {
5+
Button,
6+
ButtonVariant,
7+
Checkbox,
8+
Popover,
9+
} from "@patternfly/react-core";
10+
import { QuestionCircleIcon } from "@patternfly/react-icons";
11+
import { cellWidth } from "@patternfly/react-table";
12+
import { useHelp } from "@keycloak/keycloak-ui-shared";
13+
import { ListEmptyState } from "@keycloak/keycloak-ui-shared";
14+
import { KeycloakDataTable } from "@keycloak/keycloak-ui-shared";
15+
import { sortBy, uniqBy } from "lodash-es";
16+
import { useState } from "react";
17+
import { useTranslation } from "react-i18next";
18+
import { useAdminClient } from "../admin-client";
19+
import { GroupPath } from "../components/group/GroupPath";
20+
21+
type CredentialDataDialogProps = {
22+
user: UserRepresentation;
23+
onClose: () => void;
24+
};
25+
26+
export const MembershipsModal = ({
27+
user,
28+
onClose,
29+
}: CredentialDataDialogProps) => {
30+
const { t } = useTranslation();
31+
const { adminClient } = useAdminClient();
32+
const [key, setKey] = useState(0);
33+
const refresh = () => setKey(key + 1);
34+
const [isDirectMembership, setDirectMembership] = useState(true);
35+
const { enabled } = useHelp();
36+
const alphabetize = (groupsList: GroupRepresentation[]) => {
37+
return sortBy(groupsList, (group) => group.path?.toUpperCase());
38+
};
39+
40+
const loader = async (first?: number, max?: number, search?: string) => {
41+
const params: { [name: string]: string | number } = {
42+
first: first!,
43+
max: max!,
44+
};
45+
46+
const searchParam = search || "";
47+
if (searchParam) {
48+
params.search = searchParam;
49+
}
50+
51+
const joinedUserGroups = await adminClient.users.listGroups({
52+
...params,
53+
id: user.id!,
54+
});
55+
56+
const indirect: GroupRepresentation[] = [];
57+
if (!isDirectMembership)
58+
joinedUserGroups.forEach((g) => {
59+
const paths = (
60+
g.path?.substring(1).match(/((~\/)|[^/])+/g) || []
61+
).slice(0, -1);
62+
63+
indirect.push(
64+
...paths.map((p) => ({
65+
name: p,
66+
path: g.path?.substring(0, g.path.indexOf(p) + p.length),
67+
})),
68+
);
69+
});
70+
71+
return alphabetize(uniqBy([...joinedUserGroups, ...indirect], "path"));
72+
};
73+
74+
return (
75+
<Modal
76+
variant={ModalVariant.large}
77+
title={t("showMembershipsTitle", { username: user.username })}
78+
data-testid="showMembershipsDialog"
79+
isOpen
80+
onClose={onClose}
81+
actions={[
82+
<Button
83+
id="modal-cancel"
84+
data-testid="cancel"
85+
key="cancel"
86+
variant={ButtonVariant.primary}
87+
onClick={onClose}
88+
>
89+
{t("cancel")}
90+
</Button>,
91+
]}
92+
>
93+
<KeycloakDataTable
94+
key={key}
95+
loader={loader}
96+
className="keycloak_user-section_groups-table"
97+
isPaginated
98+
ariaLabelKey="roleList"
99+
searchPlaceholderKey="searchGroup"
100+
toolbarItem={
101+
<>
102+
<Checkbox
103+
label={t("directMembership")}
104+
key="direct-membership-check"
105+
id="kc-direct-membership-checkbox"
106+
onChange={() => {
107+
setDirectMembership(!isDirectMembership);
108+
refresh();
109+
}}
110+
isChecked={isDirectMembership}
111+
className="pf-v5-u-mt-sm"
112+
/>
113+
{enabled && (
114+
<Popover
115+
aria-label="Basic popover"
116+
position="bottom"
117+
bodyContent={<div>{t("whoWillAppearPopoverTextUsers")}</div>}
118+
>
119+
<Button
120+
variant="link"
121+
className="kc-who-will-appear-button"
122+
key="who-will-appear-button"
123+
icon={<QuestionCircleIcon />}
124+
>
125+
{t("whoWillAppearLinkTextUsers")}
126+
</Button>
127+
</Popover>
128+
)}
129+
</>
130+
}
131+
columns={[
132+
{
133+
name: "groupMembership",
134+
displayKey: "groupMembership",
135+
cellRenderer: (group: GroupRepresentation) => group.name || "-",
136+
transforms: [cellWidth(40)],
137+
},
138+
{
139+
name: "path",
140+
displayKey: "path",
141+
cellRenderer: (group: GroupRepresentation) => (
142+
<GroupPath group={group} />
143+
),
144+
transforms: [cellWidth(45)],
145+
},
146+
]}
147+
emptyState={
148+
<ListEmptyState
149+
hasIcon
150+
message={t("noGroupMemberships")}
151+
instructions={t("noGroupMembershipsText")}
152+
/>
153+
}
154+
/>
155+
</Modal>
156+
);
157+
};

js/apps/admin-ui/src/groups/components/GroupTree.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ export const GroupTree = ({
363363
name="exact"
364364
isChecked={exact}
365365
onChange={(_event, value) => setExact(value)}
366+
className="pf-v5-u-mr-xs"
366367
/>
367368
</InputGroupItem>
368369
<InputGroupItem>

js/apps/admin-ui/src/user/UserGroups.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,14 @@ export const UserGroups = ({ user }: UserGroupsProps) => {
205205
refresh();
206206
}}
207207
isChecked={isDirectMembership}
208-
className="direct-membership-check"
208+
className="pf-v5-u-mt-sm"
209209
/>
210210
<Button
211211
onClick={() => leave(selectedGroups)}
212212
data-testid="leave-group-button"
213213
variant="link"
214214
isDisabled={selectedGroups.length === 0}
215+
className="pf-v5-u-ml-md"
215216
>
216217
{t("leave")}
217218
</Button>

js/apps/admin-ui/src/user/user-section.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ button#kc-join-groups-button {
2626
}
2727

2828
.kc-who-will-appear-button {
29-
margin-left: var(--pf-v5-global--spacer--md);
29+
margin-left: var(--pf-v5-global--spacer--sm);
3030
}
3131

3232
.pf-v5-c-chip.kc-consents-chip::before {

0 commit comments

Comments
 (0)