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); .assertNoSearchResultsMessageExist(true);
}); });
it("Duplicate group", () => {
groupPage
.duplicateGroupItem(groupNames[0], true)
.assertNotificationGroupDuplicated();
});
it("Delete group from item bar", () => { it("Delete group from item bar", () => {
groupPage groupPage
.searchGroup(groupNames[0], true) .searchGroup(groupNames[0], true)

View file

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

View file

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

View file

@ -113,6 +113,14 @@ export default class GroupPage extends PageObject {
return this; return this;
} }
duplicateGroupItem(groupName: string, confirmModal = true) {
listingPage.duplicateItem(groupName);
if (confirmModal) {
groupModal.confirmDuplicateModal();
}
return this;
}
moveGroupItemAction(groupName: string, destinationGroupName: string[]) { moveGroupItemAction(groupName: string, destinationGroupName: string[]) {
listingPage.clickRowDetails(groupName); listingPage.clickRowDetails(groupName);
listingPage.clickDetailMenu("Move to"); listingPage.clickDetailMenu("Move to");
@ -202,6 +210,11 @@ export default class GroupPage extends PageObject {
return this; return this;
} }
assertNotificationGroupDuplicated() {
masthead.checkNotificationMessage("Group duplicated");
return this;
}
goToGroupActions(groupName: string) { goToGroupActions(groupName: string) {
listingPage.clickRowDetails(groupName); 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"; #addModalDropdownItem = ".pf-v5-c-modal-box__footer .pf-v5-c-menu__content";
#addBtn = "add"; #addBtn = "add";
#tablePage = new TablePage(TablePage.tableSelector); #tablePage = new TablePage(TablePage.tableSelector);
#confirmDuplicateModalBtn = "duplicateGroup";
table() { table() {
return this.#tablePage; return this.#tablePage;
@ -29,6 +30,12 @@ export default class ModalUtils extends PageObject {
return this; return this;
} }
confirmDuplicateModal() {
cy.findByTestId(this.#confirmDuplicateModalBtn).click({ force: true });
return this;
}
checkConfirmButtonText(text: string) { checkConfirmButtonText(text: string) {
cy.findByTestId(this.#confirmModalBtn).contains(text); 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.description=Remove credential
eventTypes.REMOVE_CREDENTIAL_ERROR.name=Remove credential error 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.

View file

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

View file

@ -1,5 +1,6 @@
import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; import type GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation";
import { import {
Alert,
AlertVariant, AlertVariant,
Button, Button,
ButtonVariant, ButtonVariant,
@ -9,38 +10,223 @@ import {
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; 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 { useAdminClient } from "../admin-client";
import { useAlerts } from "@keycloak/keycloak-ui-shared"; import { useAlerts } from "@keycloak/keycloak-ui-shared";
import { useState } from "react";
type GroupsModalProps = { type GroupsModalProps = {
id?: string; id?: string;
rename?: GroupRepresentation; rename?: GroupRepresentation;
duplicateId?: string;
handleModalToggle: () => void; handleModalToggle: () => void;
refresh: (group?: GroupRepresentation) => void; refresh: (group?: GroupRepresentation) => void;
}; };
type RoleMappingPayload = {
id: string;
name?: string;
clientUniqueId?: string;
};
type ClientRoleMapping = {
clientId: string;
roles: RoleMappingPayload[];
};
export const GroupsModal = ({ export const GroupsModal = ({
id, id,
rename, rename,
duplicateId,
handleModalToggle, handleModalToggle,
refresh, refresh,
}: GroupsModalProps) => { }: GroupsModalProps) => {
const { adminClient } = useAdminClient(); const { adminClient } = useAdminClient();
const { t } = useTranslation(); const { t } = useTranslation();
const { addAlert, addError } = useAlerts(); const { addAlert, addError } = useAlerts();
const [duplicateGroupDetails, setDuplicateGroupDetails] =
useState<GroupRepresentation | null>(null);
const form = useForm({ const form = useForm({
defaultValues: { name: rename?.name }, defaultValues: { name: "" },
}); });
const { handleSubmit, formState } = form; 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) => { const submitForm = async (group: GroupRepresentation) => {
group.name = group.name?.trim(); group.name = group.name?.trim();
try { try {
if (!id) { if (duplicateId && duplicateGroupDetails) {
await duplicateGroup(duplicateGroupDetails);
} else if (!id) {
await adminClient.groups.create(group); await adminClient.groups.create(group);
} else if (rename) { } else if (rename) {
await adminClient.groups.update( await adminClient.groups.update(
@ -48,15 +234,19 @@ export const GroupsModal = ({
{ ...rename, name: group.name }, { ...rename, name: group.name },
); );
} else { } else {
await (group.id await adminClient.groups.updateChildGroup({ id }, group);
? adminClient.groups.updateChildGroup({ id }, group)
: adminClient.groups.createChildGroup({ id }, group));
} }
refresh(rename ? { ...rename, name: group.name } : undefined); refresh(rename ? { ...rename, name: group.name } : undefined);
handleModalToggle(); handleModalToggle();
addAlert( addAlert(
t(rename ? "groupUpdated" : "groupCreated"), t(
rename
? "groupUpdated"
: duplicateId
? "groupDuplicated"
: "groupCreated",
),
AlertVariant.success, AlertVariant.success,
); );
} catch (error) { } catch (error) {
@ -67,28 +257,32 @@ export const GroupsModal = ({
return ( return (
<Modal <Modal
variant={ModalVariant.small} variant={ModalVariant.small}
title={t(rename ? "renameAGroup" : "createAGroup")} title={
rename
? t("renameAGroup")
: duplicateId
? t("duplicateAGroup")
: t("createAGroup")
}
isOpen={true} isOpen={true}
onClose={handleModalToggle} onClose={handleModalToggle}
actions={[ actions={[
<FormSubmitButton <FormSubmitButton
formState={formState} formState={formState}
data-testid={`${rename ? "rename" : "create"}Group`} data-testid={`${rename ? "rename" : duplicateId ? "duplicate" : "create"}Group`}
key="confirm" key="confirm"
form="group-form" form="group-form"
allowInvalid allowInvalid
allowNonDirty allowNonDirty
> >
{t(rename ? "rename" : "create")} {t(rename ? "rename" : duplicateId ? "duplicate" : "create")}
</FormSubmitButton>, </FormSubmitButton>,
<Button <Button
id="modal-cancel" id="modal-cancel"
data-testid="cancel" data-testid="cancel"
key="cancel" key="cancel"
variant={ButtonVariant.link} variant={ButtonVariant.link}
onClick={() => { onClick={handleModalToggle}
handleModalToggle();
}}
> >
{t("cancel")} {t("cancel")}
</Button>, </Button>,
@ -96,6 +290,14 @@ export const GroupsModal = ({
> >
<FormProvider {...form}> <FormProvider {...form}>
<Form id="group-form" isHorizontal onSubmit={handleSubmit(submitForm)}> <Form id="group-form" isHorizontal onSubmit={handleSubmit(submitForm)}>
{duplicateId && (
<Alert
variant="warning"
component="h2"
isInline
title={t("duplicateGroupWarning")}
/>
)}
<TextControl <TextControl
name="name" name="name"
label={t("name")} label={t("name")}