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:
parent
9681bce5fa
commit
09e3784f84
8 changed files with 209 additions and 9 deletions
|
@ -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", () => {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
157
js/apps/admin-ui/src/groups/MembershipsModal.tsx
Normal file
157
js/apps/admin-ui/src/groups/MembershipsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in a new issue