From afe8f248f660d11de706f7274e63110413348ae9 Mon Sep 17 00:00:00 2001 From: Carla Martinez <carlmart@redhat.com> Date: Tue, 10 Sep 2024 16:51:18 +0200 Subject: [PATCH] Implement User ID Overrides The 'User groups' > 'Members' > 'User ID Overrides' section needs to be implemented and its components adapted to reflect the same behavior as in the modern WebUI (e.g., its 'Add' modal is slightly different than in other sections). Signed-off-by: Carla Martinez <carlmart@redhat.com> --- src/components/Members/AddModalBySelector.tsx | 235 +++++++++++ .../Members/MembersUserIDOverrides.tsx | 385 ++++++++++++++++++ src/components/tables/MembershipTable.tsx | 7 +- src/navigation/AppRoutes.tsx | 4 + src/pages/UserGroups/UserGroupsMembers.tsx | 38 +- src/services/rpcIDViews.ts | 18 + src/services/rpcUserGroups.ts | 10 +- src/services/rpcUserIdOverrides.tsx | 102 +++++ src/utils/datatypes/globalDataTypes.ts | 19 + src/utils/userIdOverrideUtils.tsx | 63 +++ 10 files changed, 874 insertions(+), 7 deletions(-) create mode 100644 src/components/Members/AddModalBySelector.tsx create mode 100644 src/components/Members/MembersUserIDOverrides.tsx create mode 100644 src/services/rpcUserIdOverrides.tsx create mode 100644 src/utils/userIdOverrideUtils.tsx diff --git a/src/components/Members/AddModalBySelector.tsx b/src/components/Members/AddModalBySelector.tsx new file mode 100644 index 00000000..cfcfd409 --- /dev/null +++ b/src/components/Members/AddModalBySelector.tsx @@ -0,0 +1,235 @@ +import React, { ReactNode } from "react"; +// PatternFly +import { + Button, + DualListSelector, + Form, + FormGroup, + MenuToggle, + MenuToggleElement, + Modal, + Select, + SelectOption, +} from "@patternfly/react-core"; +// Utils +import { AvailableItems } from "../MemberOf/MemberOfAddModal"; +import { UserIDOverride } from "src/utils/datatypes/globalDataTypes"; +import { useGetUserIdOverridesInfoByIdViewMutation } from "src/services/rpcUserIdOverrides"; + +export interface PropsToAdd { + showModal: boolean; + onCloseModal: () => void; + availableItemsSelector: AvailableItems[]; + onAdd: (items: AvailableItems[]) => void; + onSearchTextChange: (searchText: string) => void; + title: string; + ariaLabel: string; + spinning: boolean; +} + +const AddModalBySelector = (props: PropsToAdd) => { + // API call + const [getUidOverrides] = useGetUserIdOverridesInfoByIdViewMutation(); + + // List of User Id Overrides associated to the selected id view + const [uidOverridesList, setUidOverridesList] = React.useState< + UserIDOverride[] + >([]); + + // Selector data + const availableOptionsSelector = props.availableItemsSelector.map( + (d) => d.key + ); + + // Selector + const [isSelectorOpen, setIsSelectorOpen] = React.useState(false); + const [idViewSelected, setIdViewSelected] = React.useState( + props.availableItemsSelector[0].title || "" + ); // By default: first item + + const serviceOnToggle = () => { + setIsSelectorOpen(!isSelectorOpen); + }; + + const toggleIDView = (toggleRef: React.Ref<MenuToggleElement>) => ( + <MenuToggle + ref={toggleRef} + onClick={serviceOnToggle} + className="pf-v5-u-w-100" + > + {idViewSelected} + </MenuToggle> + ); + + const onChangeSelector = (_event, value) => { + setIdViewSelected(value as string); + setIsSelectorOpen(false); + }; + + // Perform API call to get available idOverride data when the selector's value changes + React.useEffect(() => { + getUidOverrides(idViewSelected).then((result) => { + if ("data" in result) { + setUidOverridesList(result.data); + setAvailableOptionsDualList(result.data); + setChosenOptionsDualList([]); + } + }); + }, [idViewSelected]); + + // reset dialog on close + React.useEffect(() => { + if (!props.showModal) { + cleanData(); + } + }, [props.showModal]); + + const listChange = ( + newAvailableOptions: ReactNode[], + newChosenOptions: ReactNode[] + ) => { + setAvailableOptionsDualList(newAvailableOptions.sort()); + setChosenOptionsDualList(newChosenOptions.sort()); + }; + + // Manage data shown in Dual list selector + const getAvailableOptionsDualListSelector = () => { + const result = uidOverridesList.map((item) => item.ipaoriginaluid); + return result as ReactNode[]; + }; + + // Dual list data + const [availableOptionsDualList, setAvailableOptionsDualList] = + React.useState<ReactNode[]>(getAvailableOptionsDualListSelector()); + const [chosenOptionsDualList, setChosenOptionsDualList] = React.useState< + ReactNode[] + >([]); + + const cleanData = () => { + setAvailableOptionsDualList(getAvailableOptionsDualListSelector()); + setChosenOptionsDualList([]); + }; + + // Update available and chosen options when props.availableItemsSelector changes + React.useEffect(() => { + const newAval = uidOverridesList.filter( + (d) => !chosenOptionsDualList.includes(d.ipaoriginaluid) + ); + setAvailableOptionsDualList(newAval.map((item) => item.ipaoriginaluid)); + }, [uidOverridesList]); + + const fields = [ + { + id: "selector", + name: "Selector", + pfComponent: ( + <Select + id="id-view-selector" + aria-label="Select ID View" + toggle={toggleIDView} + onSelect={onChangeSelector} + selected={idViewSelected} + isOpen={isSelectorOpen} + aria-labelledby="idview-selector" + > + {availableOptionsSelector.map((option, index) => ( + <SelectOption key={index} value={option}> + {option} + </SelectOption> + ))} + </Select> + ), + }, + { + id: "dual-list-selector", + name: "Available options", + pfComponent: ( + <DualListSelector + isSearchable + availableOptions={availableOptionsDualList} + chosenOptions={chosenOptionsDualList} + onAvailableOptionsSearchInputChanged={(_event, searchText) => + props.onSearchTextChange(searchText) + } + onListChange={( + _event, + newAvailableOptions: ReactNode[], + newChosenOptions: ReactNode[] + ) => listChange(newAvailableOptions, newChosenOptions)} + id="basicSelectorWithSearch" + /> + ), + }, + ]; + + // Buttons are disabled until the user fills the required fields + const [buttonDisabled, setButtonDisabled] = React.useState(true); + + React.useEffect(() => { + if (chosenOptionsDualList.length > 0) { + setButtonDisabled(false); + } else { + setButtonDisabled(true); + } + }, [chosenOptionsDualList]); + + // Add option + const onClickAddHandler = () => { + const optionsToAdd: AvailableItems[] = []; + chosenOptionsDualList.map((opt) => { + optionsToAdd.push({ + key: opt as string, + title: opt as string, + }); + }); + props.onAdd(optionsToAdd); + setChosenOptionsDualList([]); + props.onCloseModal(); + }; + + // Buttons that will be shown at the end of the form + const modalActions = [ + <Button + key="add-new-user-id-override" + variant="secondary" + isDisabled={buttonDisabled || props.spinning} + form="modal-form" + onClick={onClickAddHandler} + spinnerAriaValueText="Adding" + spinnerAriaLabel="Adding" + isLoading={props.spinning} + > + {props.spinning ? "Adding" : "Add"} + </Button>, + <Button + key="cancel-new-user-id-override" + variant="link" + onClick={props.onCloseModal} + > + Cancel + </Button>, + ]; + + return ( + <Modal + variant={"medium"} + position={"top"} + positionOffset={"76px"} + isOpen={props.showModal} + onClose={props.onCloseModal} + actions={modalActions} + title={props.title} + aria-label={props.ariaLabel} + > + <Form id={"is-member-of-add-modal"}> + {fields.map((field) => ( + <FormGroup key={field.id} fieldId={field.id}> + {field.pfComponent} + </FormGroup> + ))} + </Form> + </Modal> + ); +}; + +export default AddModalBySelector; diff --git a/src/components/Members/MembersUserIDOverrides.tsx b/src/components/Members/MembersUserIDOverrides.tsx new file mode 100644 index 00000000..907ebcef --- /dev/null +++ b/src/components/Members/MembersUserIDOverrides.tsx @@ -0,0 +1,385 @@ +import React from "react"; +// PatternFly +import { Pagination, PaginationVariant } from "@patternfly/react-core"; +// Components +import MemberOfToolbar, { + MembershipDirection, +} from "../MemberOf/MemberOfToolbar"; +import { AvailableItems } from "../MemberOf/MemberOfAddModal"; +import AddModalBySelector from "./AddModalBySelector"; +import MemberOfDeleteModal from "../MemberOf/MemberOfDeleteModal"; +import MemberTable from "src/components/tables/MembershipTable"; +// Data types +import { UserGroup, UserIDOverride } from "src/utils/datatypes/globalDataTypes"; +// Hooks +import useAlerts from "src/hooks/useAlerts"; +import useListPageSearchParams from "src/hooks/useListPageSearchParams"; +// RPC +import { ErrorResult } from "src/services/rpc"; +import { + // MemberPayload, + useAddAsMemberMutation, + useRemoveAsMemberMutation, +} from "src/services/rpcUserGroups"; +import { MemberPayload } from "src/services/rpc"; +import { useGetUserIdOverridesInfoByUidQuery } from "src/services/rpcUserIdOverrides"; +import { useGetIDViewsQuery } from "src/services/rpcIDViews"; +// Utils +import { paginate } from "src/utils/utils"; + +interface PropsToUserIDOverrides { + entity: Partial<UserGroup>; + id: string; + from: string; + isDataLoading: boolean; + onRefreshData: () => void; + member_idoverrideuser: string[]; + memberindirect_idoverrideuser: string[]; + membershipDisabled?: boolean; + setDirection: (direction: MembershipDirection) => void; + direction: MembershipDirection; +} + +const MembersUserIDOverrides = (props: PropsToUserIDOverrides) => { + // Alerts to show in the UI + const alerts = useAlerts(); + + const membershipDisabled = + props.membershipDisabled === undefined ? false : props.membershipDisabled; + + // Get parameters from URL + const { + page, + setPage, + perPage, + setPerPage, + searchValue, + setSearchValue, + membershipDirection, + setMembershipDirection, + } = useListPageSearchParams(); + + // Other states + const [idOverridesSelected, setIdOverridesSelected] = React.useState< + string[] + >([]); + const [indirectIdOverridesSelected, setindirectIdOverridesSelected] = + React.useState<string[]>([]); + + // Loaded ID overrides based on paging and member attributes + const [idOverrides, setIdOverrides] = React.useState<UserIDOverride[]>([]); + + // Choose the correct ID overrides based on the membership direction + const member_idoverrideuser = props.member_idoverrideuser || []; + const memberindirect_idoverrideuser = + props.memberindirect_idoverrideuser || []; + let idoverrideNames = + membershipDirection === "direct" + ? member_idoverrideuser + : memberindirect_idoverrideuser; + idoverrideNames = [...idoverrideNames]; + + const getIdOverridesNameToLoad = (): string[] => { + let toLoad = [...idoverrideNames]; + toLoad.sort(); + + // Filter by search + if (searchValue) { + toLoad = toLoad.filter((name) => + name.toLowerCase().includes(searchValue.toLowerCase()) + ); + } + + // Apply paging + toLoad = paginate(toLoad, page, perPage); + + return toLoad; + }; + + const [idOverrideNamesToLoad, setIdOverrideNamesToLoad] = React.useState< + string[] + >(getIdOverridesNameToLoad()); + + // Load idOverrides + const fullIdOverridesQuery = useGetUserIdOverridesInfoByUidQuery( + idOverrideNamesToLoad + ); + + // Refresh ID Overrides + React.useEffect(() => { + const idOverridesNames = getIdOverridesNameToLoad(); + setIdOverrideNamesToLoad(idOverridesNames); + props.setDirection(membershipDirection); + }, [props.entity, membershipDirection, searchValue, page, perPage]); + + React.useEffect(() => { + setMembershipDirection(props.direction); + }, [props.entity]); + + React.useEffect(() => { + if (idOverrideNamesToLoad.length > 0) { + fullIdOverridesQuery.refetch(); + } + }, [idOverrideNamesToLoad]); + + // Update ID Overrides + React.useEffect(() => { + if (fullIdOverridesQuery.data && !fullIdOverridesQuery.isFetching) { + setIdOverrides(fullIdOverridesQuery.data); + } + }, [fullIdOverridesQuery.data, fullIdOverridesQuery.isFetching]); + + // Computed "states" + const someItemSelected = idOverridesSelected.length > 0; + const showTableRows = idOverrides.length > 0; + const idOverrideColumnNames = ["User to override"]; + const idOverrideProperties = ["uid"]; + + // 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 = + !fullIdOverridesQuery.isFetching && !props.isDataLoading; + const isDeleteEnabled = + someItemSelected && membershipDirection !== "indirect"; + const isAddButtonEnabled = + membershipDirection !== "indirect" && isRefreshButtonEnabled; + + // Add new member to 'IdOverride' + // API calls + const [addMemberToIdOverride] = useAddAsMemberMutation(); + const [removeMembersFromIdOverrides] = useRemoveAsMemberMutation(); + const [selectorValue, setSelectorValue] = React.useState(""); + const [availableIdViews, setAvailableIdViews] = React.useState<string[]>([]); + const [availableItems, setAvailableItems] = React.useState<AvailableItems[]>( + [] + ); + + // Load available ID Overrides, delay the search for opening the modal + const idViewsQuery = useGetIDViewsQuery(); + + // Trigger available ID Views + React.useEffect(() => { + if (showAddModal) { + idViewsQuery.refetch(); + } + }, [showAddModal, selectorValue, props.entity]); + + // Update available ID Overrides + React.useEffect(() => { + if (idViewsQuery.data && !idViewsQuery.isFetching) { + // Transform data to AvailableItems data type + const count = idViewsQuery.data.length; + const results = idViewsQuery.data; + let items: AvailableItems[] = []; + const avalIdViews: string[] = []; + for (let i = 0; i < count; i++) { + const idView = results[i]; + avalIdViews.push(results[i]); + items.push({ + key: idView, + title: idView, + }); + } + + items = items.filter((item) => !idoverrideNames.includes(item.key)); + + setAvailableIdViews(avalIdViews); + setAvailableItems(items); + } + }, [idViewsQuery.data, idViewsQuery.isFetching]); + + // Add + const onAddIdOverride = (items: AvailableItems[]) => { + const newIdOverrideNames = items.map((item) => item.key); + if (props.id === undefined || newIdOverrideNames.length == 0) { + return; + } + + const payload = { + entryName: props.id, + entityType: "idoverrideuser", + idsToAdd: newIdOverrideNames, + } as MemberPayload; + + setSpinning(true); + addMemberToIdOverride(payload).then((response) => { + if ("data" in response) { + if (response.data.result) { + // Set alert: success + alerts.addAlert( + "add-member-success", + "Assigned new User IDs to User group " + props.id, + "success" + ); + // Refresh data + props.onRefreshData(); + // Close modal + setShowAddModal(false); + } else if (response.data.error) { + // Set alert: error + const errorMessage = response.data.error as unknown as ErrorResult; + alerts.addAlert("add-member-error", errorMessage.message, "danger"); + } + } + setSpinning(false); + }); + }; + + // Delete + const onDeleteIdOverride = () => { + const payload = { + entryName: props.id, + entityType: "idoverrideuser", + idsToAdd: idOverridesSelected, + } as MemberPayload; + + setSpinning(true); + removeMembersFromIdOverrides(payload).then((response) => { + if ("data" in response) { + if (response.data.result) { + // Set alert: success + alerts.addAlert( + "remove-id-overrides-success", + "Removed User IDs from User group '" + props.id + "'", + "success" + ); + // Refresh + props.onRefreshData(); + // Close modal + setShowDeleteModal(false); + // Back to page 1 + setPage(1); + } else if (response.data.error) { + // Set alert: error + const errorMessage = response.data.error as unknown as ErrorResult; + alerts.addAlert( + "remove-id-overrides-error", + errorMessage.message, + "danger" + ); + } + } + setSpinning(false); + }); + }; + + return ( + <> + <alerts.ManagedAlerts /> + {membershipDisabled ? ( + <MemberOfToolbar + searchText={searchValue} + onSearchTextChange={setSearchValue} + // eslint-disable-next-line @typescript-eslint/no-empty-function + onSearch={() => {}} + refreshButtonEnabled={isRefreshButtonEnabled} + onRefreshButtonClick={props.onRefreshData} + deleteButtonEnabled={isDeleteEnabled} + onDeleteButtonClick={() => setShowDeleteModal(true)} + addButtonEnabled={isAddButtonEnabled} + onAddButtonClick={() => setShowAddModal(true)} + helpIconEnabled={true} + totalItems={idoverrideNames.length} + perPage={perPage} + page={page} + onPerPageChange={setPerPage} + onPageChange={setPage} + /> + ) : ( + <MemberOfToolbar + searchText={searchValue} + onSearchTextChange={setSearchValue} + // eslint-disable-next-line @typescript-eslint/no-empty-function + onSearch={() => {}} + refreshButtonEnabled={isRefreshButtonEnabled} + onRefreshButtonClick={props.onRefreshData} + deleteButtonEnabled={ + membershipDirection === "direct" + ? idOverridesSelected.length > 0 + : indirectIdOverridesSelected.length > 0 + } + onDeleteButtonClick={() => setShowDeleteModal(true)} + addButtonEnabled={isAddButtonEnabled} + onAddButtonClick={() => setShowAddModal(true)} + membershipDirectionEnabled={true} + membershipDirection={membershipDirection} + onMembershipDirectionChange={setMembershipDirection} + helpIconEnabled={true} + totalItems={idoverrideNames.length} + perPage={perPage} + page={page} + onPerPageChange={setPerPage} + onPageChange={setPage} + /> + )} + <MemberTable + entityList={idOverrides} + from="idoverrideuser" + idKey="uid" + columnNamesToShow={idOverrideColumnNames} + propertiesToShow={idOverrideProperties} + checkedItems={ + membershipDirection === "direct" + ? idOverridesSelected + : indirectIdOverridesSelected + } + onCheckItemsChange={ + membershipDirection === "direct" + ? setIdOverridesSelected + : setindirectIdOverridesSelected + } + showTableRows={showTableRows} + /> + <Pagination + className="pf-v5-u-pb-0 pf-v5-u-pr-md" + itemCount={idoverrideNames.length} + widgetId="pagination-options-menu-bottom" + perPage={perPage} + page={page} + variant={PaginationVariant.bottom} + onSetPage={(_e, page) => setPage(page)} + onPerPageSelect={(_e, perPage) => setPerPage(perPage)} + /> + {showAddModal && ( + <AddModalBySelector + showModal={showAddModal} + onCloseModal={() => setShowAddModal(false)} + availableItemsSelector={availableItems} + onAdd={onAddIdOverride} + onSearchTextChange={setSelectorValue} + title={"Assign User ID Overrides to User group " + props.id} + ariaLabel={"Add User group of User ID Override modal"} + spinning={spinning} + /> + )} + {showDeleteModal && someItemSelected && ( + <MemberOfDeleteModal + showModal={showDeleteModal} + onCloseModal={() => setShowDeleteModal(false)} + title={"Delete User group from User ID Overrides"} + onDelete={onDeleteIdOverride} + spinning={spinning} + > + <MemberTable + entityList={availableIdViews.filter((idOverride) => + membershipDirection === "direct" + ? idOverridesSelected.includes(idOverride) + : indirectIdOverridesSelected.includes(idOverride) + )} + idKey="uid" + from="idoverrideuser" + columnNamesToShow={idOverrideColumnNames} + propertiesToShow={idOverrideProperties} + showTableRows + /> + </MemberOfDeleteModal> + )} + </> + ); +}; + +export default MembersUserIDOverrides; diff --git a/src/components/tables/MembershipTable.tsx b/src/components/tables/MembershipTable.tsx index 9f8dc0ba..0a90c3ed 100644 --- a/src/components/tables/MembershipTable.tsx +++ b/src/components/tables/MembershipTable.tsx @@ -13,6 +13,7 @@ import { Role, SudoRule, SubId, + UserIDOverride, } from "src/utils/datatypes/globalDataTypes"; // Components import SkeletonOnTableLayout from "../layouts/Skeleton/SkeletonOnTableLayout"; @@ -36,7 +37,8 @@ type EntryDataTypes = | SudoRule | User | UserGroup - | string; // external + | string // external + | UserIDOverride; // idoverrideuser type FromTypes = | "active-users" @@ -48,7 +50,8 @@ type FromTypes = | "services" | "sudo-rules" | "user-groups" - | "external"; + | "external" + | "idoverrideuser"; export interface MemberTableProps { entityList: EntryDataTypes[]; // More types can be added here diff --git a/src/navigation/AppRoutes.tsx b/src/navigation/AppRoutes.tsx index 3ed9ec98..705ca941 100644 --- a/src/navigation/AppRoutes.tsx +++ b/src/navigation/AppRoutes.tsx @@ -173,6 +173,10 @@ export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => { path="member_external" element={<UserGroupsTabs section="member_external" />} /> + <Route + path="member_idoverrideuser" + element={<UserGroupsTabs section="member_idoverrideuser" />} + /> <Route path="memberof_usergroup" element={<UserGroupsTabs section="memberof_usergroup" />} diff --git a/src/pages/UserGroups/UserGroupsMembers.tsx b/src/pages/UserGroups/UserGroupsMembers.tsx index 306c26a9..3528bcba 100644 --- a/src/pages/UserGroups/UserGroupsMembers.tsx +++ b/src/pages/UserGroups/UserGroupsMembers.tsx @@ -17,6 +17,7 @@ import MembersUsers from "src/components/Members/MembersUsers"; import MembersUserGroups from "src/components/Members/MembersUserGroups"; import MembersServices from "src/components/Members/MembersServices"; import MembersExternal from "src/components/Members/MembersExternal"; +import MembersUserIDOverrides from "src/components/Members/MembersUserIDOverrides"; interface PropsToUserGroupsMembers { userGroup: UserGroup; @@ -58,6 +59,9 @@ const UserGroupsMembers = (props: PropsToUserGroupsMembers) => { const [serviceDirection, setServiceDirection] = React.useState( "direct" as MembershipDirection ); + const [overrideDirection, setOverrideDirection] = React.useState( + "direct" as MembershipDirection + ); const updateUserDirection = (direction: MembershipDirection) => { if (direction === "direct") { @@ -103,6 +107,22 @@ const UserGroupsMembers = (props: PropsToUserGroupsMembers) => { } setServiceDirection(direction); }; + const updateUserIdOverrideDirection = (direction: MembershipDirection) => { + if (direction === "direct") { + setOverrideCount( + userGroup && userGroup.member_idoverrideuser + ? userGroup.member_idoverrideuser.length + : 0 + ); + } else { + setOverrideCount( + userGroup && userGroup.memberindirect_idoverrideuser + ? userGroup.memberindirect_idoverrideuser.length + : 0 + ); + } + setOverrideDirection(direction); + }; React.useEffect(() => { if (userDirection === "direct") { @@ -271,7 +291,7 @@ const UserGroupsMembers = (props: PropsToUserGroupsMembers) => { /> </Tab> <Tab - eventKey={"member_iduseroverride"} + eventKey={"member_idoverrideuser"} name="idoverrideuser" title={ <TabTitleText> @@ -281,7 +301,21 @@ const UserGroupsMembers = (props: PropsToUserGroupsMembers) => { </Badge> </TabTitleText> } - ></Tab> + > + <MembersUserIDOverrides + entity={userGroup} + id={userGroup.cn as string} + from="user-groups" + isDataLoading={userGroupQuery.isFetching} + onRefreshData={onRefreshUserGroupData} + member_idoverrideuser={userGroup.member_idoverrideuser || []} + memberindirect_idoverrideuser={ + userGroup.memberindirect_idoverrideuser || [] + } + setDirection={updateUserIdOverrideDirection} + direction={overrideDirection} + /> + </Tab> </Tabs> </TabLayout> ); diff --git a/src/services/rpcIDViews.ts b/src/services/rpcIDViews.ts index 77b68440..aa123cd7 100644 --- a/src/services/rpcIDViews.ts +++ b/src/services/rpcIDViews.ts @@ -41,8 +41,25 @@ export type ViewFullData = { idView?: Partial<IDView>; }; +export interface FindIdViewResult { + cn: string; + dn: string; +} + const extendedApi = api.injectEndpoints({ endpoints: (build) => ({ + getIDViews: build.query<string[], void>({ + query: () => { + return getCommand({ + method: "idview_find", + params: [[], { version: API_VERSION_BACKUP }], + }); + }, + transformResponse: (response: FindRPCResponse): string[] => { + const views = response.result.result as unknown as FindIdViewResult[]; + return views.map((view) => view.cn[0]); + }, + }), getIDViewsFullData: build.query<ViewFullData, string>({ query: (viewId) => { // Prepare search parameters @@ -200,6 +217,7 @@ export const useGettingIDViewsQuery = (payloadData) => { }; export const { + useGetIDViewsQuery, useAddIDViewMutation, useRemoveIDViewsMutation, useGetIDViewInfoByNameQuery, diff --git a/src/services/rpcUserGroups.ts b/src/services/rpcUserGroups.ts index e8c53bd8..a8eb21b1 100644 --- a/src/services/rpcUserGroups.ts +++ b/src/services/rpcUserGroups.ts @@ -346,15 +346,19 @@ const extendedApi = api.injectEndpoints({ */ removeAsMember: build.mutation<FindRPCResponse, MemberPayload>({ query: (payload) => { - const userGroup = payload.entryName; - const idsToAdd = payload.idsToAdd; + const userGroup = payload.entityType; + const idsToRemove = payload.idsToAdd; const memberType = payload.entityType; return getCommand({ method: "group_remove_member", params: [ [userGroup], - { all: true, [memberType]: idsToAdd, version: API_VERSION_BACKUP }, + { + all: true, + [memberType]: idsToRemove, + version: API_VERSION_BACKUP, + }, ], }); }, diff --git a/src/services/rpcUserIdOverrides.tsx b/src/services/rpcUserIdOverrides.tsx new file mode 100644 index 00000000..7a978288 --- /dev/null +++ b/src/services/rpcUserIdOverrides.tsx @@ -0,0 +1,102 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + api, + Command, + getBatchCommand, + getCommand, + BatchRPCResponse, + FindRPCResponse, +} from "./rpc"; +import { API_VERSION_BACKUP } from "../utils/utils"; +import { apiToUserIDOverride } from "src/utils/userIdOverrideUtils"; +import { UserIDOverride } from "src/utils/datatypes/globalDataTypes"; + +/** + * User ID override-related endpoints: getUserIdOverride, addUserIdOverride, removeUserIdOverride + * + * API commands: + * - user_idoverride_show: https://freeipa.readthedocs.io/en/latest/api/user_idoverride_show.html + * - user_idoverride_add: https://freeipa.readthedocs.io/en/latest/api/user_idoverride_add.html + * - user_idoverride_del: https://freeipa.readthedocs.io/en/latest/api/user_idoverride_del.html + * - user_idoverride_find: https://freeipa.readthedocs.io/en/latest/api/user_idoverride_find.html + */ +const extendedApi = api.injectEndpoints({ + endpoints: (build) => ({ + /** + * Given a list of User ID overrides, show the full data of those services + * @param {string[]} - Payload with service IDs and options + * @returns {BatchRPCResponse} - Batch response + */ + getUserIdOverridesInfoByUid: build.query<UserIDOverride[], string[]>({ + query: (userIDOverridesList) => { + const serviceShowCommands: Command[] = userIDOverridesList.map( + (userIDOverride) => ({ + method: "idoverrideuser_show", + params: [[userIDOverride], { no_members: true }], + }) + ); + return getBatchCommand(serviceShowCommands, API_VERSION_BACKUP); + }, + transformResponse: (response: BatchRPCResponse): UserIDOverride[] => { + const serviceList: UserIDOverride[] = []; + const results = response.result.results; + const count = response.result.count; + for (let i = 0; i < count; i++) { + const serviceData = apiToUserIDOverride(results[i].result); + serviceList.push(serviceData); + } + return serviceList; + }, + }), + /** + * Given a group ID, show the User ID overrides associated with it + * @param {string} - Group ID + * @returns {FindRPCResponse} - Find response + */ + getUserIdOverridesInfoByGroup: build.query<FindRPCResponse, string>({ + query: (groupId) => { + return getCommand({ + method: "idoverrideuser_find", + params: [ + [groupId], + { no_members: true, version: API_VERSION_BACKUP }, + ], + }); + }, + }), + /** + * Given a ID View, show the User ID overrides associated with it + * @param {string} - ID View name + * @returns {FindRPCResponse} - Find response + */ + getUserIdOverridesInfoByIdView: build.mutation<UserIDOverride[], string>({ + query: (IDView) => { + return getCommand({ + method: "idoverrideuser_find", + params: [[IDView], { no_members: true, version: API_VERSION_BACKUP }], + }); + }, + transformResponse: (response: FindRPCResponse): UserIDOverride[] => { + const userIdOverrideList: UserIDOverride[] = []; + if (response.result.result !== undefined) { + const results = response.result.result; + const count = response.result.count; + for (let i = 0; i < count; i++) { + const idOverrideData = apiToUserIDOverride( + results[i] as Record<string, unknown> + ); + userIdOverrideList.push(idOverrideData); + } + } + return userIdOverrideList; + }, + }), + }), + overrideExisting: false, +}); + +export const { + useGetUserIdOverridesInfoByUidQuery, + useGetUserIdOverridesInfoByGroupQuery, + useGetUserIdOverridesInfoByIdViewMutation, +} = extendedApi; diff --git a/src/utils/datatypes/globalDataTypes.ts b/src/utils/datatypes/globalDataTypes.ts index f8399a4e..e4cdb04f 100644 --- a/src/utils/datatypes/globalDataTypes.ts +++ b/src/utils/datatypes/globalDataTypes.ts @@ -540,3 +540,22 @@ export interface SubId { export interface DNSZone { idnsname: string; } + +export interface UserIDOverride { + description: string; + gecos: string; + gidnumber: string; + homedirectory: string; + ipaanchoruuid: string; + ipaoriginaluid: string; + ipasshpubkey: string[]; + loginshell: string; + memberof_group: string[]; + memberofindirect_group: string[]; + memberof_role: string[]; + memberofindirect_role: string[]; + objectclass: string[]; + uid: string; + uidnumber: string; + usercertificate: string[]; +} diff --git a/src/utils/userIdOverrideUtils.tsx b/src/utils/userIdOverrideUtils.tsx new file mode 100644 index 00000000..7a615594 --- /dev/null +++ b/src/utils/userIdOverrideUtils.tsx @@ -0,0 +1,63 @@ +// Data types +import { UserIDOverride } from "src/utils/datatypes/globalDataTypes"; +// Utils +import { convertApiObj } from "./ipaObjectUtils"; + +const simpleValues = new Set([ + "description", + "dn", + "gecos", + "gidnumber", + "homedirectory", + "ipaanchoruuid", + "ipaoriginaluid", + "loginshell", + "uid", + "uidnumber", +]); + +const dateValues = new Set([]); + +export function apiToUserIDOverride( + apiRecord: Record<string, unknown> +): UserIDOverride { + const converted = convertApiObj( + apiRecord, + simpleValues, + dateValues + ) as Partial<UserIDOverride>; + return partialUserIDOverrideToUserIDOverride(converted) as UserIDOverride; +} + +export function partialUserIDOverrideToUserIDOverride( + partialUserIDOverride: Partial<UserIDOverride> +): UserIDOverride { + return { + ...createEmptyUserIDOverride(), + ...partialUserIDOverride, + }; +} + +// Get empty User object initialized with default values +export function createEmptyUserIDOverride(): UserIDOverride { + const userIdOverride: UserIDOverride = { + description: "", + gecos: "", + gidnumber: "", + homedirectory: "", + ipaanchoruuid: "", + ipaoriginaluid: "", + ipasshpubkey: [], + loginshell: "", + memberof_group: [], + memberofindirect_group: [], + memberof_role: [], + memberofindirect_role: [], + objectclass: [], + uid: "", + uidnumber: "", + usercertificate: [], + }; + + return userIdOverride; +}