From 9555204188db61d40ef0f851170c2985d2b201f0 Mon Sep 17 00:00:00 2001 From: Carla Martinez Date: Mon, 10 Aug 2026 11:29:51 +0200 Subject: [PATCH 1/2] Add 'Self service permissions' main page The 'Self service permissions' page must show a table with all the entries from `selfservice_find` API command and allow refresh, add, and delete operations. Assisted-by: Claude Signed-off-by: Carla Martinez --- src/components/TypeAheadWithCheckbox.tsx | 346 +++++++++++++++ .../AddSelfServicePermissionModal.tsx | 278 ++++++++++++ .../DeleteSelfServicePermissionsModal.tsx | 199 +++++++++ src/navigation/AppRoutes.tsx | 4 + src/navigation/NavRoutes.ts | 8 + .../SelfServicePermissions.tsx | 399 ++++++++++++++++++ src/services/rpcSelfServicePermissions.ts | 154 +++++++ src/utils/datatypes/globalDataTypes.ts | 7 + src/utils/selfServicePermissionsUtils.tsx | 48 +++ src/utils/utils.tsx | 5 + 10 files changed, 1448 insertions(+) create mode 100644 src/components/TypeAheadWithCheckbox.tsx create mode 100644 src/components/modals/SelfServicePermissionModals/AddSelfServicePermissionModal.tsx create mode 100644 src/components/modals/SelfServicePermissionModals/DeleteSelfServicePermissionsModal.tsx create mode 100644 src/pages/SelfServicePermissions/SelfServicePermissions.tsx create mode 100644 src/services/rpcSelfServicePermissions.ts create mode 100644 src/utils/selfServicePermissionsUtils.tsx 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..000d7c13f --- /dev/null +++ b/src/components/modals/SelfServicePermissionModals/DeleteSelfServicePermissionsModal.tsx @@ -0,0 +1,199 @@ +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(); + } + } + } + }) + .finally(() => { + 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 49c92419c..50ad1a4df 100644 --- a/src/utils/datatypes/globalDataTypes.ts +++ b/src/utils/datatypes/globalDataTypes.ts @@ -225,6 +225,13 @@ export interface Privilege { description: string; } +export interface SelfServicePermission { + aciname: string; + permissions: string[]; + attrs: string[]; + aci: string; +} + export interface HBACRule { hostcategory: string; servicecategory: 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 From be82cd874059ddd54161ab5bbbcb3a413faf8080 Mon Sep 17 00:00:00 2001 From: Carla Martinez Date: Wed, 19 Aug 2026 14:22:24 +0200 Subject: [PATCH 2/2] Document .finally() rule for resetting UI flags after async operations Command handlers (add, delete, enable, disable) that set a spinner or button-state flag before an API call must reset it in a .finally() block, not at the end of .then(). This prevents the UI from staying stuck in a spinning state when the promise rejects due to a network error or unhandled exception. Assisted-by: Claude Signed-off-by: Carla Martinez --- doc/designs/main-pages/07-add-modal.md | 33 +++++++------ doc/designs/sub-pages/00-best-practices.md | 35 +++++++++++++- doc/designs/sub-pages/09-modals.md | 56 ++++++++++++---------- 3 files changed, 83 insertions(+), 41 deletions(-) diff --git a/doc/designs/main-pages/07-add-modal.md b/doc/designs/main-pages/07-add-modal.md index 350de51aa..a1fee96b9 100644 --- a/doc/designs/main-pages/07-add-modal.md +++ b/doc/designs/main-pages/07-add-modal.md @@ -68,7 +68,7 @@ if (error) { } ``` -When an error occurs: dispatch a danger alert, reset the spinner, keep the modal open. +When an error occurs: dispatch a danger alert and keep the modal open. The spinner reset (`setIsAddButtonSpinning(false)`) must always live in a `.finally()` block so it runs regardless of success, error, or rejection. See [Best Practices §6](../sub-pages/00-best-practices.md) for the full rule. ## Modal Action Buttons @@ -143,21 +143,24 @@ const AddEntityModal = (props: PropsToAddModal) => { const onAdd = () => { setIsAddButtonSpinning(true); - addEntity({ cn: entityName, description: description || undefined }).then((response) => { - if ("data" in response) { - const error = response.data?.error as SerializedError; - if (error) { - dispatch(addAlert({ name: "add-entity-error", title: error.message, variant: "danger" })); + addEntity({ cn: entityName, description: description || undefined }) + .then((response) => { + if ("data" in response) { + const error = response.data?.error as SerializedError; + if (error) { + dispatch(addAlert({ name: "add-entity-error", title: error.message, variant: "danger" })); + } + if (response.data?.result) { + dispatch(addAlert({ name: "add-entity-success", title: "New entity added", variant: "success" })); + clearFields(); + props.onRefresh(); + props.onClose(); + } } - if (response.data?.result) { - dispatch(addAlert({ name: "add-entity-success", title: "New entity added", variant: "success" })); - clearFields(); - props.onRefresh(); - props.onClose(); - } - } - setIsAddButtonSpinning(false); - }); + }) + .finally(() => { + setIsAddButtonSpinning(false); + }); }; const cleanAndCloseModal = () => { clearFields(); props.onClose(); }; diff --git a/doc/designs/sub-pages/00-best-practices.md b/doc/designs/sub-pages/00-best-practices.md index 89141172f..7f581873a 100644 --- a/doc/designs/sub-pages/00-best-practices.md +++ b/doc/designs/sub-pages/00-best-practices.md @@ -97,7 +97,38 @@ When creating membership tabs using `MembershipTable`: Failing to register types causes TypeScript errors that may surface later. -### 6. Use Absolute Imports +### 6. Reset UI Flags in `.finally()`, Not Inside `.then()` + +When an async command (add, delete, enable, disable, save, etc.) sets a UI flag like `setSpinning(true)` or `setBtnSpinning(true)` before the API call, the reset (`setSpinning(false)`) **must** go in a `.finally()` block — **not** at the end of `.then()`. + +This guarantees the flag is reset even when the promise rejects with a network error or an unhandled exception, preventing the UI from staying in a stuck/spinning state. + +```tsx +// ✅ Correct — flag resets regardless of outcome: +const onAdd = () => { + setSpinning(true); + addEntity(payload) + .then((response) => { + // handle success / error from response… + }) + .finally(() => { + setSpinning(false); + }); +}; + +// ❌ Wrong — if the promise rejects, setSpinning(false) never runs: +const onAdd = () => { + setSpinning(true); + addEntity(payload).then((response) => { + // handle success / error… + setSpinning(false); + }); +}; +``` + +This rule applies to **every** command operation: add modals, delete modals, enable/disable modals, save handlers, and any other async action that toggles a loading/spinner flag. + +### 7. Use Absolute Imports ```tsx // ✅ Correct: @@ -137,6 +168,7 @@ Fix **all errors** before committing. | Missing `documentation-links.json` entry | Runtime crash | | Unregistered types in `MembershipTable` | TypeScript errors (may appear later) | | Disabled buttons without user approval | Incomplete, unusable component | +| Spinner/flag reset inside `.then()` instead of `.finally()` | UI stuck in spinning state on network error or rejection | | Skipping validation commands | Build failures in CI | --- @@ -150,6 +182,7 @@ Before committing any new sub-page: - [ ] **Set `showLink={true}`** in main page - [ ] **Added documentation-links.json entry** (even if empty array) - [ ] **Registered types** if using MembershipTable +- [ ] **Reset UI flags in `.finally()`** (spinner, button state — never inside `.then()`) - [ ] **Used absolute imports** (starting with `src/`) - [ ] **Ran validation commands** and fixed all errors diff --git a/doc/designs/sub-pages/09-modals.md b/doc/designs/sub-pages/09-modals.md index 1670ce6d3..d391c6a17 100644 --- a/doc/designs/sub-pages/09-modals.md +++ b/doc/designs/sub-pages/09-modals.md @@ -43,18 +43,21 @@ const AddModal = (props: AddModalProps) => { const onAdd = () => { setSpinning(true); - add({ : props., field1 }).then((response) => { - if ("data" in response) { - if (response.data?.error) { - dispatch(addAlert({ name: "add-error", title: response.data.error.message, variant: "danger" })); - } else { - dispatch(addAlert({ name: "add-success", title: " added", variant: "success" })); - props.onRefresh(); - onClose(); + add({ : props., field1 }) + .then((response) => { + if ("data" in response) { + if (response.data?.error) { + dispatch(addAlert({ name: "add-error", title: response.data.error.message, variant: "danger" })); + } else { + dispatch(addAlert({ name: "add-success", title: " added", variant: "success" })); + props.onRefresh(); + onClose(); + } } - } - setSpinning(false); - }); + }) + .finally(() => { + setSpinning(false); + }); }; const onFormSubmit = (event: React.FormEvent) => { @@ -126,21 +129,24 @@ const DeleteModal = (props: DeleteModalProps) => { setSpinning(true); const idsToDelete = props.elementsToDelete.map((el) => el.); - delete({ : props., ids: idsToDelete }).then((response) => { - if ("data" in response) { - const data = response.data as BatchRPCResponse; - if (data.result) { - props.clearSelectedElements(); - props.updateIsDeleteButtonDisabled(true); - dispatch(addAlert({ name: "delete-success", title: "Items deleted", variant: "success" })); - props.onRefresh(); - props.onClose(); + delete({ : props., ids: idsToDelete }) + .then((response) => { + if ("data" in response) { + const data = response.data as BatchRPCResponse; + if (data.result) { + props.clearSelectedElements(); + props.updateIsDeleteButtonDisabled(true); + dispatch(addAlert({ name: "delete-success", title: "Items deleted", variant: "success" })); + props.onRefresh(); + props.onClose(); + } + } else if ("error" in response) { + dispatch(addAlert({ name: "delete-error", title: "Delete failed", variant: "danger" })); } - } else if ("error" in response) { - dispatch(addAlert({ name: "delete-error", title: "Delete failed", variant: "danger" })); - } - setSpinning(false); - }); + }) + .finally(() => { + setSpinning(false); + }); }; const modalActions = [