Duplicating groups (#32454)

* Duplicating groups - wip

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

* Duplicating groups - wip

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

* Duplicating groups - wip

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

* Duplicating groups - wip

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

* Duplicating groups - wip

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

* Duplicating groups - wip

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

* Duplicating groups - wip

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

* Duplicating groups - enhancement

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

* Duplicating groups - enhancement

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

* Enhancements

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

* Enhancements

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

* Enhancements

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

* Enhancements

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

* improvements

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

* improvements

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

* improvements

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

---------

Signed-off-by: Agnieszka Gancarczyk <agagancarczyk@gmail.com>
This commit is contained in:
Agnieszka Gancarczyk 2024-09-27 12:25:09 +01:00 committed by GitHub
parent 022ab4d263
commit 805a92adbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 292 additions and 22 deletions

View file

@ -119,6 +119,12 @@ describe("Group test", () => {
.assertNoSearchResultsMessageExist(true);
});
it("Duplicate group", () => {
groupPage
.duplicateGroupItem(groupNames[0], true)
.assertNotificationGroupDuplicated();
});
it("Delete group from item bar", () => {
groupPage
.searchGroup(groupNames[0], true)

View file

@ -198,6 +198,14 @@ export default class ListingPage extends CommonElements {
return this;
}
clickMenuDuplicate() {
cy.get(this.#menuContent)
.find(this.#menuItemText)
.contains("Duplicate")
.click({ force: true });
return this;
}
clickItemCheckbox(itemName: string) {
cy.get(this.#itemsRows)
.contains(itemName)
@ -261,6 +269,13 @@ export default class ListingPage extends CommonElements {
return this;
}
duplicateItem(itemName: string) {
this.clickRowDetails(itemName);
this.clickMenuDuplicate();
return this;
}
removeItem(itemName: string) {
this.clickRowDetails(itemName);
this.clickDetailMenu("Unassign");

View file

@ -5,6 +5,7 @@ export default class GroupModal extends ModalUtils {
#groupNameInput = "name";
#createGroupBnt = "createGroup";
#renameButton = "renameGroup";
#duplicateGroup = "duplicateGroup";
public setGroupNameInput(name: string) {
cy.findByTestId(this.#groupNameInput).clear().type(name);
@ -21,6 +22,11 @@ export default class GroupModal extends ModalUtils {
return this;
}
public duplicate() {
cy.findByTestId(this.#duplicateGroup).click();
return this;
}
public assertCreateGroupModalVisible(isVisible: boolean) {
super
.assertModalVisible(isVisible)

View file

@ -113,6 +113,14 @@ export default class GroupPage extends PageObject {
return this;
}
duplicateGroupItem(groupName: string, confirmModal = true) {
listingPage.duplicateItem(groupName);
if (confirmModal) {
groupModal.confirmDuplicateModal();
}
return this;
}
moveGroupItemAction(groupName: string, destinationGroupName: string[]) {
listingPage.clickRowDetails(groupName);
listingPage.clickDetailMenu("Move to");
@ -202,6 +210,11 @@ export default class GroupPage extends PageObject {
return this;
}
assertNotificationGroupDuplicated() {
masthead.checkNotificationMessage("Group duplicated");
return this;
}
goToGroupActions(groupName: string) {
listingPage.clickRowDetails(groupName);

View file

@ -13,6 +13,7 @@ export default class ModalUtils extends PageObject {
#addModalDropdownItem = ".pf-v5-c-modal-box__footer .pf-v5-c-menu__content";
#addBtn = "add";
#tablePage = new TablePage(TablePage.tableSelector);
#confirmDuplicateModalBtn = "duplicateGroup";
table() {
return this.#tablePage;
@ -29,6 +30,12 @@ export default class ModalUtils extends PageObject {
return this;
}
confirmDuplicateModal() {
cy.findByTestId(this.#confirmDuplicateModalBtn).click({ force: true });
return this;
}
checkConfirmButtonText(text: string) {
cy.findByTestId(this.#confirmModalBtn).contains(text);

View file

@ -3256,3 +3256,8 @@ eventTypes.REMOVE_CREDENTIAL.name=Remove credential
eventTypes.REMOVE_CREDENTIAL.description=Remove credential
eventTypes.REMOVE_CREDENTIAL_ERROR.name=Remove credential error
eventTypes.REMOVE_CREDENTIAL_ERROR.description=Remove credential error
groupDuplicated=Group duplicated
duplicateAGroup=Duplicate group
duplicate=Duplicate
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.

View file

@ -25,25 +25,19 @@ type GroupTableProps = {
export const GroupTable = ({ refresh: viewRefresh }: GroupTableProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const [selectedRows, setSelectedRows] = useState<GroupRepresentation[]>([]);
const [rename, setRename] = useState<GroupRepresentation>();
const [isCreateModalOpen, toggleCreateOpen] = useToggle();
const [duplicateId, setDuplicateId] = useState<string>();
const [showDelete, toggleShowDelete] = useToggle();
const [move, setMove] = useState<GroupRepresentation>();
const { currentGroup } = useSubGroups();
const [key, setKey] = useState(0);
const refresh = () => setKey(key + 1);
const [search, setSearch] = useState<string>();
const location = useLocation();
const id = getLastId(location.pathname);
const { hasAccess } = useAccess();
const isManager = hasAccess("manage-users") || currentGroup()?.access?.manage;
@ -103,6 +97,17 @@ export const GroupTable = ({ refresh: viewRefresh }: GroupTableProps) => {
}}
/>
)}
{duplicateId && (
<GroupsModal
id={duplicateId}
duplicateId={duplicateId}
refresh={() => {
refresh();
viewRefresh();
}}
handleModalToggle={() => setDuplicateId(undefined)}
/>
)}
{move && (
<MoveDialog
source={move}
@ -172,6 +177,17 @@ export const GroupTable = ({ refresh: viewRefresh }: GroupTableProps) => {
return false;
},
},
...(!id
? [
{
title: t("duplicate"),
onRowClick: async (group: GroupRepresentation) => {
setDuplicateId(group.id);
return false;
},
},
]
: []),
{
isSeparator: true,
},

View file

@ -1,5 +1,6 @@
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import {
Alert,
AlertVariant,
Button,
ButtonVariant,
@ -9,38 +10,223 @@ import {
} from "@patternfly/react-core";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FormSubmitButton, TextControl } from "@keycloak/keycloak-ui-shared";
import {
FormSubmitButton,
TextControl,
useFetch,
} from "@keycloak/keycloak-ui-shared";
import { useAdminClient } from "../admin-client";
import { useAlerts } from "@keycloak/keycloak-ui-shared";
import { useState } from "react";
type GroupsModalProps = {
id?: string;
rename?: GroupRepresentation;
duplicateId?: string;
handleModalToggle: () => void;
refresh: (group?: GroupRepresentation) => void;
};
type RoleMappingPayload = {
id: string;
name?: string;
clientUniqueId?: string;
};
type ClientRoleMapping = {
clientId: string;
roles: RoleMappingPayload[];
};
export const GroupsModal = ({
id,
rename,
duplicateId,
handleModalToggle,
refresh,
}: GroupsModalProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const { addAlert, addError } = useAlerts();
const [duplicateGroupDetails, setDuplicateGroupDetails] =
useState<GroupRepresentation | null>(null);
const form = useForm({
defaultValues: { name: rename?.name },
defaultValues: { name: "" },
});
const { handleSubmit, formState } = form;
useFetch(
async () => {
if (duplicateId) {
return adminClient.groups.findOne({ id: duplicateId });
}
},
(group) => {
if (group) {
setDuplicateGroupDetails(group);
form.reset({ name: t("copyOf", { name: group.name }) });
}
},
[duplicateId],
);
const fetchClientRoleMappings = async (groupId: string) => {
try {
const clientRoleMappings: ClientRoleMapping[] = [];
const clients = await adminClient.clients.find();
for (const client of clients) {
const roles = await adminClient.groups.listClientRoleMappings({
id: groupId,
clientUniqueId: client.id!,
});
const clientRoles = roles
.filter((role) => role.id && role.name)
.map((role) => ({
id: role.id!,
name: role.name!,
}));
if (clientRoles.length > 0) {
clientRoleMappings.push({ clientId: client.id!, roles: clientRoles });
}
}
return clientRoleMappings;
} catch (error) {
addError("couldNotFetchClientRoleMappings", error);
throw error;
}
};
const duplicateGroup = async (
sourceGroup: GroupRepresentation,
parentId?: string,
isSubGroup: boolean = false,
) => {
try {
const newGroup: GroupRepresentation = {
...sourceGroup,
name: isSubGroup
? sourceGroup.name
: t("copyOf", { name: sourceGroup.name }),
...(parentId ? {} : { attributes: duplicateGroupDetails?.attributes }),
};
delete newGroup.id;
const createdGroup = parentId
? await adminClient.groups.createChildGroup({ id: parentId }, newGroup)
: await adminClient.groups.create(newGroup);
const members = await adminClient.groups.listMembers({
id: sourceGroup.id!,
});
for (const member of members) {
await adminClient.users.addToGroup({
id: member.id!,
groupId: createdGroup.id,
});
}
const permissions = await adminClient.groups.listPermissions({
id: sourceGroup.id!,
});
if (permissions) {
await adminClient.groups.updatePermission(
{ id: createdGroup.id },
permissions,
);
}
const realmRoles = await adminClient.groups.listRealmRoleMappings({
id: sourceGroup.id!,
});
const realmRolesPayload: RoleMappingPayload[] = realmRoles.map(
(role) => ({ id: role.id!, name: role.name! }),
);
const clientRoleMappings = await fetchClientRoleMappings(sourceGroup.id!);
const clientRolesPayload: RoleMappingPayload[] =
clientRoleMappings?.flatMap((clientRoleMapping) =>
clientRoleMapping.roles.map((role) => ({
id: role.id!,
name: role.name!,
clientUniqueId: clientRoleMapping.clientId,
})),
);
const rolesToAssign: RoleMappingPayload[] = [
...realmRolesPayload,
...clientRolesPayload,
];
await assignRoles(rolesToAssign, createdGroup.id);
const subGroups = await adminClient.groups.listSubGroups({
parentId: sourceGroup.id!,
});
for (const childGroup of subGroups) {
const childAttributes = childGroup.attributes;
await duplicateGroup(
{ ...childGroup, attributes: childAttributes },
createdGroup.id,
true,
);
}
return createdGroup;
} catch (error) {
addError("couldNotDuplicateGroup", error);
throw error;
}
};
const assignRoles = async (roles: RoleMappingPayload[], groupId: string) => {
try {
const realmRoles = roles.filter(
(role) => !role.clientUniqueId && role.name,
);
const clientRoles = roles.filter(
(role) => role.clientUniqueId && role.name,
);
await adminClient.groups.addRealmRoleMappings({
id: groupId,
roles: realmRoles.map(({ id, name }) => ({ id, name: name! })),
});
await Promise.all(
clientRoles.map((clientRole) => {
if (clientRole.clientUniqueId && clientRole.name) {
return adminClient.groups.addClientRoleMappings({
id: groupId,
clientUniqueId: clientRole.clientUniqueId,
roles: [{ id: clientRole.id, name: clientRole.name }],
});
}
return Promise.resolve();
}),
);
} catch (error) {
addError("roleMappingUpdatedError", error);
}
};
const submitForm = async (group: GroupRepresentation) => {
group.name = group.name?.trim();
try {
if (!id) {
if (duplicateId && duplicateGroupDetails) {
await duplicateGroup(duplicateGroupDetails);
} else if (!id) {
await adminClient.groups.create(group);
} else if (rename) {
await adminClient.groups.update(
@ -48,15 +234,19 @@ export const GroupsModal = ({
{ ...rename, name: group.name },
);
} else {
await (group.id
? adminClient.groups.updateChildGroup({ id }, group)
: adminClient.groups.createChildGroup({ id }, group));
await adminClient.groups.updateChildGroup({ id }, group);
}
refresh(rename ? { ...rename, name: group.name } : undefined);
handleModalToggle();
addAlert(
t(rename ? "groupUpdated" : "groupCreated"),
t(
rename
? "groupUpdated"
: duplicateId
? "groupDuplicated"
: "groupCreated",
),
AlertVariant.success,
);
} catch (error) {
@ -67,28 +257,32 @@ export const GroupsModal = ({
return (
<Modal
variant={ModalVariant.small}
title={t(rename ? "renameAGroup" : "createAGroup")}
title={
rename
? t("renameAGroup")
: duplicateId
? t("duplicateAGroup")
: t("createAGroup")
}
isOpen={true}
onClose={handleModalToggle}
actions={[
<FormSubmitButton
formState={formState}
data-testid={`${rename ? "rename" : "create"}Group`}
data-testid={`${rename ? "rename" : duplicateId ? "duplicate" : "create"}Group`}
key="confirm"
form="group-form"
allowInvalid
allowNonDirty
>
{t(rename ? "rename" : "create")}
{t(rename ? "rename" : duplicateId ? "duplicate" : "create")}
</FormSubmitButton>,
<Button
id="modal-cancel"
data-testid="cancel"
key="cancel"
variant={ButtonVariant.link}
onClick={() => {
handleModalToggle();
}}
onClick={handleModalToggle}
>
{t("cancel")}
</Button>,
@ -96,6 +290,14 @@ export const GroupsModal = ({
>
<FormProvider {...form}>
<Form id="group-form" isHorizontal onSubmit={handleSubmit(submitForm)}>
{duplicateId && (
<Alert
variant="warning"
component="h2"
isInline
title={t("duplicateGroupWarning")}
/>
)}
<TextControl
name="name"
label={t("name")}