From 2338e068b00f9a0d0c3199c6a4be63967ebcba1d Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Tue, 23 Jul 2024 16:13:21 +0200 Subject: [PATCH 01/26] create spaces assigned role table --- .../component/space_assigned_roles_table.tsx | 369 ++++++++++++++++++ .../view_space/view_space_roles.tsx | 127 ++---- .../management/view_space/view_space_tabs.tsx | 2 +- 3 files changed, 399 insertions(+), 99 deletions(-) create mode 100644 x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx new file mode 100644 index 00000000000000..fde66153a49826 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx @@ -0,0 +1,369 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiContextMenu, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiInMemoryTable, + EuiPopover, + EuiText, + EuiTextColor, +} from '@elastic/eui'; +import type { + CriteriaWithPagination, + EuiBasicTableColumn, + EuiInMemoryTableProps, + EuiSearchBarProps, + EuiTableFieldDataColumnType, + EuiTableSelectionType, +} from '@elastic/eui'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; + +import { i18n } from '@kbn/i18n'; +import type { Role } from '@kbn/security-plugin-types-common'; + +interface ISpaceAssignedRolesTableProps { + isReadOnly: boolean; + assignedRoles: Role[]; + onAssignNewRoleClick: () => Promise; +} + +export const isReservedRole = (role: Role) => { + return role.metadata?._reserved; +}; + +const getTableColumns = ({ isReadOnly }: Pick) => { + const columns: Array> = [ + { + field: 'name', + name: i18n.translate('xpack.spaces.management.spaceDetails.rolesTable.column.name.title', { + defaultMessage: 'Role', + }), + }, + { + field: 'privileges', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.privileges.title', + { + defaultMessage: 'Privileges', + } + ), + render: (_, record) => { + return record.kibana.map((kibanaPrivilege) => { + return kibanaPrivilege.base.join(', '); + }); + }, + }, + { + field: 'metadata', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.title', + { + defaultMessage: 'Role type', + } + ), + render: (_value: Role['metadata']) => { + return React.createElement(EuiBadge, { + children: _value?._reserved + ? i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.reserved', + { + defaultMessage: 'Reserved', + } + ) + : i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.custom', + { + defaultMessage: 'Custom', + } + ), + color: _value?._reserved ? undefined : 'success', + }); + }, + }, + ]; + + if (!isReadOnly) { + columns.push({ + name: 'Actions', + actions: [ + { + type: 'icon', + icon: 'lock', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.reservedIndictor.title', + { + defaultMessage: 'Reserved', + } + ), + isPrimary: true, + enabled: () => false, + available: (rowRecord) => isReservedRole(rowRecord), + onClick() { + return null; + }, + }, + { + type: 'icon', + icon: 'pencil', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.title', + { + defaultMessage: 'Remove from space', + } + ), + isPrimary: true, + description: 'Click this action to edit the role privileges of this user for this space.', + showOnHover: true, + available: (rowRecord) => !isReservedRole(rowRecord), + onClick: () => { + window.alert('Not yet implemented.'); + }, + }, + { + isPrimary: true, + type: 'icon', + icon: 'trash', + color: 'danger', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.remove.title', + { + defaultMessage: 'Remove from space', + } + ), + description: 'Click this action to remove the user from this space.', + showOnHover: true, + available: (rowRecord) => !isReservedRole(rowRecord), + onClick: () => { + window.alert('Not yet implemented.'); + }, + }, + ], + }); + } + + return columns; +}; + +const getRowProps = (item: Role) => { + const { name } = item; + return { + 'data-test-subj': `space-role-row-${name}`, + onClick: () => {}, + }; +}; + +const getCellProps = (item: Role, column: EuiTableFieldDataColumnType) => { + const { name } = item; + const { field } = column; + return { + 'data-test-subj': `space-role-cell-${name}-${String(field)}`, + textOnly: true, + }; +}; + +export const SpaceAssignedRolesTable = ({ + isReadOnly, + assignedRoles, + onAssignNewRoleClick, +}: ISpaceAssignedRolesTableProps) => { + const tableColumns = useMemo(() => getTableColumns({ isReadOnly }), [isReadOnly]); + const [rolesInView, setRolesInView] = useState(assignedRoles); + const [selectedRoles, setSelectedRoles] = useState([]); + const [isBulkActionContextOpen, setBulkActionContextOpen] = useState(false); + const selectableRoles = useRef(rolesInView.filter((role) => !isReservedRole(role))); + const [pagination, setPagination] = useState['page']>({ + index: 0, + size: 10, + }); + + const onSearchQueryChange = useCallback>>( + ({ query }) => { + if (query?.text) { + setRolesInView( + assignedRoles.filter((role) => role.name.includes(query.text.toLowerCase())) + ); + } + }, + [assignedRoles] + ); + + const searchElementDefinition = useMemo(() => { + return { + box: { + incremental: true, + placeholder: i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.searchField.placeholder', + { defaultMessage: 'Search' } + ), + }, + onChange: onSearchQueryChange, + toolsRight: ( + <> + {!isReadOnly && ( + + + {i18n.translate('xpack.spaces.management.spaceDetails.roles.assign', { + defaultMessage: 'Assign role', + })} + + + )} + + ), + }; + }, [isReadOnly, onAssignNewRoleClick, onSearchQueryChange]); + + const tableHeader = useMemo['childrenBetween']>(() => { + const pageSize = pagination.size; + + return ( + + + + + + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectedStatusInfo', + { + defaultMessage: + 'Showing: {pageItemLength} of {rolesInViewCount} | Selected: {selectedCount} roles', + values: { + pageItemLength: + rolesInView.length < pageSize ? rolesInView.length : pageSize, + rolesInViewCount: rolesInView.length, + selectedCount: selectedRoles.length, + }, + } + )} + + + + + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.contextMenuOpener', + { + defaultMessage: 'Bulk actions', + } + )} + + } + isOpen={isBulkActionContextOpen} + closePopover={setBulkActionContextOpen.bind(null, false)} + anchorPosition="downCenter" + > + , + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.editPrivilege', + { + defaultMessage: 'Edit privileges', + } + ), + onClick: () => {}, + }, + { + icon: , + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.remove', + { + defaultMessage: 'Remove from space', + } + ), + onClick: () => {}, + }, + ], + }, + ]} + /> + + + + + {i18n.translate('xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', { + defaultMessage: 'Select all {selectableRolesCount} roles', + values: { + selectableRolesCount: selectableRoles.current.length, + }, + })} + + + + + + + + + ); + }, [isBulkActionContextOpen, pagination, rolesInView, selectedRoles]); + + const onTableChange = ({ page }: CriteriaWithPagination) => { + setPagination(page); + }; + + const onSelectionChange = (selection: Role[]) => { + setSelectedRoles(selection); + }; + + const selection: EuiTableSelectionType = { + selected: selectedRoles, + selectable: (role: Role) => !isReservedRole(role), + selectableMessage: (selectable: boolean, role: Role) => + !selectable ? `${role.name} is reserved` : `Select ${role.name}`, + onSelectionChange, + }; + + return ( + + + + search={searchElementDefinition} + childrenBetween={tableHeader} + itemId="name" + columns={tableColumns} + items={rolesInView} + rowProps={getRowProps} + cellProps={getCellProps} + selection={selection} + pagination={{ + pageSize: pagination.size, + pageIndex: pagination.index, + pageSizeOptions: [50, 25, 10, 0], + }} + onChange={onTableChange} + /> + + + ); +}; diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx index 51a8fa17ac003e..ad271e754948cd 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx @@ -6,7 +6,6 @@ */ import { - EuiBasicTable, EuiButton, EuiButtonEmpty, EuiButtonGroup, @@ -19,15 +18,12 @@ import { EuiFlyoutHeader, EuiForm, EuiFormRow, + EuiLink, EuiSpacer, EuiText, EuiTitle, } from '@elastic/eui'; -import type { - EuiBasicTableColumn, - EuiComboBoxOptionOption, - EuiTableFieldDataColumnType, -} from '@elastic/eui'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; import type { FC } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; @@ -36,6 +32,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { Role } from '@kbn/security-plugin-types-common'; +import { SpaceAssignedRolesTable } from './component/space_assigned_roles_table'; import { useViewSpaceServices, type ViewSpaceServices } from './hooks/view_space_context_provider'; import type { Space } from '../../../common'; import { FeatureTable } from '../edit_space/enabled_features/feature_table'; @@ -73,7 +70,7 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe const rolesAPIClient = useRef(); - const { getRolesAPIClient } = useViewSpaceServices(); + const { getRolesAPIClient, getUrlForApp } = useViewSpaceServices(); const resolveRolesAPIClient = useCallback(async () => { try { @@ -100,63 +97,6 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe } }, [roleAPIClientInitialized]); - const getRowProps = (item: Role) => { - const { name } = item; - return { - 'data-test-subj': `space-role-row-${name}`, - onClick: () => {}, - }; - }; - - const getCellProps = (item: Role, column: EuiTableFieldDataColumnType) => { - const { name } = item; - const { field } = column; - return { - 'data-test-subj': `space-role-cell-${name}-${String(field)}`, - textOnly: true, - }; - }; - - const columns: Array> = [ - { - field: 'name', - name: i18n.translate('xpack.spaces.management.spaceDetails.roles.column.name.title', { - defaultMessage: 'Role', - }), - }, - { - field: 'privileges', - name: i18n.translate('xpack.spaces.management.spaceDetails.roles.column.privileges.title', { - defaultMessage: 'Privileges', - }), - render: (_value, record) => { - return record.kibana.map((kibanaPrivilege) => { - return kibanaPrivilege.base.join(', '); - }); - }, - }, - ]; - - if (!isReadOnly) { - columns.push({ - name: 'Actions', - actions: [ - { - name: i18n.translate( - 'xpack.spaces.management.spaceDetails.roles.column.actions.remove.title', - { - defaultMessage: 'Remove from space', - } - ), - description: 'Click this action to remove the role privileges from this space.', - onClick: () => { - window.alert('Not yet implemented.'); - }, - }, - ], - }); - } - const rolesInUse = filterRolesAssignedToSpace(roles, space); if (!rolesInUse) { @@ -182,42 +122,33 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe )} - - - -

- {i18n.translate('xpack.spaces.management.spaceDetails.roles.heading', { - defaultMessage: - 'Roles that can access this space. Privileges are managed at the role level.', - })} -

-
-
- {!isReadOnly && ( - - { - if (!roleAPIClientInitialized) { - await resolveRolesAPIClient(); - } - setShowRolesPrivilegeEditor(true); - }} - > - {i18n.translate('xpack.spaces.management.spaceDetails.roles.assign', { - defaultMessage: 'Assign role', - })} - - - )} -
+ + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.rolesPageAnchorText', + { defaultMessage: 'Roles' } + )} + + ), + }} + /> +
- { + if (!roleAPIClientInitialized) { + await resolveRolesAPIClient(); + } + setShowRolesPrivilegeEditor(true); + }} />
diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx index 8a82cc1bcaa9e9..bd17d2e68abadf 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx @@ -98,7 +98,7 @@ export const getTabs = ({ tabsDefinition.push({ id: TAB_ID_ROLES, name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.roles.heading', { - defaultMessage: 'Assigned roles', + defaultMessage: 'Roles', }), append: ( From 8872a5dd9dbefac779ba401393294be4222c8180 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Fri, 26 Jul 2024 15:47:35 +0200 Subject: [PATCH 02/26] extend definition of roles that can be edited --- .../component/space_assigned_roles_table.tsx | 63 ++++++++++++------- .../public/management/view_space/utils.ts | 14 +++++ .../view_space/view_space_roles.tsx | 23 ++----- .../management/view_space/view_space_tabs.tsx | 7 ++- 4 files changed, 62 insertions(+), 45 deletions(-) create mode 100644 x-pack/plugins/spaces/public/management/view_space/utils.ts diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx index fde66153a49826..72711ee081fb89 100644 --- a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx @@ -38,8 +38,17 @@ interface ISpaceAssignedRolesTableProps { onAssignNewRoleClick: () => Promise; } -export const isReservedRole = (role: Role) => { - return role.metadata?._reserved; +/** + * @description checks if the passed role qualifies as one that can + * be edited by a user with sufficient permissions + */ +export const isEditableRole = (role: Role) => { + return !( + role.metadata?._reserved || + role.kibana.reduce((acc, cur) => { + return cur.spaces.includes('*') || acc; + }, false) + ); }; const getTableColumns = ({ isReadOnly }: Pick) => { @@ -100,6 +109,8 @@ const getTableColumns = ({ isReadOnly }: Pick false, - available: (rowRecord) => isReservedRole(rowRecord), - onClick() { - return null; - }, + available: (rowRecord) => !isEditableRole(rowRecord), }, { type: 'icon', @@ -125,7 +133,7 @@ const getTableColumns = ({ isReadOnly }: Pick !isReservedRole(rowRecord), + available: (rowRecord) => isEditableRole(rowRecord), onClick: () => { window.alert('Not yet implemented.'); }, @@ -143,8 +151,8 @@ const getTableColumns = ({ isReadOnly }: Pick !isReservedRole(rowRecord), - onClick: () => { + available: (rowRecord) => isEditableRole(rowRecord), + onClick: (rowRecord, event) => { window.alert('Not yet implemented.'); }, }, @@ -181,7 +189,7 @@ export const SpaceAssignedRolesTable = ({ const [rolesInView, setRolesInView] = useState(assignedRoles); const [selectedRoles, setSelectedRoles] = useState([]); const [isBulkActionContextOpen, setBulkActionContextOpen] = useState(false); - const selectableRoles = useRef(rolesInView.filter((role) => !isReservedRole(role))); + const selectableRoles = useRef(rolesInView.filter((role) => isEditableRole(role))); const [pagination, setPagination] = useState['page']>({ index: 0, size: 10, @@ -193,6 +201,8 @@ export const SpaceAssignedRolesTable = ({ setRolesInView( assignedRoles.filter((role) => role.name.includes(query.text.toLowerCase())) ); + } else { + setRolesInView(assignedRoles); } }, [assignedRoles] @@ -306,19 +316,24 @@ export const SpaceAssignedRolesTable = ({ /> - - - {i18n.translate('xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', { - defaultMessage: 'Select all {selectableRolesCount} roles', - values: { - selectableRolesCount: selectableRoles.current.length, - }, - })} - - + {Boolean(selectableRoles.current.length) && ( + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', + { + defaultMessage: 'Select all {selectableRolesCount} roles', + values: { + selectableRolesCount: selectableRoles.current.length, + }, + } + )} + + + )} @@ -338,7 +353,7 @@ export const SpaceAssignedRolesTable = ({ const selection: EuiTableSelectionType = { selected: selectedRoles, - selectable: (role: Role) => !isReservedRole(role), + selectable: (role: Role) => isEditableRole(role), selectableMessage: (selectable: boolean, role: Role) => !selectable ? `${role.name} is reserved` : `Select ${role.name}`, onSelectionChange, diff --git a/x-pack/plugins/spaces/public/management/view_space/utils.ts b/x-pack/plugins/spaces/public/management/view_space/utils.ts new file mode 100644 index 00000000000000..2b109336885348 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/view_space/utils.ts @@ -0,0 +1,14 @@ +import type { Role } from '@kbn/security-plugin-types-common'; +import type { Space } from '../../../common'; + +export const filterRolesAssignedToSpace = (roles: Role[], space: Space) => { + return roles.filter((role) => + role.kibana.reduce((acc, cur) => { + return ( + (cur.spaces.includes(space.name) || cur.spaces.includes('*')) && + Boolean(cur.base.length) && + acc + ); + }, true) + ); +}; diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx index ad271e754948cd..586185fdcfbf38 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx @@ -45,23 +45,14 @@ type KibanaPrivilegeBase = keyof NonNullable; interface Props { space: Space; + /** + * List of roles assigned to this space + */ roles: Role[]; features: KibanaFeature[]; isReadOnly: boolean; } -const filterRolesAssignedToSpace = (roles: Role[], space: Space) => { - return roles.filter((role) => - role.kibana.reduce((acc, cur) => { - return ( - (cur.spaces.includes(space.name) || cur.spaces.includes('*')) && - Boolean(cur.base.length) && - acc - ); - }, true) - ); -}; - // FIXME: rename to EditSpaceAssignedRoles export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isReadOnly }) => { const [showRolesPrivilegeEditor, setShowRolesPrivilegeEditor] = useState(false); @@ -97,12 +88,6 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe } }, [roleAPIClientInitialized]); - const rolesInUse = filterRolesAssignedToSpace(roles, space); - - if (!rolesInUse) { - return null; - } - return ( <> {showRolesPrivilegeEditor && ( @@ -142,7 +127,7 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe { if (!roleAPIClientInitialized) { await resolveRolesAPIClient(); diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx index bd17d2e68abadf..1ba963a17bb73a 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx @@ -15,6 +15,7 @@ import type { Role } from '@kbn/security-plugin-types-common'; import { withSuspense } from '@kbn/shared-ux-utility'; import { TAB_ID_CONTENT, TAB_ID_GENERAL, TAB_ID_ROLES } from './constants'; +import { filterRolesAssignedToSpace } from './utils'; import type { Space } from '../../../common'; // FIXME: rename to EditSpaceTab @@ -95,6 +96,8 @@ export const getTabs = ({ ]; if (canUserViewRoles) { + const rolesAssignedToSpace = filterRolesAssignedToSpace(roles, space); + tabsDefinition.push({ id: TAB_ID_ROLES, name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.roles.heading', { @@ -102,13 +105,13 @@ export const getTabs = ({ }), append: ( - {roles.length} + {rolesAssignedToSpace.length} ), content: ( From bb69ad5d018ceef5fe703ca44b9bb0a3510c73aa Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Fri, 26 Jul 2024 16:03:10 +0200 Subject: [PATCH 03/26] fix pluralization in select all button --- .../view_space/component/space_assigned_roles_table.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx index 72711ee081fb89..4e1353f5bbadee 100644 --- a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx @@ -325,9 +325,10 @@ export const SpaceAssignedRolesTable = ({ {i18n.translate( 'xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', { - defaultMessage: 'Select all {selectableRolesCount} roles', + defaultMessage: + 'Select {count, plural, one {role} other {all {count} roles}}', values: { - selectableRolesCount: selectableRoles.current.length, + count: selectableRoles.current.length, }, } )} From 73a0dd133d447640922f2a898a5e2debdb24a1ab Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Fri, 26 Jul 2024 18:08:54 +0200 Subject: [PATCH 04/26] slight adjustments rendered items count --- .../view_space/component/space_assigned_roles_table.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx index 4e1353f5bbadee..3c139baa85ee8e 100644 --- a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx @@ -236,6 +236,7 @@ export const SpaceAssignedRolesTable = ({ const tableHeader = useMemo['childrenBetween']>(() => { const pageSize = pagination.size; + const pageIndex = pagination.index; return ( @@ -251,8 +252,11 @@ export const SpaceAssignedRolesTable = ({ defaultMessage: 'Showing: {pageItemLength} of {rolesInViewCount} | Selected: {selectedCount} roles', values: { - pageItemLength: - rolesInView.length < pageSize ? rolesInView.length : pageSize, + pageItemLength: Math.floor( + rolesInView.length / (pageSize * (pageIndex + 1)) + ) + ? pageSize * (pageIndex + 1) + : rolesInView.length % pageSize, rolesInViewCount: rolesInView.length, selectedCount: selectedRoles.length, }, From 88a2c2df731a0cf62182ea9736b2e080a3aae012 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Wed, 31 Jul 2024 16:58:41 +0200 Subject: [PATCH 05/26] fix text, and hide assign to space button when there are roles to assign --- .../component/space_assigned_roles_table.tsx | 21 +++++++++++++-- .../hooks/view_space_context_provider.tsx | 2 +- .../view_space/view_space_roles.tsx | 27 +++++++++++++------ 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx index 3c139baa85ee8e..ee24792b1d43b9 100644 --- a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx @@ -117,6 +117,12 @@ const getTableColumns = ({ isReadOnly }: Pick false, available: (rowRecord) => !isEditableRole(rowRecord), @@ -131,7 +137,13 @@ const getTableColumns = ({ isReadOnly }: Pick isEditableRole(rowRecord), onClick: () => { @@ -149,7 +161,12 @@ const getTableColumns = ({ isReadOnly }: Pick isEditableRole(rowRecord), onClick: (rowRecord, event) => { diff --git a/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx b/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx index ee0bfbf1014aab..3e05d122d7de82 100644 --- a/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx @@ -44,7 +44,7 @@ export const useViewSpaceServices = (): ViewSpaceServices => { const context = useContext(ViewSpaceContext); if (!context) { throw new Error( - 'ViewSpace Context is mising. Ensure the component or React root is wrapped with ViewSpaceContext' + 'ViewSpace Context is missing. Ensure the component or React root is wrapped with ViewSpaceContext' ); } diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx index 586185fdcfbf38..5d00b54bd521cb 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx @@ -57,7 +57,7 @@ interface Props { export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isReadOnly }) => { const [showRolesPrivilegeEditor, setShowRolesPrivilegeEditor] = useState(false); const [roleAPIClientInitialized, setRoleAPIClientInitialized] = useState(false); - const [systemRoles, setSystemRoles] = useState([]); + const [spaceUnallocatedRole, setSpaceUnallocatedRole] = useState([]); const rolesAPIClient = useRef(); @@ -80,13 +80,24 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe useEffect(() => { async function fetchAllSystemRoles() { - setSystemRoles((await rolesAPIClient.current?.getRoles()) ?? []); + const systemRoles = (await rolesAPIClient.current?.getRoles()) ?? []; + + // exclude roles that are already assigned to this space + const spaceUnallocatedRoles = systemRoles.filter( + (role) => + !role.metadata?._reserved && + role.kibana.some((privileges) => { + return !privileges.spaces.includes(space.id) || !privileges.spaces.includes('*'); + }) + ); + + setSpaceUnallocatedRole(spaceUnallocatedRoles); } if (roleAPIClientInitialized) { fetchAllSystemRoles?.(); } - }, [roleAPIClientInitialized]); + }, [roleAPIClientInitialized, space.id]); return ( <> @@ -100,7 +111,7 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe onSaveClick={() => { setShowRolesPrivilegeEditor(false); }} - systemRoles={systemRoles} + spaceUnallocatedRole={spaceUnallocatedRole} // rolesAPIClient would have been initialized before the privilege editor is displayed roleAPIClient={rolesAPIClient.current!} /> @@ -126,7 +137,7 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe { if (!roleAPIClientInitialized) { @@ -144,7 +155,7 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe interface PrivilegesRolesFormProps extends Omit { closeFlyout: () => void; onSaveClick: () => void; - systemRoles: Role[]; + spaceUnallocatedRole: Role[]; roleAPIClient: RolesAPIClient; } @@ -155,7 +166,7 @@ const createRolesComboBoxOptions = (roles: Role[]): Array = (props) => { - const { onSaveClick, closeFlyout, features, roleAPIClient, systemRoles } = props; + const { onSaveClick, closeFlyout, features, roleAPIClient, spaceUnallocatedRole } = props; const [space, setSpaceState] = useState>(props.space); const [spacePrivilege, setSpacePrivilege] = useState('all'); @@ -192,7 +203,7 @@ export const PrivilegesRolesForm: FC = (props) => { values: { spaceName: space.name }, })} placeholder="Select roles" - options={createRolesComboBoxOptions(systemRoles)} + options={createRolesComboBoxOptions(spaceUnallocatedRole)} selectedOptions={selectedRoles} onChange={(value) => { setSelectedRoles((prevRoles) => { From 4b76e01daddddc55091e1dba3a5c0a29f947e758 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Thu, 1 Aug 2024 13:25:40 +0200 Subject: [PATCH 06/26] integrate security packages --- .../plugin_types_public/src/privileges/privileges_api_client.ts | 2 +- .../src/kibana_privilege_table/feature_table.test.tsx | 2 +- .../kibana_privilege_table/feature_table_expanded_row.test.tsx | 2 +- .../src/kibana_privilege_table/sub_feature_form.tsx | 1 - .../privilege_form_calculator/privilege_form_calculator.test.ts | 2 +- .../plugins/spaces/public/management/spaces_management_app.tsx | 2 ++ 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/x-pack/packages/security/plugin_types_public/src/privileges/privileges_api_client.ts b/x-pack/packages/security/plugin_types_public/src/privileges/privileges_api_client.ts index e3a97398db7a32..25d768cb7b1acf 100644 --- a/x-pack/packages/security/plugin_types_public/src/privileges/privileges_api_client.ts +++ b/x-pack/packages/security/plugin_types_public/src/privileges/privileges_api_client.ts @@ -15,7 +15,7 @@ export interface PrivilegesAPIClientGetAllArgs { */ respectLicenseLevel: boolean; } -// TODO: Eyo include the proper return types for contract + export abstract class PrivilegesAPIClientPublicContract { abstract getAll(args: PrivilegesAPIClientGetAllArgs): Promise; } diff --git a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.test.tsx b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.test.tsx index 83a0da2e26815a..2380088dd713f9 100644 --- a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.test.tsx +++ b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.test.tsx @@ -15,10 +15,10 @@ import { kibanaFeatures, } from '@kbn/security-role-management-model/src/__fixtures__'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; +import type { Role } from '@kbn/security-plugin-types-common'; import { getDisplayedFeaturePrivileges } from './__fixtures__'; import { FeatureTable } from './feature_table'; -import type { Role } from '@kbn/security-plugin-types-common'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; const createRole = (kibana: Role['kibana'] = []): Role => { diff --git a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table_expanded_row.test.tsx b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table_expanded_row.test.tsx index 3b787f01cdf92c..5e4f4ce021d449 100644 --- a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table_expanded_row.test.tsx +++ b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table_expanded_row.test.tsx @@ -12,10 +12,10 @@ import { createKibanaPrivileges, kibanaFeatures, } from '@kbn/security-role-management-model/src/__fixtures__'; +import type { Role } from '@kbn/security-plugin-types-common'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; import { FeatureTableExpandedRow } from './feature_table_expanded_row'; -import type { Role } from '@kbn/security-plugin-types-common'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; const createRole = (kibana: Role['kibana'] = []): Role => { diff --git a/x-pack/packages/security/ui_components/src/kibana_privilege_table/sub_feature_form.tsx b/x-pack/packages/security/ui_components/src/kibana_privilege_table/sub_feature_form.tsx index 9155d8ae52835f..2797e4d64a35e5 100644 --- a/x-pack/packages/security/ui_components/src/kibana_privilege_table/sub_feature_form.tsx +++ b/x-pack/packages/security/ui_components/src/kibana_privilege_table/sub_feature_form.tsx @@ -21,7 +21,6 @@ import type { SubFeaturePrivilege, SubFeaturePrivilegeGroup, } from '@kbn/security-role-management-model'; - import { NO_PRIVILEGE_VALUE } from '../constants'; import type { PrivilegeFormCalculator } from '../privilege_form_calculator'; diff --git a/x-pack/packages/security/ui_components/src/privilege_form_calculator/privilege_form_calculator.test.ts b/x-pack/packages/security/ui_components/src/privilege_form_calculator/privilege_form_calculator.test.ts index 0281605f00f347..e61134b816ffab 100644 --- a/x-pack/packages/security/ui_components/src/privilege_form_calculator/privilege_form_calculator.test.ts +++ b/x-pack/packages/security/ui_components/src/privilege_form_calculator/privilege_form_calculator.test.ts @@ -9,9 +9,9 @@ import { createKibanaPrivileges, kibanaFeatures, } from '@kbn/security-role-management-model/src/__fixtures__'; +import type { Role } from '@kbn/security-plugin-types-common'; import { PrivilegeFormCalculator } from './privilege_form_calculator'; -import type { Role } from '@kbn/security-plugin-types-common'; const createRole = (kibana: Role['kibana'] = []): Role => { return { diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx index 037453e1a215fd..4644cf1f072124 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx @@ -44,6 +44,7 @@ export const spacesManagementApp = Object.freeze({ config, eventTracker, getRolesAPIClient, + getPrivilegesAPIClient, }: CreateParams) { const title = i18n.translate('xpack.spaces.displayName', { defaultMessage: 'Spaces', @@ -155,6 +156,7 @@ export const spacesManagementApp = Object.freeze({ getRolesAPIClient={getRolesAPIClient} allowFeatureVisibility={config.allowFeatureVisibility} allowSolutionVisibility={config.allowSolutionVisibility} + getPrivilegesAPIClient={getPrivilegesAPIClient} /> ); }; From 9cbf1ece26608b75732a092a8185656ed41690c1 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Fri, 2 Aug 2024 18:26:12 +0200 Subject: [PATCH 07/26] add tests for provisioning privilege API client --- .../management/privilege_api_client.mock.ts | 18 ++++++++++++++++++ .../public/management/roles_api_client.mock.ts | 1 + 2 files changed, 19 insertions(+) create mode 100644 x-pack/plugins/spaces/public/management/privilege_api_client.mock.ts diff --git a/x-pack/plugins/spaces/public/management/privilege_api_client.mock.ts b/x-pack/plugins/spaces/public/management/privilege_api_client.mock.ts new file mode 100644 index 00000000000000..a8351e2d88ad52 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/privilege_api_client.mock.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PrivilegesAPIClientPublicContract } from '@kbn/security-plugin-types-public'; + +export const createPrivilegeAPIClientMock = (): PrivilegesAPIClientPublicContract => { + return { + getAll: jest.fn(), + }; +}; + +export const getPrivilegeAPIClientMock = jest + .fn() + .mockResolvedValue(createPrivilegeAPIClientMock()); diff --git a/x-pack/plugins/spaces/public/management/roles_api_client.mock.ts b/x-pack/plugins/spaces/public/management/roles_api_client.mock.ts index dd996814f9e512..66a356b3fdb752 100644 --- a/x-pack/plugins/spaces/public/management/roles_api_client.mock.ts +++ b/x-pack/plugins/spaces/public/management/roles_api_client.mock.ts @@ -13,6 +13,7 @@ export const createRolesAPIClientMock = (): RolesAPIClient => { getRole: jest.fn(), saveRole: jest.fn(), deleteRole: jest.fn(), + bulkUpdateRoles: jest.fn(), }; }; From 8f2f38d91630ce63b496c0d18ef05ae71ec69e6f Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Mon, 12 Aug 2024 13:14:27 +0200 Subject: [PATCH 08/26] even more UI improvements --- .../component/space_assigned_roles_table.tsx | 89 +++++++++++++------ .../public/management/view_space/utils.ts | 8 ++ .../view_space/view_space_roles.tsx | 40 ++++++++- .../management/view_space/view_space_tabs.tsx | 8 +- 4 files changed, 113 insertions(+), 32 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx index ee24792b1d43b9..4bd9692c96feb8 100644 --- a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx @@ -36,6 +36,8 @@ interface ISpaceAssignedRolesTableProps { isReadOnly: boolean; assignedRoles: Role[]; onAssignNewRoleClick: () => Promise; + onClickBulkEdit: (selectedRoles: Role[]) => Promise; + onClickBulkRemove: (selectedRoles: Role[]) => Promise; } /** @@ -69,6 +71,14 @@ const getTableColumns = ({ isReadOnly }: Pick { return record.kibana.map((kibanaPrivilege) => { + if (!kibanaPrivilege.base.length) { + return i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.privileges.customPrivilege', + { + defaultMessage: 'custom', + } + ); + } return kibanaPrivilege.base.join(', '); }); }, @@ -201,6 +211,8 @@ export const SpaceAssignedRolesTable = ({ isReadOnly, assignedRoles, onAssignNewRoleClick, + onClickBulkEdit, + onClickBulkRemove, }: ISpaceAssignedRolesTableProps) => { const tableColumns = useMemo(() => getTableColumns({ isReadOnly }), [isReadOnly]); const [rolesInView, setRolesInView] = useState(assignedRoles); @@ -319,17 +331,23 @@ export const SpaceAssignedRolesTable = ({ defaultMessage: 'Edit privileges', } ), - onClick: () => {}, + onClick: async () => { + await onClickBulkEdit(selectedRoles); + }, }, { icon: , - name: i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.remove', - { - defaultMessage: 'Remove from space', - } + name: ( + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.remove', + { defaultMessage: 'Remove from space' } + )} + ), - onClick: () => {}, + onClick: async () => { + await onClickBulkRemove(selectedRoles); + }, }, ], }, @@ -337,25 +355,36 @@ export const SpaceAssignedRolesTable = ({ /> - {Boolean(selectableRoles.current.length) && ( - - - {i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', - { - defaultMessage: - 'Select {count, plural, one {role} other {all {count} roles}}', - values: { - count: selectableRoles.current.length, - }, + + {React.createElement(EuiButtonEmpty, { + size: 's', + ...(Boolean(selectedRoles.length) + ? { + iconType: 'crossInCircle', + onClick: setSelectedRoles.bind(null, []), + children: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.clearRolesSelection', + { + defaultMessage: 'Clear selection', + } + ), } - )} - - - )} + : { + iconType: 'pagesSelect', + onClick: setSelectedRoles.bind(null, selectableRoles.current), + children: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', + { + defaultMessage: + 'Select {count, plural, one {role} other {all {count} roles}}', + values: { + count: selectableRoles.current.length, + }, + } + ), + }), + })} + @@ -363,7 +392,15 @@ export const SpaceAssignedRolesTable = ({ ); - }, [isBulkActionContextOpen, pagination, rolesInView, selectedRoles]); + }, [ + isBulkActionContextOpen, + onClickBulkEdit, + onClickBulkRemove, + pagination.index, + pagination.size, + rolesInView.length, + selectedRoles, + ]); const onTableChange = ({ page }: CriteriaWithPagination) => { setPagination(page); diff --git a/x-pack/plugins/spaces/public/management/view_space/utils.ts b/x-pack/plugins/spaces/public/management/view_space/utils.ts index 2b109336885348..2492c8b081df9c 100644 --- a/x-pack/plugins/spaces/public/management/view_space/utils.ts +++ b/x-pack/plugins/spaces/public/management/view_space/utils.ts @@ -1,4 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + import type { Role } from '@kbn/security-plugin-types-common'; + import type { Space } from '../../../common'; export const filterRolesAssignedToSpace = (roles: Role[], space: Space) => { diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx index 5d00b54bd521cb..f44ffdd339b3c2 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx @@ -9,6 +9,7 @@ import { EuiButton, EuiButtonEmpty, EuiButtonGroup, + EuiCallOut, EuiComboBox, EuiFlexGroup, EuiFlexItem, @@ -25,7 +26,7 @@ import { } from '@elastic/eui'; import type { EuiComboBoxOptionOption } from '@elastic/eui'; import type { FC } from 'react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { KibanaFeature, KibanaFeatureConfig } from '@kbn/features-plugin/common'; import { i18n } from '@kbn/i18n'; @@ -173,6 +174,12 @@ export const PrivilegesRolesForm: FC = (props) => { const [selectedRoles, setSelectedRoles] = useState>( [] ); + const selectedRolesHasPrivilegeConflict = useMemo(() => { + return selectedRoles.reduce((result, selectedRole) => { + // TODO: determine heuristics for role privilege conflicts + return result; + }, false); + }, [selectedRoles]); const [assigningToRole, setAssigningToRole] = useState(false); @@ -231,6 +238,30 @@ export const PrivilegesRolesForm: FC = (props) => { fullWidth /> + <> + {!selectedRolesHasPrivilegeConflict && ( + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.description', + { + defaultMessage: + 'Updating the settings here in a bulk will override current individual settings.', + } + )} + + + )} + = (props) => { -

Assign role to {space.name}

+

+ {i18n.translate('xpack.spaces.management.spaceDetails.roles.assign.privileges.custom', { + defaultMessage: 'Assign role to {spaceName}', + values: { spaceName: space.name }, + })} +

diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx index 1ba963a17bb73a..8210ab2d7a1cc5 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx @@ -15,7 +15,7 @@ import type { Role } from '@kbn/security-plugin-types-common'; import { withSuspense } from '@kbn/shared-ux-utility'; import { TAB_ID_CONTENT, TAB_ID_GENERAL, TAB_ID_ROLES } from './constants'; -import { filterRolesAssignedToSpace } from './utils'; +// import { filterRolesAssignedToSpace } from './utils'; import type { Space } from '../../../common'; // FIXME: rename to EditSpaceTab @@ -96,7 +96,7 @@ export const getTabs = ({ ]; if (canUserViewRoles) { - const rolesAssignedToSpace = filterRolesAssignedToSpace(roles, space); + // const rolesAssignedToSpace = filterRolesAssignedToSpace(roles, space); tabsDefinition.push({ id: TAB_ID_ROLES, @@ -105,13 +105,13 @@ export const getTabs = ({ }), append: ( - {rolesAssignedToSpace.length} + {roles.length} ), content: ( From 1bc65288d1ddcfdebd102526355fef0e88c8fdb3 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Tue, 13 Aug 2024 12:17:23 +0200 Subject: [PATCH 09/26] refactor trigger for flyout and integrate it with bulk actions --- .../management/spaces_management_app.tsx | 4 +- .../space_assign_role_privilege_form.tsx | 342 ++++++++++++++++ .../component/space_assigned_roles_table.tsx | 19 +- .../hooks/view_space_context_provider.tsx | 16 +- .../view_space/view_space_roles.tsx | 366 ++++-------------- 5 files changed, 431 insertions(+), 316 deletions(-) create mode 100644 x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx index 4644cf1f072124..49663dcf9191b5 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx @@ -72,7 +72,7 @@ export const spacesManagementApp = Object.freeze({ text: title, href: `/`, }; - const { notifications, application, chrome, http, overlays } = coreStart; + const { notifications, application, chrome, http, overlays, theme } = coreStart; chrome.docTitle.change(title); @@ -148,6 +148,8 @@ export const spacesManagementApp = Object.freeze({ http={http} overlays={overlays} notifications={notifications} + theme={theme} + i18n={coreStart.i18n} spacesManager={spacesManager} spaceId={spaceId} onLoadSpace={onLoadSpace} diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx new file mode 100644 index 00000000000000..f7728d4d9b8e89 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx @@ -0,0 +1,342 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiButtonGroup, + EuiCallOut, + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import type { FC } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { KibanaFeature, KibanaFeatureConfig } from '@kbn/features-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { Role } from '@kbn/security-plugin-types-common'; +import { KibanaPrivileges, type RawKibanaPrivileges } from '@kbn/security-role-management-model'; +import { KibanaPrivilegeTable, PrivilegeFormCalculator } from '@kbn/security-ui-components'; + +import type { Space } from '../../../../common'; +import type { ViewSpaceServices } from '../hooks/view_space_context_provider'; + +export type RolesAPIClient = ReturnType extends Promise< + infer R +> + ? R + : never; + +export type PrivilegesAPIClient = ReturnType< + ViewSpaceServices['getPrivilegesAPIClient'] +> extends Promise + ? R + : never; + +type KibanaRolePrivilege = keyof NonNullable | 'custom'; + +interface PrivilegesRolesFormProps { + space: Space; + features: KibanaFeature[]; + closeFlyout: () => void; + onSaveClick: () => void; + roleAPIClient: RolesAPIClient; + privilegesAPIClient: PrivilegesAPIClient; + spaceUnallocatedRole: Role[]; + defaultSelected?: Role[]; +} + +const createRolesComboBoxOptions = (roles: Role[]): Array> => + roles.map((role) => ({ + label: role.name, + value: role, + })); + +export const PrivilegesRolesForm: FC = (props) => { + const { + onSaveClick, + closeFlyout, + features, + roleAPIClient, + defaultSelected = [], + privilegesAPIClient, + spaceUnallocatedRole, + } = props; + + const [space, setSpaceState] = useState>(props.space); + const [roleSpacePrivilege, setRoleSpacePrivilege] = useState('all'); + const [selectedRoles, setSelectedRoles] = useState>( + createRolesComboBoxOptions(defaultSelected) + ); + const [privileges, setPrivileges] = useState<[RawKibanaPrivileges] | null>(null); + + const selectedRolesHasPrivilegeConflict = useMemo(() => { + let privilegeAnchor: string; + + return selectedRoles.reduce((result, selectedRole) => { + let rolePrivilege: string; + + selectedRole.value?.kibana.forEach(({ spaces, base }) => { + // TODO: consider wildcard situations + if (spaces.includes(space.id!) && base.length) { + rolePrivilege = base[0]; + } + + if (!privilegeAnchor) { + setRoleSpacePrivilege((privilegeAnchor = rolePrivilege)); + } + }); + + return result || privilegeAnchor !== rolePrivilege; + }, false); + }, [selectedRoles, space.id]); + + useEffect(() => { + Promise.all([ + privilegesAPIClient.getAll({ includeActions: true, respectLicenseLevel: false }), + privilegesAPIClient.getBuiltIn(), + ]).then( + ([kibanaPrivileges, builtInESPrivileges]) => + setPrivileges([kibanaPrivileges, builtInESPrivileges]) + // (err) => fatalErrors.add(err) + ); + }, [privilegesAPIClient]); + + const [assigningToRole, setAssigningToRole] = useState(false); + + const assignRolesToSpace = useCallback(async () => { + try { + setAssigningToRole(true); + + await Promise.all( + selectedRoles.map((selectedRole) => { + roleAPIClient.saveRole({ role: selectedRole.value! }); + }) + ).then(setAssigningToRole.bind(null, false)); + + onSaveClick(); + } catch { + // Handle resulting error + } + }, [onSaveClick, roleAPIClient, selectedRoles]); + + const getForm = () => { + return ( + + + { + setSelectedRoles((prevRoles) => { + if (prevRoles.length < value.length) { + const newlyAdded = value[value.length - 1]; + + const { id: spaceId } = space; + if (!spaceId) { + throw new Error('space state requires space to have an ID'); + } + + // Add kibana space privilege definition to role + newlyAdded.value!.kibana.push({ + base: roleSpacePrivilege === 'custom' ? [] : [roleSpacePrivilege], + feature: {}, + spaces: [spaceId], + }); + + return prevRoles.concat(newlyAdded); + } else { + return value; + } + }); + }} + fullWidth + /> + + <> + {selectedRolesHasPrivilegeConflict && ( + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.description', + { + defaultMessage: + 'Updating the settings here in a bulk will override current individual settings.', + } + )} + + + )} + + + ({ + ...privilege, + 'data-test-subj': `${privilege.id}-privilege-button`, + }))} + color="primary" + idSelected={roleSpacePrivilege} + onChange={(id) => setRoleSpacePrivilege(id as KibanaRolePrivilege)} + buttonSize="compressed" + isFullWidth + /> + + {roleSpacePrivilege === 'custom' && ( + + <> + +

+ +

+
+ + {/** TODO: rework privilege table to accommodate operating on multiple roles */} + + +
+ )} +
+ ); + }; + + const getSaveButton = () => { + return ( + assignRolesToSpace()} + data-test-subj={'createRolesPrivilegeButton'} + > + {i18n.translate('xpack.spaces.management.spaceDetails.roles.assignRoleButton', { + defaultMessage: 'Assign roles', + })} + + ); + }; + + return ( + + + +

+ {i18n.translate('xpack.spaces.management.spaceDetails.roles.assign.privileges.custom', { + defaultMessage: 'Assign role to {spaceName}', + values: { spaceName: space.name }, + })} +

+
+ + +

+ +

+
+
+ {getForm()} + + + + + {i18n.translate('xpack.spaces.management.spaceDetails.roles.cancelRoleButton', { + defaultMessage: 'Cancel', + })} + + + {getSaveButton()} + + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx index 4bd9692c96feb8..f1bf4da4c6b170 100644 --- a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx @@ -36,8 +36,8 @@ interface ISpaceAssignedRolesTableProps { isReadOnly: boolean; assignedRoles: Role[]; onAssignNewRoleClick: () => Promise; - onClickBulkEdit: (selectedRoles: Role[]) => Promise; - onClickBulkRemove: (selectedRoles: Role[]) => Promise; + onClickBulkEdit: (selectedRoles: Role[]) => void; + onClickBulkRemove: (selectedRoles: Role[]) => void; } /** @@ -268,7 +268,7 @@ export const SpaceAssignedRolesTable = ({ const pageIndex = pagination.index; return ( - + @@ -297,12 +297,16 @@ export const SpaceAssignedRolesTable = ({ {i18n.translate( 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.contextMenuOpener', @@ -312,9 +316,6 @@ export const SpaceAssignedRolesTable = ({ )} } - isOpen={isBulkActionContextOpen} - closePopover={setBulkActionContextOpen.bind(null, false)} - anchorPosition="downCenter" > { await onClickBulkEdit(selectedRoles); + setBulkActionContextOpen(false); }, }, { @@ -347,6 +349,7 @@ export const SpaceAssignedRolesTable = ({ ), onClick: async () => { await onClickBulkRemove(selectedRoles); + setBulkActionContextOpen(false); }, }, ], diff --git a/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx b/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx index 3e05d122d7de82..d5476966cf6dd3 100644 --- a/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx @@ -9,24 +9,24 @@ import type { FC, PropsWithChildren } from 'react'; import React, { createContext, useContext } from 'react'; import type { ApplicationStart } from '@kbn/core-application-browser'; -import type { HttpStart } from '@kbn/core-http-browser'; -import type { NotificationsStart } from '@kbn/core-notifications-browser'; -import type { OverlayStart } from '@kbn/core-overlays-browser'; -import type { RolesAPIClient } from '@kbn/security-plugin-types-public'; +import type { CoreStart } from '@kbn/core-lifecycle-browser'; +import type { + PrivilegesAPIClientPublicContract, + RolesAPIClient, +} from '@kbn/security-plugin-types-public'; import type { SpacesManager } from '../../../spaces_manager'; // FIXME: rename to EditSpaceServices -export interface ViewSpaceServices { +export interface ViewSpaceServices + extends Pick { capabilities: ApplicationStart['capabilities']; getUrlForApp: ApplicationStart['getUrlForApp']; navigateToUrl: ApplicationStart['navigateToUrl']; serverBasePath: string; spacesManager: SpacesManager; getRolesAPIClient: () => Promise; - http: HttpStart; - overlays: OverlayStart; - notifications: NotificationsStart; + getPrivilegesAPIClient: () => Promise; } const ViewSpaceContext = createContext(null); diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx index f44ffdd339b3c2..9a11be03fa96ef 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx @@ -5,44 +5,24 @@ * 2.0. */ -import { - EuiButton, - EuiButtonEmpty, - EuiButtonGroup, - EuiCallOut, - EuiComboBox, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiForm, - EuiFormRow, - EuiLink, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import type { FC } from 'react'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; -import type { KibanaFeature, KibanaFeatureConfig } from '@kbn/features-plugin/common'; +import type { KibanaFeature } from '@kbn/features-plugin/common'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { toMountPoint } from '@kbn/react-kibana-mount'; import type { Role } from '@kbn/security-plugin-types-common'; +import { + type PrivilegesAPIClient, + PrivilegesRolesForm, + type RolesAPIClient, +} from './component/space_assign_role_privilege_form'; import { SpaceAssignedRolesTable } from './component/space_assigned_roles_table'; -import { useViewSpaceServices, type ViewSpaceServices } from './hooks/view_space_context_provider'; +import { useViewSpaceServices } from './hooks/view_space_context_provider'; import type { Space } from '../../../common'; -import { FeatureTable } from '../edit_space/enabled_features/feature_table'; - -type RolesAPIClient = ReturnType extends Promise - ? R - : never; - -type KibanaPrivilegeBase = keyof NonNullable; interface Props { space: Space; @@ -56,28 +36,39 @@ interface Props { // FIXME: rename to EditSpaceAssignedRoles export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isReadOnly }) => { - const [showRolesPrivilegeEditor, setShowRolesPrivilegeEditor] = useState(false); + // const [showRolesPrivilegeEditor, setShowRolesPrivilegeEditor] = useState(false); const [roleAPIClientInitialized, setRoleAPIClientInitialized] = useState(false); const [spaceUnallocatedRole, setSpaceUnallocatedRole] = useState([]); const rolesAPIClient = useRef(); - - const { getRolesAPIClient, getUrlForApp } = useViewSpaceServices(); - - const resolveRolesAPIClient = useCallback(async () => { + const privilegesAPIClient = useRef(); + + const { + getRolesAPIClient, + getUrlForApp, + getPrivilegesAPIClient, + overlays, + theme, + i18n: i18nStart, + } = useViewSpaceServices(); + + const resolveAPIClients = useCallback(async () => { try { - rolesAPIClient.current = await getRolesAPIClient(); + [rolesAPIClient.current, privilegesAPIClient.current] = await Promise.all([ + getRolesAPIClient(), + getPrivilegesAPIClient(), + ]); setRoleAPIClientInitialized(true); } catch { // } - }, [getRolesAPIClient]); + }, [getPrivilegesAPIClient, getRolesAPIClient]); useEffect(() => { if (!isReadOnly) { - resolveRolesAPIClient(); + resolveAPIClients(); } - }, [isReadOnly, resolveRolesAPIClient]); + }, [isReadOnly, resolveAPIClients]); useEffect(() => { async function fetchAllSystemRoles() { @@ -88,7 +79,7 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe (role) => !role.metadata?._reserved && role.kibana.some((privileges) => { - return !privileges.spaces.includes(space.id) || !privileges.spaces.includes('*'); + return !privileges.spaces.includes(space.id) && !privileges.spaces.includes('*'); }) ); @@ -100,23 +91,35 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe } }, [roleAPIClientInitialized, space.id]); + const showRolesPrivilegeEditor = useCallback( + (defaultSelected?: Role[]) => { + const overlayRef = overlays.openFlyout( + toMountPoint( + overlayRef.close(), + closeFlyout: () => overlayRef.close(), + defaultSelected, + spaceUnallocatedRole, + // APIClient would have been initialized before the privilege editor is displayed + roleAPIClient: rolesAPIClient.current!, + privilegesAPIClient: privilegesAPIClient.current!, + }} + />, + { theme, i18n: i18nStart } + ), + { + size: 's', + } + ); + }, + [features, i18nStart, overlays, space, spaceUnallocatedRole, theme] + ); + return ( - <> - {showRolesPrivilegeEditor && ( - { - setShowRolesPrivilegeEditor(false); - }} - onSaveClick={() => { - setShowRolesPrivilegeEditor(false); - }} - spaceUnallocatedRole={spaceUnallocatedRole} - // rolesAPIClient would have been initialized before the privilege editor is displayed - roleAPIClient={rolesAPIClient.current!} - /> - )} + @@ -138,256 +141,21 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe { + // TODO: add logic to remove selected roles from space + }} onAssignNewRoleClick={async () => { if (!roleAPIClientInitialized) { - await resolveRolesAPIClient(); + await resolveAPIClients(); } - setShowRolesPrivilegeEditor(true); + showRolesPrivilegeEditor(); }} /> - - ); -}; - -interface PrivilegesRolesFormProps extends Omit { - closeFlyout: () => void; - onSaveClick: () => void; - spaceUnallocatedRole: Role[]; - roleAPIClient: RolesAPIClient; -} - -const createRolesComboBoxOptions = (roles: Role[]): Array> => - roles.map((role) => ({ - label: role.name, - value: role, - })); - -export const PrivilegesRolesForm: FC = (props) => { - const { onSaveClick, closeFlyout, features, roleAPIClient, spaceUnallocatedRole } = props; - - const [space, setSpaceState] = useState>(props.space); - const [spacePrivilege, setSpacePrivilege] = useState('all'); - const [selectedRoles, setSelectedRoles] = useState>( - [] - ); - const selectedRolesHasPrivilegeConflict = useMemo(() => { - return selectedRoles.reduce((result, selectedRole) => { - // TODO: determine heuristics for role privilege conflicts - return result; - }, false); - }, [selectedRoles]); - - const [assigningToRole, setAssigningToRole] = useState(false); - - const assignRolesToSpace = useCallback(async () => { - try { - setAssigningToRole(true); - - await Promise.all( - selectedRoles.map((selectedRole) => { - roleAPIClient.saveRole({ role: selectedRole.value! }); - }) - ).then(setAssigningToRole.bind(null, false)); - - onSaveClick(); - } catch { - // Handle resulting error - } - }, [onSaveClick, roleAPIClient, selectedRoles]); - - const getForm = () => { - return ( - - - { - setSelectedRoles((prevRoles) => { - if (prevRoles.length < value.length) { - const newlyAdded = value[value.length - 1]; - - const { name: spaceName } = space; - if (!spaceName) { - throw new Error('space state requires name!'); - } - - // Add kibana space privilege definition to role - newlyAdded.value!.kibana.push({ - spaces: [spaceName], - base: spacePrivilege === 'custom' ? [] : [spacePrivilege], - feature: {}, - }); - - return prevRoles.concat(newlyAdded); - } else { - return value; - } - }); - }} - fullWidth - /> - - <> - {!selectedRolesHasPrivilegeConflict && ( - - - {i18n.translate( - 'xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.description', - { - defaultMessage: - 'Updating the settings here in a bulk will override current individual settings.', - } - )} - - - )} - - - ({ - ...privilege, - 'data-test-subj': `${privilege.id}-privilege-button`, - }))} - color="primary" - idSelected={spacePrivilege} - onChange={(id) => setSpacePrivilege(id as KibanaPrivilegeBase | 'custom')} - buttonSize="compressed" - isFullWidth - /> - - {spacePrivilege === 'custom' && ( - - <> - -

- -

-
- - - -
- )} -
- ); - }; - - const getSaveButton = () => { - return ( - assignRolesToSpace()} - data-test-subj={'createRolesPrivilegeButton'} - > - {i18n.translate('xpack.spaces.management.spaceDetails.roles.assignRoleButton', { - defaultMessage: 'Assign roles', - })} - - ); - }; - - return ( - - - -

- {i18n.translate('xpack.spaces.management.spaceDetails.roles.assign.privileges.custom', { - defaultMessage: 'Assign role to {spaceName}', - values: { spaceName: space.name }, - })} -

-
- - -

- -

-
-
- {getForm()} - - - - - {i18n.translate('xpack.spaces.management.spaceDetails.roles.cancelRoleButton', { - defaultMessage: 'Cancel', - })} - - - {getSaveButton()} - - -
+
); }; From 63035a70e835be6487d4e130ec81b59d6767f261 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Tue, 13 Aug 2024 13:05:22 +0200 Subject: [PATCH 10/26] make accomodation for edit existing record --- .../component/space_assigned_roles_table.tsx | 34 ++++++++++++------- .../view_space/view_space_roles.tsx | 6 +++- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx index f1bf4da4c6b170..0804f2273186b0 100644 --- a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx @@ -35,9 +35,11 @@ import type { Role } from '@kbn/security-plugin-types-common'; interface ISpaceAssignedRolesTableProps { isReadOnly: boolean; assignedRoles: Role[]; - onAssignNewRoleClick: () => Promise; + onClickAssignNewRole: () => Promise; onClickBulkEdit: (selectedRoles: Role[]) => void; onClickBulkRemove: (selectedRoles: Role[]) => void; + onClickRowEditAction: (role: Role) => void; + onClickRowRemoveAction: (role: Role) => void; } /** @@ -53,7 +55,14 @@ export const isEditableRole = (role: Role) => { ); }; -const getTableColumns = ({ isReadOnly }: Pick) => { +const getTableColumns = ({ + isReadOnly, + onClickRowEditAction, + onClickRowRemoveAction, +}: Pick< + ISpaceAssignedRolesTableProps, + 'isReadOnly' | 'onClickRowEditAction' | 'onClickRowRemoveAction' +>) => { const columns: Array> = [ { field: 'name', @@ -156,9 +165,7 @@ const getTableColumns = ({ isReadOnly }: Pick isEditableRole(rowRecord), - onClick: () => { - window.alert('Not yet implemented.'); - }, + onClick: onClickRowEditAction, }, { isPrimary: true, @@ -179,9 +186,7 @@ const getTableColumns = ({ isReadOnly }: Pick isEditableRole(rowRecord), - onClick: (rowRecord, event) => { - window.alert('Not yet implemented.'); - }, + onClick: onClickRowRemoveAction, }, ], }); @@ -210,11 +215,16 @@ const getCellProps = (item: Role, column: EuiTableFieldDataColumnType) => export const SpaceAssignedRolesTable = ({ isReadOnly, assignedRoles, - onAssignNewRoleClick, + onClickAssignNewRole, onClickBulkEdit, onClickBulkRemove, + onClickRowEditAction, + onClickRowRemoveAction, }: ISpaceAssignedRolesTableProps) => { - const tableColumns = useMemo(() => getTableColumns({ isReadOnly }), [isReadOnly]); + const tableColumns = useMemo( + () => getTableColumns({ isReadOnly, onClickRowEditAction, onClickRowRemoveAction }), + [isReadOnly, onClickRowEditAction, onClickRowRemoveAction] + ); const [rolesInView, setRolesInView] = useState(assignedRoles); const [selectedRoles, setSelectedRoles] = useState([]); const [isBulkActionContextOpen, setBulkActionContextOpen] = useState(false); @@ -251,7 +261,7 @@ export const SpaceAssignedRolesTable = ({ <> {!isReadOnly && ( - + {i18n.translate('xpack.spaces.management.spaceDetails.roles.assign', { defaultMessage: 'Assign role', })} @@ -261,7 +271,7 @@ export const SpaceAssignedRolesTable = ({ ), }; - }, [isReadOnly, onAssignNewRoleClick, onSearchQueryChange]); + }, [isReadOnly, onClickAssignNewRole, onSearchQueryChange]); const tableHeader = useMemo['childrenBetween']>(() => { const pageSize = pagination.size; diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx index 9a11be03fa96ef..3d1257302cebcd 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx @@ -144,10 +144,14 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe isReadOnly={isReadOnly} assignedRoles={roles} onClickBulkEdit={showRolesPrivilegeEditor} + onClickRowEditAction={(rowRecord) => showRolesPrivilegeEditor([rowRecord])} onClickBulkRemove={(selectedRoles) => { // TODO: add logic to remove selected roles from space }} - onAssignNewRoleClick={async () => { + onClickRowRemoveAction={(rowRecord) => { + // TODO: add logic to remove single role from space + }} + onClickAssignNewRole={async () => { if (!roleAPIClientInitialized) { await resolveAPIClients(); } From 9541fc431a45233a0ae5ff183763ec8eb581bf81 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Tue, 13 Aug 2024 23:59:57 +0200 Subject: [PATCH 11/26] integrate API to update roles, leverage this to update existing space privilege --- .../src/roles/roles_api_client.ts | 1 + .../public/authentication/index.mock.ts | 2 + .../authorization/authorization_service.ts | 1 + .../management/roles/roles_api_client.mock.ts | 1 + .../management/roles/roles_api_client.ts | 11 +++++ .../plugins/security/public/plugin.test.tsx | 1 + .../space_assign_role_privilege_form.tsx | 46 ++++++++++++------- .../component/space_assigned_roles_table.tsx | 31 ++++++++----- .../view_space/view_space_roles.tsx | 19 ++++++-- 9 files changed, 83 insertions(+), 30 deletions(-) diff --git a/x-pack/packages/security/plugin_types_public/src/roles/roles_api_client.ts b/x-pack/packages/security/plugin_types_public/src/roles/roles_api_client.ts index b5c45c5160fde7..a936741ad806e1 100644 --- a/x-pack/packages/security/plugin_types_public/src/roles/roles_api_client.ts +++ b/x-pack/packages/security/plugin_types_public/src/roles/roles_api_client.ts @@ -16,4 +16,5 @@ export interface RolesAPIClient { getRole: (roleName: string) => Promise; deleteRole: (roleName: string) => Promise; saveRole: (payload: RolePutPayload) => Promise; + bulkUpdateRoles: (payload: { rolesUpdate: Role[] }) => Promise; } diff --git a/x-pack/plugins/security/public/authentication/index.mock.ts b/x-pack/plugins/security/public/authentication/index.mock.ts index 166583b1274cbe..f30d47af3f7011 100644 --- a/x-pack/plugins/security/public/authentication/index.mock.ts +++ b/x-pack/plugins/security/public/authentication/index.mock.ts @@ -31,6 +31,7 @@ export const authorizationMock = { getRole: jest.fn(), deleteRole: jest.fn(), saveRole: jest.fn(), + bulkUpdateRoles: jest.fn(), }, privileges: { getAll: jest.fn(), @@ -43,6 +44,7 @@ export const authorizationMock = { getRole: jest.fn(), deleteRole: jest.fn(), saveRole: jest.fn(), + bulkUpdateRoles: jest.fn(), }, privileges: { getAll: jest.fn(), diff --git a/x-pack/plugins/security/public/authorization/authorization_service.ts b/x-pack/plugins/security/public/authorization/authorization_service.ts index c650d381be1af9..4fbae4fb54e6ae 100644 --- a/x-pack/plugins/security/public/authorization/authorization_service.ts +++ b/x-pack/plugins/security/public/authorization/authorization_service.ts @@ -29,6 +29,7 @@ export class AuthorizationService { getRole: rolesAPIClient.getRole, deleteRole: rolesAPIClient.deleteRole, saveRole: rolesAPIClient.saveRole, + bulkUpdateRoles: rolesAPIClient.bulkUpdateRoles, }, privileges: { getAll: privilegesAPIClient.getAll.bind(privilegesAPIClient), diff --git a/x-pack/plugins/security/public/management/roles/roles_api_client.mock.ts b/x-pack/plugins/security/public/management/roles/roles_api_client.mock.ts index 0e756e87c081c5..5f868fda093a42 100644 --- a/x-pack/plugins/security/public/management/roles/roles_api_client.mock.ts +++ b/x-pack/plugins/security/public/management/roles/roles_api_client.mock.ts @@ -11,5 +11,6 @@ export const rolesAPIClientMock = { getRole: jest.fn(), deleteRole: jest.fn(), saveRole: jest.fn(), + bulkUpdateRoles: jest.fn(), }), }; diff --git a/x-pack/plugins/security/public/management/roles/roles_api_client.ts b/x-pack/plugins/security/public/management/roles/roles_api_client.ts index c870f99e24dd3e..5bf58244a969a7 100644 --- a/x-pack/plugins/security/public/management/roles/roles_api_client.ts +++ b/x-pack/plugins/security/public/management/roles/roles_api_client.ts @@ -32,6 +32,17 @@ export class RolesAPIClient { }); }; + public bulkUpdateRoles = async ({ rolesUpdate }: { rolesUpdate: Role[] }) => { + await this.http.post('/api/security/roles', { + body: JSON.stringify({ + roles: rolesUpdate.reduce((transformed, value) => { + transformed[value.name] = this.transformRoleForSave(copyRole(value)); + return transformed; + }, {} as Record>), + }), + }); + }; + private transformRoleForSave = (role: Role) => { // Remove any placeholder index privileges const isPlaceholderPrivilege = ( diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 336a42a1fd3242..e58539bf2bc8f1 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -137,6 +137,7 @@ describe('Security Plugin', () => { "getAll": [Function], }, "roles": Object { + "bulkUpdateRoles": [Function], "deleteRole": [Function], "getRole": [Function], "getRoles": [Function], diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx index f7728d4d9b8e89..bc6d7615510fd6 100644 --- a/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx @@ -54,7 +54,7 @@ interface PrivilegesRolesFormProps { space: Space; features: KibanaFeature[]; closeFlyout: () => void; - onSaveClick: () => void; + onSaveCompleted: () => void; roleAPIClient: RolesAPIClient; privilegesAPIClient: PrivilegesAPIClient; spaceUnallocatedRole: Role[]; @@ -69,7 +69,7 @@ const createRolesComboBoxOptions = (roles: Role[]): Array = (props) => { const { - onSaveClick, + onSaveCompleted, closeFlyout, features, roleAPIClient, @@ -83,6 +83,7 @@ export const PrivilegesRolesForm: FC = (props) => { const [selectedRoles, setSelectedRoles] = useState>( createRolesComboBoxOptions(defaultSelected) ); + const [assigningToRole, setAssigningToRole] = useState(false); const [privileges, setPrivileges] = useState<[RawKibanaPrivileges] | null>(null); const selectedRolesHasPrivilegeConflict = useMemo(() => { @@ -91,14 +92,16 @@ export const PrivilegesRolesForm: FC = (props) => { return selectedRoles.reduce((result, selectedRole) => { let rolePrivilege: string; - selectedRole.value?.kibana.forEach(({ spaces, base }) => { + selectedRole.value!.kibana.forEach(({ spaces, base }) => { // TODO: consider wildcard situations if (spaces.includes(space.id!) && base.length) { rolePrivilege = base[0]; } if (!privilegeAnchor) { - setRoleSpacePrivilege((privilegeAnchor = rolePrivilege)); + setRoleSpacePrivilege( + (privilegeAnchor = rolePrivilege === '*' ? 'custom' : rolePrivilege) + ); } }); @@ -117,23 +120,34 @@ export const PrivilegesRolesForm: FC = (props) => { ); }, [privilegesAPIClient]); - const [assigningToRole, setAssigningToRole] = useState(false); - const assignRolesToSpace = useCallback(async () => { try { setAssigningToRole(true); - await Promise.all( - selectedRoles.map((selectedRole) => { - roleAPIClient.saveRole({ role: selectedRole.value! }); - }) - ).then(setAssigningToRole.bind(null, false)); + await roleAPIClient + .bulkUpdateRoles({ rolesUpdate: selectedRoles.map((role) => role.value!) }) + .then(setAssigningToRole.bind(null, false)); - onSaveClick(); - } catch { + onSaveCompleted(); + } catch (err) { // Handle resulting error } - }, [onSaveClick, roleAPIClient, selectedRoles]); + }, [onSaveCompleted, roleAPIClient, selectedRoles]); + + useEffect(() => { + setSelectedRoles((prevSelectedRoles) => { + return structuredClone(prevSelectedRoles).map((selectedRole) => { + for (let i = 0; i < selectedRole.value!.kibana.length; i++) { + if (selectedRole.value!.kibana[i].spaces.includes(space.id!)) { + selectedRole.value!.kibana[i].base = [roleSpacePrivilege]; + break; + } + } + + return selectedRole; + }); + }); + }, [roleSpacePrivilege, space.id]); const getForm = () => { return ( @@ -152,13 +166,13 @@ export const PrivilegesRolesForm: FC = (props) => { setSelectedRoles((prevRoles) => { if (prevRoles.length < value.length) { const newlyAdded = value[value.length - 1]; - const { id: spaceId } = space; + if (!spaceId) { throw new Error('space state requires space to have an ID'); } - // Add kibana space privilege definition to role + // Add new kibana privilege definition particular for the current space to role newlyAdded.value!.kibana.push({ base: roleSpacePrivilege === 'custom' ? [] : [roleSpacePrivilege], feature: {}, diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx index 0804f2273186b0..461532a502e3cc 100644 --- a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx @@ -79,17 +79,26 @@ const getTableColumns = ({ } ), render: (_, record) => { - return record.kibana.map((kibanaPrivilege) => { - if (!kibanaPrivilege.base.length) { - return i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.column.privileges.customPrivilege', - { - defaultMessage: 'custom', - } - ); - } - return kibanaPrivilege.base.join(', '); - }); + const uniquePrivilege = new Set( + record.kibana.reduce((privilegeBaseTuple, kibanaPrivilege) => { + if (!kibanaPrivilege.base.length) { + privilegeBaseTuple.push( + i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.privileges.customPrivilege', + { + defaultMessage: 'custom', + } + ) + ); + + return privilegeBaseTuple; + } + + return privilegeBaseTuple.concat(kibanaPrivilege.base); + }, [] as string[]) + ); + + return Array.from(uniquePrivilege).join(','); }, }, { diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx index 3d1257302cebcd..b4a3a2a56284e5 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx @@ -36,7 +36,6 @@ interface Props { // FIXME: rename to EditSpaceAssignedRoles export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isReadOnly }) => { - // const [showRolesPrivilegeEditor, setShowRolesPrivilegeEditor] = useState(false); const [roleAPIClientInitialized, setRoleAPIClientInitialized] = useState(false); const [spaceUnallocatedRole, setSpaceUnallocatedRole] = useState([]); @@ -50,6 +49,7 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe overlays, theme, i18n: i18nStart, + notifications, } = useViewSpaceServices(); const resolveAPIClients = useCallback(async () => { @@ -99,7 +99,20 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe {...{ space, features, - onSaveClick: () => overlayRef.close(), + onSaveCompleted: () => { + notifications.toasts.addSuccess( + i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.assignmentSuccessMsg', + { + defaultMessage: `Selected roles have been assigned to the {spaceName} space`, + values: { + spaceName: space.name, + }, + } + ) + ); + overlayRef.close(); + }, closeFlyout: () => overlayRef.close(), defaultSelected, spaceUnallocatedRole, @@ -115,7 +128,7 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe } ); }, - [features, i18nStart, overlays, space, spaceUnallocatedRole, theme] + [features, i18nStart, notifications.toasts, overlays, space, spaceUnallocatedRole, theme] ); return ( From ee5e218b687a5ec566ff9a104dc76c33c7f7b110 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Wed, 14 Aug 2024 11:56:03 +0200 Subject: [PATCH 12/26] add logic to handle removing roles from space --- .../space_assign_role_privilege_form.tsx | 65 ++++++++++--------- .../view_space/view_space_roles.tsx | 51 +++++++++++++-- 2 files changed, 78 insertions(+), 38 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx index bc6d7615510fd6..8b748fa7fa3675 100644 --- a/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx @@ -86,27 +86,22 @@ export const PrivilegesRolesForm: FC = (props) => { const [assigningToRole, setAssigningToRole] = useState(false); const [privileges, setPrivileges] = useState<[RawKibanaPrivileges] | null>(null); - const selectedRolesHasPrivilegeConflict = useMemo(() => { - let privilegeAnchor: string; - - return selectedRoles.reduce((result, selectedRole) => { - let rolePrivilege: string; - - selectedRole.value!.kibana.forEach(({ spaces, base }) => { - // TODO: consider wildcard situations - if (spaces.includes(space.id!) && base.length) { - rolePrivilege = base[0]; + const selectedRolesHasSpacePrivilegeConflict = useMemo(() => { + const combinedPrivilege = new Set( + selectedRoles.reduce((result, selectedRole) => { + let match: string[] = []; + for (let i = 0; i < selectedRole.value!.kibana.length; i++) { + if (selectedRole.value!.kibana[i].spaces.includes(space.id!)) { + match = selectedRole.value!.kibana[i].base; + break; + } } - if (!privilegeAnchor) { - setRoleSpacePrivilege( - (privilegeAnchor = rolePrivilege === '*' ? 'custom' : rolePrivilege) - ); - } - }); + return result.concat(match); + }, [] as string[]) + ); - return result || privilegeAnchor !== rolePrivilege; - }, false); + return combinedPrivilege.size > 1; }, [selectedRoles, space.id]); useEffect(() => { @@ -134,20 +129,28 @@ export const PrivilegesRolesForm: FC = (props) => { } }, [onSaveCompleted, roleAPIClient, selectedRoles]); - useEffect(() => { - setSelectedRoles((prevSelectedRoles) => { - return structuredClone(prevSelectedRoles).map((selectedRole) => { - for (let i = 0; i < selectedRole.value!.kibana.length; i++) { - if (selectedRole.value!.kibana[i].spaces.includes(space.id!)) { - selectedRole.value!.kibana[i].base = [roleSpacePrivilege]; - break; + const updateRoleSpacePrivilege = useCallback( + (spacePrivilege: KibanaRolePrivilege) => { + // persist select privilege for UI + setRoleSpacePrivilege(spacePrivilege); + + // update preselected roles with new privilege + setSelectedRoles((prevSelectedRoles) => { + return structuredClone(prevSelectedRoles).map((selectedRole) => { + for (let i = 0; i < selectedRole.value!.kibana.length; i++) { + if (selectedRole.value!.kibana[i].spaces.includes(space.id!)) { + selectedRole.value!.kibana[i].base = + spacePrivilege === 'custom' ? [] : [spacePrivilege]; + break; + } } - } - return selectedRole; + return selectedRole; + }); }); - }); - }, [roleSpacePrivilege, space.id]); + }, + [space.id] + ); const getForm = () => { return ( @@ -189,7 +192,7 @@ export const PrivilegesRolesForm: FC = (props) => { />
<> - {selectedRolesHasPrivilegeConflict && ( + {selectedRolesHasSpacePrivilegeConflict && ( = (props) => { }))} color="primary" idSelected={roleSpacePrivilege} - onChange={(id) => setRoleSpacePrivilege(id as KibanaRolePrivilege)} + onChange={(id) => updateRoleSpacePrivilege(id as KibanaRolePrivilege)} buttonSize="compressed" isFullWidth /> diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx index b4a3a2a56284e5..4745bec219a0ce 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx @@ -78,9 +78,10 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe const spaceUnallocatedRoles = systemRoles.filter( (role) => !role.metadata?._reserved && - role.kibana.some((privileges) => { - return !privileges.spaces.includes(space.id) && !privileges.spaces.includes('*'); - }) + (!role.kibana.length || + role.kibana.some((privileges) => { + return !privileges.spaces.includes(space.id) && !privileges.spaces.includes('*'); + })) ); setSpaceUnallocatedRole(spaceUnallocatedRoles); @@ -131,6 +132,42 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe [features, i18nStart, notifications.toasts, overlays, space, spaceUnallocatedRole, theme] ); + const removeRole = useCallback( + async (payload: Role[]) => { + const updateDoc = structuredClone(payload).map((roleDef) => { + roleDef.kibana = roleDef.kibana.filter(({ spaces }) => { + let spaceIdIndex: number; + + if (spaces.length && (spaceIdIndex = spaces.indexOf(space.id)) > -1) { + if (spaces.length > 1) { + spaces.splice(spaceIdIndex, 1); + return true; + } else { + return false; + } + } + return true; + }); + + return roleDef; + }); + + await rolesAPIClient.current?.bulkUpdateRoles({ rolesUpdate: updateDoc }).then(() => + notifications.toasts.addSuccess( + i18n.translate('xpack.spaces.management.spaceDetails.roles.removalSuccessMsg', { + defaultMessage: + 'Removed {count, plural, one {role} other {{count} roles}} from {spaceName} space', + values: { + spaceName: space.name, + count: updateDoc.length, + }, + }) + ) + ); + }, + [notifications.toasts, space.id, space.name] + ); + return ( @@ -158,11 +195,11 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe assignedRoles={roles} onClickBulkEdit={showRolesPrivilegeEditor} onClickRowEditAction={(rowRecord) => showRolesPrivilegeEditor([rowRecord])} - onClickBulkRemove={(selectedRoles) => { - // TODO: add logic to remove selected roles from space + onClickBulkRemove={async (selectedRoles) => { + await removeRole(selectedRoles); }} - onClickRowRemoveAction={(rowRecord) => { - // TODO: add logic to remove single role from space + onClickRowRemoveAction={async (rowRecord) => { + await removeRole([rowRecord]); }} onClickAssignNewRole={async () => { if (!roleAPIClientInitialized) { From c13613e0e54420c65e49611efa8592a6b8799b09 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Thu, 15 Aug 2024 07:21:45 +0200 Subject: [PATCH 13/26] refactor implementation to provide visual feedback on UI actions --- .../management/view_space/hooks/use_tabs.ts | 2 +- .../hooks/view_space_context_provider.tsx | 52 ---- .../public/management/view_space/index.tsx | 40 +++ .../view_space/{ => provider}/index.ts | 7 +- .../view_space/provider/reducers/index.ts | 53 ++++ .../provider/view_space_provider.tsx | 164 ++++++++++++ .../space_assign_role_privilege_form.tsx | 144 ++++++---- .../component/space_assigned_roles_table.tsx | 123 +++++---- .../management/view_space/view_space.tsx | 253 +++++++++--------- .../view_space/view_space_content_tab.tsx | 2 +- .../view_space/view_space_features_tab.tsx | 2 +- .../view_space/view_space_general_tab.tsx | 2 +- .../view_space/view_space_roles.tsx | 112 ++------ .../management/view_space/view_space_tabs.tsx | 8 +- 14 files changed, 584 insertions(+), 380 deletions(-) delete mode 100644 x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx create mode 100644 x-pack/plugins/spaces/public/management/view_space/index.tsx rename x-pack/plugins/spaces/public/management/view_space/{ => provider}/index.ts (54%) create mode 100644 x-pack/plugins/spaces/public/management/view_space/provider/reducers/index.ts create mode 100644 x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.tsx rename x-pack/plugins/spaces/public/management/view_space/{ => roles}/component/space_assign_role_privilege_form.tsx (76%) rename x-pack/plugins/spaces/public/management/view_space/{ => roles}/component/space_assigned_roles_table.tsx (82%) diff --git a/x-pack/plugins/spaces/public/management/view_space/hooks/use_tabs.ts b/x-pack/plugins/spaces/public/management/view_space/hooks/use_tabs.ts index 38bf7fd02f94fb..1623f19920bcdd 100644 --- a/x-pack/plugins/spaces/public/management/view_space/hooks/use_tabs.ts +++ b/x-pack/plugins/spaces/public/management/view_space/hooks/use_tabs.ts @@ -13,7 +13,7 @@ import type { KibanaFeature } from '@kbn/features-plugin/public'; import type { Space } from '../../../../common'; import { getTabs, type GetTabsProps, type ViewSpaceTab } from '../view_space_tabs'; -type UseTabsProps = Pick & { +type UseTabsProps = Pick & { space: Space | null; features: KibanaFeature[] | null; currentSelectedTabId: string; diff --git a/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx b/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx deleted file mode 100644 index d5476966cf6dd3..00000000000000 --- a/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { FC, PropsWithChildren } from 'react'; -import React, { createContext, useContext } from 'react'; - -import type { ApplicationStart } from '@kbn/core-application-browser'; -import type { CoreStart } from '@kbn/core-lifecycle-browser'; -import type { - PrivilegesAPIClientPublicContract, - RolesAPIClient, -} from '@kbn/security-plugin-types-public'; - -import type { SpacesManager } from '../../../spaces_manager'; - -// FIXME: rename to EditSpaceServices -export interface ViewSpaceServices - extends Pick { - capabilities: ApplicationStart['capabilities']; - getUrlForApp: ApplicationStart['getUrlForApp']; - navigateToUrl: ApplicationStart['navigateToUrl']; - serverBasePath: string; - spacesManager: SpacesManager; - getRolesAPIClient: () => Promise; - getPrivilegesAPIClient: () => Promise; -} - -const ViewSpaceContext = createContext(null); - -// FIXME: rename to EditSpaceContextProvider -export const ViewSpaceContextProvider: FC> = ({ - children, - ...services -}) => { - return {children}; -}; - -// FIXME: rename to useEditSpaceServices -export const useViewSpaceServices = (): ViewSpaceServices => { - const context = useContext(ViewSpaceContext); - if (!context) { - throw new Error( - 'ViewSpace Context is missing. Ensure the component or React root is wrapped with ViewSpaceContext' - ); - } - - return context; -}; diff --git a/x-pack/plugins/spaces/public/management/view_space/index.tsx b/x-pack/plugins/spaces/public/management/view_space/index.tsx new file mode 100644 index 00000000000000..8a796fdd33f415 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/view_space/index.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { ComponentProps, PropsWithChildren } from 'react'; + +import { ViewSpaceProvider, type ViewSpaceProviderProps } from './provider'; +import { ViewSpace } from './view_space'; + +type ViewSpacePageProps = ComponentProps & ViewSpaceProviderProps; + +export function ViewSpacePage({ + spaceId, + getFeatures, + history, + onLoadSpace, + selectedTabId, + allowFeatureVisibility, + allowSolutionVisibility, + children, + ...viewSpaceServicesProps +}: PropsWithChildren) { + return ( + + + + ); +} diff --git a/x-pack/plugins/spaces/public/management/view_space/index.ts b/x-pack/plugins/spaces/public/management/view_space/provider/index.ts similarity index 54% rename from x-pack/plugins/spaces/public/management/view_space/index.ts rename to x-pack/plugins/spaces/public/management/view_space/provider/index.ts index ff9ddac4a28e5d..74c713ee2e56ad 100644 --- a/x-pack/plugins/spaces/public/management/view_space/index.ts +++ b/x-pack/plugins/spaces/public/management/view_space/provider/index.ts @@ -5,4 +5,9 @@ * 2.0. */ -export { ViewSpacePage } from './view_space'; +export { ViewSpaceProvider, useViewSpaceServices, useViewSpaceStore } from './view_space_provider'; +export type { + ViewSpaceProviderProps, + ViewSpaceServices, + ViewSpaceStore, +} from './view_space_provider'; diff --git a/x-pack/plugins/spaces/public/management/view_space/provider/reducers/index.ts b/x-pack/plugins/spaces/public/management/view_space/provider/reducers/index.ts new file mode 100644 index 00000000000000..6040b69d3ba9d0 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/view_space/provider/reducers/index.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { type Reducer } from 'react'; + +import type { Role } from '@kbn/security-plugin-types-common'; + +export type IDispatchAction = + | { + /** @description updates a single role record */ + type: 'update_roles' | 'remove_roles'; + payload: Role[]; + } + | { + type: 'string'; + payload: any; + }; + +export interface IViewSpaceStoreState { + /** roles assigned to current space */ + roles: Map; +} + +export const createSpaceRolesReducer: Reducer = ( + state, + action +) => { + const _state = structuredClone(state); + + switch (action.type) { + case 'update_roles': { + action.payload.forEach((role) => { + _state.roles.set(role.name, role); + }); + + return _state; + } + case 'remove_roles': { + action.payload.forEach((role) => { + _state.roles.delete(role.name); + }); + + return _state; + } + default: { + return _state; + } + } +}; diff --git a/x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.tsx b/x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.tsx new file mode 100644 index 00000000000000..e2f31b15d7df1e --- /dev/null +++ b/x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { once } from 'lodash'; +import React, { + createContext, + type Dispatch, + type FC, + type PropsWithChildren, + useCallback, + useContext, + useEffect, + useReducer, + useRef, +} from 'react'; + +import type { ApplicationStart } from '@kbn/core-application-browser'; +import type { CoreStart } from '@kbn/core-lifecycle-browser'; +import type { + PrivilegesAPIClientPublicContract, + RolesAPIClient, +} from '@kbn/security-plugin-types-public'; + +import { + createSpaceRolesReducer, + type IDispatchAction, + type IViewSpaceStoreState, +} from './reducers'; +import type { SpacesManager } from '../../../spaces_manager'; + +// FIXME: rename to EditSpaceServices +export interface ViewSpaceProviderProps + extends Pick { + capabilities: ApplicationStart['capabilities']; + getUrlForApp: ApplicationStart['getUrlForApp']; + navigateToUrl: ApplicationStart['navigateToUrl']; + serverBasePath: string; + spacesManager: SpacesManager; + getRolesAPIClient: () => Promise; + getPrivilegesAPIClient: () => Promise; +} + +export interface ViewSpaceServices + extends Omit { + invokeClient Promise>( + arg: ARG + ): ReturnType; +} + +interface ViewSpaceClients { + spacesManager: ViewSpaceProviderProps['spacesManager']; + rolesClient: RolesAPIClient; + privilegesClient: PrivilegesAPIClientPublicContract; +} + +export interface ViewSpaceStore { + state: IViewSpaceStoreState; + dispatch: Dispatch; +} + +const createSpaceRolesContext = once(() => + createContext({ + state: { + roles: [], + }, + dispatch: () => { }, + }) +); + +const createViewSpaceServicesContext = once(() => createContext(null)); + +// FIXME: rename to EditSpaceProvider +export const ViewSpaceProvider: FC> = ({ + children, + getRolesAPIClient, + getPrivilegesAPIClient, + ...services +}) => { + const ViewSpaceStoreContext = createSpaceRolesContext(); + const ViewSpaceServicesContext = createViewSpaceServicesContext(); + + const clients = useRef(Promise.all([getRolesAPIClient(), getPrivilegesAPIClient()])); + const rolesAPIClientRef = useRef(); + const privilegesClientRef = useRef(); + + const initialStoreState = useRef({ + roles: new Map(), + }); + + const resolveAPIClients = useCallback(async () => { + try { + [rolesAPIClientRef.current, privilegesClientRef.current] = await clients.current; + } catch { + // handle errors + } + }, []); + + useEffect(() => { + resolveAPIClients(); + }, [resolveAPIClients]); + + const createInitialState = useCallback((state: IViewSpaceStoreState) => { + return state; + }, []); + + const [state, dispatch] = useReducer( + createSpaceRolesReducer, + initialStoreState.current, + createInitialState + ); + + const invokeClient = useCallback( + async (...args: Parameters) => { + await resolveAPIClients(); + + return args[0]({ + spacesManager: services.spacesManager, + rolesClient: rolesAPIClientRef.current!, + privilegesClient: privilegesClientRef.current!, + }); + }, + [resolveAPIClients, services.spacesManager] + ); + + return ( + + + {children} + + + ); +}; + +// FIXME: rename to useEditSpaceServices +export const useViewSpaceServices = (): ViewSpaceServices => { + const context = useContext(createViewSpaceServicesContext()); + if (!context) { + throw new Error( + 'ViewSpaceService Context is missing. Ensure the component or React root is wrapped with ViewSpaceProvider' + ); + } + + return context; +}; + +export const useViewSpaceStore = () => { + const context = useContext(createSpaceRolesContext()); + if (!context) { + throw new Error( + 'ViewSpaceStore Context is missing. Ensure the component or React root is wrapped with ViewSpaceProvider' + ); + } + + return context; +}; + +export const useViewSpaceStoreDispatch = () => { + const { dispatch } = useViewSpaceStore(); + return dispatch; +}; diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx similarity index 76% rename from x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx rename to x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx index 8b748fa7fa3675..ece27928bbb66e 100644 --- a/x-pack/plugins/spaces/public/management/view_space/component/space_assign_role_privilege_form.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx @@ -33,20 +33,8 @@ import type { Role } from '@kbn/security-plugin-types-common'; import { KibanaPrivileges, type RawKibanaPrivileges } from '@kbn/security-role-management-model'; import { KibanaPrivilegeTable, PrivilegeFormCalculator } from '@kbn/security-ui-components'; -import type { Space } from '../../../../common'; -import type { ViewSpaceServices } from '../hooks/view_space_context_provider'; - -export type RolesAPIClient = ReturnType extends Promise< - infer R -> - ? R - : never; - -export type PrivilegesAPIClient = ReturnType< - ViewSpaceServices['getPrivilegesAPIClient'] -> extends Promise - ? R - : never; +import type { Space } from '../../../../../common'; +import type { ViewSpaceServices, ViewSpaceStore } from '../../provider'; type KibanaRolePrivilege = keyof NonNullable | 'custom'; @@ -55,10 +43,9 @@ interface PrivilegesRolesFormProps { features: KibanaFeature[]; closeFlyout: () => void; onSaveCompleted: () => void; - roleAPIClient: RolesAPIClient; - privilegesAPIClient: PrivilegesAPIClient; - spaceUnallocatedRole: Role[]; defaultSelected?: Role[]; + storeDispatch: ViewSpaceStore['dispatch']; + spacesClientsInvocator: ViewSpaceServices['invokeClient']; } const createRolesComboBoxOptions = (roles: Role[]): Array> => @@ -72,64 +59,79 @@ export const PrivilegesRolesForm: FC = (props) => { onSaveCompleted, closeFlyout, features, - roleAPIClient, defaultSelected = [], - privilegesAPIClient, - spaceUnallocatedRole, + spacesClientsInvocator, + storeDispatch, } = props; - const [space, setSpaceState] = useState>(props.space); - const [roleSpacePrivilege, setRoleSpacePrivilege] = useState('all'); + const [assigningToRole, setAssigningToRole] = useState(false); + const [fetchingSystemRoles, setFetchingSystemRoles] = useState(false); + const [privileges, setPrivileges] = useState<[RawKibanaPrivileges] | null>(null); + const [spaceUnallocatedRoles, setSpaceUnallocatedRole] = useState([]); const [selectedRoles, setSelectedRoles] = useState>( createRolesComboBoxOptions(defaultSelected) ); - const [assigningToRole, setAssigningToRole] = useState(false); - const [privileges, setPrivileges] = useState<[RawKibanaPrivileges] | null>(null); + const selectedRolesCombinedPrivileges = useMemo(() => { - const selectedRolesHasSpacePrivilegeConflict = useMemo(() => { const combinedPrivilege = new Set( selectedRoles.reduce((result, selectedRole) => { - let match: string[] = []; + let match: Array> = []; for (let i = 0; i < selectedRole.value!.kibana.length; i++) { if (selectedRole.value!.kibana[i].spaces.includes(space.id!)) { + // @ts-ignore - TODO resolve this match = selectedRole.value!.kibana[i].base; break; } } return result.concat(match); - }, [] as string[]) + }, [] as Array>) ); - return combinedPrivilege.size > 1; + return Array.from(combinedPrivilege); }, [selectedRoles, space.id]); + const [roleSpacePrivilege, setRoleSpacePrivilege] = useState( + selectedRolesCombinedPrivileges.length === 1 ? selectedRolesCombinedPrivileges[0] : 'all' + ); + + useEffect(() => { + async function fetchAllSystemRoles() { + setFetchingSystemRoles(true); + const systemRoles = await spacesClientsInvocator((clients) => clients.rolesClient.getRoles()); + + // exclude roles that are already assigned to this space + setSpaceUnallocatedRole( + systemRoles.filter( + (role) => + !role.metadata?._reserved && + (!role.kibana.length || + role.kibana.some((rolePrivileges) => { + return ( + !rolePrivileges.spaces.includes(space.id!) && !rolePrivileges.spaces.includes('*') + ); + })) + ) + ); + } + + fetchAllSystemRoles().finally(() => setFetchingSystemRoles(false)); + }, [space.id, spacesClientsInvocator]); + useEffect(() => { Promise.all([ - privilegesAPIClient.getAll({ includeActions: true, respectLicenseLevel: false }), - privilegesAPIClient.getBuiltIn(), + spacesClientsInvocator((clients) => + clients.privilegesClient.getAll({ includeActions: true, respectLicenseLevel: false }) + ), + spacesClientsInvocator((clients) => clients.privilegesClient.getBuiltIn()), ]).then( ([kibanaPrivileges, builtInESPrivileges]) => setPrivileges([kibanaPrivileges, builtInESPrivileges]) // (err) => fatalErrors.add(err) ); - }, [privilegesAPIClient]); - - const assignRolesToSpace = useCallback(async () => { - try { - setAssigningToRole(true); + }, [spacesClientsInvocator]); - await roleAPIClient - .bulkUpdateRoles({ rolesUpdate: selectedRoles.map((role) => role.value!) }) - .then(setAssigningToRole.bind(null, false)); - - onSaveCompleted(); - } catch (err) { - // Handle resulting error - } - }, [onSaveCompleted, roleAPIClient, selectedRoles]); - - const updateRoleSpacePrivilege = useCallback( + const onRoleSpacePrivilegeChange = useCallback( (spacePrivilege: KibanaRolePrivilege) => { // persist select privilege for UI setRoleSpacePrivilege(spacePrivilege); @@ -152,6 +154,29 @@ export const PrivilegesRolesForm: FC = (props) => { [space.id] ); + const assignRolesToSpace = useCallback(async () => { + try { + setAssigningToRole(true); + + const updatedRoles = selectedRoles.map((role) => role.value!); + + await spacesClientsInvocator((clients) => + clients.rolesClient + .bulkUpdateRoles({ rolesUpdate: updatedRoles }) + .then(setAssigningToRole.bind(null, false)) + ); + + storeDispatch({ + type: 'update_roles', + payload: updatedRoles, + }); + + onSaveCompleted(); + } catch (err) { + // Handle resulting error + } + }, [onSaveCompleted, selectedRoles, spacesClientsInvocator, storeDispatch]); + const getForm = () => { return ( @@ -162,8 +187,14 @@ export const PrivilegesRolesForm: FC = (props) => { defaultMessage: 'Select role to assign to the {spaceName} space', values: { spaceName: space.name }, })} - placeholder="Select roles" - options={createRolesComboBoxOptions(spaceUnallocatedRole)} + isLoading={fetchingSystemRoles} + placeholder={i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.selectRolesPlaceholder', + { + defaultMessage: 'Select roles', + } + )} + options={createRolesComboBoxOptions(spaceUnallocatedRoles)} selectedOptions={selectedRoles} onChange={(value) => { setSelectedRoles((prevRoles) => { @@ -192,7 +223,7 @@ export const PrivilegesRolesForm: FC = (props) => { /> <> - {selectedRolesHasSpacePrivilegeConflict && ( + {selectedRolesCombinedPrivileges.length > 1 && ( = (props) => { }))} color="primary" idSelected={roleSpacePrivilege} - onChange={(id) => updateRoleSpacePrivilege(id as KibanaRolePrivilege)} + onChange={(id) => onRoleSpacePrivilegeChange(id as KibanaRolePrivilege)} buttonSize="compressed" isFullWidth /> @@ -284,7 +315,16 @@ export const PrivilegesRolesForm: FC = (props) => { { + console.log('value returned from change!', args); + // setSpaceState() + }} + onChangeAll={(privilege) => { + // setSelectedRoles((prevRoleDefinition) => { + // prevRoleDefinition.slice(0)[0].value?.kibana[0].base.concat(privilege); + // return prevRoleDefinition; + // }); + }} kibanaPrivileges={new KibanaPrivileges(privileges?.[0]!, features)} privilegeCalculator={ new PrivilegeFormCalculator( @@ -292,6 +332,8 @@ export const PrivilegesRolesForm: FC = (props) => { selectedRoles[0].value! ) } + allSpacesSelected={false} + canCustomizeSubFeaturePrivileges={false} /> diff --git a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx similarity index 82% rename from x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx rename to x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx index 461532a502e3cc..ebbe1235e9f2e3 100644 --- a/x-pack/plugins/spaces/public/management/view_space/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx @@ -27,14 +27,17 @@ import type { EuiTableFieldDataColumnType, EuiTableSelectionType, } from '@elastic/eui'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { i18n } from '@kbn/i18n'; import type { Role } from '@kbn/security-plugin-types-common'; +import type { Space } from '../../../../../common'; + interface ISpaceAssignedRolesTableProps { isReadOnly: boolean; - assignedRoles: Role[]; + currentSpace: Space; + assignedRoles: Map; onClickAssignNewRole: () => Promise; onClickBulkEdit: (selectedRoles: Role[]) => void; onClickBulkRemove: (selectedRoles: Role[]) => void; @@ -57,11 +60,12 @@ export const isEditableRole = (role: Role) => { const getTableColumns = ({ isReadOnly, + currentSpace, onClickRowEditAction, onClickRowRemoveAction, }: Pick< ISpaceAssignedRolesTableProps, - 'isReadOnly' | 'onClickRowEditAction' | 'onClickRowRemoveAction' + 'isReadOnly' | 'onClickRowEditAction' | 'onClickRowRemoveAction' | 'currentSpace' >) => { const columns: Array> = [ { @@ -81,20 +85,25 @@ const getTableColumns = ({ render: (_, record) => { const uniquePrivilege = new Set( record.kibana.reduce((privilegeBaseTuple, kibanaPrivilege) => { - if (!kibanaPrivilege.base.length) { - privilegeBaseTuple.push( - i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.column.privileges.customPrivilege', - { - defaultMessage: 'custom', - } - ) - ); - - return privilegeBaseTuple; + if ( + kibanaPrivilege.spaces.includes(currentSpace.id) || + kibanaPrivilege.spaces.includes('*') + ) { + if (!kibanaPrivilege.base.length) { + privilegeBaseTuple.push( + i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.privileges.customPrivilege', + { + defaultMessage: 'custom', + } + ) + ); + } else { + return privilegeBaseTuple.concat(kibanaPrivilege.base); + } } - return privilegeBaseTuple.concat(kibanaPrivilege.base); + return privilegeBaseTuple; }, [] as string[]) ); @@ -113,17 +122,17 @@ const getTableColumns = ({ return React.createElement(EuiBadge, { children: _value?._reserved ? i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.reserved', - { - defaultMessage: 'Reserved', - } - ) + 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.reserved', + { + defaultMessage: 'Reserved', + } + ) : i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.custom', - { - defaultMessage: 'Custom', - } - ), + 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.custom', + { + defaultMessage: 'Custom', + } + ), color: _value?._reserved ? undefined : 'success', }); }, @@ -208,7 +217,7 @@ const getRowProps = (item: Role) => { const { name } = item; return { 'data-test-subj': `space-role-row-${name}`, - onClick: () => {}, + onClick: () => { }, }; }; @@ -224,6 +233,7 @@ const getCellProps = (item: Role, column: EuiTableFieldDataColumnType) => export const SpaceAssignedRolesTable = ({ isReadOnly, assignedRoles, + currentSpace, onClickAssignNewRole, onClickBulkEdit, onClickBulkRemove, @@ -231,10 +241,11 @@ export const SpaceAssignedRolesTable = ({ onClickRowRemoveAction, }: ISpaceAssignedRolesTableProps) => { const tableColumns = useMemo( - () => getTableColumns({ isReadOnly, onClickRowEditAction, onClickRowRemoveAction }), - [isReadOnly, onClickRowEditAction, onClickRowRemoveAction] + () => + getTableColumns({ isReadOnly, onClickRowEditAction, onClickRowRemoveAction, currentSpace }), + [currentSpace, isReadOnly, onClickRowEditAction, onClickRowRemoveAction] ); - const [rolesInView, setRolesInView] = useState(assignedRoles); + const [rolesInView, setRolesInView] = useState([]); const [selectedRoles, setSelectedRoles] = useState([]); const [isBulkActionContextOpen, setBulkActionContextOpen] = useState(false); const selectableRoles = useRef(rolesInView.filter((role) => isEditableRole(role))); @@ -243,14 +254,20 @@ export const SpaceAssignedRolesTable = ({ size: 10, }); + useEffect(() => { + setRolesInView(Array.from(assignedRoles.values())); + }, [assignedRoles]); + const onSearchQueryChange = useCallback>>( ({ query }) => { + const _assignedRolesTransformed = Array.from(assignedRoles.values()); + if (query?.text) { setRolesInView( - assignedRoles.filter((role) => role.name.includes(query.text.toLowerCase())) + _assignedRolesTransformed.filter((role) => role.name.includes(query.text.toLowerCase())) ); } else { - setRolesInView(assignedRoles); + setRolesInView(_assignedRolesTransformed); } }, [assignedRoles] @@ -382,29 +399,29 @@ export const SpaceAssignedRolesTable = ({ size: 's', ...(Boolean(selectedRoles.length) ? { - iconType: 'crossInCircle', - onClick: setSelectedRoles.bind(null, []), - children: i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.clearRolesSelection', - { - defaultMessage: 'Clear selection', - } - ), - } + iconType: 'crossInCircle', + onClick: setSelectedRoles.bind(null, []), + children: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.clearRolesSelection', + { + defaultMessage: 'Clear selection', + } + ), + } : { - iconType: 'pagesSelect', - onClick: setSelectedRoles.bind(null, selectableRoles.current), - children: i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', - { - defaultMessage: - 'Select {count, plural, one {role} other {all {count} roles}}', - values: { - count: selectableRoles.current.length, - }, - } - ), - }), + iconType: 'pagesSelect', + onClick: setSelectedRoles.bind(null, selectableRoles.current), + children: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', + { + defaultMessage: + 'Select {count, plural, one {role} other {all {count} roles}}', + values: { + count: selectableRoles.current.length, + }, + } + ), + }), })} diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space.tsx index dee51f28c798cd..037004fdd7b965 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space.tsx @@ -20,7 +20,7 @@ import { import React, { lazy, Suspense, useEffect, useState } from 'react'; import type { FC } from 'react'; -import type { Capabilities, ScopedHistory } from '@kbn/core/public'; +import type { ScopedHistory } from '@kbn/core/public'; import type { FeaturesPluginStart, KibanaFeature } from '@kbn/features-plugin/public'; import { FormattedMessage } from '@kbn/i18n-react'; import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; @@ -28,10 +28,7 @@ import type { Role } from '@kbn/security-plugin-types-common'; import { TAB_ID_CONTENT, TAB_ID_GENERAL, TAB_ID_ROLES } from './constants'; import { useTabs } from './hooks/use_tabs'; -import { - ViewSpaceContextProvider, - type ViewSpaceServices, -} from './hooks/view_space_context_provider'; +import { useViewSpaceServices, useViewSpaceStore } from './provider'; import { addSpaceIdToPath, ENTER_SPACE_PATH, type Space } from '../../../common'; import { getSpaceAvatarComponent } from '../../space_avatar'; import { SpaceSolutionBadge } from '../../space_solution_badge'; @@ -49,11 +46,10 @@ const getSelectedTabId = (canUserViewRoles: boolean, selectedTabId?: string) => : TAB_ID_GENERAL; }; -interface PageProps extends ViewSpaceServices { +interface PageProps { spaceId?: string; history: ScopedHistory; selectedTabId?: string; - capabilities: Capabilities; getFeatures: FeaturesPluginStart['getFeatures']; onLoadSpace: (space: Space) => void; allowFeatureVisibility: boolean; @@ -68,24 +64,20 @@ const handleApiError = (error: Error) => { // FIXME: rename to EditSpacePage // FIXME: add eventTracker -export const ViewSpacePage: FC = (props) => { - const { - spaceId, - getFeatures, - spacesManager, - history, - onLoadSpace, - selectedTabId: _selectedTabId, - capabilities, - getUrlForApp, - navigateToUrl, - ...viewSpaceServices - } = props; - +export const ViewSpace: FC = ({ + spaceId, + getFeatures, + history, + onLoadSpace, + selectedTabId: _selectedTabId, + ...props +}) => { + const { state, dispatch } = useViewSpaceStore(); + const { invokeClient } = useViewSpaceServices(); + const { spacesManager, capabilities, serverBasePath } = useViewSpaceServices(); const [space, setSpace] = useState(null); const [userActiveSpace, setUserActiveSpace] = useState(null); const [features, setFeatures] = useState(null); - const [roles, setRoles] = useState([]); const [isLoadingSpace, setIsLoadingSpace] = useState(true); const [isLoadingFeatures, setIsLoadingFeatures] = useState(true); const [isLoadingRoles, setIsLoadingRoles] = useState(true); @@ -93,7 +85,9 @@ export const ViewSpacePage: FC = (props) => { const [tabs, selectedTabContent] = useTabs({ space, features, - roles, + rolesCount: state.roles.size, + capabilities, + history, currentSelectedTabId: selectedTabId, ...props, }); @@ -123,33 +117,38 @@ export const ViewSpacePage: FC = (props) => { } const getRoles = async () => { - let result: Role[] = []; - try { - result = await spacesManager.getRolesForSpace(spaceId); - } catch (error) { - const message = error?.body?.message ?? error.toString(); - const statusCode = error?.body?.statusCode ?? null; - if (statusCode === 403) { - // eslint-disable-next-line no-console - console.log('Insufficient permissions to get list of roles for the space'); - // eslint-disable-next-line no-console - console.log(message); - } else { - // eslint-disable-next-line no-console - console.error('Encountered error while getting list of roles for space!'); - // eslint-disable-next-line no-console - console.error(error); - throw error; + await invokeClient(async (clients) => { + let result: Role[] = []; + try { + result = await clients.spacesManager.getRolesForSpace(spaceId); + + dispatch({ type: 'update_roles', payload: result }); + } catch (error) { + const message = error?.body?.message ?? error.toString(); + const statusCode = error?.body?.statusCode ?? null; + if (statusCode === 403) { + // eslint-disable-next-line no-console + console.log('Insufficient permissions to get list of roles for the space'); + // eslint-disable-next-line no-console + console.log(message); + } else { + // eslint-disable-next-line no-console + console.error('Encountered error while getting list of roles for space!'); + // eslint-disable-next-line no-console + console.error(error); + throw error; + } } - } + }); - setRoles(result); setIsLoadingRoles(false); }; - // maybe we do not make this call if user can't view roles? 🤔 - getRoles().catch(handleApiError); - }, [spaceId, spacesManager]); + if (!state.roles.size) { + // maybe we do not make this call if user can't view roles? 🤔 + getRoles().catch(handleApiError); + } + }, [dispatch, invokeClient, spaceId, state.roles]); useEffect(() => { const _getFeatures = async () => { @@ -194,98 +193,90 @@ export const ViewSpacePage: FC = (props) => { return (
- - - - - - - - -

- {space.name} - {shouldShowSolutionBadge ? ( - <> - {' '} - + + + + + + +

+ {space.name} + {shouldShowSolutionBadge ? ( + <> + {' '} + + + ) : null} + {userActiveSpace?.id === id ? ( + <> + {' '} + + - - ) : null} - {userActiveSpace?.id === id ? ( - <> - {' '} - - - - - ) : null} -

-
+ + + ) : null} +

+
- -

- {space.description ?? ( - - )} -

-
-
- {userActiveSpace?.id !== id ? ( - - + +

+ {space.description ?? ( - - - ) : null} - + )} +

+
+
+ {userActiveSpace?.id !== id ? ( + + + + + + ) : null} +
- + - - - - {tabs.map((tab, index) => ( - - {tab.name} - - ))} - - - {selectedTabContent ?? null} - -
-
+ + + + {tabs.map((tab, index) => ( + + {tab.name} + + ))} + + + {selectedTabContent ?? null} + +
); }; diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_content_tab.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_content_tab.tsx index 6e256a14330d0c..61d6ff516e027d 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_content_tab.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_content_tab.tsx @@ -18,7 +18,7 @@ import { capitalize } from 'lodash'; import type { FC } from 'react'; import React, { useEffect, useState } from 'react'; -import { useViewSpaceServices } from './hooks/view_space_context_provider'; +import { useViewSpaceServices } from './provider'; import { addSpaceIdToPath, ENTER_SPACE_PATH, type Space } from '../../../common'; import type { SpaceContentTypeSummaryItem } from '../../types'; diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_features_tab.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_features_tab.tsx index 4d4a1a1668b0f1..5f7fc4df3f3bcd 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_features_tab.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_features_tab.tsx @@ -12,7 +12,7 @@ import React from 'react'; import type { KibanaFeature } from '@kbn/features-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useViewSpaceServices } from './hooks/view_space_context_provider'; +import { useViewSpaceServices } from './provider'; import type { Space } from '../../../common'; import { FeatureTable } from '../edit_space/enabled_features/feature_table'; import { SectionPanel } from '../edit_space/section_panel'; diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.tsx index 6b92662af420ec..7e4b9ee160931d 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.tsx @@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n'; import { useUnsavedChangesPrompt } from '@kbn/unsaved-changes-prompt'; import { ViewSpaceTabFooter } from './footer'; -import { useViewSpaceServices } from './hooks/view_space_context_provider'; +import { useViewSpaceServices } from './provider'; import { ViewSpaceEnabledFeatures } from './view_space_features_tab'; import type { Space } from '../../../common'; import { ConfirmDeleteModal } from '../components'; diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx index 4745bec219a0ce..a4d3e113665370 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx @@ -7,7 +7,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import type { FC } from 'react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback } from 'react'; import type { KibanaFeature } from '@kbn/features-plugin/common'; import { i18n } from '@kbn/i18n'; @@ -15,83 +15,29 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { toMountPoint } from '@kbn/react-kibana-mount'; import type { Role } from '@kbn/security-plugin-types-common'; -import { - type PrivilegesAPIClient, - PrivilegesRolesForm, - type RolesAPIClient, -} from './component/space_assign_role_privilege_form'; -import { SpaceAssignedRolesTable } from './component/space_assigned_roles_table'; -import { useViewSpaceServices } from './hooks/view_space_context_provider'; +import { useViewSpaceServices, useViewSpaceStore } from './provider'; +import { PrivilegesRolesForm } from './roles/component/space_assign_role_privilege_form'; +import { SpaceAssignedRolesTable } from './roles/component/space_assigned_roles_table'; import type { Space } from '../../../common'; interface Props { space: Space; - /** - * List of roles assigned to this space - */ - roles: Role[]; features: KibanaFeature[]; isReadOnly: boolean; } // FIXME: rename to EditSpaceAssignedRoles -export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isReadOnly }) => { - const [roleAPIClientInitialized, setRoleAPIClientInitialized] = useState(false); - const [spaceUnallocatedRole, setSpaceUnallocatedRole] = useState([]); - - const rolesAPIClient = useRef(); - const privilegesAPIClient = useRef(); - +export const ViewSpaceAssignedRoles: FC = ({ space, features, isReadOnly }) => { + const { dispatch, state } = useViewSpaceStore(); const { - getRolesAPIClient, getUrlForApp, - getPrivilegesAPIClient, overlays, theme, i18n: i18nStart, notifications, + invokeClient, } = useViewSpaceServices(); - const resolveAPIClients = useCallback(async () => { - try { - [rolesAPIClient.current, privilegesAPIClient.current] = await Promise.all([ - getRolesAPIClient(), - getPrivilegesAPIClient(), - ]); - setRoleAPIClientInitialized(true); - } catch { - // - } - }, [getPrivilegesAPIClient, getRolesAPIClient]); - - useEffect(() => { - if (!isReadOnly) { - resolveAPIClients(); - } - }, [isReadOnly, resolveAPIClients]); - - useEffect(() => { - async function fetchAllSystemRoles() { - const systemRoles = (await rolesAPIClient.current?.getRoles()) ?? []; - - // exclude roles that are already assigned to this space - const spaceUnallocatedRoles = systemRoles.filter( - (role) => - !role.metadata?._reserved && - (!role.kibana.length || - role.kibana.some((privileges) => { - return !privileges.spaces.includes(space.id) && !privileges.spaces.includes('*'); - })) - ); - - setSpaceUnallocatedRole(spaceUnallocatedRoles); - } - - if (roleAPIClientInitialized) { - fetchAllSystemRoles?.(); - } - }, [roleAPIClientInitialized, space.id]); - const showRolesPrivilegeEditor = useCallback( (defaultSelected?: Role[]) => { const overlayRef = overlays.openFlyout( @@ -116,10 +62,8 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe }, closeFlyout: () => overlayRef.close(), defaultSelected, - spaceUnallocatedRole, - // APIClient would have been initialized before the privilege editor is displayed - roleAPIClient: rolesAPIClient.current!, - privilegesAPIClient: privilegesAPIClient.current!, + storeDispatch: dispatch, + spacesClientsInvocator: invokeClient, }} />, { theme, i18n: i18nStart } @@ -129,7 +73,7 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe } ); }, - [features, i18nStart, notifications.toasts, overlays, space, spaceUnallocatedRole, theme] + [dispatch, features, i18nStart, invokeClient, notifications.toasts, overlays, space, theme] ); const removeRole = useCallback( @@ -152,20 +96,24 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe return roleDef; }); - await rolesAPIClient.current?.bulkUpdateRoles({ rolesUpdate: updateDoc }).then(() => - notifications.toasts.addSuccess( - i18n.translate('xpack.spaces.management.spaceDetails.roles.removalSuccessMsg', { - defaultMessage: - 'Removed {count, plural, one {role} other {{count} roles}} from {spaceName} space', - values: { - spaceName: space.name, - count: updateDoc.length, - }, - }) - ) - ); + await invokeClient((clients) => { + return clients.rolesClient.bulkUpdateRoles({ rolesUpdate: updateDoc }).then(() => + notifications.toasts.addSuccess( + i18n.translate('xpack.spaces.management.spaceDetails.roles.removalSuccessMsg', { + defaultMessage: + 'Removed {count, plural, one {role} other {{count} roles}} from {spaceName} space', + values: { + spaceName: space.name, + count: updateDoc.length, + }, + }) + ) + ); + }); + + dispatch({ type: 'remove_roles', payload: updateDoc }); }, - [notifications.toasts, space.id, space.name] + [dispatch, invokeClient, notifications.toasts, space.id, space.name] ); return ( @@ -192,7 +140,8 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe showRolesPrivilegeEditor([rowRecord])} onClickBulkRemove={async (selectedRoles) => { @@ -202,9 +151,6 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isRe await removeRole([rowRecord]); }} onClickAssignNewRole={async () => { - if (!roleAPIClientInitialized) { - await resolveAPIClients(); - } showRolesPrivilegeEditor(); }} /> diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx index 8210ab2d7a1cc5..138afbf01121fe 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx @@ -11,7 +11,6 @@ import React from 'react'; import type { Capabilities, ScopedHistory } from '@kbn/core/public'; import type { KibanaFeature } from '@kbn/features-plugin/common'; import { i18n } from '@kbn/i18n'; -import type { Role } from '@kbn/security-plugin-types-common'; import { withSuspense } from '@kbn/shared-ux-utility'; import { TAB_ID_CONTENT, TAB_ID_GENERAL, TAB_ID_ROLES } from './constants'; @@ -29,7 +28,7 @@ export interface ViewSpaceTab { export interface GetTabsProps { space: Space; - roles: Role[]; + rolesCount: number; features: KibanaFeature[]; history: ScopedHistory; capabilities: Capabilities & { @@ -68,7 +67,7 @@ export const getTabs = ({ features, history, capabilities, - roles, + rolesCount, ...props }: GetTabsProps): ViewSpaceTab[] => { const canUserViewRoles = Boolean(capabilities?.roles?.view); @@ -105,13 +104,12 @@ export const getTabs = ({ }), append: ( - {roles.length} + {rolesCount} ), content: ( From a50eade2a6d8dbaa8cc620e2ed8941ae613cae06 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Thu, 15 Aug 2024 07:53:06 +0200 Subject: [PATCH 14/26] fix logic for excluding roles already ppart of space --- .../roles/component/space_assign_role_privilege_form.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx index ece27928bbb66e..45ebb05c8259bb 100644 --- a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx @@ -106,9 +106,9 @@ export const PrivilegesRolesForm: FC = (props) => { (role) => !role.metadata?._reserved && (!role.kibana.length || - role.kibana.some((rolePrivileges) => { - return ( - !rolePrivileges.spaces.includes(space.id!) && !rolePrivileges.spaces.includes('*') + role.kibana.every((rolePrivileges) => { + return !( + rolePrivileges.spaces.includes(space.id!) || rolePrivileges.spaces.includes('*') ); })) ) From ad1e5263c3446897ecd87caa6da99d71e2d80227 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Thu, 15 Aug 2024 08:03:38 +0200 Subject: [PATCH 15/26] fix logic with selectable items --- .../component/space_assigned_roles_table.tsx | 73 ++++++++++--------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx index ebbe1235e9f2e3..daa9a863b74715 100644 --- a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx @@ -27,7 +27,7 @@ import type { EuiTableFieldDataColumnType, EuiTableSelectionType, } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import type { Role } from '@kbn/security-plugin-types-common'; @@ -122,17 +122,17 @@ const getTableColumns = ({ return React.createElement(EuiBadge, { children: _value?._reserved ? i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.reserved', - { - defaultMessage: 'Reserved', - } - ) + 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.reserved', + { + defaultMessage: 'Reserved', + } + ) : i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.custom', - { - defaultMessage: 'Custom', - } - ), + 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.custom', + { + defaultMessage: 'Custom', + } + ), color: _value?._reserved ? undefined : 'success', }); }, @@ -217,7 +217,7 @@ const getRowProps = (item: Role) => { const { name } = item; return { 'data-test-subj': `space-role-row-${name}`, - onClick: () => { }, + onClick: () => {}, }; }; @@ -248,7 +248,6 @@ export const SpaceAssignedRolesTable = ({ const [rolesInView, setRolesInView] = useState([]); const [selectedRoles, setSelectedRoles] = useState([]); const [isBulkActionContextOpen, setBulkActionContextOpen] = useState(false); - const selectableRoles = useRef(rolesInView.filter((role) => isEditableRole(role))); const [pagination, setPagination] = useState['page']>({ index: 0, size: 10, @@ -303,6 +302,8 @@ export const SpaceAssignedRolesTable = ({ const pageSize = pagination.size; const pageIndex = pagination.index; + const selectableRoles = rolesInView.filter((role) => isEditableRole(role)); + return ( @@ -399,29 +400,29 @@ export const SpaceAssignedRolesTable = ({ size: 's', ...(Boolean(selectedRoles.length) ? { - iconType: 'crossInCircle', - onClick: setSelectedRoles.bind(null, []), - children: i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.clearRolesSelection', - { - defaultMessage: 'Clear selection', - } - ), - } + iconType: 'crossInCircle', + onClick: setSelectedRoles.bind(null, []), + children: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.clearRolesSelection', + { + defaultMessage: 'Clear selection', + } + ), + } : { - iconType: 'pagesSelect', - onClick: setSelectedRoles.bind(null, selectableRoles.current), - children: i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', - { - defaultMessage: - 'Select {count, plural, one {role} other {all {count} roles}}', - values: { - count: selectableRoles.current.length, - }, - } - ), - }), + iconType: 'pagesSelect', + onClick: setSelectedRoles.bind(null, selectableRoles), + children: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', + { + defaultMessage: + 'Select {count, plural, one {role} other {all {count} roles}}', + values: { + count: selectableRoles.length, + }, + } + ), + }), })} @@ -437,7 +438,7 @@ export const SpaceAssignedRolesTable = ({ onClickBulkRemove, pagination.index, pagination.size, - rolesInView.length, + rolesInView, selectedRoles, ]); From f51800b1025f7073a8c5c1d30f1fb62b2b642a3a Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Thu, 15 Aug 2024 11:47:00 +0200 Subject: [PATCH 16/26] add implementation for assigning custom roles privileges --- .../space_assign_role_privilege_form.tsx | 220 ++++++++++-------- 1 file changed, 127 insertions(+), 93 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx index 45ebb05c8259bb..60c9e0da83c34a 100644 --- a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx @@ -56,6 +56,7 @@ const createRolesComboBoxOptions = (roles: Role[]): Array = (props) => { const { + space, onSaveCompleted, closeFlyout, features, @@ -63,42 +64,54 @@ export const PrivilegesRolesForm: FC = (props) => { spacesClientsInvocator, storeDispatch, } = props; - const [space, setSpaceState] = useState>(props.space); const [assigningToRole, setAssigningToRole] = useState(false); - const [fetchingSystemRoles, setFetchingSystemRoles] = useState(false); - const [privileges, setPrivileges] = useState<[RawKibanaPrivileges] | null>(null); + const [fetchingDataDeps, setFetchingDataDeps] = useState(false); + const [kibanaPrivileges, setKibanaPrivileges] = useState(null); const [spaceUnallocatedRoles, setSpaceUnallocatedRole] = useState([]); const [selectedRoles, setSelectedRoles] = useState>( createRolesComboBoxOptions(defaultSelected) ); - const selectedRolesCombinedPrivileges = useMemo(() => { + const [roleCustomizationAnchor, setRoleCustomizationAnchor] = useState({ + value: selectedRoles?.[0]?.value, + privilegeIndex: 0, + }); + const selectedRolesCombinedPrivileges = useMemo(() => { const combinedPrivilege = new Set( selectedRoles.reduce((result, selectedRole) => { - let match: Array> = []; + let match: KibanaRolePrivilege[] = []; for (let i = 0; i < selectedRole.value!.kibana.length; i++) { - if (selectedRole.value!.kibana[i].spaces.includes(space.id!)) { + const { spaces, base } = selectedRole.value!.kibana[i]; + if (spaces.includes(space.id!)) { // @ts-ignore - TODO resolve this - match = selectedRole.value!.kibana[i].base; + match = base.length ? base : ['custom']; break; } } return result.concat(match); - }, [] as Array>) + }, [] as KibanaRolePrivilege[]) ); return Array.from(combinedPrivilege); }, [selectedRoles, space.id]); const [roleSpacePrivilege, setRoleSpacePrivilege] = useState( - selectedRolesCombinedPrivileges.length === 1 ? selectedRolesCombinedPrivileges[0] : 'all' + !selectedRoles.length || selectedRolesCombinedPrivileges.length > 1 + ? 'all' + : selectedRolesCombinedPrivileges[0] ); useEffect(() => { - async function fetchAllSystemRoles() { - setFetchingSystemRoles(true); - const systemRoles = await spacesClientsInvocator((clients) => clients.rolesClient.getRoles()); + async function fetchAllSystemRoles(spaceId: string) { + setFetchingDataDeps(true); + + const [systemRoles, _kibanaPrivileges] = await Promise.all([ + spacesClientsInvocator((clients) => clients.rolesClient.getRoles()), + spacesClientsInvocator((clients) => + clients.privilegesClient.getAll({ includeActions: true, respectLicenseLevel: false }) + ), + ]); // exclude roles that are already assigned to this space setSpaceUnallocatedRole( @@ -108,57 +121,85 @@ export const PrivilegesRolesForm: FC = (props) => { (!role.kibana.length || role.kibana.every((rolePrivileges) => { return !( - rolePrivileges.spaces.includes(space.id!) || rolePrivileges.spaces.includes('*') + rolePrivileges.spaces.includes(spaceId) || rolePrivileges.spaces.includes('*') ); })) ) ); + + setKibanaPrivileges(_kibanaPrivileges); } - fetchAllSystemRoles().finally(() => setFetchingSystemRoles(false)); + fetchAllSystemRoles(space.id!).finally(() => setFetchingDataDeps(false)); }, [space.id, spacesClientsInvocator]); useEffect(() => { - Promise.all([ - spacesClientsInvocator((clients) => - clients.privilegesClient.getAll({ includeActions: true, respectLicenseLevel: false }) - ), - spacesClientsInvocator((clients) => clients.privilegesClient.getBuiltIn()), - ]).then( - ([kibanaPrivileges, builtInESPrivileges]) => - setPrivileges([kibanaPrivileges, builtInESPrivileges]) - // (err) => fatalErrors.add(err) - ); - }, [spacesClientsInvocator]); - - const onRoleSpacePrivilegeChange = useCallback( - (spacePrivilege: KibanaRolePrivilege) => { - // persist select privilege for UI - setRoleSpacePrivilege(spacePrivilege); - - // update preselected roles with new privilege - setSelectedRoles((prevSelectedRoles) => { - return structuredClone(prevSelectedRoles).map((selectedRole) => { - for (let i = 0; i < selectedRole.value!.kibana.length; i++) { - if (selectedRole.value!.kibana[i].spaces.includes(space.id!)) { - selectedRole.value!.kibana[i].base = - spacePrivilege === 'custom' ? [] : [spacePrivilege]; + if (roleSpacePrivilege === 'custom') { + let anchor: typeof roleCustomizationAnchor | null = null; + + /** + * when custom privilege is selected we selected the first role that already has a custom privilege + * and use that as the starting point for all customizations that will happen to all the other selected roles + */ + for (let i = 0; i < selectedRoles.length; i++) { + for (let j = 0; i < selectedRoles[i].value?.kibana!.length!; j++) { + let iterationIndexPrivilegeValue; + + // check that the current iteration has a value, since roles can have uneven privilege defs + if ((iterationIndexPrivilegeValue = selectedRoles[i].value?.kibana[j])) { + const { spaces, base } = iterationIndexPrivilegeValue; + if (spaces.includes(space.id) && !base.length) { + anchor = { + value: structuredClone(selectedRoles[i].value), + privilegeIndex: j, + }; break; } } + } - return selectedRole; - }); - }); - }, - [space.id] - ); + if (anchor) break; + } + + if (anchor) setRoleCustomizationAnchor(anchor); + } + }, [selectedRoles, roleSpacePrivilege, space.id]); + + const onRoleSpacePrivilegeChange = useCallback((spacePrivilege: KibanaRolePrivilege) => { + // persist selected privilege for UI + setRoleSpacePrivilege(spacePrivilege); + }, []); const assignRolesToSpace = useCallback(async () => { try { setAssigningToRole(true); - const updatedRoles = selectedRoles.map((role) => role.value!); + const newPrivileges = { + base: roleSpacePrivilege === 'custom' ? [] : [roleSpacePrivilege], + feature: + roleSpacePrivilege === 'custom' + ? roleCustomizationAnchor.value?.kibana[roleCustomizationAnchor.privilegeIndex].feature! + : {}, + }; + + const updatedRoles = structuredClone(selectedRoles).map((selectedRole) => { + let found = false; + + // TODO: account for case where previous assignment included multiple spaces assigned to a particular base + for (let i = 0; i < selectedRole.value!.kibana.length; i++) { + if (selectedRole.value!.kibana[i].spaces.includes(space.id!)) { + Object.assign(selectedRole.value!.kibana[i], newPrivileges); + found = true; + break; + } + } + + if (!found) { + selectedRole.value?.kibana.push(Object.assign({ spaces: [space.id] }, newPrivileges)); + } + + return selectedRole.value!; + }); await spacesClientsInvocator((clients) => clients.rolesClient @@ -175,7 +216,15 @@ export const PrivilegesRolesForm: FC = (props) => { } catch (err) { // Handle resulting error } - }, [onSaveCompleted, selectedRoles, spacesClientsInvocator, storeDispatch]); + }, [ + selectedRoles, + spacesClientsInvocator, + storeDispatch, + onSaveCompleted, + space.id, + roleSpacePrivilege, + roleCustomizationAnchor, + ]); const getForm = () => { return ( @@ -187,7 +236,7 @@ export const PrivilegesRolesForm: FC = (props) => { defaultMessage: 'Select role to assign to the {spaceName} space', values: { spaceName: space.name }, })} - isLoading={fetchingSystemRoles} + isLoading={fetchingDataDeps} placeholder={i18n.translate( 'xpack.spaces.management.spaceDetails.roles.selectRolesPlaceholder', { @@ -196,29 +245,7 @@ export const PrivilegesRolesForm: FC = (props) => { )} options={createRolesComboBoxOptions(spaceUnallocatedRoles)} selectedOptions={selectedRoles} - onChange={(value) => { - setSelectedRoles((prevRoles) => { - if (prevRoles.length < value.length) { - const newlyAdded = value[value.length - 1]; - const { id: spaceId } = space; - - if (!spaceId) { - throw new Error('space state requires space to have an ID'); - } - - // Add new kibana privilege definition particular for the current space to role - newlyAdded.value!.kibana.push({ - base: roleSpacePrivilege === 'custom' ? [] : [roleSpacePrivilege], - feature: {}, - spaces: [spaceId], - }); - - return prevRoles.concat(newlyAdded); - } else { - return value; - } - }); - }} + onChange={(value) => setSelectedRoles(value)} fullWidth /> @@ -312,29 +339,36 @@ export const PrivilegesRolesForm: FC = (props) => { {/** TODO: rework privilege table to accommodate operating on multiple roles */} - { - console.log('value returned from change!', args); - // setSpaceState() - }} - onChangeAll={(privilege) => { - // setSelectedRoles((prevRoleDefinition) => { - // prevRoleDefinition.slice(0)[0].value?.kibana[0].base.concat(privilege); - // return prevRoleDefinition; - // }); - }} - kibanaPrivileges={new KibanaPrivileges(privileges?.[0]!, features)} - privilegeCalculator={ - new PrivilegeFormCalculator( - new KibanaPrivileges(privileges?.[0]!, features), - selectedRoles[0].value! - ) - } - allSpacesSelected={false} - canCustomizeSubFeaturePrivileges={false} - /> + + {!kibanaPrivileges ? ( +

loading...

+ ) : ( + { + // apply selected changes only to customization anchor, this delay we delay reconciling the intending privileges + // of the selected roles till we decide to commit the changes chosen + setRoleCustomizationAnchor(({ value, privilegeIndex }) => { + value!.kibana[privilegeIndex].feature[featureId] = selectedPrivileges; + return { value, privilegeIndex }; + }); + }} + onChangeAll={(privilege) => { + // dummy function we wouldn't be using this + }} + kibanaPrivileges={new KibanaPrivileges(kibanaPrivileges, features)} + privilegeCalculator={ + new PrivilegeFormCalculator( + new KibanaPrivileges(kibanaPrivileges, features), + selectedRoles[0].value! + ) + } + allSpacesSelected={false} + canCustomizeSubFeaturePrivileges={false} + /> + )} +
)} From 8be80768b3eda6c19c478ff74c671702fe9692af Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Mon, 19 Aug 2024 11:35:57 +0200 Subject: [PATCH 17/26] refactor logic for selecting role cutomization anchor --- .../space_assign_role_privilege_form.tsx | 146 ++++++++++++------ 1 file changed, 101 insertions(+), 45 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx index 60c9e0da83c34a..cb3bfd1b0f3670 100644 --- a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx @@ -18,6 +18,7 @@ import { EuiFlyoutHeader, EuiForm, EuiFormRow, + EuiLoadingSpinner, EuiSpacer, EuiText, EuiTitle, @@ -71,9 +72,17 @@ export const PrivilegesRolesForm: FC = (props) => { const [selectedRoles, setSelectedRoles] = useState>( createRolesComboBoxOptions(defaultSelected) ); - const [roleCustomizationAnchor, setRoleCustomizationAnchor] = useState({ - value: selectedRoles?.[0]?.value, - privilegeIndex: 0, + const [roleCustomizationAnchor, setRoleCustomizationAnchor] = useState(() => { + // support instance where the form is opened with roles already preselected + const defaultAnchor = selectedRoles?.[0]?.value; + const privilegeIndex = defaultAnchor?.kibana.findIndex(({ spaces }) => + spaces.includes(space.id!) + ); + + return { + value: defaultAnchor, + privilegeIndex: (privilegeIndex || -1) >= 0 ? privilegeIndex : 0, + }; }); const selectedRolesCombinedPrivileges = useMemo(() => { @@ -83,8 +92,7 @@ export const PrivilegesRolesForm: FC = (props) => { for (let i = 0; i < selectedRole.value!.kibana.length; i++) { const { spaces, base } = selectedRole.value!.kibana[i]; if (spaces.includes(space.id!)) { - // @ts-ignore - TODO resolve this - match = base.length ? base : ['custom']; + match = (base.length ? base : ['custom']) as [KibanaRolePrivilege]; break; } } @@ -98,12 +106,12 @@ export const PrivilegesRolesForm: FC = (props) => { const [roleSpacePrivilege, setRoleSpacePrivilege] = useState( !selectedRoles.length || selectedRolesCombinedPrivileges.length > 1 - ? 'all' + ? 'read' : selectedRolesCombinedPrivileges[0] ); useEffect(() => { - async function fetchAllSystemRoles(spaceId: string) { + async function fetchRequiredData(spaceId: string) { setFetchingDataDeps(true); const [systemRoles, _kibanaPrivileges] = await Promise.all([ @@ -130,45 +138,79 @@ export const PrivilegesRolesForm: FC = (props) => { setKibanaPrivileges(_kibanaPrivileges); } - fetchAllSystemRoles(space.id!).finally(() => setFetchingDataDeps(false)); + fetchRequiredData(space.id!).finally(() => setFetchingDataDeps(false)); }, [space.id, spacesClientsInvocator]); - useEffect(() => { - if (roleSpacePrivilege === 'custom') { + const computeRoleCustomizationAnchor = useCallback( + (spaceId: string, _selectedRoles: ReturnType) => { let anchor: typeof roleCustomizationAnchor | null = null; - /** - * when custom privilege is selected we selected the first role that already has a custom privilege - * and use that as the starting point for all customizations that will happen to all the other selected roles - */ - for (let i = 0; i < selectedRoles.length; i++) { - for (let j = 0; i < selectedRoles[i].value?.kibana!.length!; j++) { - let iterationIndexPrivilegeValue; - - // check that the current iteration has a value, since roles can have uneven privilege defs - if ((iterationIndexPrivilegeValue = selectedRoles[i].value?.kibana[j])) { - const { spaces, base } = iterationIndexPrivilegeValue; - if (spaces.includes(space.id) && !base.length) { - anchor = { - value: structuredClone(selectedRoles[i].value), - privilegeIndex: j, - }; - break; + for (let i = 0; i < _selectedRoles.length; i++) { + let role; + + if ((role = _selectedRoles[i].value)) { + for (let j = 0; j < _selectedRoles[i].value!.kibana.length; j++) { + let privilegeIterationIndexValue; + + if ((privilegeIterationIndexValue = role.kibana[j])) { + const { spaces, base } = privilegeIterationIndexValue; + /* + * check to see if current role already has a custom privilege, if it does we use that as the starting point for all customizations + * that will happen to all the other selected roles and exit + */ + if (spaces.includes(spaceId) && !base.length) { + anchor = { + value: structuredClone(role), + privilegeIndex: j, + }; + + break; + } } } } if (anchor) break; + + // provide a fallback anchor if no suitable anchor was discovered, and we have reached the end of selected roles iteration + if (!anchor && role && i === _selectedRoles.length - 1) { + const fallbackRole = structuredClone(role); + + const spacePrivilegeIndex = fallbackRole.kibana.findIndex(({ spaces }) => + spaces.includes(spaceId) + ); + + anchor = { + value: fallbackRole, + privilegeIndex: + (spacePrivilegeIndex || -1) >= 0 + ? spacePrivilegeIndex + : (fallbackRole?.kibana?.push?.({ + spaces: [spaceId], + base: [], + feature: {}, + }) || 0) - 1, + }; + } } - if (anchor) setRoleCustomizationAnchor(anchor); - } - }, [selectedRoles, roleSpacePrivilege, space.id]); + return anchor; + }, + [] + ); - const onRoleSpacePrivilegeChange = useCallback((spacePrivilege: KibanaRolePrivilege) => { - // persist selected privilege for UI - setRoleSpacePrivilege(spacePrivilege); - }, []); + const onRoleSpacePrivilegeChange = useCallback( + (spacePrivilege: KibanaRolePrivilege) => { + if (spacePrivilege === 'custom') { + const _roleCustomizationAnchor = computeRoleCustomizationAnchor(space.id, selectedRoles); + if (_roleCustomizationAnchor) setRoleCustomizationAnchor(_roleCustomizationAnchor); + } + + // persist selected privilege for UI + setRoleSpacePrivilege(spacePrivilege); + }, + [computeRoleCustomizationAnchor, selectedRoles, space.id] + ); const assignRolesToSpace = useCallback(async () => { try { @@ -178,18 +220,28 @@ export const PrivilegesRolesForm: FC = (props) => { base: roleSpacePrivilege === 'custom' ? [] : [roleSpacePrivilege], feature: roleSpacePrivilege === 'custom' - ? roleCustomizationAnchor.value?.kibana[roleCustomizationAnchor.privilegeIndex].feature! + ? roleCustomizationAnchor.value?.kibana[roleCustomizationAnchor.privilegeIndex!] + .feature! : {}, }; const updatedRoles = structuredClone(selectedRoles).map((selectedRole) => { let found = false; - // TODO: account for case where previous assignment included multiple spaces assigned to a particular base for (let i = 0; i < selectedRole.value!.kibana.length; i++) { - if (selectedRole.value!.kibana[i].spaces.includes(space.id!)) { - Object.assign(selectedRole.value!.kibana[i], newPrivileges); - found = true; + const { spaces } = selectedRole.value!.kibana[i]; + + if (spaces.includes(space.id!)) { + if (spaces.length > 1) { + // space belongs to a collection of other spaces that share the same privileges, + // so we have to assign the new privilege to apply only to the specific space + // hence we remove the space from the shared privilege + spaces.splice(i, 1); + } else { + Object.assign(selectedRole.value!.kibana[i], newPrivileges); + found = true; + } + break; } } @@ -338,19 +390,23 @@ export const PrivilegesRolesForm: FC = (props) => {

- {/** TODO: rework privilege table to accommodate operating on multiple roles */} {!kibanaPrivileges ? ( -

loading...

+ ) : ( { - // apply selected changes only to customization anchor, this delay we delay reconciling the intending privileges - // of the selected roles till we decide to commit the changes chosen + // apply selected changes only to customization anchor, this way we delay reconciling the intending privileges + // of the selected roles till we decide to commit the changes chosen setRoleCustomizationAnchor(({ value, privilegeIndex }) => { - value!.kibana[privilegeIndex].feature[featureId] = selectedPrivileges; + let privilege; + + if ((privilege = value!.kibana?.[privilegeIndex!])) { + privilege.feature[featureId] = selectedPrivileges; + } + return { value, privilegeIndex }; }); }} @@ -361,7 +417,7 @@ export const PrivilegesRolesForm: FC = (props) => { privilegeCalculator={ new PrivilegeFormCalculator( new KibanaPrivileges(kibanaPrivileges, features), - selectedRoles[0].value! + roleCustomizationAnchor.value! ) } allSpacesSelected={false} From e91dff48e6ad643b66c87e8367aea10c60571aae Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Wed, 21 Aug 2024 18:11:49 +0200 Subject: [PATCH 18/26] UI cleanup --- .../component/space_assigned_roles_table.tsx | 4 +++- .../public/management/view_space/utils.ts | 22 ------------------- .../management/view_space/view_space_tabs.tsx | 5 +---- 3 files changed, 4 insertions(+), 27 deletions(-) delete mode 100644 x-pack/plugins/spaces/public/management/view_space/utils.ts diff --git a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx index daa9a863b74715..8b12c5f69c9a6a 100644 --- a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx @@ -360,6 +360,8 @@ export const SpaceAssignedRolesTable = ({ panels={[ { id: 0, + size: 's', + width: 180, items: [ { icon: , @@ -473,7 +475,7 @@ export const SpaceAssignedRolesTable = ({ pagination={{ pageSize: pagination.size, pageIndex: pagination.index, - pageSizeOptions: [50, 25, 10, 0], + pageSizeOptions: [50, 25, 10], }} onChange={onTableChange} /> diff --git a/x-pack/plugins/spaces/public/management/view_space/utils.ts b/x-pack/plugins/spaces/public/management/view_space/utils.ts deleted file mode 100644 index 2492c8b081df9c..00000000000000 --- a/x-pack/plugins/spaces/public/management/view_space/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Role } from '@kbn/security-plugin-types-common'; - -import type { Space } from '../../../common'; - -export const filterRolesAssignedToSpace = (roles: Role[], space: Space) => { - return roles.filter((role) => - role.kibana.reduce((acc, cur) => { - return ( - (cur.spaces.includes(space.name) || cur.spaces.includes('*')) && - Boolean(cur.base.length) && - acc - ); - }, true) - ); -}; diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx index 138afbf01121fe..15a8b831bd46d4 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx @@ -14,7 +14,6 @@ import { i18n } from '@kbn/i18n'; import { withSuspense } from '@kbn/shared-ux-utility'; import { TAB_ID_CONTENT, TAB_ID_GENERAL, TAB_ID_ROLES } from './constants'; -// import { filterRolesAssignedToSpace } from './utils'; import type { Space } from '../../../common'; // FIXME: rename to EditSpaceTab @@ -95,12 +94,10 @@ export const getTabs = ({ ]; if (canUserViewRoles) { - // const rolesAssignedToSpace = filterRolesAssignedToSpace(roles, space); - tabsDefinition.push({ id: TAB_ID_ROLES, name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.roles.heading', { - defaultMessage: 'Roles', + defaultMessage: 'Assigned roles', }), append: ( From 1b7d51689a1b3c020d5d1b736a638f5ffff0d12f Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 23 Aug 2024 17:17:01 +0000 Subject: [PATCH 19/26] [CI] Auto-commit changed files from 'node scripts/lint_ts_projects --fix' --- x-pack/packages/security/plugin_types_public/tsconfig.json | 3 ++- x-pack/plugins/spaces/tsconfig.json | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/x-pack/packages/security/plugin_types_public/tsconfig.json b/x-pack/packages/security/plugin_types_public/tsconfig.json index 5c97e25656ecf2..4a5db65acaf425 100644 --- a/x-pack/packages/security/plugin_types_public/tsconfig.json +++ b/x-pack/packages/security/plugin_types_public/tsconfig.json @@ -14,6 +14,7 @@ "@kbn/core-user-profile-common", "@kbn/security-plugin-types-common", "@kbn/core-security-common", - "@kbn/security-authorization-core" + "@kbn/security-authorization-core", + "@kbn/security-role-management-model" ] } diff --git a/x-pack/plugins/spaces/tsconfig.json b/x-pack/plugins/spaces/tsconfig.json index dbcb925f9cc5cc..a59765a8d866b3 100644 --- a/x-pack/plugins/spaces/tsconfig.json +++ b/x-pack/plugins/spaces/tsconfig.json @@ -42,9 +42,10 @@ "@kbn/security-plugin-types-common", "@kbn/core-application-browser", "@kbn/unsaved-changes-prompt", - "@kbn/core-http-browser", - "@kbn/core-overlays-browser", - "@kbn/core-notifications-browser", + "@kbn/core-lifecycle-browser", + "@kbn/security-role-management-model", + "@kbn/security-ui-components", + "@kbn/react-kibana-mount", "@kbn/shared-ux-utility", "@kbn/core-application-common", ], From 7f5cff9c318e489017bbf996f6a42706c6115f3c Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Mon, 26 Aug 2024 15:51:05 +0200 Subject: [PATCH 20/26] fix failing tests --- .../management/spaces_management_app.test.tsx | 2 +- .../view_space/view_space_general_tab.test.tsx | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx index c5a2672f615130..c3a5b8560da369 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx @@ -175,7 +175,7 @@ describe('spacesManagementApp', () => { css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)." data-test-subj="kbnRedirectAppLink" > - Spaces View Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"serverBasePath":"","http":{"basePath":{"basePath":"","serverBasePath":"","assetsHrefBase":""},"anonymousPaths":{},"externalUrl":{},"staticAssets":{}},"overlays":{"banners":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"spaceId":"some-space","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/some-space","search":"","hash":""}},"allowFeatureVisibility":true,"allowSolutionVisibility":true} + Spaces View Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"serverBasePath":"","http":{"basePath":{"basePath":"","serverBasePath":"","assetsHrefBase":""},"anonymousPaths":{},"externalUrl":{},"staticAssets":{}},"overlays":{"banners":{}},"notifications":{"toasts":{}},"theme":{"theme$":{}},"i18n":{},"spacesManager":{"onActiveSpaceChange$":{}},"spaceId":"some-space","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/some-space","search":"","hash":""}},"allowFeatureVisibility":true,"allowSolutionVisibility":true} `); diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.test.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.test.tsx index bad47aa9d2ca27..6240242710feb7 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.test.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.test.tsx @@ -10,18 +10,21 @@ import React from 'react'; import { httpServiceMock, + i18nServiceMock, notificationServiceMock, overlayServiceMock, scopedHistoryMock, + themeServiceMock, } from '@kbn/core/public/mocks'; import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; import { KibanaFeature } from '@kbn/features-plugin/common'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; -import { ViewSpaceContextProvider } from './hooks/view_space_context_provider'; +import { ViewSpaceProvider } from './provider/view_space_provider'; import { ViewSpaceSettings } from './view_space_general_tab'; import type { SolutionView } from '../../../common'; import { spacesManagerMock } from '../../spaces_manager/spaces_manager.mock'; +import { getPrivilegeAPIClientMock } from '../privilege_api_client.mock'; import { getRolesAPIClientMock } from '../roles_api_client.mock'; const space = { id: 'default', name: 'Default', disabledFeatures: [], _reserved: true }; @@ -30,11 +33,14 @@ const getUrlForApp = (appId: string) => appId; const navigateToUrl = jest.fn(); const spacesManager = spacesManagerMock.create(); const getRolesAPIClient = getRolesAPIClientMock(); +const getPrivilegeAPIClient = getPrivilegeAPIClientMock(); const reloadWindow = jest.fn(); const http = httpServiceMock.createStartContract(); const notifications = notificationServiceMock.createStartContract(); const overlays = overlayServiceMock.createStartContract(); +const theme = themeServiceMock.createStartContract(); +const i18n = i18nServiceMock.createStartContract(); const navigateSpy = jest.spyOn(history, 'push').mockImplementation(() => {}); const updateSpaceSpy = jest @@ -54,7 +60,7 @@ describe('ViewSpaceSettings', () => { const TestComponent: React.FC = ({ children }) => { return ( - { http={http} notifications={notifications} overlays={overlays} + getPrivilegesAPIClient={getPrivilegeAPIClient} + theme={theme} + i18n={i18n} > {children} - + ); }; From 27b1428acc9d3023cc5a05b9dd1938ccb902cc9a Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Mon, 26 Aug 2024 16:47:37 +0200 Subject: [PATCH 21/26] UI tweaks --- .../component/space_assigned_roles_table.tsx | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx index 8b12c5f69c9a6a..8e61c080af7c64 100644 --- a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx @@ -398,34 +398,35 @@ export const SpaceAssignedRolesTable = ({
- {React.createElement(EuiButtonEmpty, { - size: 's', - ...(Boolean(selectedRoles.length) - ? { - iconType: 'crossInCircle', - onClick: setSelectedRoles.bind(null, []), - children: i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.clearRolesSelection', - { - defaultMessage: 'Clear selection', - } - ), - } - : { - iconType: 'pagesSelect', - onClick: setSelectedRoles.bind(null, selectableRoles), - children: i18n.translate( - 'xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', - { - defaultMessage: - 'Select {count, plural, one {role} other {all {count} roles}}', - values: { - count: selectableRoles.length, - }, - } - ), - }), - })} + {Boolean(selectableRoles.length) && + React.createElement(EuiButtonEmpty, { + size: 's', + ...(Boolean(selectedRoles.length) + ? { + iconType: 'crossInCircle', + onClick: setSelectedRoles.bind(null, []), + children: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.clearRolesSelection', + { + defaultMessage: 'Clear selection', + } + ), + } + : { + iconType: 'pagesSelect', + onClick: setSelectedRoles.bind(null, selectableRoles), + children: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', + { + defaultMessage: + 'Select {count, plural, one {role} other {all {count} roles}}', + values: { + count: selectableRoles.length, + }, + } + ), + }), + })} From 3fb9d8e097718382cccd1311ce67e63769ee033a Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Mon, 26 Aug 2024 18:49:56 +0200 Subject: [PATCH 22/26] add tests for view space provider --- .../provider/view_space_provider.test.tsx | 110 ++++++++++++++++++ .../provider/view_space_provider.tsx | 22 +--- 2 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.test.tsx diff --git a/x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.test.tsx b/x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.test.tsx new file mode 100644 index 00000000000000..872454da0afc5a --- /dev/null +++ b/x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.test.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import type { PropsWithChildren } from 'react'; +import React from 'react'; + +import { + httpServiceMock, + i18nServiceMock, + notificationServiceMock, + overlayServiceMock, + themeServiceMock, +} from '@kbn/core/public/mocks'; +import type { ApplicationStart } from '@kbn/core-application-browser'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +import { useViewSpaceServices, useViewSpaceStore, ViewSpaceProvider } from './view_space_provider'; +import { spacesManagerMock } from '../../../spaces_manager/spaces_manager.mock'; +import { getPrivilegeAPIClientMock } from '../../privilege_api_client.mock'; +import { getRolesAPIClientMock } from '../../roles_api_client.mock'; + +const http = httpServiceMock.createStartContract(); +const notifications = notificationServiceMock.createStartContract(); +const overlays = overlayServiceMock.createStartContract(); +const theme = themeServiceMock.createStartContract(); +const i18n = i18nServiceMock.createStartContract(); + +const spacesManager = spacesManagerMock.create(); + +const SUTProvider = ({ + children, + capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + spaces: { manage: true }, + }, +}: PropsWithChildren>>) => { + return ( + + _, + getRolesAPIClient: getRolesAPIClientMock, + getPrivilegesAPIClient: getPrivilegeAPIClientMock, + navigateToUrl: jest.fn(), + capabilities, + }} + > + {children} + + + ); +}; + +describe('ViewSpaceProvider', () => { + describe('useViewSpaceServices', () => { + it('returns an object of predefined properties', () => { + const { result } = renderHook(useViewSpaceServices, { wrapper: SUTProvider }); + + expect(result.current).toEqual( + expect.objectContaining({ + invokeClient: expect.any(Function), + }) + ); + }); + + it('throws when the hook is used within a tree that does not have the provider', () => { + const { result } = renderHook(useViewSpaceServices); + expect(result.error).toBeDefined(); + expect(result.error?.message).toEqual( + expect.stringMatching('ViewSpaceService Context is missing.') + ); + }); + }); + + describe('useViewSpaceStore', () => { + it('returns an object of predefined properties', () => { + const { result } = renderHook(useViewSpaceStore, { wrapper: SUTProvider }); + + expect(result.current).toEqual( + expect.objectContaining({ + state: expect.objectContaining({ roles: expect.any(Map) }), + dispatch: expect.any(Function), + }) + ); + }); + + it('throws when the hook is used within a tree that does not have the provider', () => { + const { result } = renderHook(useViewSpaceStore); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toEqual( + expect.stringMatching('ViewSpaceStore Context is missing.') + ); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.tsx b/x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.tsx index e2f31b15d7df1e..86732f63b5fdfd 100644 --- a/x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.tsx @@ -9,7 +9,6 @@ import { once } from 'lodash'; import React, { createContext, type Dispatch, - type FC, type PropsWithChildren, useCallback, useContext, @@ -46,9 +45,7 @@ export interface ViewSpaceProviderProps export interface ViewSpaceServices extends Omit { - invokeClient Promise>( - arg: ARG - ): ReturnType; + invokeClient(arg: (clients: ViewSpaceClients) => Promise): Promise; } interface ViewSpaceClients { @@ -62,24 +59,17 @@ export interface ViewSpaceStore { dispatch: Dispatch; } -const createSpaceRolesContext = once(() => - createContext({ - state: { - roles: [], - }, - dispatch: () => { }, - }) -); +const createSpaceRolesContext = once(() => createContext(null)); const createViewSpaceServicesContext = once(() => createContext(null)); // FIXME: rename to EditSpaceProvider -export const ViewSpaceProvider: FC> = ({ +export const ViewSpaceProvider = ({ children, getRolesAPIClient, getPrivilegesAPIClient, ...services -}) => { +}: PropsWithChildren) => { const ViewSpaceStoreContext = createSpaceRolesContext(); const ViewSpaceServicesContext = createViewSpaceServicesContext(); @@ -113,8 +103,8 @@ export const ViewSpaceProvider: FC> = createInitialState ); - const invokeClient = useCallback( - async (...args: Parameters) => { + const invokeClient: ViewSpaceServices['invokeClient'] = useCallback( + async (...args) => { await resolveAPIClients(); return args[0]({ From f18eba3af08b33122d8bc09f516db98e28d1835d Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 27 Aug 2024 07:52:25 +0000 Subject: [PATCH 23/26] [CI] Auto-commit changed files from 'node scripts/notice' [CI] Auto-commit changed files from 'node scripts/lint_ts_projects --fix' --- x-pack/plugins/spaces/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/spaces/tsconfig.json b/x-pack/plugins/spaces/tsconfig.json index a59765a8d866b3..bde909653702d4 100644 --- a/x-pack/plugins/spaces/tsconfig.json +++ b/x-pack/plugins/spaces/tsconfig.json @@ -48,6 +48,7 @@ "@kbn/react-kibana-mount", "@kbn/shared-ux-utility", "@kbn/core-application-common", + "@kbn/security-authorization-core", ], "exclude": [ "target/**/*", From d29d066bb1a9c79ac3979e16fd08941d7c8f44c3 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Mon, 26 Aug 2024 17:43:12 +0200 Subject: [PATCH 24/26] add tests for space assign role privilege form --- .../space_assign_role_privilege_form.test.tsx | 203 ++++++++++++++++++ .../space_assign_role_privilege_form.tsx | 20 +- 2 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.test.tsx diff --git a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.test.tsx b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.test.tsx new file mode 100644 index 00000000000000..bf645d1d17178c --- /dev/null +++ b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.test.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import crypto from 'crypto'; +import React from 'react'; + +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import type { Role } from '@kbn/security-plugin-types-common'; +import { + createRawKibanaPrivileges, + kibanaFeatures, +} from '@kbn/security-role-management-model/src/__fixtures__'; + +import { PrivilegesRolesForm } from './space_assign_role_privilege_form'; +import type { Space } from '../../../../../common'; +import { createPrivilegeAPIClientMock } from '../../../privilege_api_client.mock'; +import { createRolesAPIClientMock } from '../../../roles_api_client.mock'; + +const rolesAPIClient = createRolesAPIClientMock(); +const privilegeAPIClient = createPrivilegeAPIClientMock(); + +const createRole = (roleName: string, kibana: Role['kibana'] = []): Role => { + return { + name: roleName, + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, + }; +}; + +const space: Space = { + id: crypto.randomUUID(), + name: 'Odyssey', + description: 'Journey vs. Destination', + disabledFeatures: [], +}; + +const spacesClientsInvocatorMock = jest.fn((fn) => + fn({ + rolesClient: rolesAPIClient, + privilegesClient: privilegeAPIClient, + }) +); +const dispatchMock = jest.fn(); +const onSaveCompleted = jest.fn(); +const closeFlyout = jest.fn(); + +const renderPrivilegeRolesForm = ({ + preSelectedRoles, +}: { + preSelectedRoles?: Role[]; +} = {}) => { + return render( + + + + ); +}; + +describe('PrivilegesRolesForm', () => { + let getRolesSpy: jest.SpiedFunction['getRoles']>; + let getAllKibanaPrivilegeSpy: jest.SpiedFunction< + ReturnType['getAll'] + >; + + beforeAll(() => { + getRolesSpy = jest.spyOn(rolesAPIClient, 'getRoles'); + getAllKibanaPrivilegeSpy = jest.spyOn(privilegeAPIClient, 'getAll'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the privilege permission selector disabled when no role is selected', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + renderPrivilegeRolesForm(); + + await waitFor(() => null); + + ['all', 'read', 'custom'].forEach((privilege) => { + expect(screen.getByTestId(`${privilege}-privilege-button`)).toBeDisabled(); + }); + }); + + it('preselects the privilege of the selected role when one is provided', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + const privilege = 'all'; + + renderPrivilegeRolesForm({ + preSelectedRoles: [ + createRole('test_role_1', [{ base: [privilege], feature: {}, spaces: [space.id] }]), + ], + }); + + await waitFor(() => null); + + expect(screen.getByTestId(`${privilege}-privilege-button`)).toHaveAttribute( + 'aria-pressed', + String(true) + ); + }); + + it('displays a warning message when roles with different privilege levels are selected', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + const roles: Role[] = [ + createRole('test_role_1', [{ base: ['all'], feature: {}, spaces: [space.id] }]), + createRole('test_role_2', [{ base: ['read'], feature: {}, spaces: [space.id] }]), + ]; + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await waitFor(() => null); + + expect(screen.getByTestId('privilege-conflict-callout')).toBeInTheDocument(); + }); + + describe('applying custom privileges', () => { + it('displays the privilege customization form, when custom privilege button is selected', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + const roles: Role[] = [ + createRole('test_role_1', [{ base: ['all'], feature: {}, spaces: [space.id] }]), + ]; + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await waitFor(() => null); + + expect(screen.queryByTestId('rolePrivilegeCustomizationForm')).not.toBeInTheDocument(); + + userEvent.click(screen.getByTestId('custom-privilege-button')); + + expect(screen.getByTestId('rolePrivilegeCustomizationForm')).toBeInTheDocument(); + }); + + it('for a selection of roles pre-assigned to a space, the first encountered privilege with a custom privilege is used as the starting point', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + const featureIds: string[] = kibanaFeatures.map((kibanaFeature) => kibanaFeature.id); + + const roles: Role[] = [ + createRole('test_role_1', [{ base: ['all'], feature: {}, spaces: [space.id] }]), + createRole('test_role_2', [ + { base: [], feature: { [featureIds[0]]: ['all'] }, spaces: [space.id] }, + ]), + createRole('test_role_3', [{ base: ['read'], feature: {}, spaces: [space.id] }]), + createRole('test_role_4', [{ base: ['read'], feature: {}, spaces: [space.id] }]), + createRole('test_role_5', [ + { base: [], feature: { [featureIds[0]]: ['read'] }, spaces: [space.id] }, + ]), + ]; + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await waitFor(() => null); + + expect(screen.queryByTestId('rolePrivilegeCustomizationForm')).not.toBeInTheDocument(); + + userEvent.click(screen.getByTestId('custom-privilege-button')); + + expect(screen.getByTestId('rolePrivilegeCustomizationForm')).toBeInTheDocument(); + + expect(screen.queryByTestId(`${featureIds[0]}_read`)).not.toHaveAttribute( + 'aria-pressed', + String(true) + ); + + expect(screen.getByTestId(`${featureIds[0]}_all`)).toHaveAttribute( + 'aria-pressed', + String(true) + ); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx index cb3bfd1b0f3670..e8b281bf3e2ab9 100644 --- a/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx @@ -30,8 +30,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { KibanaFeature, KibanaFeatureConfig } from '@kbn/features-plugin/common'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { type RawKibanaPrivileges } from '@kbn/security-authorization-core'; import type { Role } from '@kbn/security-plugin-types-common'; -import { KibanaPrivileges, type RawKibanaPrivileges } from '@kbn/security-role-management-model'; +import { KibanaPrivileges } from '@kbn/security-role-management-model'; import { KibanaPrivilegeTable, PrivilegeFormCalculator } from '@kbn/security-ui-components'; import type { Space } from '../../../../../common'; @@ -105,7 +106,7 @@ export const PrivilegesRolesForm: FC = (props) => { }, [selectedRoles, space.id]); const [roleSpacePrivilege, setRoleSpacePrivilege] = useState( - !selectedRoles.length || selectedRolesCombinedPrivileges.length > 1 + !selectedRoles.length || !selectedRolesCombinedPrivileges.length ? 'read' : selectedRolesCombinedPrivileges[0] ); @@ -114,12 +115,12 @@ export const PrivilegesRolesForm: FC = (props) => { async function fetchRequiredData(spaceId: string) { setFetchingDataDeps(true); - const [systemRoles, _kibanaPrivileges] = await Promise.all([ - spacesClientsInvocator((clients) => clients.rolesClient.getRoles()), - spacesClientsInvocator((clients) => - clients.privilegesClient.getAll({ includeActions: true, respectLicenseLevel: false }) - ), - ]); + const [systemRoles, _kibanaPrivileges] = await spacesClientsInvocator((clients) => + Promise.all([ + clients.rolesClient.getRoles(), + clients.privilegesClient.getAll({ includeActions: true, respectLicenseLevel: false }), + ]) + ); // exclude roles that are already assigned to this space setSpaceUnallocatedRole( @@ -307,6 +308,7 @@ export const PrivilegesRolesForm: FC = (props) => { = (props) => { )} > = (props) => { {roleSpacePrivilege === 'custom' && ( Date: Tue, 27 Aug 2024 18:47:03 +0000 Subject: [PATCH 25/26] [CI] Auto-commit changed files from 'node scripts/notice' --- x-pack/packages/security/plugin_types_public/tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/packages/security/plugin_types_public/tsconfig.json b/x-pack/packages/security/plugin_types_public/tsconfig.json index 4a5db65acaf425..305d4411b42e5a 100644 --- a/x-pack/packages/security/plugin_types_public/tsconfig.json +++ b/x-pack/packages/security/plugin_types_public/tsconfig.json @@ -15,6 +15,5 @@ "@kbn/security-plugin-types-common", "@kbn/core-security-common", "@kbn/security-authorization-core", - "@kbn/security-role-management-model" ] } From 18c0d8365c0963c595218f67921bb3e12663c382 Mon Sep 17 00:00:00 2001 From: Eyo Okon Eyo Date: Tue, 27 Aug 2024 23:45:09 +0200 Subject: [PATCH 26/26] pass appropriate types to component --- .../management/view_space/view_space_general_tab.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.test.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.test.tsx index 6240242710feb7..81f4de6680ac77 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.test.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.test.tsx @@ -32,8 +32,8 @@ const history = scopedHistoryMock.create(); const getUrlForApp = (appId: string) => appId; const navigateToUrl = jest.fn(); const spacesManager = spacesManagerMock.create(); -const getRolesAPIClient = getRolesAPIClientMock(); -const getPrivilegeAPIClient = getPrivilegeAPIClientMock(); +const getRolesAPIClient = getRolesAPIClientMock; +const getPrivilegeAPIClient = getPrivilegeAPIClientMock; const reloadWindow = jest.fn(); const http = httpServiceMock.createStartContract();