diff --git a/src/components/TypeAheadWithCheckbox.tsx b/src/components/TypeAheadWithCheckbox.tsx new file mode 100644 index 000000000..8f35e9080 --- /dev/null +++ b/src/components/TypeAheadWithCheckbox.tsx @@ -0,0 +1,346 @@ +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 ( + onAdd()} + onClose={cleanAndCloseModal} + actions={modalActions} + /> + ); +}; + +export default AddSelfServicePermissionModal; diff --git a/src/components/modals/SelfServicePermissionModals/DeleteSelfServicePermissionsModal.tsx b/src/components/modals/SelfServicePermissionModals/DeleteSelfServicePermissionsModal.tsx new file mode 100644 index 000000000..cc4757bf3 --- /dev/null +++ b/src/components/modals/SelfServicePermissionModals/DeleteSelfServicePermissionsModal.tsx @@ -0,0 +1,196 @@ +import React from "react"; +import { Content, ContentVariants, Button } from "@patternfly/react-core"; +import ModalWithFormLayout from "src/components/layouts/ModalWithFormLayout"; +import DeletedElementsTable from "src/components/tables/DeletedElementsTable"; +import { addAlert } from "src/store/Global/alerts-slice"; +import { useAppDispatch } from "src/store/hooks"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; +import { SerializedError } from "@reduxjs/toolkit"; +import { + ErrorData, + SelfServicePermission, +} from "src/utils/datatypes/globalDataTypes"; +import ErrorModal from "src/components/modals/ErrorModal"; +import { BatchRPCResponse } from "src/services/rpc"; +import { useDeleteSelfServicePermissionsMutation } from "src/services/rpcSelfServicePermissions"; + +interface DeleteSelfServicePermissionsModalProps { + isOpen: boolean; + onClose: () => void; + elementsToDelete: SelfServicePermission[]; + clearSelectedElements: () => void; + columnNames: string[]; + keyNames: string[]; + onRefresh: () => void; + updateIsDeleteButtonDisabled: (value: boolean) => void; + updateIsDeletion: (value: boolean) => void; +} + +const DeleteSelfServicePermissionsModal = ( + props: DeleteSelfServicePermissionsModalProps +) => { + const dispatch = useAppDispatch(); + + const [executeDeleteCommand] = useDeleteSelfServicePermissionsMutation(); + + 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 self-service permissions? + + ), + }, + { + id: "deleted-self-service-permissions-table", + pfComponent: ( + + ), + }, + ]; + + 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(""); + }; + + const onDelete = () => { + setBtnSpinning(true); + + executeDeleteCommand(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-self-service-permissions-success", + title: "Self-service permissions removed", + variant: "success", + }) + ); + + props.onClose(); + props.onRefresh(); + } + } + } + setBtnSpinning(false); + }); + }; + + const modalActions: JSX.Element[] = [ + , + , + ]; + + const errorModalActions = [ + , + ]; + + return ( + <> + + {isModalErrorOpen && ( + + )} + + ); +}; + +export default DeleteSelfServicePermissionsModal; diff --git a/src/navigation/AppRoutes.tsx b/src/navigation/AppRoutes.tsx index 0674350fe..40cae0838 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 SelfServicePermissions from "src/pages/SelfServicePermissions/SelfServicePermissions"; // 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..ec29aeaaf 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 SelfServicePermissionsGroupRef = "selfservice-permissions"; // - Configuration const ConfigRef = "configuration"; @@ -445,6 +446,13 @@ export const getNavigationRoutes = ( path: "privileges", items: [], }, + { + label: "Self service permissions", + group: SelfServicePermissionsGroupRef, + title: `${BASE_TITLE} - Self service permissions`, + path: "selfservice-permissions", + items: [], + }, ], }, { diff --git a/src/pages/SelfServicePermissions/SelfServicePermissions.tsx b/src/pages/SelfServicePermissions/SelfServicePermissions.tsx new file mode 100644 index 000000000..189c1bf0c --- /dev/null +++ b/src/pages/SelfServicePermissions/SelfServicePermissions.tsx @@ -0,0 +1,399 @@ +import React, { useMemo, useState } from "react"; +import { + Flex, + FlexItem, + PageSection, + PaginationVariant, + ToolbarItemVariant, +} from "@patternfly/react-core"; +import { + InnerScrollContainer, + OuterScrollContainer, +} from "@patternfly/react-table"; +import { SelfServicePermission } from "src/utils/datatypes/globalDataTypes"; +import { ToolbarItem } from "src/components/layouts/ToolbarLayout"; +import { useAppDispatch, useAppSelector } from "src/store/hooks"; +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"; +import MainTable from "src/components/tables/MainTable"; +import PaginationLayout from "src/components/layouts/PaginationLayout"; +import BulkSelectorPrep from "src/components/BulkSelectorPrep"; +import AddSelfServicePermissionModal from "src/components/modals/SelfServicePermissionModals/AddSelfServicePermissionModal"; +import DeleteSelfServicePermissionsModal from "src/components/modals/SelfServicePermissionModals/DeleteSelfServicePermissionsModal"; +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"; +import { + API_VERSION_BACKUP, + isSelfServicePermissionSelectable, +} from "src/utils/utils"; +import { + getSelectedPerPageData, + ipaPrimaryKey, +} from "src/utils/selectedPerPage"; +import { useGetSelfServicePermissionsFullDataQuery } from "src/services/rpcSelfServicePermissions"; +import useApiError from "src/hooks/useApiError"; +import GlobalErrors from "src/components/errors/GlobalErrors"; +import ModalErrors from "src/components/errors/ModalErrors"; + +const SelfServicePermissions = () => { + const dispatch = useAppDispatch(); + + useUpdateRoute({ pathname: "selfservice-permissions" }); + useContextualHelpTopic("selfservice-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 dataResponse = useGetSelfServicePermissionsFullDataQuery({ + searchValue: searchValue, + sizeLimit: 0, + apiVersion: apiVersion || API_VERSION_BACKUP, + startIdx: firstIdx, + stopIdx: lastIdx, + }); + + const { + data: batchResponse, + isLoading: isBatchLoading, + isFetching, + error: batchError, + } = dataResponse; + + const { elementsList, totalCount } = useMemo(() => { + if (batchResponse?.result) { + const results = batchResponse.result.results; + const listSize = batchResponse.result.count; + const items: SelfServicePermission[] = []; + + for (let i = 0; i < listSize; i++) { + if (results[i]?.result) { + items.push(results[i].result); + } + } + + return { + elementsList: items, + totalCount: batchResponse.result.totalCount, + }; + } + + return { elementsList: [], totalCount: 0 }; + }, [batchResponse]); + + React.useEffect(() => { + if (isFetching) { + globalErrors.clear(); + } + }, [isFetching]); + + React.useEffect(() => { + if ( + !isBatchLoading && + !isFetching && + dataResponse.isError && + dataResponse.error !== undefined + ) { + const err = dataResponse.error; + let contextMsg = "Error loading self-service permissions"; + if ("error" in err && typeof err.error === "string" && err.error) { + contextMsg += ": " + err.error; + } + globalErrors.addError( + err, + contextMsg, + "selfservice-permissions-fetch-error" + ); + } + }, [ + dataResponse.isError, + dataResponse.error, + isBatchLoading, + isFetching, + globalErrors, + ]); + + const refreshData = () => { + clearSelectedPermissions(); + dataResponse.refetch(); + }; + + const [isDeleteButtonDisabled, setIsDeleteButtonDisabled] = + useState(true); + + const [isDeletion, setIsDeletion] = useState(false); + + const [selectedPermissions, setSelectedPermissions] = useState< + SelfServicePermission[] + >([]); + + const clearSelectedPermissions = () => { + setSelectedPermissions([]); + }; + + const [showAddModal, setShowAddModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const selectableTable = elementsList.filter( + isSelfServicePermissionSelectable + ); + + const updateSelectedPermissions = ( + permissions: SelfServicePermission[], + isSelected: boolean + ) => { + let newSelected: SelfServicePermission[] = []; + if (isSelected) { + newSelected = JSON.parse(JSON.stringify(selectedPermissions)); + for (let i = 0; i < permissions.length; i++) { + const alreadySelected = selectedPermissions.find( + (s) => + ipaPrimaryKey(s.aciname) === ipaPrimaryKey(permissions[i].aciname) + ); + if (alreadySelected) { + continue; + } + newSelected.push(permissions[i]); + } + } else { + for (let i = 0; i < selectedPermissions.length; i++) { + let found = false; + for (let ii = 0; ii < permissions.length; ii++) { + if ( + ipaPrimaryKey(selectedPermissions[i].aciname) === + ipaPrimaryKey(permissions[ii].aciname) + ) { + found = true; + break; + } + } + if (!found) { + newSelected.push(selectedPermissions[i]); + } + } + } + setSelectedPermissions(newSelected); + setIsDeleteButtonDisabled(newSelected.length === 0); + }; + + const setPermissionSelected = ( + permission: SelfServicePermission, + isSelecting = true + ) => { + if (isSelfServicePermissionSelectable(permission)) { + updateSelectedPermissions([permission], isSelecting); + } + }; + + const selectedPerPageData = getSelectedPerPageData( + elementsList, + selectedPermissions.map((item) => ipaPrimaryKey(item.aciname)), + (item) => ipaPrimaryKey(item.aciname) + ); + + const bulkSelectorData = { + selected: selectedPermissions, + updateSelected: updateSelectedPermissions, + selectableTable: selectableTable, + nameAttr: "aciname", + }; + + const buttonsData = { + updateIsDeleteButtonDisabled: setIsDeleteButtonDisabled, + }; + + const columnNames = ["Self-service name"]; + const keyNames = ["aciname"]; + + 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="selfservice-permissions-button-delete" + > + Delete + + ), + }, + { + key: 5, + element: ( + setShowAddModal(true)} + isDisabled={isFetching} + dataCy="selfservice-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 self-service permission" + onRefresh={refreshData} + /> + setShowDeleteModal(false)} + elementsToDelete={selectedPermissions} + clearSelectedElements={clearSelectedPermissions} + columnNames={columnNames} + keyNames={keyNames} + onRefresh={refreshData} + updateIsDeleteButtonDisabled={setIsDeleteButtonDisabled} + updateIsDeletion={setIsDeletion} + /> + +
+ ); +}; + +export default SelfServicePermissions; diff --git a/src/services/rpcSelfServicePermissions.ts b/src/services/rpcSelfServicePermissions.ts new file mode 100644 index 000000000..e1a3c900f --- /dev/null +++ b/src/services/rpcSelfServicePermissions.ts @@ -0,0 +1,154 @@ +import { + api, + Command, + ErrorResult, + getBatchCommand, + getCommand, + BatchRPCResponse, + FindRPCResponse, +} from "./rpc"; +import { API_VERSION_BACKUP } from "../utils/utils"; +import { SelfServicePermission } from "../utils/datatypes/globalDataTypes"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; + +/** + * Self-service permission-related endpoints + * + * API commands: + * - selfservice_find: https://freeipa.readthedocs.io/en/latest/api/selfservice_find.html + * - selfservice_show: https://freeipa.readthedocs.io/en/latest/api/selfservice_show.html + * - selfservice_add: https://freeipa.readthedocs.io/en/latest/api/selfservice_add.html + * - selfservice_del: https://freeipa.readthedocs.io/en/latest/api/selfservice_del.html + */ + +interface SelfServicePermissionsFullDataPayload { + searchValue: string; + sizeLimit?: number; + apiVersion: string; + startIdx: number; + stopIdx: number; +} + +interface SelfServicePermissionAddPayload { + aciname: string; + attrs: string[]; +} + +const extendedApi = api.injectEndpoints({ + endpoints: (build) => ({ + getSelfServicePermissionsFullData: build.query< + BatchRPCResponse, + SelfServicePermissionsFullDataPayload + >({ + async queryFn(payloadData, _queryApi, _extraOptions, fetchWithBQ) { + const { searchValue, apiVersion, startIdx, stopIdx, sizeLimit } = + payloadData; + + const effectiveStopIdx = + typeof sizeLimit === "number" && sizeLimit > 0 + ? Math.min(stopIdx, startIdx + sizeLimit) + : stopIdx; + + const params = { + pkey_only: true, + version: apiVersion, + }; + + const findCommand: Command = { + method: "selfservice_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; + + if (!findResponse.result) { + const ipaError = findResponse.error as ErrorResult | string; + const errorMsg = + typeof ipaError === "object" && ipaError?.message + ? ipaError.message + : String(ipaError || "selfservice_find returned no result"); + + return { + error: { + status: "CUSTOM_ERROR", + data: errorMsg, + error: errorMsg, + } as FetchBaseQueryError, + }; + } + + const totalCount = findResponse.result.result.length as number; + const ids: string[] = []; + + for (let i = startIdx; i < totalCount && i < effectiveStopIdx; i++) { + const item = findResponse.result.result[i] as Record; + const aciname = item.aciname; + ids.push( + Array.isArray(aciname) + ? (aciname[0] as string) + : (aciname as string) + ); + } + + const showCommands: Command[] = ids.map((id) => ({ + method: "selfservice_show", + params: [[id], {}], + })); + + 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 }; + }, + }), + + addSelfServicePermission: build.mutation< + FindRPCResponse, + SelfServicePermissionAddPayload + >({ + query: (payload) => { + const params: Record = { + attrs: payload.attrs, + version: API_VERSION_BACKUP, + }; + return getCommand({ + method: "selfservice_add", + params: [[payload.aciname], params], + }); + }, + }), + + deleteSelfServicePermissions: build.mutation< + BatchRPCResponse, + SelfServicePermission[] + >({ + query: (permissions) => { + const commands: Command[] = permissions.map((perm) => ({ + method: "selfservice_del", + params: [[perm.aciname], {}], + })); + return getBatchCommand(commands, API_VERSION_BACKUP); + }, + }), + }), + overrideExisting: false, +}); + +export const { + useGetSelfServicePermissionsFullDataQuery, + useAddSelfServicePermissionMutation, + useDeleteSelfServicePermissionsMutation, +} = extendedApi; diff --git a/src/utils/datatypes/globalDataTypes.ts b/src/utils/datatypes/globalDataTypes.ts index 4d6b8e2be..3723c7b16 100644 --- a/src/utils/datatypes/globalDataTypes.ts +++ b/src/utils/datatypes/globalDataTypes.ts @@ -230,6 +230,13 @@ export interface Privilege { description: string; } +export interface SelfServicePermission { + aciname: string; + permissions: string[]; + attrs: string[]; + aci: string; +} + export interface HBACRulesOld { name: string; status: string; diff --git a/src/utils/selfServicePermissionsUtils.tsx b/src/utils/selfServicePermissionsUtils.tsx new file mode 100644 index 000000000..3cca80e25 --- /dev/null +++ b/src/utils/selfServicePermissionsUtils.tsx @@ -0,0 +1,48 @@ +import { SelfServicePermission } from "src/utils/datatypes/globalDataTypes"; +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 SelfServicePermission); + } + + return { ipaObject, recordOnChange }; +}; + +const simpleValues = new Set(["aciname", "aci"]); +const dateValues = new Set([]); + +export function apiToSelfServicePermission( + apiRecord: Record +): SelfServicePermission { + const converted = convertApiObj( + apiRecord, + simpleValues, + dateValues + ) as Partial; + return partialToSelfServicePermission(converted); +} + +export function partialToSelfServicePermission( + partial: Partial +): SelfServicePermission { + return { + ...createEmptySelfServicePermission(), + ...partial, + }; +} + +export function createEmptySelfServicePermission(): SelfServicePermission { + return { + aciname: "", + permissions: [], + attrs: [], + aci: "", + }; +} diff --git a/src/utils/utils.tsx b/src/utils/utils.tsx index ea7ca0cbe..2fdf0594d 100644 --- a/src/utils/utils.tsx +++ b/src/utils/utils.tsx @@ -15,6 +15,7 @@ import { Netgroup, Privilege, Role, + SelfServicePermission, Service, SudoCmd, SudoCmdGroup, @@ -249,6 +250,10 @@ export const isOtpTokenSelectable = (otpToken: OtpToken) => export const isSelinuxUserMapSelectable = (map: SELinuxUserMap) => map.cn !== ""; +export const isSelfServicePermissionSelectable = ( + perm: SelfServicePermission +) => perm.aciname !== ""; + /** * Write JSX error messages into 'apiErrorsJsx' array * @param {FetchBaseQueryError | SerializedError} errorFromApiCall - Error from the API call