diff --git a/doc/designs/main-pages/07-add-modal.md b/doc/designs/main-pages/07-add-modal.md index 350de51aa..135ba1a14 100644 --- a/doc/designs/main-pages/07-add-modal.md +++ b/doc/designs/main-pages/07-add-modal.md @@ -151,9 +151,8 @@ const AddEntityModal = (props: PropsToAddModal) => { } if (response.data?.result) { dispatch(addAlert({ name: "add-entity-success", title: "New entity added", variant: "success" })); - clearFields(); + cleanAndCloseModal(); props.onRefresh(); - props.onClose(); } } setIsAddButtonSpinning(false); diff --git a/src/components/TypeAheadSelect.tsx b/src/components/TypeAheadSelect.tsx index 269143933..957bdc8a9 100644 --- a/src/components/TypeAheadSelect.tsx +++ b/src/components/TypeAheadSelect.tsx @@ -243,7 +243,7 @@ const TypeAheadSelect = (props: PropsToTypeAheadSelect) => { onClick={onInputClick} onChange={onTextInputChange} onKeyDown={onInputKeyDown} - id="typeahead-select-input" + id={`${props.id}-typeahead-select-input`} data-cy={"typeahead-select-input"} autoComplete="off" innerRef={textInputRef} diff --git a/src/components/TypeAheadWithCheckbox.tsx b/src/components/TypeAheadWithCheckbox.tsx new file mode 100644 index 000000000..593cf586e --- /dev/null +++ b/src/components/TypeAheadWithCheckbox.tsx @@ -0,0 +1,344 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + Select, + SelectOption, + SelectList, + SelectOptionProps, + MenuToggle, + MenuToggleElement, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Button, +} from "@patternfly/react-core"; +import { CloseIcon } from "@patternfly/react-icons"; + +type CreationProps = { + // Whenever this property changes, the options will be reset + onChangeTarget: T; +}; + +type TypeAheadWithCheckboxProps = { + id: string; + dataCy: string; + options: SelectOptionProps[]; + selected: string[]; + setSelected: (selected: string[]) => void; + creationProps?: CreationProps; +}; + +const NO_RESULTS = "no results"; +const CREATE_NEW = "create"; + +export const TypeAheadWithCheckbox = ({ + id, + dataCy, + options, + selected, + setSelected, + creationProps, +}: TypeAheadWithCheckboxProps) => { + // This is to allow mutations to options property + const [localOptions, setLocalOptions] = + useState(options); + // This one is actually shown, it can contain Create New options and no results + const [availableOptions, setAvailableOptions] = + useState(localOptions); + const [isOpen, setIsOpen] = useState(false); + const [inputValue, setInputValue] = useState(""); + const [focusedItemIndex, setFocusedItemIndex] = useState(null); + const [activeItemId, setActiveItemId] = useState(null); + const textInputRef = useRef(undefined); + + const [previousTarget, setPreviousTarget] = useState(null); + const allowCreation = creationProps !== undefined; + + if (allowCreation && previousTarget !== creationProps?.onChangeTarget) { + setPreviousTarget(creationProps.onChangeTarget); + setLocalOptions(options); + setInputValue(""); + } + + useEffect(() => { + let newSelectOptions: SelectOptionProps[] = localOptions; + + // Filter menu items based on the text input value when one exists + if (inputValue) { + newSelectOptions = localOptions.filter((menuItem) => + String(menuItem.children) + .toLowerCase() + .includes(inputValue.toLowerCase()) + ); + + // If no option matches the filter exactly, display creation option + if (allowCreation) { + if (!localOptions.some((option) => option.value === inputValue)) { + newSelectOptions = [ + ...newSelectOptions, + { + children: `Create new option "${inputValue}"`, + value: CREATE_NEW, + "data-cy": `${dataCy}-create-new-option`, + }, + ]; + } + } + + // When no options are found after filtering, display 'No results found' + if (newSelectOptions.length === 0) { + newSelectOptions = [ + { + "data-cy": `${dataCy}-no-results`, + isAriaDisabled: true, + children: `No results found for "${inputValue}"`, + value: NO_RESULTS, + hasCheckbox: false, + }, + ]; + } + } + + // This sucks, but we're forced to, due to how isOpenCallback + // works, with memo the we onOpenChange is fired + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect + setAvailableOptions(newSelectOptions); + // We mutate options, if we don't, we end up with the same issue as above... + }, [inputValue, localOptions]); + + const placeholder = `${selected.length} item${selected.length !== 1 ? "s" : ""} selected`; + + const createItemId = (value: string) => + `select-multi-typeahead-${value.replace(" ", "-")}`; + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = availableOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const onInputClick = () => { + if (!isOpen) { + setIsOpen(true); + } else if (!inputValue) { + closeMenu(); + } + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } + + if (availableOptions.every((option) => option.isDisabled)) { + return; + } + + if (key === "ArrowUp") { + // When no index is set or at the first index, focus to the last, otherwise decrement focus index + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = availableOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + + // Skip disabled options + while (availableOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { + indexToFocus = availableOptions.length - 1; + } + } + } + + if (key === "ArrowDown") { + // When no index is set or at the last index, focus to the first, otherwise increment focus index + if ( + focusedItemIndex === null || + focusedItemIndex === availableOptions.length - 1 + ) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + + // Skip disabled options + while (availableOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === availableOptions.length) { + indexToFocus = 0; + } + } + } + + setActiveAndFocusedItem(indexToFocus); + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const focusedItem = + focusedItemIndex !== null ? availableOptions[focusedItemIndex] : null; + + switch (event.key) { + case "Enter": + if ( + isOpen && + focusedItem && + focusedItem.value !== NO_RESULTS && + !focusedItem.isAriaDisabled + ) { + onSelect(focusedItem.value); + } + + if (!isOpen) { + setIsOpen(true); + } + + break; + case "ArrowUp": + case "ArrowDown": + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + setIsOpen(!isOpen); + textInputRef?.current?.focus(); + }; + + const onTextInputChange = ( + _event: React.FormEvent, + value: string + ) => { + setInputValue(value); + if (value !== "" && !isOpen) setIsOpen(true); + resetActiveAndFocusedItem(); + }; + + const onSelect = (value: string) => { + if (value && value !== NO_RESULTS) { + if (value === CREATE_NEW) { + if (!availableOptions.some((item) => item.value === inputValue)) { + setLocalOptions([ + ...localOptions, + { + value: inputValue, + children: inputValue, + "data-cy": `${dataCy}-${inputValue}-create-new-option`, + }, + ]); + } + setSelected( + selected.includes(inputValue) + ? selected.filter((selection) => selection !== inputValue) + : [...selected, inputValue] + ); + resetActiveAndFocusedItem(); + } else { + setSelected( + selected.includes(value) + ? selected.filter((selection) => selection !== value) + : [...selected, value] + ); + } + } + + textInputRef.current?.focus(); + }; + + const onClearButtonClick = () => { + setSelected([]); + setInputValue(""); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; + + const toggle = (toggleRef: React.Ref) => ( + + + + + , + , + ]; + + return ( + onAddPermission()} + onClose={cleanAndCloseModal} + actions={modalActions} + /> + ); +}; + +export default AddPermissionModal; diff --git a/src/components/modals/PermissionModals/DeletePermissionsModal.tsx b/src/components/modals/PermissionModals/DeletePermissionsModal.tsx new file mode 100644 index 000000000..6f2ff2fd6 --- /dev/null +++ b/src/components/modals/PermissionModals/DeletePermissionsModal.tsx @@ -0,0 +1,204 @@ +import React from "react"; +// PatternFly +import { Content, ContentVariants, Button } from "@patternfly/react-core"; +// Layouts +import ModalWithFormLayout from "src/components/layouts/ModalWithFormLayout"; +// Tables +import DeletedElementsTable from "src/components/tables/DeletedElementsTable"; +// Hooks +import { addAlert } from "src/store/Global/alerts-slice"; +// Redux +import { useAppDispatch } from "src/store/hooks"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; +import { SerializedError } from "@reduxjs/toolkit"; +// Data types +import { ErrorData, Permission } from "src/utils/datatypes/globalDataTypes"; +// Modals +import ErrorModal from "src/components/modals/ErrorModal"; +import { BatchRPCResponse } from "src/services/rpc"; +import { useDeletePermissionsMutation } from "src/services/rpcPermissions"; + +interface DeletePermissionsModalProps { + isOpen: boolean; + onClose: () => void; + elementsToDelete: Permission[]; + clearSelectedElements: () => void; + columnNames: string[]; + keyNames: string[]; + onRefresh: () => void; + updateIsDeleteButtonDisabled: (value: boolean) => void; + updateIsDeletion: (value: boolean) => void; +} + +const DeletePermissionsModal = (props: DeletePermissionsModalProps) => { + const dispatch = useAppDispatch(); + + // RPC calls + const [executePermissionsDelCommand] = useDeletePermissionsMutation(); + + // States + const [spinning, setBtnSpinning] = React.useState(false); + const [isModalErrorOpen, setIsModalErrorOpen] = React.useState(false); + const [errorTitle, setErrorTitle] = React.useState(""); + const [errorMessage, setErrorMessage] = React.useState(""); + + const fields = [ + { + id: "question-text", + pfComponent: ( + + Are you sure you want to remove the selected permissions? + + ), + }, + { + id: "deleted-permissions-table", + pfComponent: ( + + ), + }, + ]; + + // Handle API error data + const handleAPIError = (error: FetchBaseQueryError | SerializedError) => { + if ("code" in error) { + setErrorTitle("IPA error " + error.code + ": " + error.name); + if (error.message !== undefined) { + setErrorMessage(error.message); + } + } else if ("data" in error) { + const errorData = error.data as ErrorData; + const errorCode = errorData.code as string; + const errorName = errorData.name as string; + const errorMsg = errorData.error as string; + + setErrorTitle("IPA error " + errorCode + ": " + errorName); + setErrorMessage(errorMsg); + } + setIsModalErrorOpen(true); + }; + + const closeAndCleanErrorParameters = () => { + setIsModalErrorOpen(false); + setErrorTitle(""); + setErrorMessage(""); + }; + + // Delete handler + const onDeletePermissions = () => { + setBtnSpinning(true); + + executePermissionsDelCommand(props.elementsToDelete).then((response) => { + if ("data" in response) { + const data = response.data as BatchRPCResponse; + const result = data.result; + + if (result) { + if ("error" in result.results[0] && result.results[0].error) { + const errorData = { + code: result.results[0].error_code, + name: result.results[0].error_name, + error: result.results[0].error, + } as ErrorData; + + const error = { + status: "CUSTOM_ERROR", + data: errorData, + } as FetchBaseQueryError; + + handleAPIError(error); + } else { + props.clearSelectedElements(); + props.updateIsDeleteButtonDisabled(true); + props.updateIsDeletion(true); + + dispatch( + addAlert({ + name: "remove-permissions-success", + title: "Permissions removed", + variant: "success", + }) + ); + + props.onClose(); + props.onRefresh(); + } + } + } + setBtnSpinning(false); + }); + }; + + // Modal actions + const modalActions: JSX.Element[] = [ + , + , + ]; + + // Error modal actions + const errorModalActions = [ + , + ]; + + return ( + <> + + {isModalErrorOpen && ( + + )} + + ); +}; + +export default DeletePermissionsModal; diff --git a/src/navigation/AppRoutes.tsx b/src/navigation/AppRoutes.tsx index 69c609acb..fcc8f3af9 100644 --- a/src/navigation/AppRoutes.tsx +++ b/src/navigation/AppRoutes.tsx @@ -83,6 +83,7 @@ import Roles from "src/pages/Roles/Roles"; import RolesTabs from "src/pages/Roles/RolesTabs"; import Privileges from "src/pages/Privileges/Privileges"; import PrivilegesTabs from "src/pages/Privileges/PrivilegesTabs"; +import Permissions from "src/pages/Permissions/Permissions"; // Renders routes (React) export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => { @@ -615,6 +616,9 @@ export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => { /> + + } /> + } /> {/* Redirect to Active users page if user is logged in and navigates to the root page */} } /> diff --git a/src/navigation/NavRoutes.ts b/src/navigation/NavRoutes.ts index 441ac79df..b24269822 100644 --- a/src/navigation/NavRoutes.ts +++ b/src/navigation/NavRoutes.ts @@ -72,6 +72,7 @@ const TopologyGroupRef = "topology-graph"; // - Role-based access control const RbacGroupRef = "rbac"; const PrivilegesGroupRef = "privileges"; +const PermissionsGroupRef = "permissions"; // - Configuration const ConfigRef = "configuration"; @@ -445,6 +446,13 @@ export const getNavigationRoutes = ( path: "privileges", items: [], }, + { + label: "Permissions", + group: PermissionsGroupRef, + title: `${BASE_TITLE} - Permissions`, + path: "permissions", + items: [], + }, ], }, { diff --git a/src/pages/Permissions/Permissions.tsx b/src/pages/Permissions/Permissions.tsx new file mode 100644 index 000000000..4a869ad19 --- /dev/null +++ b/src/pages/Permissions/Permissions.tsx @@ -0,0 +1,394 @@ +import React, { useMemo, useState } from "react"; +// PatternFly +import { + Flex, + FlexItem, + PageSection, + PaginationVariant, + ToolbarItemVariant, +} from "@patternfly/react-core"; +// PatternFly table +import { + InnerScrollContainer, + OuterScrollContainer, +} from "@patternfly/react-table"; +// Data types +import { Permission } from "src/utils/datatypes/globalDataTypes"; +import { ToolbarItem } from "src/components/layouts/ToolbarLayout"; +// Redux +import { useAppDispatch, useAppSelector } from "src/store/hooks"; +// Layouts +import TitleLayout from "src/components/layouts/TitleLayout"; +import HelpTextWithIconLayout from "src/components/layouts/HelpTextWithIconLayout"; +import SecondaryButton from "src/components/layouts/SecondaryButton"; +import ToolbarLayout from "src/components/layouts/ToolbarLayout"; +import SearchInputLayout from "src/components/layouts/SearchInputLayout"; +// Tables +import MainTable from "src/components/tables/MainTable"; +// Components +import PaginationLayout from "src/components/layouts/PaginationLayout"; +import BulkSelectorPrep from "src/components/BulkSelectorPrep"; +// Modals +import AddPermissionModal from "src/components/modals/PermissionModals/AddPermissionModal"; +import DeletePermissionsModal from "src/components/modals/PermissionModals/DeletePermissionsModal"; +// Hooks +import useUpdateRoute from "src/hooks/useUpdateRoute"; +import useListPageSearchParams from "src/hooks/useListPageSearchParams"; +import useContextualHelpTopic from "src/hooks/useContextualHelpTopic"; +import { toggleHelpPanel } from "src/store/Global/contextual-help-slice"; +// Utils +import { API_VERSION_BACKUP, isPermissionSelectable } from "src/utils/utils"; +// RPC client +import { useGetPermissionsFullDataQuery } from "src/services/rpcPermissions"; +// Errors +import useApiError from "src/hooks/useApiError"; +import GlobalErrors from "src/components/errors/GlobalErrors"; +import ModalErrors from "src/components/errors/ModalErrors"; +import { apiToPermission } from "src/utils/permissionsUtils"; + +const Permissions = () => { + const dispatch = useAppDispatch(); + + useUpdateRoute({ pathname: "permissions" }); + useContextualHelpTopic("permissions"); + + const apiVersion = useAppSelector( + (state) => state.global.environment.api_version + ) as string; + + const { page, perPage, searchValue } = useListPageSearchParams(); + + const globalErrors = useApiError([]); + const modalErrors = useApiError([]); + + const firstIdx = (page - 1) * perPage; + const lastIdx = page * perPage; + + const permissionsDataResponse = useGetPermissionsFullDataQuery({ + searchValue, + sizeLimit: 0, + apiVersion: apiVersion || API_VERSION_BACKUP, + startIdx: firstIdx, + stopIdx: lastIdx, + }); + + const { + data: batchResponse, + isLoading: isBatchLoading, + isFetching, + error: batchError, + } = permissionsDataResponse; + + // Derive elementsList and totalCount from query response or search results + const { elementsList, totalCount } = useMemo(() => { + // Otherwise derive from query response + if (batchResponse?.result) { + const permissionsListResult = batchResponse.result.results; + const permissionsListSize = batchResponse.result.count; + const permissions: Permission[] = []; + + for (let i = 0; i < permissionsListSize; i++) { + permissions.push(apiToPermission(permissionsListResult[i].result)); + } + + return { + elementsList: permissions, + totalCount: batchResponse.result.totalCount, + }; + } + + return { elementsList: [], totalCount: 0 }; + }, [batchResponse]); + + // Clear errors when fetching starts + React.useEffect(() => { + if (isFetching) { + globalErrors.clear(); + } + }, [isFetching]); + + // Handle query errors - add to global errors instead of reloading + React.useEffect(() => { + if ( + !isBatchLoading && + !isFetching && + permissionsDataResponse.isError && + permissionsDataResponse.error !== undefined + ) { + globalErrors.addError( + permissionsDataResponse.error, + "Error loading permissions", + "permissions-fetch-error" + ); + } + }, [permissionsDataResponse.isError, isBatchLoading, isFetching]); + + const refreshData = () => { + clearSelectedPermissions(); + permissionsDataResponse.refetch(); + }; + + const [isDeleteButtonDisabled, setIsDeleteButtonDisabled] = + useState(true); + + const [isDeletion, setIsDeletion] = useState(false); + + const [selectedPerPage, setSelectedPerPage] = useState(0); + + const [selectedPermissions, setSelectedPermissions] = useState( + [] + ); + + const clearSelectedPermissions = () => { + setSelectedPermissions([]); + }; + + const [showAddModal, setShowAddModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const selectablePermissionsTable = elementsList.filter( + isPermissionSelectable + ); + + const updateSelectedPermissions = ( + permissions: Permission[], + isSelected: boolean + ) => { + let newSelectedPermissions: Permission[] = []; + if (isSelected) { + newSelectedPermissions = JSON.parse(JSON.stringify(selectedPermissions)); + for (let i = 0; i < permissions.length; i++) { + if (selectedPermissions.find((s) => s.cn === permissions[i].cn)) { + continue; + } + newSelectedPermissions.push(permissions[i]); + } + } else { + for (let i = 0; i < selectedPermissions.length; i++) { + let found = false; + for (let ii = 0; ii < permissions.length; ii++) { + if (selectedPermissions[i].cn === permissions[ii].cn) { + found = true; + break; + } + } + if (!found) { + newSelectedPermissions.push(selectedPermissions[i]); + } + } + } + setSelectedPermissions(newSelectedPermissions); + setIsDeleteButtonDisabled(newSelectedPermissions.length === 0); + }; + + const setPermissionSelected = ( + permission: Permission, + isSelecting = true + ) => { + if (isPermissionSelectable(permission)) { + updateSelectedPermissions([permission], isSelecting); + } + }; + + const bulkSelectorData = { + selected: selectedPermissions, + updateSelected: updateSelectedPermissions, + selectableTable: selectablePermissionsTable, + nameAttr: "cn", + }; + + const buttonsData = { + updateIsDeleteButtonDisabled: setIsDeleteButtonDisabled, + }; + + const selectedPerPageData = { + selectedPerPage, + updateSelectedPerPage: setSelectedPerPage, + }; + + const columnNames = ["Permission name", "Granted rights"]; + const keyNames = ["cn", "ipapermright"]; + + const toolbarItems: ToolbarItem[] = [ + { + key: 0, + element: ( + + ), + }, + { + key: 1, + element: ( + + ), + toolbarItemVariant: ToolbarItemVariant.label, + toolbarItemGap: { default: "gapMd" }, + }, + { + key: 2, + toolbarItemVariant: ToolbarItemVariant.separator, + }, + { + key: 3, + element: ( + + Refresh + + ), + }, + { + key: 4, + element: ( + setShowDeleteModal(true)} + dataCy="permissions-button-delete" + > + Delete + + ), + }, + { + key: 5, + element: ( + setShowAddModal(true)} + isDisabled={isFetching} + dataCy="permissions-button-add" + > + Add + + ), + }, + { + key: 6, + toolbarItemVariant: ToolbarItemVariant.separator, + }, + { + key: 7, + element: ( + dispatch(toggleHelpPanel())} + /> + ), + }, + { + key: 8, + element: ( + + ), + toolbarItemAlignment: { default: "alignEnd" }, + }, + ]; + + return ( +
+ + + + + + + + + + + + {batchError !== undefined && batchError ? ( + + ) : ( + + )} + + + + + + + + + setShowAddModal(false)} + title="Add permission" + onRefresh={refreshData} + /> + setShowDeleteModal(false)} + elementsToDelete={selectedPermissions} + clearSelectedElements={clearSelectedPermissions} + columnNames={columnNames} + keyNames={keyNames} + onRefresh={refreshData} + updateIsDeleteButtonDisabled={setIsDeleteButtonDisabled} + updateIsDeletion={setIsDeletion} + /> + +
+ ); +}; + +export default Permissions; diff --git a/src/services/rpcPermissions.ts b/src/services/rpcPermissions.ts new file mode 100644 index 000000000..82b083751 --- /dev/null +++ b/src/services/rpcPermissions.ts @@ -0,0 +1,162 @@ +import { + api, + Command, + getBatchCommand, + getCommand, + BatchRPCResponse, + FindRPCResponse, +} from "./rpc"; +import { API_VERSION_BACKUP } from "../utils/utils"; +import { Permission, cnType } from "../utils/datatypes/globalDataTypes"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; + +/** + * Permissions-related endpoints + * + * API commands: + * - permission_find: https://freeipa.readthedocs.io/en/latest/api/permission_find.html + * - permission_show: https://freeipa.readthedocs.io/en/latest/api/permission_show.html + * - permission_add: https://freeipa.readthedocs.io/en/latest/api/permission_add.html + * - permission_del: https://freeipa.readthedocs.io/en/latest/api/permission_del.html + */ + +interface PermissionAddPayload { + cn: string; + ipapermright: string[]; + ipapermbindruletype: string; + type?: string; + ipapermlocation?: string; + extratargetfilter?: string[]; + memberof?: string[]; + ipapermtarget?: string; + attrs?: string[]; +} + +interface PermissionsFullDataPayload { + searchValue: string; + sizeLimit: number; + apiVersion: string; + startIdx: number; + stopIdx: number; +} + +const extendedApi = api.injectEndpoints({ + endpoints: (build) => ({ + /** + * Get permissions with full data via two-step permission_find + permission_show pattern + * @param {PermissionsFullDataPayload} - Payload with search parameters + * @returns {BatchRPCResponse} - Batch response with permission data + */ + getPermissionsFullData: build.query< + BatchRPCResponse, + PermissionsFullDataPayload + >({ + async queryFn(payloadData, _queryApi, _extraOptions, fetchWithBQ) { + const { searchValue, sizeLimit, apiVersion, startIdx, stopIdx } = + payloadData; + + const params = { + pkey_only: true, + sizelimit: sizeLimit, + version: apiVersion, + }; + + // Step 1: Find permission IDs + const findCommand: Command = { + method: "permission_find", + params: [[searchValue], params], + }; + + const findResult = await fetchWithBQ(getCommand(findCommand)); + if (findResult.error) { + return { error: findResult.error as FetchBaseQueryError }; + } + + const findResponse = findResult.data as FindRPCResponse; + const totalCount = findResponse.result.result.length as number; + const ids: string[] = []; + + for (let i = startIdx; i < totalCount && i < stopIdx; i++) { + const permissionId = findResponse.result.result[i] as cnType; + ids.push(permissionId.cn[0] as string); + } + + // Step 2: Batch show for each permission + const showCommands: Command[] = ids.map((id) => ({ + method: "permission_show", + params: [[id], { no_members: true }], + })); + + const showResult = await fetchWithBQ( + getBatchCommand(showCommands, apiVersion) + ); + + const response = showResult.data as BatchRPCResponse; + if (response) { + response.result.totalCount = totalCount; + } + + return response + ? { data: response } + : { error: showResult.error as FetchBaseQueryError }; + }, + }), + /** + * Add a new permission via `permission_add` + * @param {PermissionAddPayload} - Payload with permission fields + * @returns {FindRPCResponse} - Response from API + */ + addPermission: build.mutation({ + query: (payload) => { + const params: Record = { + version: API_VERSION_BACKUP, + ipapermright: payload.ipapermright, + ipapermbindruletype: payload.ipapermbindruletype, + }; + if (payload.type) { + params.type = payload.type; + } + if (payload.ipapermlocation) { + params.ipapermlocation = payload.ipapermlocation; + } + if (payload.extratargetfilter) { + params.extratargetfilter = payload.extratargetfilter; + } + if (payload.memberof) { + params.memberof = payload.memberof; + } + if (payload.ipapermtarget) { + params.ipapermtarget = payload.ipapermtarget; + } + if (payload.attrs && payload.attrs.length > 0) { + params.attrs = payload.attrs; + } + return getCommand({ + method: "permission_add", + params: [[payload.cn], params], + }); + }, + }), + /** + * Delete permissions via batch `permission_del` + * @param {Permission[]} - Array of permissions to delete + * @returns {BatchRPCResponse} - Batch response + */ + deletePermissions: build.mutation({ + query: (permissions) => { + const commands: Command[] = permissions.map((permission) => ({ + method: "permission_del", + params: [[permission.cn], {}], + })); + return getBatchCommand(commands, API_VERSION_BACKUP); + }, + }), + }), + overrideExisting: false, +}); + +export const { + useGetPermissionsFullDataQuery, + useAddPermissionMutation, + useDeletePermissionsMutation, +} = extendedApi; diff --git a/src/services/rpcUserGroups.ts b/src/services/rpcUserGroups.ts index 85c9f3129..95b597e2f 100644 --- a/src/services/rpcUserGroups.ts +++ b/src/services/rpcUserGroups.ts @@ -144,6 +144,14 @@ const extendedApi = api.injectEndpoints({ }); }, }), + findGroups: build.query({ + query: () => { + return getCommand({ + method: "group_find", + params: [[], { version: API_VERSION_BACKUP }], + }); + }, + }), /** * Remove groups * @param {UserGroup[]} listOfGroups - List of groups to remove @@ -437,6 +445,7 @@ export const useGettingGroupsQuery = (payloadData) => { export const { useAddGroupMutation, + useFindGroupsQuery, useRemoveGroupsMutation, useRemoveGroupMutation, useAddToGroupsMutation, diff --git a/src/utils/datatypes/globalDataTypes.ts b/src/utils/datatypes/globalDataTypes.ts index 4d6b8e2be..fb41a852c 100644 --- a/src/utils/datatypes/globalDataTypes.ts +++ b/src/utils/datatypes/globalDataTypes.ts @@ -230,6 +230,24 @@ export interface Privilege { description: string; } +export interface Permission { + cn: string; + ipapermright: string[]; + attrs: string[]; + ipapermincludedattrmultivalued: string[]; // mod only + ipapermexcludedattrmultivalued: string[]; // mod only + ipapermbindruletype: string; + ipapermlocation: string; + extratargetfilter: string[]; + ipapermtargetfilter: string[]; + ipapermtarget: string; + ipapermtargetto: string; + ipapermtargetfrom: string; + memberof: string[]; + targetgroup: string; + type: string; +} + export interface HBACRulesOld { name: string; status: string; @@ -555,6 +573,7 @@ export interface ParamMetadata { required: boolean; sortorder: number; type: string; + values?: string[]; } export interface RadiusServer { diff --git a/src/utils/permissionsUtils.tsx b/src/utils/permissionsUtils.tsx new file mode 100644 index 000000000..c2a99430c --- /dev/null +++ b/src/utils/permissionsUtils.tsx @@ -0,0 +1,83 @@ +// Data types +import { Permission } from "src/utils/datatypes/globalDataTypes"; +// Utils +import { convertApiObj } from "./ipaObjectUtils"; + +export const asRecord = ( + element: Partial, + onElementChange: (element: Partial) => void +) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipaObject = element as Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function recordOnChange(ipaObject: Record) { + onElementChange(ipaObject as Permission); + } + + return { ipaObject, recordOnChange }; +}; + +const simpleValues = new Set([ + "cn", + "ipapermbindruletype", + "ipapermlocation", + "ipapermtarget", + "ipapermtargetto", + "ipapermtargetfrom", + "targetgroup", + "type", +]); +const dateValues = new Set([]); + +export function apiToPermission( + apiRecord: Record +): Permission { + const converted = convertApiObj( + apiRecord, + simpleValues, + dateValues + ) as Partial; + + return { + ...createEmptyPermission(), + ...converted, + ipapermright: (apiRecord.ipapermright as string[]) || [], + attrs: (apiRecord.attrs as string[]) || [], + ipapermincludedattrmultivalued: + (apiRecord.ipapermincludedattrmultivalued as string[]) || [], + ipapermexcludedattrmultivalued: + (apiRecord.ipapermexcludedattrmultivalued as string[]) || [], + extratargetfilter: (apiRecord.extratargetfilter as string[]) || [], + ipapermtargetfilter: (apiRecord.ipapermtargetfilter as string[]) || [], + memberof: (apiRecord.memberof as string[]) || [], + }; +} + +export function partialPermissionToPermission( + partialPermission: Partial +): Permission { + return { + ...createEmptyPermission(), + ...partialPermission, + }; +} + +export function createEmptyPermission(): Permission { + return { + cn: "", + ipapermright: [], + attrs: [], + ipapermincludedattrmultivalued: [], + ipapermexcludedattrmultivalued: [], + ipapermbindruletype: "", + ipapermlocation: "", + extratargetfilter: [], + ipapermtargetfilter: [], + ipapermtarget: "", + ipapermtargetto: "", + ipapermtargetfrom: "", + memberof: [], + targetgroup: "", + type: "", + }; +} diff --git a/src/utils/utils.tsx b/src/utils/utils.tsx index ea7ca0cbe..a0d0245ca 100644 --- a/src/utils/utils.tsx +++ b/src/utils/utils.tsx @@ -13,6 +13,7 @@ import { IdRange, Metadata, Netgroup, + Permission, Privilege, Role, Service, @@ -215,6 +216,9 @@ export const isRoleSelectable = (role: Role) => role.cn !== ""; export const isPrivilegeSelectable = (privilege: Privilege) => privilege.cn !== ""; +export const isPermissionSelectable = (permission: Permission) => + permission.cn !== ""; + export const isIdpServerSelectable = (idpServer: IDPServer) => idpServer.cn !== ""; @@ -503,6 +507,17 @@ export const parseDn = (dn: string) => { return result as DN; }; +/** + * Validate a DN, very simple validation, as there are too many cases to handle + * @param dn - The DN to validate + * @returns {boolean} - True if the DN is valid, false otherwise + */ +export const isValidDn = (dn: string) => + dn.split(",").every((rdn) => { + const splitted = rdn.split("="); + return splitted.length === 2 && splitted[0] !== "" && splitted[1] !== ""; + }); + /** * Given a (potential) __datetime__ object, parse it into a Date object or null (if empty) * @param {any} param - The parameter potentially containing datetime information