diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 35c92fd3..1136f5fa 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -66,7 +66,7 @@ Cypress.Commands.add("userCleanup", () => { Cypress.Commands.add("createTestUser", (username: string) => { cy.visit(Cypress.env("base_url") + "/active-users"); - cy.wait(4000); + cy.wait(2000); cy.get("body").then(($body) => { if ($body.find("tr[id=" + username + "]").length) { return; diff --git a/src/components/Members/MembersExternal.tsx b/src/components/Members/MembersExternal.tsx index a1b7497d..12a2e902 100644 --- a/src/components/Members/MembersExternal.tsx +++ b/src/components/Members/MembersExternal.tsx @@ -188,6 +188,8 @@ const MembersExternal = (props: PropsToMembersExternal) => { ); // Refresh props.onRefreshData(); + // Disable 'remove' button + setExternalsSelected([]); // Close modal setShowDeleteModal(false); // Back to page 1 diff --git a/src/components/Members/MembersHostGroups.tsx b/src/components/Members/MembersHostGroups.tsx new file mode 100644 index 00000000..f113052c --- /dev/null +++ b/src/components/Members/MembersHostGroups.tsx @@ -0,0 +1,404 @@ +import React from "react"; +// PatternFly +import { Pagination, PaginationVariant } from "@patternfly/react-core"; +// Components +import MemberOfToolbar from "../MemberOf/MemberOfToolbar"; +import MemberOfAddModal, { AvailableItems } from "../MemberOf/MemberOfAddModal"; +import MemberOfDeleteModal from "../MemberOf/MemberOfDeleteModal"; +import MemberTable from "src/components/tables/MembershipTable"; +import { MembershipDirection } from "src/components/MemberOf/MemberOfToolbar"; +// Data types +import { HostGroup } from "src/utils/datatypes/globalDataTypes"; +// Hooks +import useAlerts from "src/hooks/useAlerts"; +import useListPageSearchParams from "src/hooks/useListPageSearchParams"; +// Utils +import { API_VERSION_BACKUP, paginate } from "src/utils/utils"; +// RPC +import { ErrorResult } from "src/services/rpc"; +import { + MemberPayload, + useAddAsMemberHGMutation, + useGetHostGroupInfoByNameQuery, + useGettingHostGroupsQuery, + useRemoveAsMemberHGMutation, +} from "src/services/rpcHostGroups"; +import { apiToHostGroup } from "src/utils/hostGroupUtils"; + +interface PropsToMembersHostGroups { + entity: Partial; + id: string; + from: string; + isDataLoading: boolean; + onRefreshData: () => void; + member_hostgroup: string[]; + memberindirect_hostgroup?: string[]; + membershipDisabled?: boolean; + setDirection: (direction: MembershipDirection) => void; + direction: MembershipDirection; +} + +const MembersHostGroups = (props: PropsToMembersHostGroups) => { + // Alerts to show in the UI + const alerts = useAlerts(); + + const membershipDisabled = + props.membershipDisabled === undefined ? false : props.membershipDisabled; + + // Get parameters from URL + const { + page, + setPage, + perPage, + setPerPage, + searchValue, + setSearchValue, + membershipDirection, + setMembershipDirection, + } = useListPageSearchParams(); + + // Other states + const [hostGroupsSelected, setHostGroupsSelected] = React.useState( + [] + ); + const [indirectHostGroupsSelected, setIndirectHostGroupsSelected] = + React.useState([]); + + // Loaded hostGroups based on paging and member attributes + const [hostGroups, setHostGroups] = React.useState([]); + + // Choose the correct hostgroups based on the membership direction + const member_hostgroup = props.member_hostgroup || []; + const memberindirect_hostgroup = props.memberindirect_hostgroup || []; + let hostGroupNames = + membershipDirection === "direct" + ? member_hostgroup + : memberindirect_hostgroup; + hostGroupNames = [...hostGroupNames]; + + const getHostGroupsNameToLoad = (): string[] => { + let toLoad = [...hostGroupNames]; + toLoad.sort(); + + // Filter by search + if (searchValue) { + toLoad = toLoad.filter((name) => + name.toLowerCase().includes(searchValue.toLowerCase()) + ); + } + + // Apply paging + toLoad = paginate(toLoad, page, perPage); + + return toLoad; + }; + + const [hostGroupNamesToLoad, setHostGroupNamesToLoad] = React.useState< + string[] + >(getHostGroupsNameToLoad()); + + // Load host groups + const fullHostGroupsQuery = useGetHostGroupInfoByNameQuery({ + groupNamesList: hostGroupNamesToLoad, + no_members: true, + version: API_VERSION_BACKUP, + }); + + // Refresh host groups + React.useEffect(() => { + const hostGroupsNames = getHostGroupsNameToLoad(); + setHostGroupNamesToLoad(hostGroupsNames); + props.setDirection(membershipDirection); + }, [props.entity, membershipDirection, searchValue, page, perPage]); + + React.useEffect(() => { + setMembershipDirection(props.direction); + }, [props.entity]); + + React.useEffect(() => { + if (hostGroupNamesToLoad.length > 0) { + fullHostGroupsQuery.refetch(); + } + }, [hostGroupNamesToLoad]); + + // Update host groups + React.useEffect(() => { + if (fullHostGroupsQuery.data && !fullHostGroupsQuery.isFetching) { + setHostGroups(fullHostGroupsQuery.data); + } + }, [fullHostGroupsQuery.data, fullHostGroupsQuery.isFetching]); + + // Computed "states" + const someItemSelected = hostGroupsSelected.length > 0; + const showTableRows = hostGroups.length > 0; + const hostGroupColumnNames = ["Host group name", "Description"]; + const hostGroupProperties = ["cn", "description"]; + + // Dialogs and actions + const [showAddModal, setShowAddModal] = React.useState(false); + const [showDeleteModal, setShowDeleteModal] = React.useState(false); + const [spinning, setSpinning] = React.useState(false); + + // Buttons functionality + const isRefreshButtonEnabled = + !fullHostGroupsQuery.isFetching && !props.isDataLoading; + const isAddButtonEnabled = + membershipDirection !== "indirect" && isRefreshButtonEnabled; + + // Add new member to 'HostGroup' + // API calls + const [addMemberToHostGroups] = useAddAsMemberHGMutation(); + const [removeMembersFromHostGroups] = useRemoveAsMemberHGMutation(); + const [adderSearchValue, setAdderSearchValue] = React.useState(""); + const [availableHostGroups, setAvailableHostGroups] = React.useState< + HostGroup[] + >([]); + const [availableItems, setAvailableItems] = React.useState( + [] + ); + + // Load available host groups, delay the search for opening the modal + const hostGroupsQuery = useGettingHostGroupsQuery({ + search: adderSearchValue, + apiVersion: API_VERSION_BACKUP, + sizelimit: 100, + startIdx: 0, + stopIdx: 100, + }); + + // Trigger available host groups search + React.useEffect(() => { + if (showAddModal) { + hostGroupsQuery.refetch(); + } + }, [showAddModal, adderSearchValue, props.entity]); + + // Update available host groups + React.useEffect(() => { + if (hostGroupsQuery.data && !hostGroupsQuery.isFetching) { + // transform data to host groups + const count = hostGroupsQuery.data.result.count; + const results = hostGroupsQuery.data.result.results; + let items: AvailableItems[] = []; + const avalHostGroups: HostGroup[] = []; + for (let i = 0; i < count; i++) { + const hostGroup = apiToHostGroup(results[i].result); + avalHostGroups.push(hostGroup); + items.push({ + key: hostGroup.cn, + title: hostGroup.cn, + }); + } + items = items.filter( + (item) => + !member_hostgroup.includes(item.key) && + !memberindirect_hostgroup.includes(item.key) && + item.key !== props.id + ); + + setAvailableHostGroups(avalHostGroups); + setAvailableItems(items); + } + }, [hostGroupsQuery.data, hostGroupsQuery.isFetching]); + + // Add + const onAddHostGroup = (items: AvailableItems[]) => { + const newHostGroupNames = items.map((item) => item.key); + if (props.id === undefined || newHostGroupNames.length == 0) { + return; + } + + const payload = { + hostGroup: props.id, + entityType: "hostgroup", + idsToAdd: newHostGroupNames, + } as MemberPayload; + + setSpinning(true); + addMemberToHostGroups(payload).then((response) => { + if ("data" in response) { + if (response.data.result) { + // Set alert: success + alerts.addAlert( + "add-member-success", + "Assigned new host groups to host group '" + props.id + "'", + "success" + ); + // Refresh data + props.onRefreshData(); + // Close modal + setShowAddModal(false); + } else if (response.data.error) { + // Set alert: error + const errorMessage = response.data.error as unknown as ErrorResult; + alerts.addAlert("add-member-error", errorMessage.message, "danger"); + } + } + setSpinning(false); + }); + }; + + // Delete + const onDeleteHostGroups = () => { + const payload = { + hostGroup: props.id, + entityType: "hostgroup", + idsToAdd: hostGroupsSelected, + } as MemberPayload; + + setSpinning(true); + removeMembersFromHostGroups(payload).then((response) => { + if ("data" in response) { + if (response.data.result) { + // Set alert: success + alerts.addAlert( + "remove-hostgroups-success", + "Removed host groups from host group '" + props.id + "'", + "success" + ); + // Refresh + props.onRefreshData(); + // Disable 'remove' button + if (membershipDirection === "direct") { + setHostGroupsSelected([]); + } else { + setIndirectHostGroupsSelected([]); + } + // Close modal + setShowDeleteModal(false); + // Back to page 1 + setPage(1); + } else if (response.data.error) { + // Set alert: error + const errorMessage = response.data.error as unknown as ErrorResult; + alerts.addAlert( + "remove-hostgroups-error", + errorMessage.message, + "danger" + ); + } + } + setSpinning(false); + }); + }; + + return ( + <> + + {membershipDisabled ? ( + {}} + refreshButtonEnabled={isRefreshButtonEnabled} + onRefreshButtonClick={props.onRefreshData} + deleteButtonEnabled={ + membershipDirection === "direct" + ? hostGroupsSelected.length > 0 + : indirectHostGroupsSelected.length > 0 + } + onDeleteButtonClick={() => setShowDeleteModal(true)} + addButtonEnabled={isAddButtonEnabled} + onAddButtonClick={() => setShowAddModal(true)} + helpIconEnabled={true} + totalItems={hostGroupNames.length} + perPage={perPage} + page={page} + onPerPageChange={setPerPage} + onPageChange={setPage} + /> + ) : ( + {}} + refreshButtonEnabled={isRefreshButtonEnabled} + onRefreshButtonClick={props.onRefreshData} + deleteButtonEnabled={ + membershipDirection === "direct" + ? hostGroupsSelected.length > 0 + : indirectHostGroupsSelected.length > 0 + } + onDeleteButtonClick={() => setShowDeleteModal(true)} + addButtonEnabled={isAddButtonEnabled} + onAddButtonClick={() => setShowAddModal(true)} + membershipDirectionEnabled={true} + membershipDirection={membershipDirection} + onMembershipDirectionChange={setMembershipDirection} + helpIconEnabled={true} + totalItems={hostGroupNames.length} + perPage={perPage} + page={page} + onPerPageChange={setPerPage} + onPageChange={setPage} + /> + )} + + setPage(page)} + onPerPageSelect={(_e, perPage) => setPerPage(perPage)} + /> + {showAddModal && ( + setShowAddModal(false)} + availableItems={availableItems} + onAdd={onAddHostGroup} + onSearchTextChange={setAdderSearchValue} + title={"Assign host groups to host group: " + props.id} + ariaLabel={"Add host groups modal"} + spinning={spinning} + /> + )} + {showDeleteModal && someItemSelected && ( + setShowDeleteModal(false)} + title={"Delete host groups from host group: " + props.id} + onDelete={onDeleteHostGroups} + spinning={spinning} + > + + membershipDirection === "direct" + ? hostGroupsSelected.includes(hostGroup.cn) + : indirectHostGroupsSelected.includes(hostGroup.cn) + )} + from="host-groups" + idKey="cn" + columnNamesToShow={hostGroupColumnNames} + propertiesToShow={hostGroupProperties} + showTableRows + /> + + )} + + ); +}; + +export default MembersHostGroups; diff --git a/src/components/Members/MembersHosts.tsx b/src/components/Members/MembersHosts.tsx new file mode 100644 index 00000000..c47b3bc1 --- /dev/null +++ b/src/components/Members/MembersHosts.tsx @@ -0,0 +1,396 @@ +import React from "react"; +// PatternFly +import { Pagination, PaginationVariant } from "@patternfly/react-core"; +// Components +import MemberOfToolbar from "../MemberOf/MemberOfToolbar"; +import MemberOfAddModal, { AvailableItems } from "../MemberOf/MemberOfAddModal"; +import MemberOfDeleteModal from "../MemberOf/MemberOfDeleteModal"; +import MemberTable from "src/components/tables/MembershipTable"; +import { MembershipDirection } from "src/components/MemberOf/MemberOfToolbar"; +// Data types +import { Host, HostGroup } from "src/utils/datatypes/globalDataTypes"; +// Hooks +import useAlerts from "src/hooks/useAlerts"; +import useListPageSearchParams from "src/hooks/useListPageSearchParams"; +// Utils +import { API_VERSION_BACKUP, paginate } from "src/utils/utils"; +// RPC +import { ErrorResult } from "src/services/rpc"; +import { + useGetHostInfoByNameQuery, + useGettingHostQuery, +} from "src/services/rpcHosts"; +import { + MemberPayload, + useAddAsMemberHGMutation, + useRemoveAsMemberHGMutation, +} from "src/services/rpcHostGroups"; +import { apiToHost } from "src/utils/hostUtils"; + +interface PropsToMembersHosts { + entity: Partial; + id: string; + from: string; + isDataLoading: boolean; + onRefreshData: () => void; + member_host: string[]; + memberindirect_host?: string[]; + membershipDisabled?: boolean; + setDirection: (direction: MembershipDirection) => void; + direction: MembershipDirection; +} + +const MembersHosts = (props: PropsToMembersHosts) => { + // Alerts to show in the UI + const alerts = useAlerts(); + + const membershipDisabled = + props.membershipDisabled === undefined ? false : props.membershipDisabled; + + // Get parameters from URL + const { + page, + setPage, + perPage, + setPerPage, + searchValue, + setSearchValue, + membershipDirection, + setMembershipDirection, + } = useListPageSearchParams(); + + // Other states + const [hostsSelected, setHostsSelected] = React.useState([]); + const [indirectHostsSelected, setIndirectHostsSelected] = React.useState< + string[] + >([]); + + // Loaded hosts based on paging and member attributes + const [hosts, setHosts] = React.useState([]); + + // Choose the correct entries based on the membership direction + const member_host = props.member_host || []; + const memberindirect_host = props.memberindirect_host || []; + let hostNames = + membershipDirection === "direct" ? member_host : memberindirect_host; + hostNames = [...hostNames]; + + const getHostsNameToLoad = (): string[] => { + let toLoad = [...hostNames]; + toLoad.sort(); + + // Filter by search + if (searchValue) { + toLoad = toLoad.filter((name) => + name.toLowerCase().includes(searchValue.toLowerCase()) + ); + } + + // Apply paging + toLoad = paginate(toLoad, page, perPage); + + return toLoad; + }; + + const [hostNamesToLoad, setHostNamesToLoad] = React.useState( + getHostsNameToLoad() + ); + + // Load hosts + const fullHostsQuery = useGetHostInfoByNameQuery({ + hostNamesList: hostNamesToLoad, + no_members: true, + version: API_VERSION_BACKUP, + }); + + // Refresh hosts + React.useEffect(() => { + const hostsNames = getHostsNameToLoad(); + setHostNamesToLoad(hostsNames); + props.setDirection(membershipDirection); + }, [props.entity, membershipDirection, searchValue, page, perPage]); + + React.useEffect(() => { + setMembershipDirection(props.direction); + }, [props.entity]); + + React.useEffect(() => { + if (hostNamesToLoad.length > 0) { + fullHostsQuery.refetch(); + } + }, [hostNamesToLoad]); + + React.useEffect(() => { + if (fullHostsQuery.data && !fullHostsQuery.isFetching) { + setHosts(fullHostsQuery.data); + } + }, [fullHostsQuery.data, fullHostsQuery.isFetching]); + + // Computed "states" + const someItemSelected = hostsSelected.length > 0; + const showTableRows = hosts.length > 0; + const hostColumnNames = ["Host name", "Description"]; + const hostProperties = ["fqdn", "description"]; + + // Dialogs and actions + const [showAddModal, setShowAddModal] = React.useState(false); + const [showDeleteModal, setShowDeleteModal] = React.useState(false); + const [spinning, setSpinning] = React.useState(false); + + // Buttons functionality + const isRefreshButtonEnabled = + !fullHostsQuery.isFetching && !props.isDataLoading; + const isAddButtonEnabled = + membershipDirection !== "indirect" && isRefreshButtonEnabled; + + // Add new member to 'Host' + // API calls + const [addMemberToHostGroups] = useAddAsMemberHGMutation(); + const [removeMembersFromHostGroups] = useRemoveAsMemberHGMutation(); + const [adderSearchValue, setAdderSearchValue] = React.useState(""); + const [availableHosts, setAvailableHosts] = React.useState([]); + const [availableItems, setAvailableItems] = React.useState( + [] + ); + + // Load available hosts, delay the search for opening the modal + const hostsQuery = useGettingHostQuery({ + search: adderSearchValue, + apiVersion: API_VERSION_BACKUP, + sizelimit: 100, + startIdx: 0, + stopIdx: 100, + }); + + // Trigger available hosts search + React.useEffect(() => { + if (showAddModal) { + hostsQuery.refetch(); + } + }, [showAddModal, adderSearchValue, props.entity]); + + // Update available hosts + React.useEffect(() => { + if (hostsQuery.data && !hostsQuery.isFetching) { + // transform data + const count = hostsQuery.data.result.count; + const results = hostsQuery.data.result.results; + let items: AvailableItems[] = []; + const avalHosts: Host[] = []; + for (let i = 0; i < count; i++) { + const host = apiToHost(results[i].result); + avalHosts.push(host); + items.push({ + key: host.fqdn, + title: host.fqdn, + }); + } + items = items.filter( + (item) => + !member_host.includes(item.key) && + !memberindirect_host.includes(item.key) && + item.key !== props.id + ); + + setAvailableHosts(avalHosts); + setAvailableItems(items); + } + }, [hostsQuery.data, hostsQuery.isFetching]); + + // Add + const onAddHost = (items: AvailableItems[]) => { + const newHostNames = items.map((item) => item.key); + if (props.id === undefined || newHostNames.length == 0) { + return; + } + + const payload = { + hostGroup: props.id, + entityType: "host", + idsToAdd: newHostNames, + } as MemberPayload; + + setSpinning(true); + addMemberToHostGroups(payload).then((response) => { + if ("data" in response) { + if (response.data.result) { + // Set alert: success + alerts.addAlert( + "add-member-success", + "Assigned new hosts to host group '" + props.id + "'", + "success" + ); + // Refresh data + props.onRefreshData(); + // Close modal + setShowAddModal(false); + } else if (response.data.error) { + // Set alert: error + const errorMessage = response.data.error as unknown as ErrorResult; + alerts.addAlert("add-member-error", errorMessage.message, "danger"); + } + } + setSpinning(false); + }); + }; + + // Delete + const onDeleteHosts = () => { + const payload = { + hostGroup: props.id, + entityType: "host", + idsToAdd: hostsSelected, + } as MemberPayload; + + setSpinning(true); + removeMembersFromHostGroups(payload).then((response) => { + if ("data" in response) { + if (response.data.result) { + // Set alert: success + alerts.addAlert( + "remove-host-success", + "Removed hosts from host group '" + props.id + "'", + "success" + ); + // Refresh + props.onRefreshData(); + // Disable 'remove' button + if (membershipDirection === "direct") { + setHostsSelected([]); + } else { + setIndirectHostsSelected([]); + } + // Close modal + setShowDeleteModal(false); + // Back to page 1 + setPage(1); + } else if (response.data.error) { + // Set alert: error + const errorMessage = response.data.error as unknown as ErrorResult; + alerts.addAlert("remove-hosts-error", errorMessage.message, "danger"); + } + } + setSpinning(false); + }); + }; + + return ( + <> + + {membershipDisabled ? ( + {}} + refreshButtonEnabled={isRefreshButtonEnabled} + onRefreshButtonClick={props.onRefreshData} + deleteButtonEnabled={ + membershipDirection === "direct" + ? hostsSelected.length > 0 + : indirectHostsSelected.length > 0 + } + onDeleteButtonClick={() => setShowDeleteModal(true)} + addButtonEnabled={isAddButtonEnabled} + onAddButtonClick={() => setShowAddModal(true)} + helpIconEnabled={true} + totalItems={hostNames.length} + perPage={perPage} + page={page} + onPerPageChange={setPerPage} + onPageChange={setPage} + /> + ) : ( + {}} + refreshButtonEnabled={isRefreshButtonEnabled} + onRefreshButtonClick={props.onRefreshData} + deleteButtonEnabled={ + membershipDirection === "direct" + ? hostsSelected.length > 0 + : indirectHostsSelected.length > 0 + } + onDeleteButtonClick={() => setShowDeleteModal(true)} + addButtonEnabled={isAddButtonEnabled} + onAddButtonClick={() => setShowAddModal(true)} + membershipDirectionEnabled={true} + membershipDirection={membershipDirection} + onMembershipDirectionChange={setMembershipDirection} + helpIconEnabled={true} + totalItems={hostNames.length} + perPage={perPage} + page={page} + onPerPageChange={setPerPage} + onPageChange={setPage} + /> + )} + + setPage(page)} + onPerPageSelect={(_e, perPage) => setPerPage(perPage)} + /> + {showAddModal && ( + setShowAddModal(false)} + availableItems={availableItems} + onAdd={onAddHost} + onSearchTextChange={setAdderSearchValue} + title={"Assign hosts to host group: " + props.id} + ariaLabel={"Add hosts modal"} + spinning={spinning} + /> + )} + {showDeleteModal && someItemSelected && ( + setShowDeleteModal(false)} + title={"Delete hosts from host group: " + props.id} + onDelete={onDeleteHosts} + spinning={spinning} + > + + membershipDirection === "direct" + ? hostsSelected.includes(host.fqdn) + : indirectHostsSelected.includes(host.fqdn) + )} + from="hosts" + idKey="fqdn" + columnNamesToShow={hostColumnNames} + propertiesToShow={hostProperties} + showTableRows + /> + + )} + + ); +}; + +export default MembersHosts; diff --git a/src/components/Members/MembersServices.tsx b/src/components/Members/MembersServices.tsx index ccb9a6b4..94a44413 100644 --- a/src/components/Members/MembersServices.tsx +++ b/src/components/Members/MembersServices.tsx @@ -122,20 +122,9 @@ const MembersServices = (props: PropsToMembersServices) => { } }, [fullServicesQuery.data, fullServicesQuery.isFetching]); - // Get type of the entity to show as text - const getEntityType = () => { - if (props.from === "user-groups") { - return "user group"; - } else { - // Return 'group' as default - return "group"; - } - }; - // Computed "states" const someItemSelected = servicesSelected.length > 0; const showTableRows = services.length > 0; - const entityType = getEntityType(); const serviceColumnNames = ["Principal name"]; const serviceProperties = ["krbcanonicalname"]; @@ -196,7 +185,12 @@ const MembersServices = (props: PropsToMembersServices) => { title: service.krbcanonicalname, }); } - items = items.filter((item) => !member_service.includes(item.key)); + items = items.filter( + (item) => + !member_service.includes(item.key) && + !memberindirect_service.includes(item.key) && + item.key !== props.id + ); setAvailableServices(avalServices); setAvailableItems(items); @@ -223,7 +217,7 @@ const MembersServices = (props: PropsToMembersServices) => { // Set alert: success alerts.addAlert( "add-member-success", - "Assigned new services to " + entityType + " " + props.id, + "Assigned new services to user group '" + props.id + "'", "success" ); // Refresh data @@ -255,11 +249,17 @@ const MembersServices = (props: PropsToMembersServices) => { // Set alert: success alerts.addAlert( "remove-services-success", - "Removed services from " + entityType + " '" + props.id + "'", + "Removed services from user group '" + props.id + "'", "success" ); // Refresh props.onRefreshData(); + // Disable 'remove' button + if (membershipDirection === "direct") { + setServicesSelected([]); + } else { + setIndirectServicesSelected([]); + } // Close modal setShowDeleteModal(false); // Back to page 1 @@ -362,8 +362,8 @@ const MembersServices = (props: PropsToMembersServices) => { availableItems={availableItems} onAdd={onAddService} onSearchTextChange={setAdderSearchValue} - title={"Assign services to " + entityType + " " + props.id} - ariaLabel={"Add " + entityType + " of service modal"} + title={"Assign services to user group: " + props.id} + ariaLabel={"Add services to user group"} spinning={spinning} /> )} @@ -371,7 +371,7 @@ const MembersServices = (props: PropsToMembersServices) => { setShowDeleteModal(false)} - title={"Delete " + entityType + " from Services"} + title={"Delete services from user group: " + props.id} onDelete={onDeleteService} spinning={spinning} > diff --git a/src/components/Members/MembersUserGroups.tsx b/src/components/Members/MembersUserGroups.tsx index 4d1bf8fa..286abbdc 100644 --- a/src/components/Members/MembersUserGroups.tsx +++ b/src/components/Members/MembersUserGroups.tsx @@ -198,7 +198,12 @@ const MembersUserGroups = (props: PropsToMembersUsergroups) => { title: userGroup.cn, }); } - items = items.filter((item) => !member_group.includes(item.key)); + items = items.filter( + (item) => + !member_group.includes(item.key) && + !memberindirect_group.includes(item.key) && + item.key !== props.id + ); setAvailableUserGroups(avalUserGroups); setAvailableItems(items); @@ -225,7 +230,7 @@ const MembersUserGroups = (props: PropsToMembersUsergroups) => { // Set alert: success alerts.addAlert( "add-member-success", - "Assigned new user groups to " + entityType + " " + props.id, + "Assigned new user groups to " + entityType + " '" + props.id + "'", "success" ); // Refresh data @@ -262,6 +267,12 @@ const MembersUserGroups = (props: PropsToMembersUsergroups) => { ); // Refresh props.onRefreshData(); + // Disable 'remove' button + if (membershipDirection === "direct") { + setUserGroupsSelected([]); + } else { + setIndirectUserGroupsSelected([]); + } // Close modal setShowDeleteModal(false); // Back to page 1 @@ -368,8 +379,8 @@ const MembersUserGroups = (props: PropsToMembersUsergroups) => { availableItems={availableItems} onAdd={onAddUserGroup} onSearchTextChange={setAdderSearchValue} - title={"Assign User groups to " + entityType + " " + props.id} - ariaLabel={"Add " + entityType + " of user groups modal"} + title={"Assign user groups to " + entityType + ": " + props.id} + ariaLabel={"Add " + entityType + " to user groups modal"} spinning={spinning} /> )} @@ -377,7 +388,7 @@ const MembersUserGroups = (props: PropsToMembersUsergroups) => { setShowDeleteModal(false)} - title={"Delete " + entityType + " from User groups"} + title={"Delete " + entityType + "s from user group: " + props.id} onDelete={onDeleteUserGroups} spinning={spinning} > diff --git a/src/components/Members/MembersUsers.tsx b/src/components/Members/MembersUsers.tsx index 1c51fad2..551e8077 100644 --- a/src/components/Members/MembersUsers.tsx +++ b/src/components/Members/MembersUsers.tsx @@ -202,7 +202,12 @@ const MembersUsers = (props: PropsToMembersUsers) => { title: user.uid, }); } - items = items.filter((item) => !member_user.includes(item.key)); + items = items.filter( + (item) => + !member_user.includes(item.key) && + !memberindirect_user.includes(item.key) && + item.key !== props.id + ); setAvailableUsers(avalUsers); setAvailableItems(items); @@ -229,7 +234,7 @@ const MembersUsers = (props: PropsToMembersUsers) => { // Set alert: success alerts.addAlert( "add-member-success", - "Assigned new users to " + entityType + " " + props.id, + "Assigned new users to " + entityType + " '" + props.id + "'", "success" ); // Refresh data @@ -266,6 +271,12 @@ const MembersUsers = (props: PropsToMembersUsers) => { ); // Refresh props.onRefreshData(); + // Disable 'remove' button + if (membershipDirection === "direct") { + setUsersSelected([]); + } else { + setIndirectUsersSelected([]); + } // Close modal setShowDeleteModal(false); // Back to page 1 @@ -368,7 +379,7 @@ const MembersUsers = (props: PropsToMembersUsers) => { availableItems={availableItems} onAdd={onAddUser} onSearchTextChange={setAdderSearchValue} - title={"Assign users to " + entityType + " " + props.id} + title={"Assign users to " + entityType + ": " + props.id} ariaLabel={"Add " + entityType + " of user modal"} spinning={spinning} /> @@ -377,7 +388,7 @@ const MembersUsers = (props: PropsToMembersUsers) => { setShowDeleteModal(false)} - title={"Delete " + entityType + " from Users"} + title={"Delete users from " + entityType + ": " + props.id} onDelete={onDeleteUser} spinning={spinning} > diff --git a/src/components/modals/AddIDView.tsx b/src/components/modals/AddIDView.tsx index 971e08d0..6e44b078 100644 --- a/src/components/modals/AddIDView.tsx +++ b/src/components/modals/AddIDView.tsx @@ -12,13 +12,8 @@ import { // Layouts import SecondaryButton from "../layouts/SecondaryButton"; import ModalWithFormLayout from "../layouts/ModalWithFormLayout"; -// Data types -import { IDView } from "../../utils/datatypes/globalDataTypes"; // Modals import ErrorModal from "./ErrorModal"; -// Redux -import { useAppDispatch } from "../../store/hooks"; -import { addGroup } from "../../store/Identity/hostGroups-slice"; // Errors import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query"; import { SerializedError } from "@reduxjs/toolkit"; @@ -38,9 +33,6 @@ interface PropsToAddIDView { } const AddIDViewModal = (props: PropsToAddIDView) => { - // Set dispatch (Redux) - const dispatch = useAppDispatch(); - // Alerts to show in the UI const alerts = useAlerts(); @@ -138,7 +130,6 @@ const AddIDViewModal = (props: PropsToAddIDView) => { if ("data" in view) { const data = view.data as FindRPCResponse; const error = data.error as FetchBaseQueryError | SerializedError; - const result = data.result; if (error) { // Set status flag: error @@ -153,9 +144,6 @@ const AddIDViewModal = (props: PropsToAddIDView) => { "success" ); - // Dispatch host data to redux - const newView = result.result as unknown as IDView; - dispatch(addGroup(newView)); // Set status flag: success isAdditionSuccess = true; // Refresh data diff --git a/src/components/tables/MembershipTable.tsx b/src/components/tables/MembershipTable.tsx index 1406aefe..9f8dc0ba 100644 --- a/src/components/tables/MembershipTable.tsx +++ b/src/components/tables/MembershipTable.tsx @@ -3,6 +3,7 @@ import React from "react"; import { Table, Tr, Th, Td, Thead, Tbody } from "@patternfly/react-table"; // Data types import { + Host, Service, User, UserGroup, @@ -26,6 +27,7 @@ import { Link } from "react-router-dom"; type EntryDataTypes = | HBACRule + | Host | HostGroup | Netgroup | Role @@ -39,6 +41,7 @@ type EntryDataTypes = type FromTypes = | "active-users" | "hbac-rules" + | "hosts" | "host-groups" | "netgroups" | "roles" // Not in AppRoutes yet (no Link) @@ -155,12 +158,12 @@ export default function MemberTable(props: MemberTableProps) { // Return empty placeholder return ( @@ -187,12 +190,12 @@ export default function MemberTable(props: MemberTableProps) { return (
diff --git a/src/navigation/AppRoutes.tsx b/src/navigation/AppRoutes.tsx index 1e164dad..87277991 100644 --- a/src/navigation/AppRoutes.tsx +++ b/src/navigation/AppRoutes.tsx @@ -202,6 +202,18 @@ export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => { path="" element={} /> + } + /> + } + /> + } + /> diff --git a/src/pages/ActiveUsers/ActiveUsersTabs.tsx b/src/pages/ActiveUsers/ActiveUsersTabs.tsx index a5382fc7..edbdaf6c 100644 --- a/src/pages/ActiveUsers/ActiveUsersTabs.tsx +++ b/src/pages/ActiveUsers/ActiveUsersTabs.tsx @@ -6,7 +6,6 @@ import { PageSection, PageSectionVariants, TextContent, - Text, Tabs, Tab, TabTitleText, @@ -106,7 +105,7 @@ const ActiveUsersTabs = ({ memberof }) => { /> - <Text + <div className="pf-v5-u-display-flex" title={disabled ? "User is disabled" : ""} > @@ -122,7 +121,7 @@ const ActiveUsersTabs = ({ memberof }) => { ) : ( "" )} - </Text> + </div> diff --git a/src/pages/HostGroups/HostGroupsMembers.tsx b/src/pages/HostGroups/HostGroupsMembers.tsx new file mode 100644 index 00000000..ef1b1c78 --- /dev/null +++ b/src/pages/HostGroups/HostGroupsMembers.tsx @@ -0,0 +1,191 @@ +import React, { useState } from "react"; +// PatternFly +import { Badge, Tab, Tabs, TabTitleText } from "@patternfly/react-core"; +// Data types +import { HostGroup } from "src/utils/datatypes/globalDataTypes"; +import { MembershipDirection } from "src/components/MemberOf/MemberOfToolbar"; +// Layouts +import TabLayout from "src/components/layouts/TabLayout"; +// Navigation +import { useNavigate } from "react-router-dom"; +// Hooks +import useUpdateRoute from "src/hooks/useUpdateRoute"; +// RPC +import { useGetHostGroupByIdQuery } from "src/services/rpcHostGroups"; +// 'Members' sections +import MembersHosts from "src/components/Members/MembersHosts"; +import MembersHostGroups from "src/components/Members/MembersHostGroups"; + +interface PropsToHostGroupsMembers { + hostGroup: HostGroup; + tabSection: string; +} + +const HostGroupsMembers = (props: PropsToHostGroupsMembers) => { + const navigate = useNavigate(); + + const hostGroupQuery = useGetHostGroupByIdQuery(props.hostGroup.cn); + const hostGroupData = hostGroupQuery.data || {}; + const [hostGroup, setHostGroup] = useState>({}); + + React.useEffect(() => { + if (!hostGroupQuery.isFetching && hostGroupData) { + setHostGroup({ ...hostGroupData }); + } + }, [hostGroupData, hostGroupQuery.isFetching]); + + const onRefreshHostGroupData = () => { + hostGroupQuery.refetch(); + }; + + // Update current route data to Redux and highlight the current page in the Nav bar + useUpdateRoute({ pathname: "host-groups", noBreadcrumb: true }); + + // Tab counters + const [hostCount, setHostCount] = React.useState(0); + const [groupCount, setGroupCount] = React.useState(0); + + // group Directions + const [hostDirection, setHostDirection] = React.useState( + "direct" as MembershipDirection + ); + const [groupDirection, setGroupDirection] = React.useState( + "direct" as MembershipDirection + ); + + const updateHostDirection = (direction: MembershipDirection) => { + if (direction === "direct") { + setHostCount( + hostGroup && hostGroup.member_host ? hostGroup.member_host.length : 0 + ); + } else { + setHostCount( + hostGroup && hostGroup.memberindirect_host + ? hostGroup.memberindirect_host.length + : 0 + ); + } + setHostDirection(direction); + }; + const updateGroupDirection = (direction: MembershipDirection) => { + if (direction === "direct") { + setGroupCount( + hostGroup && hostGroup.member_hostgroup + ? hostGroup.member_hostgroup.length + : 0 + ); + } else { + setGroupCount( + hostGroup && hostGroup.memberindirect_hostgroup + ? hostGroup.memberindirect_hostgroup.length + : 0 + ); + } + setGroupDirection(direction); + }; + + React.useEffect(() => { + if (hostDirection === "direct") { + setHostCount( + hostGroup && hostGroup.member_host ? hostGroup.member_host.length : 0 + ); + } else { + setHostCount( + hostGroup && hostGroup.memberindirect_host + ? hostGroup.memberindirect_host.length + : 0 + ); + } + if (groupDirection === "direct") { + setGroupCount( + hostGroup && hostGroup.member_hostgroup + ? hostGroup.member_hostgroup.length + : 0 + ); + } else { + setGroupCount( + hostGroup && hostGroup.memberindirect_hostgroup + ? hostGroup.memberindirect_hostgroup.length + : 0 + ); + } + }, [hostGroup]); + + // Tab + const [activeTabKey, setActiveTabKey] = useState("member_host"); + + const handleTabClick = ( + _event: React.MouseEvent, + tabIndex: number | string + ) => { + setActiveTabKey(tabIndex as string); + navigate("/host-groups/" + props.hostGroup.cn + "/" + tabIndex); + }; + + React.useEffect(() => { + setActiveTabKey(props.tabSection); + }, [props.tabSection]); + + return ( + + + + Hosts{" "} + + {hostCount} + + + } + > + + + + Host groups{" "} + + {groupCount} + + + } + > + + + + + ); +}; + +export default HostGroupsMembers; diff --git a/src/pages/HostGroups/HostGroupsTabs.tsx b/src/pages/HostGroups/HostGroupsTabs.tsx index b7f4fd15..3ff46de7 100644 --- a/src/pages/HostGroups/HostGroupsTabs.tsx +++ b/src/pages/HostGroups/HostGroupsTabs.tsx @@ -23,6 +23,7 @@ import { useAppDispatch } from "src/store/hooks"; import { updateBreadCrumbPath } from "src/store/Global/routes-slice"; import { NotFound } from "src/components/errors/PageErrors"; import HostGroupsSettings from "./HostGroupsSettings"; +import HostGroupsMembers from "./HostGroupsMembers"; // eslint-disable-next-line react/prop-types const HostGroupsTabs = ({ section }) => { @@ -34,6 +35,7 @@ const HostGroupsTabs = ({ section }) => { const [breadcrumbItems, setBreadcrumbItems] = React.useState< BreadCrumbItem[] >([]); + const [groupId, setGroupId] = useState(""); // Tab const [activeTabKey, setActiveTabKey] = useState(section); @@ -45,6 +47,8 @@ const HostGroupsTabs = ({ section }) => { setActiveTabKey(tabIndex as string); if (tabIndex === "settings") { navigate("/host-groups/" + cn); + } else if (tabIndex === "member") { + navigate("/host-groups/" + cn + "/member_host"); } else if (tabIndex === "memberof") { // navigate("/host-groups/" + cn + "/memberof_hostgroup"); } else if (tabIndex === "managedby") { @@ -57,6 +61,7 @@ const HostGroupsTabs = ({ section }) => { // Redirect to the main page navigate("/host-groups"); } else { + setGroupId(cn); // Update breadcrumb route const currentPath: BreadCrumbItem[] = [ { @@ -78,9 +83,16 @@ const HostGroupsTabs = ({ section }) => { // Redirect to the settings page if the section is not defined React.useEffect(() => { if (!section) { - navigate(URL_PREFIX + "/host-groups/" + cn); + navigate(URL_PREFIX + "/host-groups/" + groupId); + } + const section_string = section as string; + if (section_string.startsWith("memberof_")) { + setActiveTabKey("memberof"); + } else if (section_string.startsWith("member_")) { + setActiveTabKey("member"); + } else if (section_string.startsWith("managedby")) { + // setActiveTabKey("managedby"); } - setActiveTabKey(section); }, [section]); if ( @@ -141,6 +153,13 @@ const HostGroupsTabs = ({ section }) => { modifiedValues={hostGroupSettingsData.modifiedValues} /> + Members} + > + + { id={settingsData.group.cn} text={settingsData.group.cn} headingLevel="h1" - preText="Sudo command group" + preText="Sudo command group:" /> diff --git a/src/pages/SudoCmds/SudoCmdsTabs.tsx b/src/pages/SudoCmds/SudoCmdsTabs.tsx index 7b025335..77c2a51a 100644 --- a/src/pages/SudoCmds/SudoCmdsTabs.tsx +++ b/src/pages/SudoCmds/SudoCmdsTabs.tsx @@ -95,7 +95,7 @@ const SudoCmdsTabs = ({ section }) => { id={settingsData.cmd.sudocmd} text={settingsData.cmd.sudocmd} headingLevel="h1" - preText="Sudo command" + preText="Sudo command:" /> diff --git a/src/pages/UserGroups/UserGroupsMembers.tsx b/src/pages/UserGroups/UserGroupsMembers.tsx index eb4611c5..306c26a9 100644 --- a/src/pages/UserGroups/UserGroupsMembers.tsx +++ b/src/pages/UserGroups/UserGroupsMembers.tsx @@ -181,7 +181,7 @@ const UserGroupsMembers = (props: PropsToUserGroupsMembers) => { title={ Users{" "} - + {userCount} @@ -204,8 +204,8 @@ const UserGroupsMembers = (props: PropsToUserGroupsMembers) => { name="member_group" title={ - User Groups{" "} - + User groups{" "} + {groupCount} @@ -229,7 +229,7 @@ const UserGroupsMembers = (props: PropsToUserGroupsMembers) => { title={ Services{" "} - + {serviceCount} @@ -253,7 +253,7 @@ const UserGroupsMembers = (props: PropsToUserGroupsMembers) => { title={ External{" "} - + {userGroup.member_external ? userGroup.member_external.length : 0} @@ -276,7 +276,7 @@ const UserGroupsMembers = (props: PropsToUserGroupsMembers) => { title={ User ID overrides{" "} - + {overrideCount} diff --git a/src/pages/UserGroups/UserGroupsTabs.tsx b/src/pages/UserGroups/UserGroupsTabs.tsx index 737ba3be..0cc9fdd9 100644 --- a/src/pages/UserGroups/UserGroupsTabs.tsx +++ b/src/pages/UserGroups/UserGroupsTabs.tsx @@ -166,7 +166,7 @@ const UserGroupsTabs = ({ section }) => { Members} > diff --git a/src/services/rpcHostGroups.ts b/src/services/rpcHostGroups.ts index d95371e8..c1cd2af7 100644 --- a/src/services/rpcHostGroups.ts +++ b/src/services/rpcHostGroups.ts @@ -42,6 +42,12 @@ export type GroupFullData = { hostGroup?: Partial; }; +export interface MemberPayload { + hostGroup: string; + idsToAdd: string[]; + entityType: string; +} + const extendedApi = api.injectEndpoints({ endpoints: (build) => ({ getHostGroupsFullData: build.query({ @@ -194,6 +200,22 @@ const extendedApi = api.injectEndpoints({ return groupList; }, }), + /** + * Get host group info by name + */ + getHostGroupById: build.query({ + query: (groupId) => { + return getCommand({ + method: "hostgroup_show", + params: [ + [groupId], + { all: true, rights: true, version: API_VERSION_BACKUP }, + ], + }); + }, + transformResponse: (response: FindRPCResponse): HostGroup => + apiToHostGroup(response.result.result), + }), saveHostGroup: build.mutation>({ query: (group) => { const params = { @@ -209,6 +231,44 @@ const extendedApi = api.injectEndpoints({ }, invalidatesTags: ["FullHostGroup"], }), + /** + * Given a list of user IDs, add them as members to a group + * @param {MemberPayload} - Payload with user IDs and options + */ + addAsMemberHG: build.mutation({ + query: (payload) => { + const hostGroup = payload.hostGroup; + const idsToAdd = payload.idsToAdd; + const memberType = payload.entityType; + + return getCommand({ + method: "hostgroup_add_member", + params: [ + [hostGroup], + { all: true, [memberType]: idsToAdd, version: API_VERSION_BACKUP }, + ], + }); + }, + }), + /** + * Remove a user group from some user members + * @param {MemberPayload} - Payload with user IDs and options + */ + removeAsMemberHG: build.mutation({ + query: (payload) => { + const hostGroup = payload.hostGroup; + const idsToAdd = payload.idsToAdd; + const memberType = payload.entityType; + + return getCommand({ + method: "hostgroup_remove_member", + params: [ + [hostGroup], + { all: true, [memberType]: idsToAdd, version: API_VERSION_BACKUP }, + ], + }); + }, + }), }), overrideExisting: false, }); @@ -228,4 +288,7 @@ export const { useGetHostGroupInfoByNameQuery, useGetHostGroupsFullDataQuery, useSaveHostGroupMutation, + useGetHostGroupByIdQuery, + useAddAsMemberHGMutation, + useRemoveAsMemberHGMutation, } = extendedApi; diff --git a/src/services/rpcHosts.ts b/src/services/rpcHosts.ts index 4c641ed1..2e56e5a2 100644 --- a/src/services/rpcHosts.ts +++ b/src/services/rpcHosts.ts @@ -54,6 +54,12 @@ export interface HostShowPayload { version: string; } +export interface MemberPayload { + host: string; + idsToAdd: string[]; + entityType: string; +} + const extendedApi = api.injectEndpoints({ endpoints: (build) => ({ getHostsFullData: build.query({ diff --git a/src/utils/datatypes/globalDataTypes.ts b/src/utils/datatypes/globalDataTypes.ts index bca88acf..af3f8871 100644 --- a/src/utils/datatypes/globalDataTypes.ts +++ b/src/utils/datatypes/globalDataTypes.ts @@ -315,6 +315,12 @@ export interface HostGroup { dn: string; cn: string; description: string; + membermanager_user: string[]; + membermanager_group: string[]; + member_host: string[]; + member_hostgroup: string[]; + memberindirect_host: string[]; + memberindirect_hostgroup: string[]; } export interface Service { diff --git a/src/utils/hostGroupUtils.tsx b/src/utils/hostGroupUtils.tsx index 55b8705f..539237cd 100644 --- a/src/utils/hostGroupUtils.tsx +++ b/src/utils/hostGroupUtils.tsx @@ -30,6 +30,12 @@ export function createEmptyGroup(): HostGroup { dn: "", cn: "", description: "", + membermanager_user: [], + membermanager_group: [], + member_host: [], + member_hostgroup: [], + memberindirect_host: [], + memberindirect_hostgroup: [], }; return group; diff --git a/tests/features/hostgroup_members.feature b/tests/features/hostgroup_members.feature new file mode 100644 index 00000000..d189f262 --- /dev/null +++ b/tests/features/hostgroup_members.feature @@ -0,0 +1,119 @@ +Feature: Hostgroup members + Work with hostgroup Members section and its operations in all the available + tabs (Hosts, Host groups) + + Background: + Given I am logged in as "Administrator" + Given I am on "host-groups" page + + Scenario: Add a new test host + Given I am on "hosts" page + When I click on "Add" button + * I type in the field "Host name" text "myhost" + * in the modal dialog I click on "Add" button + * I should see "success" alert with text "New host added" + Then I should see partial "myhost" entry in the data table + + Scenario: Add a new test host group + When I click on "Add" button + * I type in the field "Group name" text "testgroup" + When in the modal dialog I click on "Add" button + * I should see "success" alert with text "New host group added" + Then I should see "testgroup" entry in the data table + + # + # Test "Host" members + # + Scenario: Add a Host member into the host group + Given I click on "ipaservers" entry in the data table + Given I click on "Members" page tab + Given I am on "ipaservers" group > Members > "Hosts" section + Then I should see the "host" tab count is "1" + When I click on "Add" button located in the toolbar + Then I should see the dialog with title "Assign hosts to host group: ipaservers" + When I move user "myhost.dom-server.ipa.demo" from the available list and move it to the chosen options + And in the modal dialog I click on "Add" button + * I should see "success" alert with text "Assigned new hosts to host group 'ipaservers'" + * I close the alert + Then I should see the element "myhost.dom-server.ipa.demo" in the table + Then I should see the "host" tab count is "2" + + Scenario: Search for a host + When I type "myhost" in the search field + Then I should see the "myhost" text in the search input field + When I click on the arrow icon to perform search + Then I should see the element "myhost.dom-server.ipa.demo" in the table + * I should not see "server.ipa.demo" entry in the data table + * I click on the X icon to clear the search field + When I type "notthere" in the search field + Then I should see the "notthere" text in the search input field + When I click on the arrow icon to perform search + Then I should not see "myhost.dom-server.ipa.demo" entry in the data table + * I should not see "server.ipa.demo" entry in the data table + * I click on the X icon to clear the search field + Then I click on the arrow icon to perform search + + Scenario: Switch between direct and indirect memberships + When I click on the "indirect" button + Then I should see the "host" tab count is "0" + When I click on the "direct" button + Then I should see the "host" tab count is "2" + + Scenario: Remove Host from the host group + When I select entry "myhost.dom-server.ipa.demo" in the data table + And I click on "Delete" button located in the toolbar + Then I should see the dialog with title "Delete hosts from host group: ipaservers" + And the "myhost.dom-server.ipa.demo" element should be in the dialog table + When in the modal dialog I click on "Delete" button + Then I should see "success" alert with text "Removed hosts from host group 'ipaservers'" + * I close the alert + And I should not see "myhost.dom-server.ipa.demo" entry in the data table + Then I should see the "host" tab count is "1" + + # + # Test "HostGroup" members + # + Scenario: Add a Host group member into the host group + Given I click on "Host groups" page tab + Given I am on "ipaservers" group > Members > "Host groups" section + Then I should see the "hostgroup" tab count is "0" + When I click on "Add" button located in the toolbar + Then I should see the dialog with title "Assign host groups to host group: ipaservers" + When I move user "testgroup" from the available list and move it to the chosen options + And in the modal dialog I click on "Add" button + * I should see "success" alert with text "Assigned new host groups to host group 'ipaservers'" + * I close the alert + Then I should see the element "testgroup" in the table + Then I should see the "hostgroup" tab count is "1" + + Scenario: Search for a host group + When I type "testgroup" in the search field + Then I should see the "testgroup" text in the search input field + When I click on the arrow icon to perform search + Then I should see the element "testgroup" in the table + * I click on the X icon to clear the search field + When I type "notthere" in the search field + Then I should see the "notthere" text in the search input field + When I click on the arrow icon to perform search + Then I should not see "testgroup" entry in the data table + Then I click on the X icon to clear the search field + Then I click on the arrow icon to perform search + Then I should see the element "testgroup" in the table + + Scenario: Switch between direct and indirect memberships + When I click on the "indirect" button + Then I should see the "hostgroup" tab count is "0" + When I click on the "direct" button + Then I should see the "hostgroup" tab count is "1" + + Scenario: Remove Host group from the host group + When I select entry "testgroup" in the data table + And I click on "Delete" button located in the toolbar + Then I should see the dialog with title "Delete host groups from host group: ipaservers" + And the "testgroup" element should be in the dialog table + When in the modal dialog I click on "Delete" button + Then I should see "success" alert with text "Removed host groups from host group 'ipaservers'" + * I close the alert + And I should not see "testgroup" entry in the data table + Then I should see the "hostgroup" tab count is "0" + diff --git a/tests/features/steps/common.ts b/tests/features/steps/common.ts index 39b66049..e8991bfc 100644 --- a/tests/features/steps/common.ts +++ b/tests/features/steps/common.ts @@ -182,6 +182,13 @@ Then( } ); +Then( + "I should not see partial {string} entry in the data table", + (name: string) => { + cy.get("tr[id^='" + name + "']").should("not.exist"); + } +); + When("I select partial entry {string} in the data table", (name: string) => { cy.get("tr[id^='" + name + "'] input[type=checkbox]").check(); }); @@ -539,3 +546,11 @@ When("I scroll down", () => { duration: 500, }); }); + +// Get tab badge count +Then( + "I should see the {string} tab count is {string}", + (count_id: string, value: string) => { + cy.get("span.pf-v5-c-badge[id=" + count_id + "_count]").contains(value); + } +); diff --git a/tests/features/steps/memberships.ts b/tests/features/steps/memberships.ts new file mode 100644 index 00000000..bd9c15b3 --- /dev/null +++ b/tests/features/steps/memberships.ts @@ -0,0 +1,193 @@ +import { When, Then, Given } from "@badeball/cypress-cucumber-preprocessor"; + +// General + +// direct/indirect button +When("I click on the {string} button", (name: string) => { + cy.get('button[id="' + name + '"') + .click() + .wait(500); +}); + +// 'Members' section +Given("I click on the Members section", () => { + cy.get("button[name=member-details]").click(); +}); + +Then( + "I am on {string} group > Members > {string} section", + (groupname: string, sectionName: string) => { + cy.url().then(($url) => { + if ($url.includes("settings")) { + cy.get(".pf-v5-c-breadcrumb__item") + .contains(groupname) + .should("be.visible"); + cy.get("h1>p").contains(groupname).should("be.visible"); + cy.get("button[name='member-details']") + .should("have.attr", "aria-selected", "true") + .contains("Members"); + cy.get("li.pf-v5-c-tabs__item") + .children() + .get("button[name='member_group']") + .contains(sectionName); + cy.wait(2000); + } + }); + } +); + +Then("I should see member {string} tab is selected", (tabName: string) => { + let name = "group"; + switch (tabName) { + case "Users": + name = "user"; + break; + case "User groups": + name = "group"; + break; + case "Netgroups": + name = "netgroup"; + break; + case "Roles": + name = "role"; + break; + case "HBAC rules": + name = "hbacrule"; + break; + case "Sudo rules": + name = "sudorule"; + break; + case "Hosts": + name = "host"; + break; + case "Host groups": + name = "hostgroup"; + break; + case "Services": + name = "service"; + break; + case "External": + name = "external"; + break; + case "User ID overrides": + name = "idoverrideuser"; + break; + case "Subordinate ids": + name = "subid"; + break; + case "Sudo command groups": + name = "idoverrideuser"; + break; + case "HBAC service groups": + name = "hbacsvcgroup"; + break; + } + cy.get("button[name=member_" + name + "]") + .should("have.attr", "aria-selected", "true") + .contains(tabName); +}); + +// +// +// 'Is a member of' section +// +// +Given("I click on the Is a member of section", () => { + cy.get("button[name=memberof-details]").click(); +}); + +Then( + "I am on {string} user > Is a member of > {string} section", + (username: string, sectionName: string) => { + cy.url().then(($url) => { + if ($url.includes("settings")) { + cy.get(".pf-v5-c-breadcrumb__item") + .contains(username) + .should("be.visible"); + cy.get("h1>p").contains(username).should("be.visible"); + cy.get("button[name='memberof-details']") + .should("have.attr", "aria-selected", "true") + .contains("Is a member of"); + cy.get("li.pf-v5-c-tabs__item") + .children() + .get("button[name='memberof_group']") + .contains(sectionName); + cy.wait(3000); + } + }); + } +); + +When( + "I click on the {string} tab within Is a member of section", + (tabName: string) => { + cy.get("button").contains(tabName).click(); + } +); + +Then("I should see memberof {string} tab is selected", (tabName: string) => { + let name = "group"; + switch (tabName) { + case "User groups": + name = "group"; + break; + case "Netgroups": + name = "netgroup"; + break; + case "Roles": + name = "role"; + break; + case "HBAC rules": + name = "hbacrule"; + break; + case "Sudo rules": + name = "sudorule"; + break; + case "Hosts": + name = "host"; + break; + case "Host groups": + name = "hostgroup"; + break; + case "Subordinate ids": + name = "subid"; + break; + case "Sudo command groups": + name = "idoverrideuser"; + break; + case "HBAC service groups": + name = "hbacsvcgroup"; + break; + } + + cy.get("button[name=memberof_" + name + "]") + .should("have.attr", "aria-selected", "true") + .contains(tabName); +}); + +Then("I should see an empty table", () => { + cy.get("table#membership-table") + .find("h2.pf-v5-c-empty-state__title-text") + .contains("No results found"); +}); + +Then("I should see the table with {string} column", (columnName: string) => { + cy.get("th").contains(columnName).should("be.visible"); +}); + +Then( + "I should see the element {string} in the table", + (tableElement: string) => { + cy.get("table>tbody") + .find("td") + .contains(tableElement) + .should("be.visible"); + } +); + +Then( + "I should not see the element {string} in the table", + (tableElement: string) => { + cy.get("table>tbody").find("td").contains(tableElement).should("not.exist"); + } +); diff --git a/tests/features/steps/user_handling.ts b/tests/features/steps/user_handling.ts index f5907a8d..bf4a4ec7 100644 --- a/tests/features/steps/user_handling.ts +++ b/tests/features/steps/user_handling.ts @@ -355,92 +355,6 @@ Then( } ); -// 'Is a member of' section -Given("I click on the Is a member of section", () => { - cy.get("button[name=memberof-details]").click(); -}); - -Then( - "I am on {string} user > Is a member of > {string} section", - (username: string, sectionName: string) => { - cy.url().then(($url) => { - if ($url.includes("settings")) { - cy.get(".pf-v5-c-breadcrumb__item") - .contains(username) - .should("be.visible"); - cy.get("h1>p").contains(username).should("be.visible"); - cy.get("button[name='memberof-details']") - .should("have.attr", "aria-selected", "true") - .contains("Is a member of"); - cy.get("li.pf-v5-c-tabs__item") - .children() - .get("button[name='memberof_group']") - .contains(sectionName); - cy.wait(3000); - } - }); - } -); - -When( - "I click on the {string} tab within Is a member of section", - (tabName: string) => { - cy.get("button").contains(tabName).click(); - } -); - -Then("I should see {string} tab is selected", (tabName: string) => { - let name = "group"; - switch (tabName) { - case "User groups": - name = "group"; - break; - case "Netgroups": - name = "netgroup"; - break; - case "Roles": - name = "role"; - break; - case "HBAC rules": - name = "hbacrule"; - break; - case "Sudo rules": - name = "sudorule"; - break; - } - - cy.get("button[name=memberof_" + name + "]") - .should("have.attr", "aria-selected", "true") - .contains(tabName); -}); - -Then("I should see the table with {string} column", (columnName: string) => { - cy.get("th").contains(columnName).should("be.visible"); -}); - -Then("I should see an empty table", () => { - cy.get("table#member-of-table") - .find("h2.pf-v5-c-empty-state__title-text") - .contains("No results found"); -}); - -Then( - "I should see the element {string} in the table", - (tableElement: string) => { - cy.get("table>tbody") - .find("td") - .contains(tableElement) - .should("be.visible"); - } -); - -Then( - "I should not see the element {string} in the table", - (tableElement: string) => { - cy.get("table>tbody").find("td").contains(tableElement).should("not.exist"); - } -); - // - Add When( "I click on {string} button located in the toolbar", @@ -497,7 +411,7 @@ Then( "the {string} element should be in the dialog table", (groupName: string) => { cy.get("div[role='dialog'") - .find("table#member-of-table") + .find("table#membership-table") .find("td.pf-v5-c-table__td") .contains(groupName) .should("be.visible"); diff --git a/tests/features/user_is_member_of.feature b/tests/features/user_is_member_of.feature index beedc495..e6d17e3d 100644 --- a/tests/features/user_is_member_of.feature +++ b/tests/features/user_is_member_of.feature @@ -18,7 +18,7 @@ Feature: User is a member of # 'User groups' tab Scenario: Default user groups are displayed in the table When I click on the "User groups" tab within Is a member of section - Then I should see "User groups" tab is selected + Then I should see memberof "User groups" tab is selected And I should see the table with "Group name" column And I should see the table with "GID" column And I should see the table with "Description" column @@ -56,5 +56,5 @@ Feature: User is a member of # 'Netgroups' tab Scenario: Default netgroups are displayed in the table When I click on the "Netgroups" tab within Is a member of section - Then I should see "Netgroups" tab is selected + Then I should see memberof "Netgroups" tab is selected And I should see an empty table diff --git a/tests/features/usergroup_members.feature b/tests/features/usergroup_members.feature new file mode 100644 index 00000000..616afebc --- /dev/null +++ b/tests/features/usergroup_members.feature @@ -0,0 +1,153 @@ +Feature: Usergroup members + Work with usergroup Members section and its operations in all the available + tabs (Users, User groups, Services, External, User ID overrides) + TODO: external, and User ID overrides + + Background: + Given I am logged in as "Administrator" + Given I am on "user-groups" page + + Scenario: Add test user + Given I am on "active-users" page + Given sample testing user "armadillo" exists + + # + # Test "User" members + # + Scenario: Add a User member into the user group + Given I click on "admins" entry in the data table + Given I click on "Members" page tab + Given I am on "admins" group > Members > "Users" section + Then I should see the "user" tab count is "1" + When I click on "Add" button located in the toolbar + Then I should see the dialog with title "Assign users to user group: admins" + When I move user "armadillo" from the available list and move it to the chosen options + And in the modal dialog I click on "Add" button + * I should see "success" alert with text "Assigned new users to user group 'admins'" + * I close the alert + Then I should see the element "armadillo" in the table + Then I should see the "user" tab count is "2" + + Scenario: Search for a user + When I type "armadillo" in the search field + Then I should see the "armadillo" text in the search input field + When I click on the arrow icon to perform search + Then I should see the element "armadillo" in the table + * I click on the X icon to clear the search field + When I type "notthere" in the search field + Then I should see the "notthere" text in the search input field + When I click on the arrow icon to perform search + Then I should not see the element "armadillo" in the table + * I click on the X icon to clear the search field + Then I click on the arrow icon to perform search + + Scenario: Switch between direct and indirect memberships + When I click on the "indirect" button + Then I should see the "user" tab count is "0" + When I click on the "direct" button + Then I should see the "user" tab count is "2" + + Scenario: Remove User from the user group admins + When I select entry "armadillo" in the data table + And I click on "Delete" button located in the toolbar + Then I should see the dialog with title "Delete users from user group: admins" + And the "armadillo" element should be in the dialog table + When in the modal dialog I click on "Delete" button + Then I should see "success" alert with text "Removed users from user group 'admins'" + * I close the alert + And I should not see the element "armadillo" in the table + Then I should see the "user" tab count is "1" + + # + # Test "UserGroup" members + # + Scenario: Add a Usergroup member into the user group + Given I click on "User groups" page tab + Given I am on "admins" group > Members > "User groups" section + Then I should see the "usergroup" tab count is "0" + When I click on "Add" button located in the toolbar + Then I should see the dialog with title "Assign user groups to user group: admins" + When I move user "editors" from the available list and move it to the chosen options + And in the modal dialog I click on "Add" button + * I should see "success" alert with text "Assigned new user groups to user group 'admins'" + * I close the alert + Then I should see the element "editors" in the table + Then I should see the "usergroup" tab count is "1" + + Scenario: Search for a usergroup + When I type "editors" in the search field + Then I should see the "editors" text in the search input field + When I click on the arrow icon to perform search + Then I should see the element "editors" in the table + * I click on the X icon to clear the search field + When I type "notthere" in the search field + Then I should see the "notthere" text in the search input field + When I click on the arrow icon to perform search + Then I should not see the element "editors" in the table + Then I click on the X icon to clear the search field + Then I click on the arrow icon to perform search + Then I should see the element "editors" in the table + + Scenario: Switch between direct and indirect memberships + When I click on the "indirect" button + Then I should see the "usergroup" tab count is "0" + When I click on the "direct" button + Then I should see the "usergroup" tab count is "1" + + Scenario: Remove Usergroup from the user group admins + When I select entry "editors" in the data table + And I click on "Delete" button located in the toolbar + Then I should see the dialog with title "Delete user groups from user group: admins" + And the "editors" element should be in the dialog table + When in the modal dialog I click on "Delete" button + Then I should see "success" alert with text "Removed user groups from user group 'admins'" + * I close the alert + And I should not see the element "editors" in the table + Then I should see the "usergroup" tab count is "0" + + # + # Test "Service" members + # + Scenario: Add a Service member into the user group + Given I click on "Services" page tab + Given I am on "admins" group > Members > "Services" section + Then I should see the "service" tab count is "0" + When I click on "Add" button located in the toolbar + Then I should see the dialog with title "Assign services to user group: admins" + When I move user "DNS/server.ipa.demo@DOM-IPA.DEMO" from the available list and move it to the chosen options + And in the modal dialog I click on "Add" button + * I should see "success" alert with text "Assigned new services to user group 'admins'" + * I close the alert + Then I should see partial "DNS" entry in the data table + Then I should see the "service" tab count is "1" + + Scenario: Search for a service + When I type "DNS" in the search field + Then I should see the "DNS" text in the search input field + When I click on the arrow icon to perform search + Then I should see partial "DNS" entry in the data table + * I click on the X icon to clear the search field + When I type "notthere" in the search field + Then I should see the "notthere" text in the search input field + When I click on the arrow icon to perform search + Then I should not see partial "DNS" entry in the data table + Then I click on the X icon to clear the search field + Then I click on the arrow icon to perform search + Then I should see partial "DNS" entry in the data table + + Scenario: Switch between direct and indirect memberships + When I click on the "indirect" button + Then I should see the "service" tab count is "0" + When I click on the "direct" button + Then I should see the "service" tab count is "1" + + Scenario: Remove service from the user group admins + When I select partial entry "DNS" in the data table + And I click on "Delete" button located in the toolbar + Then I should see the dialog with title "Delete services from user group: admins" + And the "DNS" element should be in the dialog table + When in the modal dialog I click on "Delete" button + Then I should see "success" alert with text "Removed services from user group 'admins'" + * I close the alert + And I should not see the element "DNS" in the table + Then I should see the "service" tab count is "0"