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:
parent
022ab4d263
commit
805a92adbf
8 changed files with 292 additions and 22 deletions
|
@ -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)
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -3255,4 +3255,9 @@ eventTypes.UPDATE_CREDENTIAL_ERROR.description=Update credential error
|
|||
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
|
||||
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.
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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")}
|
||||
|
|
Loading…
Reference in a new issue