diff --git a/doc/designs/components/02-lists.md b/doc/designs/components/02-lists.md index c9586217d..656619836 100644 --- a/doc/designs/components/02-lists.md +++ b/doc/designs/components/02-lists.md @@ -57,6 +57,37 @@ Certificate mapping data management. Kerberos principal alias management. +### DualListSelectorGeneric + +Dual-pane list selector used inside `MemberOfAddModal` for picking items to add. + +**Search behaviour (`isSearchable` + `onSearchTextChange`):** + +| `onSearchTextChange` provided? | Filtering mode | `onChange` (keystroke) | `onSearch` (Enter / button) | `onClear` | +|---|----|----|----|---| +| **Yes** (server-side search) | Server handles filtering | Updates input text only | Calls `onSearchTextChange` → triggers RTK Query | Resets input + calls `onSearchTextChange("")` | +| **No** (client-only) | Client-side `isVisible` | Filters local list per-keystroke | — | Resets local filter | + +> **Important:** When `onSearchTextChange` is connected to an RTK Query (via +> `adderSearchValue`), `DualListSelectorGeneric` does **not** apply client-side +> `isVisible` filtering — the server already returns filtered results. The search +> input only fires the query on **submit** (Enter / search button), not on every +> keystroke, matching the same pattern as `SearchInputLayout`. + +**Props:** +| Prop | Type | Description | +|------|------|-------------| +| `id` | `string` | Component ID | +| `availableOptions` | `DualListOption[]` | Left-pane options | +| `setAvailableOptions` | `(options: DualListOption[]) => void` | Update available | +| `chosenOptions` | `DualListOption[]` | Right-pane options | +| `setChosenOptions` | `(options: DualListOption[]) => void` | Update chosen | +| `availableOptionsTitle?` | `string` | Left-pane heading | +| `chosenOptionsTitle?` | `string` | Right-pane heading | +| `ariaLabel?` | `string` | Accessibility label | +| `isSearchable?` | `boolean` | Show search input in available pane | +| `onSearchTextChange?` | `(searchText: string) => void` | Server-side search callback (submit-only) | + --- ## Specialized Components diff --git a/doc/designs/sub-pages.md b/doc/designs/sub-pages.md index 96d83b379..73444b7b1 100644 --- a/doc/designs/sub-pages.md +++ b/doc/designs/sub-pages.md @@ -46,6 +46,25 @@ See [00-best-practices.md](sub-pages/00-best-practices.md) for the complete guid See [03-tabs-component.md](sub-pages/03-tabs-component.md) for Tabs details. +## Bulk Selection (BulkSelectorPrep) + +Any sub-page that is **not** Settings, "Is a member of", Members, or ManagedBy +must include a `BulkSelectorPrep` component in its toolbar to allow bulk +operations (select page / select all / unselect) on the table. + +| Sub-Page Type | BulkSelectorPrep Required? | +|---------------|---------------------------| +| **Settings** | No | +| **Members** | No | +| **Is a member of** | No | +| **ManagedBy** | No | +| **Table tabs** (e.g., DNS Records) | **Yes** — see [05-table-tab.md](sub-pages/05-table-tab.md) | +| **Independent** (e.g., Privileges) | **Yes** — see [17-independent-sub-pages.md](sub-pages/17-independent-sub-pages.md) | + +For independent sub-pages that use `MemberOfToolbar`, pass the `BulkSelectorPrep` +via the `bulkSelector` prop. For table tabs that use `ToolbarLayout`, add it as +the first toolbar item. + ## Navigation Bar Highlighting **Every sub-page** must call `useUpdateRoute`: diff --git a/doc/designs/sub-pages/08a-common-issues.md b/doc/designs/sub-pages/08a-common-issues.md index 60036fd66..89cd20578 100644 --- a/doc/designs/sub-pages/08a-common-issues.md +++ b/doc/designs/sub-pages/08a-common-issues.md @@ -89,6 +89,28 @@ dispatch(addAlert({ ## RTK Query Issues +### Redundant `useEffect` + `refetch()` on Query Argument Changes + +RTK Query automatically re-fetches when its arguments change or when `skip` +transitions from `true` to `false`. Do **not** wrap these triggers in a manual +`useEffect` that calls `refetch()` — it duplicates work the hook already does. + +```tsx +// ❌ Wrong: manual refetch is redundant +const query = useGetPermissionsQuery(searchValue, { skip: !isOpen }); + +React.useEffect(() => { + if (isOpen) { + query.refetch(); + } +}, [isOpen, searchValue]); + +// ✅ Correct: RTK Query handles both triggers automatically +const query = useGetPermissionsQuery(searchValue, { skip: !isOpen }); +// No useEffect needed — the hook refetches when searchValue changes +// or when skip goes from true → false. +``` + ### Missing `skip` Option ```tsx diff --git a/doc/designs/sub-pages/09-modals.md b/doc/designs/sub-pages/09-modals.md index 1670ce6d3..4f150a097 100644 --- a/doc/designs/sub-pages/09-modals.md +++ b/doc/designs/sub-pages/09-modals.md @@ -85,6 +85,30 @@ const AddModal = (props: AddModalProps) => { export default AddModal; ``` +### Searchable Add Modal (DualListSelector with server-side search) + +When the Add modal needs server-side search (e.g., searching hundreds of +permissions), pass `isSearchable` and `onSearchTextChange` to `MemberOfAddModal`. +The search fires on **submit** (Enter / search button), not per-keystroke. +Do **not** add client-side filtering — the server returns already-filtered results. + +See [DualListSelectorGeneric](../components/02-lists.md#duallistselectorgeneric) +for the full search behaviour table. + +```tsx + setShowAddModal(false)} + availableItems={availableItems} + onAdd={onAddItem} + onSearchTextChange={setAdderSearchValue} + title="Add items" + ariaLabel="Add items modal" + isSearchable + spinning={spinning} +/> +``` + ## Delete Modal ### Props Interface diff --git a/doc/designs/sub-pages/17-independent-sub-pages.md b/doc/designs/sub-pages/17-independent-sub-pages.md index 99fab06d8..1ec2bc790 100644 --- a/doc/designs/sub-pages/17-independent-sub-pages.md +++ b/doc/designs/sub-pages/17-independent-sub-pages.md @@ -74,6 +74,13 @@ Based on the sub-pages guide, generate a 'Privileges' independent page for 'Role **Parent entity query:** Must include `all: true` to return data fields. +> **Important:** When passing a search value as the query argument and using +> `skip` to gate fetching, do **not** add a `useEffect` that calls `refetch()` +> on those same values. RTK Query automatically re-fetches when its arguments +> change or when `skip` transitions from `true` to `false`. A manual `refetch()` +> would duplicate those requests. See [08a-common-issues.md](08a-common-issues.md#redundant-useeffect--refetch-on-query-argument-changes) +> for the full anti-pattern. + ```typescript // Available items query getPrivileges: build.query({ @@ -92,6 +99,60 @@ addPrivilegeToRole: build.mutation({ }), ``` +## BulkSelectorPrep (Required) + +Independent sub-pages **must** include a `BulkSelectorPrep` component to allow +bulk operations (select page / select all / unselect) on the table. Pass it to +`MemberOfToolbar` via the `bulkSelector` prop. + +This requires tracking selected items as entity objects (not plain strings) so +`BulkSelectorPrep` can manage them. Derive a `string[]` for `MemberTable` +compatibility via `useMemo`. + +```tsx +import BulkSelectorPrep from "src/components/BulkSelectorPrep"; +import { getSelectedPerPageData } from "src/utils/selectedPerPage"; + +// Entity-based selection state +const [selectedItems, setSelectedItems] = React.useState([]); + +// Derive string[] for MemberTable +const selectedNames = useMemo(() => selectedItems.map((i) => i.cn), [selectedItems]); + +// Delete button disabled state (managed by BulkSelectorPrep and selection helpers) +const [isDeleteButtonDisabled, setIsDeleteButtonDisabled] = React.useState(true); + +// Update handler for BulkSelectorPrep +const updateSelected = (items: ItemType[], isSelected: boolean) => { + let newSelected: ItemType[]; + if (isSelected) { + const currentNames = new Set(selectedNames); + const toAdd = items.filter((item) => !currentNames.has(item.cn)); + newSelected = [...selectedItems, ...toAdd]; + } else { + const removeNames = new Set(items.map((item) => item.cn)); + newSelected = selectedItems.filter((p) => !removeNames.has(p.cn)); + } + setSelectedItems(newSelected); + setIsDeleteButtonDisabled(newSelected.length === 0); +}; + +// Adapter for MemberTable's string-based onCheckItemsChange +const onCheckItemsChange = (checkedNames: string[]) => { + setSelectedItems(checkedNames.map((name) => ({ cn: name }))); + setIsDeleteButtonDisabled(checkedNames.length === 0); +}; + +// BulkSelectorPrep data +const selectedPerPageData = getSelectedPerPageData(items, selectedNames, (i) => i.cn); +const bulkSelectorData = { + selected: selectedItems, + updateSelected, + selectableTable: items, + nameAttr: "cn", +}; +``` + ## Component Structure ```tsx @@ -100,8 +161,27 @@ const = (props) => { return ( - - + + } + deleteButtonEnabled={!isDeleteButtonDisabled && isRefreshButtonEnabled} + ... + /> + {showAddModal && } {showDeleteModal && } @@ -119,5 +199,6 @@ const = (props) => { ## Reference Implementations +- `src/pages/Privileges/PrivilegesPermissions.tsx` — uses `MemberOfToolbar` with `bulkSelector` prop - `src/pages/Roles/RolesPrivileges.tsx` -- `src/pages/DNSZones/DnsResourceRecords.tsx` +- `src/pages/DNSZones/DnsResourceRecords.tsx` — uses `ToolbarLayout` with `BulkSelectorPrep` as first item diff --git a/src/components/MemberOf/MemberOfAddModal.tsx b/src/components/MemberOf/MemberOfAddModal.tsx index 84f2146ad..c137aaf72 100644 --- a/src/components/MemberOf/MemberOfAddModal.tsx +++ b/src/components/MemberOf/MemberOfAddModal.tsx @@ -25,10 +25,11 @@ interface PropsToAdd { onCloseModal: () => void; availableItems: AvailableItems[]; onAdd: (items: AvailableItems[]) => void; - onSearchTextChange: (searchText: string) => void; + onSearchTextChange?: (searchText: string) => void; title: string; ariaLabel: string; spinning: boolean; + isSearchable?: boolean; } const MemberOfAddModal = (props: PropsToAdd) => { @@ -69,6 +70,8 @@ const MemberOfAddModal = (props: PropsToAdd) => { setAvailableOptions={setAvailableOptions} chosenOptions={chosenOptions} setChosenOptions={setChosenOptions} + isSearchable={props.isSearchable} + onSearchTextChange={props.onSearchTextChange} /> ), }, diff --git a/src/components/MemberOf/MemberOfToolbar.tsx b/src/components/MemberOf/MemberOfToolbar.tsx index 162bb4f61..f750a1dc5 100644 --- a/src/components/MemberOf/MemberOfToolbar.tsx +++ b/src/components/MemberOf/MemberOfToolbar.tsx @@ -19,6 +19,9 @@ import PaginationLayout from "../layouts/PaginationLayout"; export type MembershipDirection = "direct" | "indirect"; interface MemberOfToolbarProps { + // bulk selector (optional, rendered before search) + bulkSelector?: React.ReactElement; + // search searchPlaceholder: string; searchAriaLabel: string; @@ -56,6 +59,9 @@ const MemberOfToolbar = (props: MemberOfToolbarProps) => { return ( + {props.bulkSelector && ( + {props.bulkSelector} + )} void; } // Helper function: Parse data to 'DualListOption' @@ -38,6 +42,7 @@ export const optionsToDualListOptions = ( return options.map((option) => ({ text: option, selected: false, + isVisible: true, dataCy: `item-${option}`, })); }; @@ -51,6 +56,27 @@ const DualListSelectorGeneric = (props: DualListGenericProps) => { setChosenOptions, } = props; + const [availableFilter, setAvailableFilter] = React.useState(""); + + const onInputChange = (value: string) => { + setAvailableFilter(value); + if (!props.onSearchTextChange) { + const toFilter = [...availableOptions]; + toFilter.forEach((option) => { + option.isVisible = + value === "" || + option.text.toLowerCase().includes(value.toLowerCase()); + }); + setAvailableOptions(toFilter); + } + }; + + const onSubmitSearch = (value: string) => { + if (props.onSearchTextChange) { + props.onSearchTextChange(value); + } + }; + // callback for moving selected options between lists const moveSelected = (fromAvailable: boolean) => { const sourceOptions = fromAvailable @@ -61,7 +87,7 @@ const DualListSelectorGeneric = (props: DualListGenericProps) => { : props.availableOptions; for (let i = 0; i < sourceOptions.length; i++) { const option = sourceOptions[i]; - if (option.selected) { + if (option.selected && option.isVisible) { sourceOptions.splice(i, 1); destinationOptions.push(option); option.selected = false; @@ -80,8 +106,13 @@ const DualListSelectorGeneric = (props: DualListGenericProps) => { // callback for moving all options between lists const moveAll = (fromAvailable: boolean) => { if (fromAvailable) { - setChosenOptions([...availableOptions, ...chosenOptions]); - setAvailableOptions([]); + setChosenOptions([ + ...availableOptions.filter((option) => option.isVisible), + ...chosenOptions, + ]); + setAvailableOptions([ + ...availableOptions.filter((option) => !option.isVisible), + ]); } else { setAvailableOptions([...chosenOptions, ...availableOptions]); setChosenOptions([]); @@ -113,35 +144,58 @@ const DualListSelectorGeneric = (props: DualListGenericProps) => { > option.selected).length} of ${ - availableOptions.length + status={`${availableOptions.filter((option) => option.selected && option.isVisible).length} of ${ + availableOptions.filter((option) => option.isVisible).length } options selected`} + searchInput={ + props.isSearchable ? ( + onInputChange(value)} + onSearch={(_event, value) => onSubmitSearch(value)} + onClear={() => { + onInputChange(""); + onSubmitSearch(""); + }} + aria-label="Search available options" + data-cy="dual-list-available-search" + /> + ) : undefined + } data-cy="dual-list-left" > - {availableOptions.map((option, index) => ( - onOptionSelect(e, index, false)} - data-cy={option.dataCy} - > - {option.text} - - ))} + {availableOptions.map((option, index) => + option.isVisible ? ( + onOptionSelect(e, index, false)} + data-cy={option.dataCy} + > + {option.text} + + ) : null + )} option.selected)} + isDisabled={ + !availableOptions.some( + (option) => option.selected && option.isVisible + ) + } onClick={() => moveSelected(true)} aria-label="Add selected" data-cy="dual-list-add-selected" icon={} /> option.isVisible).length === 0 + } onClick={() => moveAll(true)} aria-label="Add all" data-cy="dual-list-add-all" diff --git a/src/components/tables/MembershipTable.tsx b/src/components/tables/MembershipTable.tsx index 48baa2555..c7a8f299b 100644 --- a/src/components/tables/MembershipTable.tsx +++ b/src/components/tables/MembershipTable.tsx @@ -59,6 +59,7 @@ type FromTypes = | "host-groups" | "idoverrideuser" | "netgroups" + | "permissions" | "privileges" | "roles" | "services" @@ -77,13 +78,14 @@ interface MemberTableProps { checkedItems?: string[]; onCheckItemsChange?: (checkedItems: string[]) => void; showTableRows: boolean; + showLink?: boolean; } // Types that use string arrays instead of objects const STRING_ARRAY_TYPES = ["external", "sysaccount", "idoverrideuser"]; // Track those types that don't have links -const NO_LINK_TYPES: string[] = ["roles", "privileges"]; +const NO_LINK_TYPES: string[] = ["roles", "privileges", "permissions"]; // Body const TableBody = (props: { @@ -95,14 +97,19 @@ const TableBody = (props: { showCheckboxColumn: boolean; checkedItems: string[]; onCheckboxChange: (checked: boolean, entityName: string) => void; + showLink?: boolean; }) => { const { list, idKey, propertiesToShow } = props; // Check if this is a string array type (external, sysaccount, idoverrideuser) const isStringArray = STRING_ARRAY_TYPES.includes(props.from); - const shouldRenderLink = (from: string, isStringArray: boolean) => - !isStringArray && !NO_LINK_TYPES.includes(from); + const shouldRenderLink = (from: string, isStringArray: boolean) => { + if (props.showLink === false) return false; + if (isStringArray) return false; + if (props.showLink === true) return true; + return !NO_LINK_TYPES.includes(from); + }; const getItemLink = (from: string, itemId: string) => from === "services" @@ -255,6 +262,7 @@ export default function MemberTable(props: MemberTableProps) { showCheckboxColumn={showCheckboxColumn} onCheckboxChange={onCheckboxChange} checkedItems={props.checkedItems || []} + showLink={props.showLink} /> )} diff --git a/src/navigation/AppRoutes.tsx b/src/navigation/AppRoutes.tsx index 0674350fe..57823c76b 100644 --- a/src/navigation/AppRoutes.tsx +++ b/src/navigation/AppRoutes.tsx @@ -613,6 +613,10 @@ export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => { path="" element={} /> + } + /> } /> diff --git a/src/pages/Privileges/PrivilegesPermissions.tsx b/src/pages/Privileges/PrivilegesPermissions.tsx new file mode 100644 index 000000000..bd355bd90 --- /dev/null +++ b/src/pages/Privileges/PrivilegesPermissions.tsx @@ -0,0 +1,390 @@ +import React, { useMemo } from "react"; +// PatternFly +import { PaginationVariant } from "@patternfly/react-core"; +// Data types +import { Privilege } from "src/utils/datatypes/globalDataTypes"; +// Components +import MemberOfToolbar from "src/components/MemberOf/MemberOfToolbar"; +import MemberTable from "src/components/tables/MembershipTable"; +import MemberOfAddModal, { + AvailableItems, +} from "src/components/MemberOf/MemberOfAddModal"; +import MemberOfDeleteModal from "src/components/MemberOf/MemberOfDeleteModal"; +import PaginationLayout from "src/components/layouts/PaginationLayout"; +import BulkSelectorPrep from "src/components/BulkSelectorPrep"; +// Layouts +import TabLayout from "src/components/layouts/TabLayout"; +// Redux +import { useAppDispatch } from "src/store/hooks"; +// Hooks +import { addAlert } from "src/store/Global/alerts-slice"; +import useListPageSearchParams from "src/hooks/useListPageSearchParams"; +import useUpdateRoute from "src/hooks/useUpdateRoute"; +// RPC +import { ErrorResult } from "src/services/rpc"; +import { + useGetPrivilegeByIdQuery, + useGetPermissionsQuery, + useAddPermissionToPrivilegeMutation, + useRemovePermissionFromPrivilegeMutation, +} from "src/services/rpcPrivileges"; +// Utils +import { paginate } from "src/utils/utils"; +import { getSelectedPerPageData } from "src/utils/selectedPerPage"; + +interface PropsToPrivilegesPermissions { + privilege: Privilege; + onOpenContextualPanel?: () => void; +} + +interface PermissionItem { + cn: string; +} + +interface PermissionsAddModalProps { + showModal: boolean; + onClose: () => void; + privilegeCn: string; + permissionNames: string[]; + onSuccess: () => void; +} + +const PermissionsAddModal = (props: PermissionsAddModalProps) => { + const dispatch = useAppDispatch(); + const [spinning, setSpinning] = React.useState(false); + const [addPermissionToPrivilege] = useAddPermissionToPrivilegeMutation(); + const [adderSearchValue, setAdderSearchValue] = React.useState(""); + const [availableItems, setAvailableItems] = React.useState( + [] + ); + + const permissionsQuery = useGetPermissionsQuery(adderSearchValue, { + skip: !props.showModal, + }); + + React.useEffect(() => { + if (permissionsQuery.data && !permissionsQuery.isFetching) { + const results = (permissionsQuery.data.result?.result || + []) as unknown as Array<{ cn: string[] }>; + let items: AvailableItems[] = results.map((perm) => ({ + key: perm.cn[0], + title: perm.cn[0], + })); + items = items.filter((item) => !props.permissionNames.includes(item.key)); + setAvailableItems(items); + } + }, [ + permissionsQuery.data, + permissionsQuery.isFetching, + props.permissionNames, + ]); + + const onAddPermission = (items: AvailableItems[]) => { + const newPermissionNames = items.map((item) => item.key); + if (!props.privilegeCn || newPermissionNames.length === 0) { + return; + } + + setSpinning(true); + addPermissionToPrivilege({ + privilegeCn: props.privilegeCn, + permissions: newPermissionNames, + }) + .then((response) => { + if ("data" in response) { + if (response.data?.result) { + dispatch( + addAlert({ + name: "add-permission-success", + title: `Added permissions to privilege '${props.privilegeCn}'`, + variant: "success", + }) + ); + props.onSuccess(); + props.onClose(); + } else if (response.data?.error) { + const errorMessage = response.data.error as unknown as ErrorResult; + dispatch( + addAlert({ + name: "add-permission-error", + title: errorMessage.message, + variant: "danger", + }) + ); + } + } + }) + .finally(() => { + setSpinning(false); + }); + }; + + return ( + + ); +}; + +const PrivilegesPermissions = (props: PropsToPrivilegesPermissions) => { + const dispatch = useAppDispatch(); + + // Update current route data to Redux and highlight the current page in the Nav bar + useUpdateRoute({ pathname: "privileges", noBreadcrumb: true }); + + // Query used for refresh operations + const privilegeQuery = useGetPrivilegeByIdQuery(props.privilege.cn); + + // Get parameters from URL + const { page, setPage, perPage, searchValue } = useListPageSearchParams(); + + // Selection state (entity-based for BulkSelectorPrep compatibility) + const [selectedPermissions, setSelectedPermissions] = React.useState< + PermissionItem[] + >([]); + + // Get permission names from privilege (prefer fresh query data, fallback to props) + const permissionNames = useMemo( + () => + privilegeQuery.data?.memberof_permission || + props.privilege.memberof_permission || + [], + [ + privilegeQuery.data?.memberof_permission, + props.privilege.memberof_permission, + ] + ); + + // Column configuration + const columnNames = ["Permission name"]; + const properties: string[] = []; + + const permissions = useMemo((): PermissionItem[] => { + let toLoad = [...permissionNames]; + toLoad.sort(); + + // Filter by search + if (searchValue) { + toLoad = toLoad.filter((name) => + name.toLowerCase().includes(searchValue.toLowerCase()) + ); + } + + // Apply paging + toLoad = paginate(toLoad, page, perPage); + + return toLoad.map((name) => ({ cn: name })); + }, [permissionNames, searchValue, page, perPage]); + + // Derive string[] for MemberTable compatibility + const permissionsSelectedNames = useMemo( + () => selectedPermissions.map((p) => p.cn), + [selectedPermissions] + ); + + // Delete button disabled state (managed by BulkSelectorPrep and selection helpers) + const [isDeleteButtonDisabled, setIsDeleteButtonDisabled] = + React.useState(true); + + // Update selected permissions (used by BulkSelectorPrep and MemberTable) + const updateSelectedPermissions = ( + items: PermissionItem[], + isSelected: boolean + ) => { + let newSelected: PermissionItem[]; + if (isSelected) { + const currentNames = new Set(permissionsSelectedNames); + const toAdd = items.filter((item) => !currentNames.has(item.cn)); + newSelected = [...selectedPermissions, ...toAdd]; + } else { + const removeNames = new Set(items.map((item) => item.cn)); + newSelected = selectedPermissions.filter((p) => !removeNames.has(p.cn)); + } + setSelectedPermissions(newSelected); + setIsDeleteButtonDisabled(newSelected.length === 0); + }; + + // Adapter for MemberTable's string-based onCheckItemsChange + const onCheckItemsChange = (checkedNames: string[]) => { + setSelectedPermissions(checkedNames.map((name) => ({ cn: name }))); + setIsDeleteButtonDisabled(checkedNames.length === 0); + }; + + // Show table rows when we have data (even during background refetches) + const showTableRows = !privilegeQuery.isFetching; + + // Dialogs and actions + const [showAddModal, setShowAddModal] = React.useState(false); + const [showDeleteModal, setShowDeleteModal] = React.useState(false); + const [spinning, setSpinning] = React.useState(false); + + // Buttons functionality + const isRefreshButtonEnabled = !privilegeQuery.isFetching; + const isAddButtonEnabled = isRefreshButtonEnabled; + + // BulkSelectorPrep data + const selectablePermissionsTable = permissions; + + const selectedPerPageData = getSelectedPerPageData( + permissions, + permissionsSelectedNames, + (perm) => perm.cn + ); + + const bulkSelectorData = { + selected: selectedPermissions, + updateSelected: updateSelectedPermissions, + selectableTable: selectablePermissionsTable, + nameAttr: "cn", + }; + + // API calls + const [removePermissionFromPrivilege] = + useRemovePermissionFromPrivilegeMutation(); + + // Refresh data + const onRefreshData = () => { + setSelectedPermissions([]); + setIsDeleteButtonDisabled(true); + privilegeQuery.refetch(); + }; + + // Remove permissions from privilege + const onDeletePermission = () => { + if ( + props.privilege.cn === undefined || + permissionsSelectedNames.length === 0 + ) { + return; + } + + setSpinning(true); + removePermissionFromPrivilege({ + privilegeCn: props.privilege.cn, + permissions: permissionsSelectedNames, + }) + .then((response) => { + if ("data" in response) { + if (response.data?.result) { + dispatch( + addAlert({ + name: "remove-permission-success", + title: `Removed permissions from privilege '${props.privilege.cn}'`, + variant: "success", + }) + ); + onRefreshData(); + setShowDeleteModal(false); + setPage(1); + } else if (response.data?.error) { + const errorMessage = response.data.error as unknown as ErrorResult; + dispatch( + addAlert({ + name: "remove-permission-error", + title: errorMessage.message, + variant: "danger", + }) + ); + } + } + }) + .finally(() => { + setSpinning(false); + }); + }; + + // Get filtered permission names count for pagination + const getFilteredCount = (): number => { + if (!searchValue) { + return permissionNames.length; + } + return permissionNames.filter((name) => + name.toLowerCase().includes(searchValue.toLowerCase()) + ).length; + }; + + return ( + + + } + searchPlaceholder="Search permissions" + searchAriaLabel="Search permissions" + refreshButtonEnabled={isRefreshButtonEnabled} + onRefreshButtonClick={onRefreshData} + deleteButtonEnabled={!isDeleteButtonDisabled && isRefreshButtonEnabled} + onDeleteButtonClick={() => setShowDeleteModal(true)} + addButtonEnabled={isAddButtonEnabled} + onAddButtonClick={() => setShowAddModal(true)} + helpIconEnabled + onHelpIconClick={props.onOpenContextualPanel} + totalItems={getFilteredCount()} + /> + + {getFilteredCount() > 0 && ( + + )} + {showAddModal && ( + setShowAddModal(false)} + privilegeCn={props.privilege.cn} + permissionNames={permissionNames} + onSuccess={onRefreshData} + /> + )} + setShowDeleteModal(false)} + title={`Remove permissions from privilege '${props.privilege.cn}'`} + onDelete={onDeletePermission} + spinning={spinning} + > + + permissionsSelectedNames.includes(perm.cn) + )} + from="permissions" + idKey="cn" + columnNamesToShow={columnNames} + propertiesToShow={properties} + showTableRows + /> + + + ); +}; + +export default PrivilegesPermissions; diff --git a/src/pages/Privileges/PrivilegesTabs.tsx b/src/pages/Privileges/PrivilegesTabs.tsx index 395eb6314..5c9bc465e 100644 --- a/src/pages/Privileges/PrivilegesTabs.tsx +++ b/src/pages/Privileges/PrivilegesTabs.tsx @@ -8,6 +8,9 @@ import PrivilegesSettings from "./PrivilegesSettings"; import BreadCrumb, { BreadCrumbItem } from "src/components/layouts/BreadCrumb"; import TitleLayout from "src/components/layouts/TitleLayout"; import DataSpinner from "src/components/layouts/DataSpinner"; +import PrivilegesPermissions from "src/pages/Privileges/PrivilegesPermissions"; +// Utils +import { partialPrivilegeToPrivilege } from "src/utils/privilegesUtils"; // Hooks import { usePrivilegeSettings } from "src/hooks/usePrivilegeSettingsData"; import useContextualHelpTopic from "src/hooks/useContextualHelpTopic"; @@ -26,6 +29,12 @@ interface PrivilegesTabsProps { section: string; } +// Central mapping between tab keys and routes +const TAB_ROUTES: Record string> = { + settings: (cn) => `/privileges/${cn}`, + permissions: (cn) => `/privileges/${cn}/permissions`, +}; + const PrivilegesTabs = ({ section }: PrivilegesTabsProps) => { const { cn } = useSafeParams(["cn"]); const navigate = useNavigate(); @@ -48,8 +57,10 @@ const PrivilegesTabs = ({ section }: PrivilegesTabsProps) => { _event: React.MouseEvent, tabIndex: number | string ) => { - if (tabIndex === "settings") { - navigate("/privileges/" + cn); + const tabKey = String(tabIndex); + const toPath = TAB_ROUTES[tabKey]; + if (toPath) { + navigate(toPath(cn)); } }; @@ -127,6 +138,18 @@ const PrivilegesTabs = ({ section }: PrivilegesTabsProps) => { onOpenContextualPanel={() => dispatch(toggleHelpPanel())} /> + Permissions} + > + dispatch(toggleHelpPanel())} + /> + diff --git a/src/services/rpcPrivileges.ts b/src/services/rpcPrivileges.ts index e962b40cd..5aa3e88b4 100644 --- a/src/services/rpcPrivileges.ts +++ b/src/services/rpcPrivileges.ts @@ -6,9 +6,9 @@ import { BatchRPCResponse, FindRPCResponse, } from "./rpc"; +import { apiToPrivilege } from "src/utils/privilegesUtils"; import { API_VERSION_BACKUP } from "../utils/utils"; import { Privilege, cnType } from "../utils/datatypes/globalDataTypes"; -import { apiToPrivilege } from "../utils/privilegesUtils"; import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; /** @@ -20,6 +20,9 @@ import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; * - privilege_add: https://freeipa.readthedocs.io/en/latest/api/privilege_add.html * - privilege_del: https://freeipa.readthedocs.io/en/latest/api/privilege_del.html * - privilege_mod: https://freeipa.readthedocs.io/en/latest/api/privilege_mod.html + * - privilege_add_permission: https://freeipa.readthedocs.io/en/latest/api/privilege_add_permission.html + * - privilege_remove_permission: https://freeipa.readthedocs.io/en/latest/api/privilege_remove_permission.html + * - permission_find: https://freeipa.readthedocs.io/en/latest/api/permission_find.html */ interface PrivilegeShowPayload { @@ -41,6 +44,11 @@ interface PrivilegesFullDataPayload { stopIdx: number; } +interface PrivilegePermissionPayload { + privilegeCn: string; + permissions: string[]; +} + const extendedApi = api.injectEndpoints({ endpoints: (build) => ({ /** @@ -180,6 +188,75 @@ const extendedApi = api.injectEndpoints({ return getBatchCommand(commands, API_VERSION_BACKUP); }, }), + /** + * Get a privilege by cn via `privilege_show` + * @param {string} cn - Privilege name + * @returns {Privilege} - Privilege data + */ + getPrivilegeById: build.query({ + query: (cn) => + getCommand({ + method: "privilege_show", + params: [[cn], { all: true, rights: true }], + }), + transformResponse: (response: FindRPCResponse): Privilege => { + return apiToPrivilege(response.result.result); + }, + }), + /** + * Get available permissions via `permission_find` + * @param {string} searchValue - Search value for filtering permissions + * @returns {FindRPCResponse} - Response from API + */ + getPermissions: build.query({ + query: (searchValue) => + getCommand({ + method: "permission_find", + params: [ + [searchValue], + { + no_members: true, + version: API_VERSION_BACKUP, + }, + ], + }), + }), + /** + * Add permissions to a privilege via `privilege_add_permission` + * @param {PrivilegePermissionPayload} - Payload with privilege cn and permissions + * @returns {FindRPCResponse} - Response from API + */ + addPermissionToPrivilege: build.mutation< + FindRPCResponse, + PrivilegePermissionPayload + >({ + query: (payload) => + getCommand({ + method: "privilege_add_permission", + params: [ + [payload.privilegeCn], + { permission: payload.permissions, version: API_VERSION_BACKUP }, + ], + }), + }), + /** + * Remove permissions from a privilege via `privilege_remove_permission` + * @param {PrivilegePermissionPayload} - Payload with privilege cn and permissions + * @returns {FindRPCResponse} - Response from API + */ + removePermissionFromPrivilege: build.mutation< + FindRPCResponse, + PrivilegePermissionPayload + >({ + query: (payload) => + getCommand({ + method: "privilege_remove_permission", + params: [ + [payload.privilegeCn], + { permission: payload.permissions, version: API_VERSION_BACKUP }, + ], + }), + }), }), overrideExisting: false, }); @@ -201,4 +278,8 @@ export const { useAddPrivilegeMutation, useDeletePrivilegesMutation, useSavePrivilegeMutation, + useGetPrivilegeByIdQuery, + useGetPermissionsQuery, + useAddPermissionToPrivilegeMutation, + useRemovePermissionFromPrivilegeMutation, } = extendedApi; diff --git a/src/utils/datatypes/globalDataTypes.ts b/src/utils/datatypes/globalDataTypes.ts index 49c92419c..5b7aea588 100644 --- a/src/utils/datatypes/globalDataTypes.ts +++ b/src/utils/datatypes/globalDataTypes.ts @@ -223,6 +223,7 @@ export interface SysAccount { export interface Privilege { cn: string; description: string; + memberof_permission: string[]; } export interface HBACRule { diff --git a/src/utils/privilegesUtils.tsx b/src/utils/privilegesUtils.tsx index bd97074b2..8a0ad4fb4 100644 --- a/src/utils/privilegesUtils.tsx +++ b/src/utils/privilegesUtils.tsx @@ -26,7 +26,12 @@ export function apiToPrivilege(apiRecord: Record): Privilege { simpleValues, dateValues ) as Partial; - return partialPrivilegeToPrivilege(converted) as Privilege; + + return { + ...createEmptyPrivilege(), + ...converted, + memberof_permission: (apiRecord.memberof_permission as string[]) || [], + }; } export function partialPrivilegeToPrivilege( @@ -42,6 +47,7 @@ export function createEmptyPrivilege(): Privilege { const privilege: Privilege = { cn: "", description: "", + memberof_permission: [], }; return privilege;