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 e3a97398db7a3..25d768cb7b1ac 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/plugin_types_public/src/roles/roles_api_client.ts b/x-pack/packages/security/plugin_types_public/src/roles/roles_api_client.ts index b5c45c5160fde..a936741ad806e 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/packages/security/plugin_types_public/tsconfig.json b/x-pack/packages/security/plugin_types_public/tsconfig.json index 5c97e25656ecf..305d4411b42e5 100644 --- a/x-pack/packages/security/plugin_types_public/tsconfig.json +++ b/x-pack/packages/security/plugin_types_public/tsconfig.json @@ -14,6 +14,6 @@ "@kbn/core-user-profile-common", "@kbn/security-plugin-types-common", "@kbn/core-security-common", - "@kbn/security-authorization-core" + "@kbn/security-authorization-core", ] } 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 83a0da2e26815..2380088dd713f 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 3b787f01cdf92..5e4f4ce021d44 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 9155d8ae52835..2797e4d64a35e 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 0281605f00f34..e61134b816ffa 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/security/public/authentication/index.mock.ts b/x-pack/plugins/security/public/authentication/index.mock.ts index 166583b1274cb..f30d47af3f701 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 c650d381be1af..4fbae4fb54e6a 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 0e756e87c081c..5f868fda093a4 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 c870f99e24dd3..5bf58244a969a 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 336a42a1fd324..e58539bf2bc8f 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/privilege_api_client.mock.ts b/x-pack/plugins/spaces/public/management/privilege_api_client.mock.ts new file mode 100644 index 0000000000000..a8351e2d88ad5 --- /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 dd996814f9e51..66a356b3fdb75 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(), }; }; 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 c5a2672f61513..c3a5b8560da36 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/spaces_management_app.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx index 037453e1a215f..49663dcf9191b 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', @@ -71,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); @@ -147,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} @@ -155,6 +158,7 @@ export const spacesManagementApp = Object.freeze({ getRolesAPIClient={getRolesAPIClient} allowFeatureVisibility={config.allowFeatureVisibility} allowSolutionVisibility={config.allowSolutionVisibility} + getPrivilegesAPIClient={getPrivilegesAPIClient} /> ); }; 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 38bf7fd02f94f..1623f19920bcd 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 ee0bfbf1014aa..0000000000000 --- 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 { 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 { SpacesManager } from '../../../spaces_manager'; - -// FIXME: rename to EditSpaceServices -export interface ViewSpaceServices { - capabilities: ApplicationStart['capabilities']; - getUrlForApp: ApplicationStart['getUrlForApp']; - navigateToUrl: ApplicationStart['navigateToUrl']; - serverBasePath: string; - spacesManager: SpacesManager; - getRolesAPIClient: () => Promise; - http: HttpStart; - overlays: OverlayStart; - notifications: NotificationsStart; -} - -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 mising. 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 0000000000000..8a796fdd33f41 --- /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 ff9ddac4a28e5..74c713ee2e56a 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 0000000000000..6040b69d3ba9d --- /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.test.tsx b/x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.test.tsx new file mode 100644 index 0000000000000..872454da0afc5 --- /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 new file mode 100644 index 0000000000000..86732f63b5fdf --- /dev/null +++ b/x-pack/plugins/spaces/public/management/view_space/provider/view_space_provider.tsx @@ -0,0 +1,154 @@ +/* + * 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 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(arg: (clients: ViewSpaceClients) => Promise): Promise; +} + +interface ViewSpaceClients { + spacesManager: ViewSpaceProviderProps['spacesManager']; + rolesClient: RolesAPIClient; + privilegesClient: PrivilegesAPIClientPublicContract; +} + +export interface ViewSpaceStore { + state: IViewSpaceStoreState; + dispatch: Dispatch; +} + +const createSpaceRolesContext = once(() => createContext(null)); + +const createViewSpaceServicesContext = once(() => createContext(null)); + +// FIXME: rename to EditSpaceProvider +export const ViewSpaceProvider = ({ + children, + getRolesAPIClient, + getPrivilegesAPIClient, + ...services +}: PropsWithChildren) => { + 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: ViewSpaceServices['invokeClient'] = useCallback( + async (...args) => { + 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/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 0000000000000..bf645d1d17178 --- /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 new file mode 100644 index 0000000000000..e8b281bf3e2ab --- /dev/null +++ b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assign_role_privilege_form.tsx @@ -0,0 +1,495 @@ +/* + * 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, + EuiLoadingSpinner, + 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 RawKibanaPrivileges } from '@kbn/security-authorization-core'; +import type { Role } from '@kbn/security-plugin-types-common'; +import { KibanaPrivileges } from '@kbn/security-role-management-model'; +import { KibanaPrivilegeTable, PrivilegeFormCalculator } from '@kbn/security-ui-components'; + +import type { Space } from '../../../../../common'; +import type { ViewSpaceServices, ViewSpaceStore } from '../../provider'; + +type KibanaRolePrivilege = keyof NonNullable | 'custom'; + +interface PrivilegesRolesFormProps { + space: Space; + features: KibanaFeature[]; + closeFlyout: () => void; + onSaveCompleted: () => void; + defaultSelected?: Role[]; + storeDispatch: ViewSpaceStore['dispatch']; + spacesClientsInvocator: ViewSpaceServices['invokeClient']; +} + +const createRolesComboBoxOptions = (roles: Role[]): Array> => + roles.map((role) => ({ + label: role.name, + value: role, + })); + +export const PrivilegesRolesForm: FC = (props) => { + const { + space, + onSaveCompleted, + closeFlyout, + features, + defaultSelected = [], + spacesClientsInvocator, + storeDispatch, + } = props; + const [assigningToRole, setAssigningToRole] = useState(false); + const [fetchingDataDeps, setFetchingDataDeps] = useState(false); + const [kibanaPrivileges, setKibanaPrivileges] = useState(null); + const [spaceUnallocatedRoles, setSpaceUnallocatedRole] = useState([]); + const [selectedRoles, setSelectedRoles] = useState>( + createRolesComboBoxOptions(defaultSelected) + ); + 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(() => { + const combinedPrivilege = new Set( + selectedRoles.reduce((result, selectedRole) => { + let match: KibanaRolePrivilege[] = []; + for (let i = 0; i < selectedRole.value!.kibana.length; i++) { + const { spaces, base } = selectedRole.value!.kibana[i]; + if (spaces.includes(space.id!)) { + match = (base.length ? base : ['custom']) as [KibanaRolePrivilege]; + break; + } + } + + return result.concat(match); + }, [] as KibanaRolePrivilege[]) + ); + + return Array.from(combinedPrivilege); + }, [selectedRoles, space.id]); + + const [roleSpacePrivilege, setRoleSpacePrivilege] = useState( + !selectedRoles.length || !selectedRolesCombinedPrivileges.length + ? 'read' + : selectedRolesCombinedPrivileges[0] + ); + + useEffect(() => { + async function fetchRequiredData(spaceId: string) { + setFetchingDataDeps(true); + + 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( + systemRoles.filter( + (role) => + !role.metadata?._reserved && + (!role.kibana.length || + role.kibana.every((rolePrivileges) => { + return !( + rolePrivileges.spaces.includes(spaceId) || rolePrivileges.spaces.includes('*') + ); + })) + ) + ); + + setKibanaPrivileges(_kibanaPrivileges); + } + + fetchRequiredData(space.id!).finally(() => setFetchingDataDeps(false)); + }, [space.id, spacesClientsInvocator]); + + const computeRoleCustomizationAnchor = useCallback( + (spaceId: string, _selectedRoles: ReturnType) => { + let anchor: typeof roleCustomizationAnchor | null = null; + + 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, + }; + } + } + + return anchor; + }, + [] + ); + + 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 { + setAssigningToRole(true); + + const newPrivileges = { + base: roleSpacePrivilege === 'custom' ? [] : [roleSpacePrivilege], + feature: + roleSpacePrivilege === 'custom' + ? roleCustomizationAnchor.value?.kibana[roleCustomizationAnchor.privilegeIndex!] + .feature! + : {}, + }; + + const updatedRoles = structuredClone(selectedRoles).map((selectedRole) => { + let found = false; + + for (let i = 0; i < selectedRole.value!.kibana.length; i++) { + 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; + } + } + + if (!found) { + selectedRole.value?.kibana.push(Object.assign({ spaces: [space.id] }, newPrivileges)); + } + + return selectedRole.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 + } + }, [ + selectedRoles, + spacesClientsInvocator, + storeDispatch, + onSaveCompleted, + space.id, + roleSpacePrivilege, + roleCustomizationAnchor, + ]); + + const getForm = () => { + return ( + + + setSelectedRoles(value)} + fullWidth + /> + + <> + {selectedRolesCombinedPrivileges.length > 1 && ( + + + {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) => onRoleSpacePrivilegeChange(id as KibanaRolePrivilege)} + buttonSize="compressed" + isFullWidth + /> + + {roleSpacePrivilege === 'custom' && ( + + <> + +

+ +

+
+ + + {!kibanaPrivileges ? ( + + ) : ( + { + // 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 }) => { + let privilege; + + if ((privilege = value!.kibana?.[privilegeIndex!])) { + privilege.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), + roleCustomizationAnchor.value! + ) + } + allSpacesSelected={false} + canCustomizeSubFeaturePrivileges={false} + /> + )} + + +
+ )} +
+ ); + }; + + 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/roles/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx new file mode 100644 index 0000000000000..8e61c080af7c6 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/view_space/roles/component/space_assigned_roles_table.tsx @@ -0,0 +1,486 @@ +/* + * 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, useEffect, useMemo, 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; + currentSpace: Space; + assignedRoles: Map; + onClickAssignNewRole: () => Promise; + onClickBulkEdit: (selectedRoles: Role[]) => void; + onClickBulkRemove: (selectedRoles: Role[]) => void; + onClickRowEditAction: (role: Role) => void; + onClickRowRemoveAction: (role: Role) => void; +} + +/** + * @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, + currentSpace, + onClickRowEditAction, + onClickRowRemoveAction, +}: Pick< + ISpaceAssignedRolesTableProps, + 'isReadOnly' | 'onClickRowEditAction' | 'onClickRowRemoveAction' | 'currentSpace' +>) => { + 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) => { + const uniquePrivilege = new Set( + record.kibana.reduce((privilegeBaseTuple, kibanaPrivilege) => { + 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; + }, [] as string[]) + ); + + return Array.from(uniquePrivilege).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', + href: '#', + target: '_self', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.reservedIndictor.title', + { + defaultMessage: 'Reserved', + } + ), + description: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.reservedIndictor.description', + { + defaultMessage: 'No action to perform, this role is reserved', + } + ), + isPrimary: true, + enabled: () => false, + available: (rowRecord) => !isEditableRole(rowRecord), + }, + { + type: 'icon', + icon: 'pencil', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.title', + { + defaultMessage: 'Remove from space', + } + ), + isPrimary: true, + description: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.description', + { + defaultMessage: + 'Click this action to edit the role privileges of this user for this space.', + } + ), + showOnHover: true, + available: (rowRecord) => isEditableRole(rowRecord), + onClick: onClickRowEditAction, + }, + { + 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: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.description', + { + defaultMessage: 'Click this action to remove the user from this space.', + } + ), + showOnHover: true, + available: (rowRecord) => isEditableRole(rowRecord), + onClick: onClickRowRemoveAction, + }, + ], + }); + } + + 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, + currentSpace, + onClickAssignNewRole, + onClickBulkEdit, + onClickBulkRemove, + onClickRowEditAction, + onClickRowRemoveAction, +}: ISpaceAssignedRolesTableProps) => { + const tableColumns = useMemo( + () => + getTableColumns({ isReadOnly, onClickRowEditAction, onClickRowRemoveAction, currentSpace }), + [currentSpace, isReadOnly, onClickRowEditAction, onClickRowRemoveAction] + ); + const [rolesInView, setRolesInView] = useState([]); + const [selectedRoles, setSelectedRoles] = useState([]); + const [isBulkActionContextOpen, setBulkActionContextOpen] = useState(false); + const [pagination, setPagination] = useState['page']>({ + index: 0, + size: 10, + }); + + useEffect(() => { + setRolesInView(Array.from(assignedRoles.values())); + }, [assignedRoles]); + + const onSearchQueryChange = useCallback>>( + ({ query }) => { + const _assignedRolesTransformed = Array.from(assignedRoles.values()); + + if (query?.text) { + setRolesInView( + _assignedRolesTransformed.filter((role) => role.name.includes(query.text.toLowerCase())) + ); + } else { + setRolesInView(_assignedRolesTransformed); + } + }, + [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, onClickAssignNewRole, onSearchQueryChange]); + + const tableHeader = useMemo['childrenBetween']>(() => { + const pageSize = pagination.size; + const pageIndex = pagination.index; + + const selectableRoles = rolesInView.filter((role) => isEditableRole(role)); + + return ( + + + + + + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectedStatusInfo', + { + defaultMessage: + 'Showing: {pageItemLength} of {rolesInViewCount} | Selected: {selectedCount} roles', + values: { + pageItemLength: Math.floor( + rolesInView.length / (pageSize * (pageIndex + 1)) + ) + ? pageSize * (pageIndex + 1) + : rolesInView.length % pageSize, + rolesInViewCount: rolesInView.length, + selectedCount: selectedRoles.length, + }, + } + )} + + + + + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.contextMenuOpener', + { + defaultMessage: 'Bulk actions', + } + )} + + } + > + , + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.editPrivilege', + { + defaultMessage: 'Edit privileges', + } + ), + onClick: async () => { + await onClickBulkEdit(selectedRoles); + setBulkActionContextOpen(false); + }, + }, + { + icon: , + name: ( + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.remove', + { defaultMessage: 'Remove from space' } + )} + + ), + onClick: async () => { + await onClickBulkRemove(selectedRoles); + setBulkActionContextOpen(false); + }, + }, + ], + }, + ]} + /> + + + + {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, + }, + } + ), + }), + })} + + + + + + + + ); + }, [ + isBulkActionContextOpen, + onClickBulkEdit, + onClickBulkRemove, + pagination.index, + pagination.size, + rolesInView, + selectedRoles, + ]); + + const onTableChange = ({ page }: CriteriaWithPagination) => { + setPagination(page); + }; + + const onSelectionChange = (selection: Role[]) => { + setSelectedRoles(selection); + }; + + const selection: EuiTableSelectionType = { + selected: selectedRoles, + selectable: (role: Role) => isEditableRole(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], + }} + onChange={onTableChange} + /> + + + ); +}; 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 dee51f28c798c..037004fdd7b96 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 6e256a14330d0..61d6ff516e027 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 4d4a1a1668b0f..5f7fc4df3f3bc 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.test.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_general_tab.test.tsx index bad47aa9d2ca2..81f4de6680ac7 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 }; @@ -29,12 +32,15 @@ const history = scopedHistoryMock.create(); const getUrlForApp = (appId: string) => appId; const navigateToUrl = jest.fn(); const spacesManager = spacesManagerMock.create(); -const getRolesAPIClient = getRolesAPIClientMock(); +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} - + ); }; 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 6b92662af420e..7e4b9ee160931 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 51a8fa17ac003..a4d3e11366537 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,426 +5,157 @@ * 2.0. */ -import { - EuiBasicTable, - EuiButton, - EuiButtonEmpty, - EuiButtonGroup, - EuiComboBox, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiForm, - EuiFormRow, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui'; -import type { - EuiBasicTableColumn, - EuiComboBoxOptionOption, - EuiTableFieldDataColumnType, -} from '@elastic/eui'; +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, 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 { useViewSpaceServices, type ViewSpaceServices } 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'; -import { FeatureTable } from '../edit_space/enabled_features/feature_table'; - -type RolesAPIClient = ReturnType extends Promise - ? R - : never; - -type KibanaPrivilegeBase = keyof NonNullable; interface Props { space: 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 +// FIXME: rename to EditSpaceAssignedRoles +export const ViewSpaceAssignedRoles: FC = ({ space, features, isReadOnly }) => { + const { dispatch, state } = useViewSpaceStore(); + const { + getUrlForApp, + overlays, + theme, + i18n: i18nStart, + notifications, + invokeClient, + } = useViewSpaceServices(); + + const showRolesPrivilegeEditor = useCallback( + (defaultSelected?: Role[]) => { + const overlayRef = overlays.openFlyout( + toMountPoint( + { + 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, + storeDispatch: dispatch, + spacesClientsInvocator: invokeClient, + }} + />, + { theme, i18n: i18nStart } + ), + { + size: 's', + } ); - }, true) + }, + [dispatch, features, i18nStart, invokeClient, notifications.toasts, overlays, space, theme] ); -}; - -// FIXME: rename to EditSpaceAssignedRoles -export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isReadOnly }) => { - const [showRolesPrivilegeEditor, setShowRolesPrivilegeEditor] = useState(false); - const [roleAPIClientInitialized, setRoleAPIClientInitialized] = useState(false); - const [systemRoles, setSystemRoles] = useState([]); - - const rolesAPIClient = useRef(); - - const { getRolesAPIClient } = useViewSpaceServices(); - - const resolveRolesAPIClient = useCallback(async () => { - try { - rolesAPIClient.current = await getRolesAPIClient(); - setRoleAPIClientInitialized(true); - } catch { - // - } - }, [getRolesAPIClient]); - - useEffect(() => { - if (!isReadOnly) { - resolveRolesAPIClient(); - } - }, [isReadOnly, resolveRolesAPIClient]); - - useEffect(() => { - async function fetchAllSystemRoles() { - setSystemRoles((await rolesAPIClient.current?.getRoles()) ?? []); - } - - if (roleAPIClientInitialized) { - fetchAllSystemRoles?.(); - } - }, [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(', '); + 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; }); - }, - }, - ]; - 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.'); - }, - }, - ], - }); - } + return roleDef; + }); - const rolesInUse = filterRolesAssignedToSpace(roles, space); + 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, + }, + }) + ) + ); + }); - if (!rolesInUse) { - return null; - } + dispatch({ type: 'remove_roles', payload: updateDoc }); + }, + [dispatch, invokeClient, notifications.toasts, space.id, space.name] + ); return ( - <> - {showRolesPrivilegeEditor && ( - { - setShowRolesPrivilegeEditor(false); - }} - onSaveClick={() => { - setShowRolesPrivilegeEditor(false); - }} - systemRoles={systemRoles} - // rolesAPIClient would have been initialized before the privilege editor is displayed - roleAPIClient={rolesAPIClient.current!} - /> - )} + - - - -

- {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' } + )} + + ), + }} + /> +
- showRolesPrivilegeEditor([rowRecord])} + onClickBulkRemove={async (selectedRoles) => { + await removeRole(selectedRoles); + }} + onClickRowRemoveAction={async (rowRecord) => { + await removeRole([rowRecord]); + }} + onClickAssignNewRole={async () => { + showRolesPrivilegeEditor(); + }} />
- - ); -}; - -interface PrivilegesRolesFormProps extends Omit { - closeFlyout: () => void; - onSaveClick: () => void; - systemRoles: 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, systemRoles } = props; - - const [space, setSpaceState] = useState>(props.space); - const [spacePrivilege, setSpacePrivilege] = useState('all'); - const [selectedRoles, setSelectedRoles] = useState>( - [] - ); - - 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 - /> - - - ({ - ...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 ( - - - -

Assign role to {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/view_space_tabs.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx index 8a82cc1bcaa9e..15a8b831bd46d 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'; @@ -28,7 +27,7 @@ export interface ViewSpaceTab { export interface GetTabsProps { space: Space; - roles: Role[]; + rolesCount: number; features: KibanaFeature[]; history: ScopedHistory; capabilities: Capabilities & { @@ -67,7 +66,7 @@ export const getTabs = ({ features, history, capabilities, - roles, + rolesCount, ...props }: GetTabsProps): ViewSpaceTab[] => { const canUserViewRoles = Boolean(capabilities?.roles?.view); @@ -102,13 +101,12 @@ export const getTabs = ({ }), append: ( - {roles.length} + {rolesCount} ), content: ( diff --git a/x-pack/plugins/spaces/tsconfig.json b/x-pack/plugins/spaces/tsconfig.json index dbcb925f9cc5c..bde909653702d 100644 --- a/x-pack/plugins/spaces/tsconfig.json +++ b/x-pack/plugins/spaces/tsconfig.json @@ -42,11 +42,13 @@ "@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", + "@kbn/security-authorization-core", ], "exclude": [ "target/**/*",