diff --git a/cypress/integration/clients_test.spec.ts b/cypress/integration/clients_test.spec.ts index 14bd93a00b..d90000be4d 100644 --- a/cypress/integration/clients_test.spec.ts +++ b/cypress/integration/clients_test.spec.ts @@ -83,8 +83,7 @@ describe("Clients test", function () { it("Initial access token", () => { const initialAccessTokenTab = new InitialAccessTokenTab(); - listingPage.goToInitialAccessTokenTab(); - initialAccessTokenTab.shouldBeEmpty(); + initialAccessTokenTab.goToInitialAccessTokenTab().shouldBeEmpty(); initialAccessTokenTab.createNewToken(1, 1).save(); modalUtils.checkModalTitle("Initial access token details").closeModal(); diff --git a/cypress/integration/group_test.spec.ts b/cypress/integration/group_test.spec.ts index 8ca66f3550..7ebba3840d 100644 --- a/cypress/integration/group_test.spec.ts +++ b/cypress/integration/group_test.spec.ts @@ -1,5 +1,5 @@ import ListingPage from "../support/pages/admin_console/ListingPage"; -import CreateGroupModal from "../support/pages/admin_console/manage/groups/CreateGroupModal"; +import GroupModal from "../support/pages/admin_console/manage/groups/GroupModal"; import GroupDetailPage from "../support/pages/admin_console/manage/groups/GroupDetailPage"; import { SearchGroupPage } from "../support/pages/admin_console/manage/groups/SearchGroup"; import Masthead from "../support/pages/admin_console/Masthead"; @@ -15,7 +15,7 @@ describe("Group test", () => { const sidebarPage = new SidebarPage(); const listingPage = new ListingPage(); const viewHeaderPage = new ViewHeaderPage(); - const createGroupModal = new CreateGroupModal(); + const groupModal = new GroupModal(); let groupName = "group"; @@ -29,7 +29,7 @@ describe("Group test", () => { it("Group CRUD test", () => { groupName += "_" + (Math.random() + 1).toString(36).substring(7); - createGroupModal + groupModal .open("empty-primary-action") .fillGroupForm(groupName) .clickCreate(); @@ -44,6 +44,23 @@ describe("Group test", () => { masthead.checkNotificationMessage("Group deleted"); }); + it("Should rename group", () => { + groupModal + .open("empty-primary-action") + .fillGroupForm(groupName) + .clickCreate(); + listingPage.goToItemDetails(groupName); + viewHeaderPage.clickAction("renameGroupAction"); + + const newName = "Renamed group"; + groupModal.fillGroupForm(newName).clickRename(); + masthead.checkNotificationMessage("Group updated"); + + sidebarPage.goToGroups(); + listingPage.searchItem(newName, false).itemExist(newName); + listingPage.deleteItem(newName); + }); + const searchGroupPage = new SearchGroupPage(); it("Group search", () => { viewHeaderPage.clickAction("searchGroup"); @@ -63,6 +80,7 @@ describe("Group test", () => { const username = "user" + i; client.createUserInGroup(username, createdGroups[i % 3].id); } + client.createUser({ username: "new", enabled: true }); }); beforeEach(() => { @@ -80,7 +98,7 @@ describe("Group test", () => { detailPage.checkListSubGroup([groups[1]]); const added = "addedGroup"; - createGroupModal.open().fillGroupForm(added).clickCreate(); + groupModal.open().fillGroupForm(added).clickCreate(); detailPage.checkListSubGroup([added, groups[1]]); }); @@ -93,6 +111,18 @@ describe("Group test", () => { .checkListMembers(["user0", "user3", "user1", "user4", "user2"]); }); + it("Should add members", () => { + listingPage.goToItemDetails(groups[0]); + detailPage + .clickMembersTab() + .clickAddMembers() + .checkSelectableMembers(["user1", "user4"]); + detailPage.selectUsers(["new"]).clickAdd(); + + masthead.checkNotificationMessage("1 user added to the group"); + detailPage.checkListMembers(["new", "user0", "user3"]); + }); + it("Attributes CRUD test", () => { listingPage.goToItemDetails(groups[0]); detailPage diff --git a/cypress/support/pages/admin_console/ListingPage.ts b/cypress/support/pages/admin_console/ListingPage.ts index c34e4eb0e4..a4be2c4f6e 100644 --- a/cypress/support/pages/admin_console/ListingPage.ts +++ b/cypress/support/pages/admin_console/ListingPage.ts @@ -7,7 +7,6 @@ export default class ListingPage { searchBtn: string; createBtn: string; importBtn: string; - initialAccessTokenTab = "initialAccessToken"; constructor() { this.searchInput = '.pf-c-toolbar__item [type="search"]'; @@ -35,11 +34,6 @@ export default class ListingPage { return this; } - goToInitialAccessTokenTab() { - cy.getId(this.initialAccessTokenTab).click(); - return this; - } - searchItem(searchValue: string, wait = true) { if (wait) { const searchUrl = `/auth/admin/realms/master/*${searchValue}*`; diff --git a/cypress/support/pages/admin_console/manage/clients/InitialAccessTokenTab.ts b/cypress/support/pages/admin_console/manage/clients/InitialAccessTokenTab.ts index 74b047cdc7..4a1980b4e2 100644 --- a/cypress/support/pages/admin_console/manage/clients/InitialAccessTokenTab.ts +++ b/cypress/support/pages/admin_console/manage/clients/InitialAccessTokenTab.ts @@ -1,10 +1,17 @@ export default class InitialAccessTokenTab { + private initialAccessTokenTab = "initialAccessToken"; + private emptyAction = "empty-primary-action"; private expirationInput = "expiration"; private countInput = "count"; private saveBtn = "save"; + goToInitialAccessTokenTab() { + cy.getId(this.initialAccessTokenTab).click(); + return this; + } + shouldBeEmpty() { cy.getId(this.emptyAction).should("exist"); return this; diff --git a/cypress/support/pages/admin_console/manage/groups/GroupDetailPage.ts b/cypress/support/pages/admin_console/manage/groups/GroupDetailPage.ts index 3deb0aa432..9a28802809 100644 --- a/cypress/support/pages/admin_console/manage/groups/GroupDetailPage.ts +++ b/cypress/support/pages/admin_console/manage/groups/GroupDetailPage.ts @@ -6,6 +6,9 @@ export default class GroupDetailPage { private attributesTab = "attributes"; private memberNameColumn = 'tbody > tr > [data-label="Name"]'; private includeSubGroupsCheck = "includeSubGroupsCheck"; + private addMembers = "addMember"; + private addMember = "add"; + private memberUsernameColumn = 'tbody > tr > [data-label="Username"]'; private keyInput = '[name="attributes[0].key"]'; private valueInput = '[name="attributes[0].value"]'; @@ -37,6 +40,31 @@ export default class GroupDetailPage { return this; } + checkSelectableMembers(members: string[]) { + cy.get(this.memberUsernameColumn).should((member) => { + for (const user of members) { + expect(member).to.contain(user); + } + }); + return this; + } + + selectUsers(users: string[]) { + for (const user of users) { + cy.get(this.memberUsernameColumn) + .contains(user) + .parent() + .find("input") + .click(); + } + return this; + } + + clickAdd() { + cy.getId(this.addMember).click(); + return this; + } + clickIncludeSubGroups() { cy.getId(this.includeSubGroupsCheck).click(); return this; @@ -47,6 +75,11 @@ export default class GroupDetailPage { return this; } + clickAddMembers() { + cy.getId(this.addMembers).click(); + return this; + } + fillAttribute(key: string, value: string) { cy.get(this.keyInput).type(key).get(this.valueInput).type(value); return this; diff --git a/cypress/support/pages/admin_console/manage/groups/CreateGroupModal.ts b/cypress/support/pages/admin_console/manage/groups/GroupModal.ts similarity index 73% rename from cypress/support/pages/admin_console/manage/groups/CreateGroupModal.ts rename to cypress/support/pages/admin_console/manage/groups/GroupModal.ts index 4950ad84f8..3c1869e92b 100644 --- a/cypress/support/pages/admin_console/manage/groups/CreateGroupModal.ts +++ b/cypress/support/pages/admin_console/manage/groups/GroupModal.ts @@ -1,7 +1,8 @@ -export default class CreateGroupModal { +export default class GroupModal { private openButton = "openCreateGroupModal"; private nameInput = "groupNameInput"; private createButton = "createGroup"; + private renameButton = "renameGroup"; open(name?: string) { cy.getId(name || this.openButton).click(); @@ -18,4 +19,9 @@ export default class CreateGroupModal { cy.getId(this.createButton).click(); return this; } + + clickRename() { + cy.getId(this.renameButton).click(); + return this; + } } diff --git a/cypress/support/util/AdminClient.ts b/cypress/support/util/AdminClient.ts index 4a8fda24c1..e980f85f19 100644 --- a/cypress/support/util/AdminClient.ts +++ b/cypress/support/util/AdminClient.ts @@ -1,4 +1,5 @@ import KeycloakAdminClient from "keycloak-admin"; +import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation"; export default class AdminClient { private client: KeycloakAdminClient; @@ -57,9 +58,14 @@ export default class AdminClient { } } + async createUser(user: UserRepresentation) { + await this.login(); + return await this.client.users.create(user); + } + async createUserInGroup(username: string, groupId: string) { await this.login(); - const user = await this.client.users.create({ username, enabled: true }); + const user = await this.createUser({ username, enabled: true }); await this.client.users.addToGroup({ id: user.id!, groupId }); } } diff --git a/src/components/table-toolbar/KeycloakDataTable.tsx b/src/components/table-toolbar/KeycloakDataTable.tsx index 88535c2a6e..4782ad7cf4 100644 --- a/src/components/table-toolbar/KeycloakDataTable.tsx +++ b/src/components/table-toolbar/KeycloakDataTable.tsx @@ -133,6 +133,7 @@ export function KeycloakDataTable({ emptyState, }: DataListProps) { const { t } = useTranslation(); + const [selected, setSelected] = useState([]); const [rows, setRows] = useState[]>(); const [filteredData, setFilteredData] = useState[]>(); const [loading, setLoading] = useState(false); @@ -154,7 +155,9 @@ export function KeycloakDataTable({ const result = data!.map((value) => { return { data: value, - selected: false, + selected: !!selected.find( + (v) => (v as any).id === (value as any).id + ), cells: columns.map((col) => { if (col.cellRenderer) { return col.cellRenderer(value); @@ -233,7 +236,17 @@ export function KeycloakDataTable({ rows![rowIndex].selected = isSelected; setRows([...rows!]); } - onSelect!(rows!.filter((row) => row.selected).map((row) => row.data)); + const difference = _.differenceBy( + selected, + rows!.map((row) => row.data), + "id" + ); + const selectedRows = [ + ...difference, + ...rows!.filter((row) => row.selected).map((row) => row.data), + ]; + setSelected(selectedRows); + onSelect!(selectedRows); }; return ( diff --git a/src/groups/GroupTable.tsx b/src/groups/GroupTable.tsx index 4c165e6280..2159a9748b 100644 --- a/src/groups/GroupTable.tsx +++ b/src/groups/GroupTable.tsx @@ -19,7 +19,7 @@ import { useAlerts } from "../components/alert/Alerts"; import { useRealm } from "../context/realm-context/RealmContext"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; -import { GroupsCreateModal } from "./GroupsCreateModal"; +import { GroupsModal } from "./GroupsModal"; import { getLastId } from "./groupIdUtils"; type GroupTableData = GroupRepresentation & { @@ -195,7 +195,7 @@ export const GroupTable = () => { } /> {isCreateModalOpen && ( - void; - refresh: () => void; -}; - -export const GroupsCreateModal = ({ - id, - handleModalToggle, - refresh, -}: GroupsCreateModalProps) => { - const { t } = useTranslation("groups"); - const adminClient = useAdminClient(); - const { addAlert } = useAlerts(); - const { register, errors, handleSubmit } = useForm(); - - const submitForm = async (group: GroupRepresentation) => { - try { - if (!id) { - await adminClient.groups.create({ name: group.name }); - } else { - await adminClient.groups.setOrCreateChild({ id }, { name: group.name }); - } - - refresh(); - handleModalToggle(); - addAlert(t("groupCreated"), AlertVariant.success); - } catch (error) { - addAlert(t("couldNotCreateGroup", { error }), AlertVariant.danger); - } - }; - - return ( - <> - - {t("create")} - , - ]} - > -
- - - -
-
- - ); -}; diff --git a/src/groups/GroupsModal.tsx b/src/groups/GroupsModal.tsx new file mode 100644 index 0000000000..5a585d55df --- /dev/null +++ b/src/groups/GroupsModal.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { + AlertVariant, + Button, + Form, + FormGroup, + Modal, + ModalVariant, + TextInput, + ValidatedOptions, +} from "@patternfly/react-core"; +import { useTranslation } from "react-i18next"; +import { useForm } from "react-hook-form"; + +import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation"; +import { useAdminClient } from "../context/auth/AdminClient"; +import { useAlerts } from "../components/alert/Alerts"; + +type GroupsModalProps = { + id?: string; + rename?: string; + handleModalToggle: () => void; + refresh: (group?: GroupRepresentation) => void; +}; + +export const GroupsModal = ({ + id, + rename, + handleModalToggle, + refresh, +}: GroupsModalProps) => { + const { t } = useTranslation("groups"); + const adminClient = useAdminClient(); + const { addAlert } = useAlerts(); + const { register, errors, handleSubmit } = useForm({ + defaultValues: { name: rename }, + }); + + const submitForm = async (group: GroupRepresentation) => { + try { + if (!id) { + await adminClient.groups.create(group); + } else if (rename) { + await adminClient.groups.update({ id }, group); + } else { + await adminClient.groups.setOrCreateChild({ id }, group); + } + + refresh(rename ? group : undefined); + handleModalToggle(); + addAlert( + t(rename ? "groupUpdated" : "groupCreated"), + AlertVariant.success + ); + } catch (error) { + addAlert(t("couldNotCreateGroup", { error }), AlertVariant.danger); + } + }; + + return ( + + {t(rename ? "rename" : "create")} + , + ]} + > +
+ + + +
+
+ ); +}; diff --git a/src/groups/GroupsSection.tsx b/src/groups/GroupsSection.tsx index c0b87b5ce2..adb80b9117 100644 --- a/src/groups/GroupsSection.tsx +++ b/src/groups/GroupsSection.tsx @@ -23,6 +23,7 @@ import { GroupTable } from "./GroupTable"; import { getId, getLastId } from "./groupIdUtils"; import { Members } from "./Members"; import { GroupAttributes } from "./GroupAttributes"; +import { GroupsModal } from "./GroupsModal"; import "./GroupsSection.css"; @@ -31,11 +32,13 @@ export const GroupsSection = () => { const [activeTab, setActiveTab] = useState(0); const adminClient = useAdminClient(); - const { subGroups, setSubGroups } = useSubGroups(); + const { subGroups, setSubGroups, currentGroup } = useSubGroups(); const { addAlert } = useAlerts(); const { realm } = useRealm(); const errorHandler = useErrorHandler(); + const [rename, setRename] = useState(); + const history = useHistory(); const location = useLocation(); const id = getLastId(location.pathname); @@ -85,34 +88,60 @@ export const GroupsSection = () => { [id] ); + const SearchDropdown = ( + history.push(`/${realm}/groups/search`)} + > + {t("searchGroup")} + + ); + return ( <> + {rename && ( + + setSubGroups([...subGroups.slice(0, subGroups.length - 1), group!]) + } + handleModalToggle={() => setRename(undefined)} + /> + )} history.push(`/${realm}/groups/search`)} - > - {t("searchGroup")} - , - addAlert("Not implemented")} - > - {t("renameGroup")} - , - deleteGroup({ id })} - > - {t("deleteGroup")} - , - ]} + dropdownItems={ + id + ? [ + SearchDropdown, + setRename(currentGroup().name)} + > + {t("renameGroup")} + , + { + deleteGroup({ id }); + history.push( + location.pathname.substr( + 0, + location.pathname.lastIndexOf("/") + ) + ); + }} + > + {t("deleteGroup")} + , + ] + : [SearchDropdown] + } /> {subGroups.length > 0 && ( diff --git a/src/groups/Members.tsx b/src/groups/Members.tsx index ac94b35fb5..487856425a 100644 --- a/src/groups/Members.tsx +++ b/src/groups/Members.tsx @@ -2,16 +2,27 @@ import React, { useEffect, useState } from "react"; import { useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import _ from "lodash"; -import { Button, Checkbox, ToolbarItem } from "@patternfly/react-core"; +import { + AlertVariant, + Button, + Checkbox, + Dropdown, + DropdownItem, + KebabToggle, + ToolbarItem, +} from "@patternfly/react-core"; import GroupRepresentation from "keycloak-admin/lib/defs/groupRepresentation"; import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { useAdminClient } from "../context/auth/AdminClient"; +import { useAlerts } from "../components/alert/Alerts"; import { emptyFormatter } from "../util"; import { getLastId } from "./groupIdUtils"; import { useSubGroups } from "./SubGroupsContext"; +import { MemberModal } from "./MembersModal"; +import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; type MembersOf = UserRepresentation & { membership: GroupRepresentation[]; @@ -20,10 +31,14 @@ type MembersOf = UserRepresentation & { export const Members = () => { const { t } = useTranslation("groups"); const adminClient = useAdminClient(); + const { addAlert } = useAlerts(); const location = useLocation(); const id = getLastId(location.pathname); const [includeSubGroup, setIncludeSubGroup] = useState(false); const { currentGroup, subGroups } = useSubGroups(); + const [addMembers, setAddMembers] = useState(false); + const [isKebabOpen, setIsKebabOpen] = useState(false); + const [selectedRows, setSelectedRows] = useState([]); const [key, setKey] = useState(0); const refresh = () => setKey(new Date().getTime()); @@ -81,55 +96,118 @@ export const Members = () => { }; return ( - - - - - - setIncludeSubGroup(!includeSubGroup)} - /> - - - } - columns={[ - { - name: "username", - displayKey: "common:name", - }, - { - name: "email", - displayKey: "groups:email", - cellFormatters: [emptyFormatter()], - }, - { - name: "firstName", - displayKey: "groups:firstName", - cellFormatters: [emptyFormatter()], - }, - { - name: "lastName", - displayKey: "groups:lastName", - cellFormatters: [emptyFormatter()], - }, - { - name: "membership", - displayKey: "groups:membership", - cellRenderer: MemberOfRenderer, - }, - ]} - /> + <> + {addMembers && ( + { + setAddMembers(false); + refresh(); + }} + /> + )} + setSelectedRows([...rows])} + toolbarItem={ + <> + + + + + setIncludeSubGroup(!includeSubGroup)} + /> + + + setIsKebabOpen(!isKebabOpen)} /> + } + isOpen={isKebabOpen} + isPlain + dropdownItems={[ + { + try { + await Promise.all( + selectedRows.map((user) => + adminClient.users.delFromGroup({ + id: user.id!, + groupId: id!, + }) + ) + ); + setIsKebabOpen(false); + addAlert( + t("usersLeft", { count: selectedRows.length }), + AlertVariant.success + ); + } catch (error) { + addAlert(t("usersLeftError"), AlertVariant.danger); + } + + refresh(); + }} + > + {t("leave")} + , + ]} + /> + + + } + columns={[ + { + name: "username", + displayKey: "common:name", + }, + { + name: "email", + displayKey: "groups:email", + cellFormatters: [emptyFormatter()], + }, + { + name: "firstName", + displayKey: "groups:firstName", + cellFormatters: [emptyFormatter()], + }, + { + name: "lastName", + displayKey: "groups:lastName", + cellFormatters: [emptyFormatter()], + }, + { + name: "membership", + displayKey: "groups:membership", + cellRenderer: MemberOfRenderer, + }, + ]} + emptyState={ + setAddMembers(true)} + /> + } + /> + ); }; diff --git a/src/groups/MembersModal.tsx b/src/groups/MembersModal.tsx new file mode 100644 index 0000000000..9c2e0137ed --- /dev/null +++ b/src/groups/MembersModal.tsx @@ -0,0 +1,130 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; +import { + AlertVariant, + Button, + Modal, + ModalVariant, +} from "@patternfly/react-core"; + +import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation"; +import { useAdminClient } from "../context/auth/AdminClient"; +import { useRealm } from "../context/realm-context/RealmContext"; +import { useAlerts } from "../components/alert/Alerts"; +import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; +import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; +import { emptyFormatter } from "../util"; +import _ from "lodash"; + +type MemberModalProps = { + groupId: string; + onClose: () => void; +}; + +export const MemberModal = ({ groupId, onClose }: MemberModalProps) => { + const { t } = useTranslation("groups"); + const adminClient = useAdminClient(); + const { addAlert } = useAlerts(); + const [selectedRows, setSelectedRows] = useState([]); + + const history = useHistory(); + const { realm } = useRealm(); + const goToCreate = () => history.push(`/${realm}/users/add-user`); + + const loader = async (first?: number, max?: number, search?: string) => { + const members = await adminClient.groups.listMembers({ id: groupId }); + const params: { [name: string]: string | number } = { + first: first!, + max: max! + members.length, + search: search || "", + }; + + try { + const users = await adminClient.users.find({ ...params }); + return _.xorBy(users, members, "id").slice(0, max); + } catch (error) { + addAlert(t("noUsersFoundError", { error }), AlertVariant.danger); + return []; + } + }; + + return ( + { + try { + await Promise.all( + selectedRows.map((user) => + adminClient.users.addToGroup({ id: user.id!, groupId }) + ) + ); + onClose(); + addAlert( + t("usersAdded", { count: selectedRows.length }), + AlertVariant.success + ); + } catch (error) { + addAlert(t("usersAddedError"), AlertVariant.danger); + } + }} + > + {t("common:add")} + , + , + ]} + > + setSelectedRows([...rows])} + emptyState={ + + } + columns={[ + { + name: "username", + displayKey: "users:username", + }, + { + name: "email", + displayKey: "users:email", + }, + { + name: "lastName", + displayKey: "users:lastName", + cellFormatters: [emptyFormatter()], + }, + { + name: "firstName", + displayKey: "users:firstName", + cellFormatters: [emptyFormatter()], + }, + ]} + /> + + ); +}; diff --git a/src/groups/messages.json b/src/groups/messages.json index ca42e41210..6b51e5d581 100644 --- a/src/groups/messages.json +++ b/src/groups/messages.json @@ -9,6 +9,13 @@ "searchGroup": "Search group", "renameGroup": "Rename group", "deleteGroup": "Delete group", + "leave": "Leave group", + "usersLeft": "{{count}} user left the group", + "usersLeft_plural": "{{count}} users left the group", + "usersLeftError": "Could not remove users from the group", + "usersAdded": "{{count}} user added to the group", + "usersAdded_plural": "{{count}} users added to the group", + "usersAddedError": "Could not add users to the group", "search": "Search", "members": "Members", "searchMembers": "Search members", @@ -21,7 +28,9 @@ "groupCreated": "Group created", "couldNotCreateGroup": "Could not create group {{error}}", "createAGroup": "Create a group", + "renameAGroup": "Rename group", "create": "Create", + "rename": "Rename", "email": "Email", "lastName": "Last name", "firstName": "First name",