diff --git a/packages/kbn-cli-dev-mode/src/dev_server.ts b/packages/kbn-cli-dev-mode/src/dev_server.ts index 631eda24ba07f..2a47042959e77 100644 --- a/packages/kbn-cli-dev-mode/src/dev_server.ts +++ b/packages/kbn-cli-dev-mode/src/dev_server.ts @@ -196,16 +196,6 @@ export class DevServer { this.phase$.next('listening'); this.ready$.next(true); } - - // TODO: remove this once Pier is done migrating log rotation to KP - if (msg === 'RELOAD_LOGGING_CONFIG_FROM_SERVER_WORKER') { - // When receive that event from server worker - // forward a reloadLoggingConfig message to parent - // and child proc. This is only used by LogRotator service - // when the cluster mode is enabled - process.emit('message' as any, { reloadLoggingConfig: true } as any); - proc.send({ reloadLoggingConfig: true }); - } }), takeUntil(exit$) ); diff --git a/src/core/server/integration_tests/saved_objects/migrations/archives/8.0.0_v1_migrations_sample_data_saved_objects.zip b/src/core/server/integration_tests/saved_objects/migrations/archives/8.0.0_v1_migrations_sample_data_saved_objects.zip index ff02fcf204845..b409f3d8a7227 100644 Binary files a/src/core/server/integration_tests/saved_objects/migrations/archives/8.0.0_v1_migrations_sample_data_saved_objects.zip and b/src/core/server/integration_tests/saved_objects/migrations/archives/8.0.0_v1_migrations_sample_data_saved_objects.zip differ diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/incompatible_cluster_routing_allocation.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/incompatible_cluster_routing_allocation.test.ts index b4d1aaa24675c..ee6c499da7ce8 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/incompatible_cluster_routing_allocation.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/incompatible_cluster_routing_allocation.test.ts @@ -97,8 +97,7 @@ async function updateRoutingAllocations( }); } -// Failing 9.0 version update: https://github.com/elastic/kibana/issues/192624 -describe.skip('incompatible_cluster_routing_allocation', () => { +describe('incompatible_cluster_routing_allocation', () => { let client: ElasticsearchClient; let root: Root; diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 841be2f775336..110ca72895e86 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -64,6 +64,7 @@ kibana_vars=( elasticsearch.logQueries elasticsearch.password elasticsearch.pingTimeout + elasticsearch.publicBaseUrl elasticsearch.requestHeadersWhitelist elasticsearch.requestTimeout elasticsearch.serviceAccountToken diff --git a/src/plugins/console/public/application/containers/main/main.tsx b/src/plugins/console/public/application/containers/main/main.tsx index c52e94f50749f..ef55f5406edaf 100644 --- a/src/plugins/console/public/application/containers/main/main.tsx +++ b/src/plugins/console/public/application/containers/main/main.tsx @@ -322,7 +322,7 @@ export function Main({ currentTabProp, isEmbeddable = false }: MainProps) { {currentTab === HISTORY_TAB_ID && } {currentTab === CONFIG_TAB_ID && } - + ; } diff --git a/x-pack/packages/security/plugin_types_public/src/roles/index.ts b/x-pack/packages/security/plugin_types_public/src/roles/index.ts index 36a3e85fa8767..9364f7cee0bc2 100644 --- a/x-pack/packages/security/plugin_types_public/src/roles/index.ts +++ b/x-pack/packages/security/plugin_types_public/src/roles/index.ts @@ -5,4 +5,9 @@ * 2.0. */ -export type { RolePutPayload, RolesAPIClient } from './roles_api_client'; +export type { + BulkUpdatePayload, + BulkUpdateRoleResponse, + RolePutPayload, + RolesAPIClient, +} from './roles_api_client'; 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..12f79d3c3449e 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 @@ -11,9 +11,20 @@ export interface RolePutPayload { createOnly?: boolean; } +export interface BulkUpdatePayload { + rolesUpdate: Role[]; +} + +export interface BulkUpdateRoleResponse { + created?: string[]; + updated?: string[]; + errors?: Record; +} + export interface RolesAPIClient { getRoles: () => Promise; getRole: (roleName: string) => Promise; deleteRole: (roleName: string) => Promise; saveRole: (payload: RolePutPayload) => Promise; + bulkUpdateRoles: (payload: BulkUpdatePayload) => 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 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.tsx b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx index daa1ddd704f74..45b263b66f2fb 100644 --- a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx +++ b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx @@ -48,6 +48,10 @@ interface Props { canCustomizeSubFeaturePrivileges: boolean; allSpacesSelected: boolean; disabled?: boolean; + /** + * default is true, to remain backwards compatible + */ + showTitle?: boolean; } interface State { @@ -58,6 +62,7 @@ export class FeatureTable extends Component { public static defaultProps = { privilegeIndex: -1, showLocks: true, + showTitle: true, }; private featureCategories: Map = new Map(); @@ -187,16 +192,18 @@ export class FeatureTable extends Component {
- - - {i18n.translate( - 'xpack.security.management.editRole.featureTable.featureVisibilityTitle', - { - defaultMessage: 'Customize feature privileges', - } - )} - - + {this.props.showTitle && ( + + + {i18n.translate( + 'xpack.security.management.editRole.featureTable.featureVisibilityTitle', + { + defaultMessage: 'Customize feature privileges', + } + )} + + + )} {!this.props.disabled && ( 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/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/cloud/public/types.ts b/x-pack/plugins/cloud/public/types.ts index 1428e887f1b9f..2a6140ba8e97e 100644 --- a/x-pack/plugins/cloud/public/types.ts +++ b/x-pack/plugins/cloud/public/types.ts @@ -228,7 +228,7 @@ export interface CloudSetup { export interface PublicElasticsearchConfigType { /** - * The URL to the Elasticsearch cluster, derived from xpack.elasticsearch.publicBaseUrl if populated + * The URL to the Elasticsearch cluster, derived from elasticsearch.publicBaseUrl if populated * Otherwise this is based on the cloudId * If neither is populated, this will be undefined */ diff --git a/x-pack/plugins/integration_assistant/server/integration_builder/agent.test.ts b/x-pack/plugins/integration_assistant/server/integration_builder/agent.test.ts index 44a26e40fe780..4c20c44b2ea78 100644 --- a/x-pack/plugins/integration_assistant/server/integration_builder/agent.test.ts +++ b/x-pack/plugins/integration_assistant/server/integration_builder/agent.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import * as Utils from '../util'; +import { ensureDirSync, createSync } from '../util'; import { createAgentInput } from './agent'; import { InputType } from '../../common'; @@ -27,13 +27,13 @@ describe('createAgentInput', () => { createAgentInput(dataStreamPath, inputTypes); - expect(Utils.ensureDirSync).toHaveBeenCalledWith(`${dataStreamPath}/agent/stream`); + expect(ensureDirSync).toHaveBeenCalledWith(`${dataStreamPath}/agent/stream`); - expect(Utils.createSync).toHaveBeenCalledWith( + expect(createSync).toHaveBeenCalledWith( `${dataStreamPath}/agent/stream/aws-s3.yml.hbs`, expect.any(String) ); - expect(Utils.createSync).toHaveBeenCalledWith( + expect(createSync).toHaveBeenCalledWith( `${dataStreamPath}/agent/stream/filestream.yml.hbs`, expect.any(String) ); @@ -42,7 +42,7 @@ describe('createAgentInput', () => { it('Should not create agent files if there are no input types', async () => { createAgentInput(dataStreamPath, []); - expect(Utils.ensureDirSync).toHaveBeenCalledWith(`${dataStreamPath}/agent/stream`); - expect(Utils.createSync).not.toHaveBeenCalled(); + expect(ensureDirSync).toHaveBeenCalledWith(`${dataStreamPath}/agent/stream`); + expect(createSync).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/integration_assistant/server/integration_builder/build_integration.test.ts b/x-pack/plugins/integration_assistant/server/integration_builder/build_integration.test.ts index d3bd013e6e267..e8800af12653f 100644 --- a/x-pack/plugins/integration_assistant/server/integration_builder/build_integration.test.ts +++ b/x-pack/plugins/integration_assistant/server/integration_builder/build_integration.test.ts @@ -5,15 +5,14 @@ * 2.0. */ -import * as buildIntegrationModule from './build_integration'; +import { buildPackage, renderPackageManifestYAML } from './build_integration'; import { testIntegration } from '../../__jest__/fixtures/build_integration'; -import * as Utils from '../util'; -import * as DataStreamModule from './data_stream'; -import * as FieldsModule from './fields'; -import * as AgentModule from './agent'; -import * as PipelineModule from './pipeline'; +import { generateUniqueId, ensureDirSync, createSync } from '../util'; +import { createDataStream } from './data_stream'; +import { createFieldMapping } from './fields'; +import { createAgentInput } from './agent'; +import { createPipeline } from './pipeline'; import { DataStream, Docs, InputType, Pipeline, Integration } from '../../common'; -import { renderPackageManifestYAML } from './build_integration'; import yaml from 'js-yaml'; const mockedDataPath = 'path'; @@ -25,7 +24,7 @@ jest.mock('./fields'); jest.mock('./agent'); jest.mock('./pipeline'); -(Utils.generateUniqueId as jest.Mock).mockReturnValue(mockedId); +(generateUniqueId as jest.Mock).mockReturnValue(mockedId); jest.mock('@kbn/utils', () => ({ getDataPath: jest.fn(() => mockedDataPath), @@ -97,77 +96,68 @@ describe('buildPackage', () => { beforeEach(async () => { jest.clearAllMocks(); - await buildIntegrationModule.buildPackage(testIntegration); + await buildPackage(testIntegration); }); it('Should create expected directories and files', async () => { // Package & integration folders - expect(Utils.ensureDirSync).toHaveBeenCalledWith(packagePath); - expect(Utils.ensureDirSync).toHaveBeenCalledWith(integrationPath); + expect(ensureDirSync).toHaveBeenCalledWith(packagePath); + expect(ensureDirSync).toHaveBeenCalledWith(integrationPath); // _dev files - expect(Utils.ensureDirSync).toHaveBeenCalledWith(`${integrationPath}/_dev/build`); - expect(Utils.createSync).toHaveBeenCalledWith( + expect(ensureDirSync).toHaveBeenCalledWith(`${integrationPath}/_dev/build`); + expect(createSync).toHaveBeenCalledWith( `${integrationPath}/_dev/build/docs/README.md`, expect.any(String) ); - expect(Utils.createSync).toHaveBeenCalledWith( + expect(createSync).toHaveBeenCalledWith( `${integrationPath}/_dev/build/build.yml`, expect.any(String) ); // Docs files - expect(Utils.ensureDirSync).toHaveBeenCalledWith(`${integrationPath}/docs/`); - expect(Utils.createSync).toHaveBeenCalledWith( + expect(ensureDirSync).toHaveBeenCalledWith(`${integrationPath}/docs/`); + expect(createSync).toHaveBeenCalledWith( `${integrationPath}/docs/README.md`, expect.any(String) ); // Changelog file - expect(Utils.createSync).toHaveBeenCalledWith( - `${integrationPath}/changelog.yml`, - expect.any(String) - ); + expect(createSync).toHaveBeenCalledWith(`${integrationPath}/changelog.yml`, expect.any(String)); // Manifest files - expect(Utils.createSync).toHaveBeenCalledWith( - `${integrationPath}/manifest.yml`, - expect.any(String) - ); + expect(createSync).toHaveBeenCalledWith(`${integrationPath}/manifest.yml`, expect.any(String)); }); it('Should create logo files if info is present in the integration', async () => { testIntegration.logo = 'logo'; - await buildIntegrationModule.buildPackage(testIntegration); + await buildPackage(testIntegration); - expect(Utils.ensureDirSync).toHaveBeenCalledWith(`${integrationPath}/img`); - expect(Utils.createSync).toHaveBeenCalledWith( - `${integrationPath}/img/logo.svg`, - expect.any(Buffer) - ); + expect(ensureDirSync).toHaveBeenCalledWith(`${integrationPath}/img`); + expect(createSync).toHaveBeenCalledWith(`${integrationPath}/img/logo.svg`, expect.any(Buffer)); }); it('Should not create logo files if info is not present in the integration', async () => { jest.clearAllMocks(); testIntegration.logo = undefined; - await buildIntegrationModule.buildPackage(testIntegration); + await buildPackage(testIntegration); - expect(Utils.ensureDirSync).not.toHaveBeenCalledWith(`${integrationPath}/img`); - expect(Utils.createSync).not.toHaveBeenCalledWith( + expect(ensureDirSync).not.toHaveBeenCalledWith(`${integrationPath}/img`); + expect(createSync).not.toHaveBeenCalledWith( `${integrationPath}/img/logo.svg`, expect.any(Buffer) ); }); it('Should call createDataStream for each datastream', async () => { - expect(DataStreamModule.createDataStream).toHaveBeenCalledWith( + expect(createDataStream).toHaveBeenCalledWith( 'integration', firstDatastreamPath, firstDataStream ); - expect(DataStreamModule.createDataStream).toHaveBeenCalledWith( + expect(createDataStream).toHaveBeenCalledWith( 'integration', secondDatastreamPath, secondDataStream @@ -175,35 +165,23 @@ describe('buildPackage', () => { }); it('Should call createAgentInput for each datastream', async () => { - expect(AgentModule.createAgentInput).toHaveBeenCalledWith( - firstDatastreamPath, - firstDataStreamInputTypes - ); - expect(AgentModule.createAgentInput).toHaveBeenCalledWith( - secondDatastreamPath, - secondDataStreamInputTypes - ); + expect(createAgentInput).toHaveBeenCalledWith(firstDatastreamPath, firstDataStreamInputTypes); + expect(createAgentInput).toHaveBeenCalledWith(secondDatastreamPath, secondDataStreamInputTypes); }); it('Should call createPipeline for each datastream', async () => { - expect(PipelineModule.createPipeline).toHaveBeenCalledWith( - firstDatastreamPath, - firstDataStreamPipeline - ); - expect(PipelineModule.createPipeline).toHaveBeenCalledWith( - secondDatastreamPath, - secondDataStreamPipeline - ); + expect(createPipeline).toHaveBeenCalledWith(firstDatastreamPath, firstDataStreamPipeline); + expect(createPipeline).toHaveBeenCalledWith(secondDatastreamPath, secondDataStreamPipeline); }); it('Should call createFieldMapping for each datastream', async () => { - expect(FieldsModule.createFieldMapping).toHaveBeenCalledWith( + expect(createFieldMapping).toHaveBeenCalledWith( 'integration', firstDatastreamName, firstDatastreamPath, firstDataStreamDocs ); - expect(FieldsModule.createFieldMapping).toHaveBeenCalledWith( + expect(createFieldMapping).toHaveBeenCalledWith( 'integration', secondDatastreamName, secondDatastreamPath, diff --git a/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.test.ts b/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.test.ts index e5b00b85bf1d5..550c6118636cc 100644 --- a/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.test.ts +++ b/x-pack/plugins/integration_assistant/server/integration_builder/data_stream.test.ts @@ -5,10 +5,10 @@ * 2.0. */ -import * as Utils from '../util'; +import { ensureDirSync, createSync, copySync } from '../util'; import { DataStream, Docs, InputType, Pipeline } from '../../common'; import { createDataStream } from './data_stream'; -import * as nunjucks from 'nunjucks'; +import { render } from 'nunjucks'; jest.mock('nunjucks'); @@ -59,31 +59,26 @@ describe('createDataStream', () => { createDataStream(packageName, dataStreamPath, firstDataStream); // pipeline - expect(Utils.ensureDirSync).toHaveBeenCalledWith(dataStreamPath); - expect(Utils.ensureDirSync).toHaveBeenCalledWith( - `${dataStreamPath}/elasticsearch/ingest_pipeline` - ); + expect(ensureDirSync).toHaveBeenCalledWith(dataStreamPath); + expect(ensureDirSync).toHaveBeenCalledWith(`${dataStreamPath}/elasticsearch/ingest_pipeline`); // dataStream files - expect(Utils.copySync).toHaveBeenCalledWith(expect.any(String), `${dataStreamPath}/fields`); + expect(copySync).toHaveBeenCalledWith(expect.any(String), `${dataStreamPath}/fields`); // test files - expect(Utils.ensureDirSync).toHaveBeenCalledWith(`${dataStreamPath}/_dev/test/pipeline`); - expect(Utils.copySync).toHaveBeenCalledWith( + expect(ensureDirSync).toHaveBeenCalledWith(`${dataStreamPath}/_dev/test/pipeline`); + expect(copySync).toHaveBeenCalledWith( expect.any(String), `${dataStreamPath}/_dev/test/pipeline/test-common-config.yml` ); - expect(Utils.createSync).toHaveBeenCalledWith( + expect(createSync).toHaveBeenCalledWith( `${dataStreamPath}/_dev/test/pipeline/test-${packageName}-datastream-1.log`, samples ); // // Manifest files - expect(Utils.createSync).toHaveBeenCalledWith(`${dataStreamPath}/manifest.yml`, undefined); - expect(nunjucks.render).toHaveBeenCalledWith(`filestream_manifest.yml.njk`, expect.anything()); - expect(nunjucks.render).toHaveBeenCalledWith( - `azure_eventhub_manifest.yml.njk`, - expect.anything() - ); + expect(createSync).toHaveBeenCalledWith(`${dataStreamPath}/manifest.yml`, undefined); + expect(render).toHaveBeenCalledWith(`filestream_manifest.yml.njk`, expect.anything()); + expect(render).toHaveBeenCalledWith(`azure_eventhub_manifest.yml.njk`, expect.anything()); }); }); diff --git a/x-pack/plugins/integration_assistant/server/integration_builder/fields.test.ts b/x-pack/plugins/integration_assistant/server/integration_builder/fields.test.ts index 9bd134b21b62e..4b85cfe7324d5 100644 --- a/x-pack/plugins/integration_assistant/server/integration_builder/fields.test.ts +++ b/x-pack/plugins/integration_assistant/server/integration_builder/fields.test.ts @@ -5,8 +5,8 @@ * 2.0. */ -import * as Utils from '../util'; -import * as nunjucks from 'nunjucks'; +import { createSync } from '../util'; +import { render } from 'nunjucks'; import { createFieldMapping } from './fields'; import { Docs } from '../../common'; @@ -19,7 +19,7 @@ jest.mock('../util', () => ({ const mockedTemplate = 'mocked template'; -(nunjucks.render as jest.Mock).mockReturnValue(mockedTemplate); +(render as jest.Mock).mockReturnValue(mockedTemplate); describe('createFieldMapping', () => { const dataStreamPath = 'path'; @@ -46,14 +46,8 @@ describe('createFieldMapping', () => { type: keyword `; - expect(Utils.createSync).toHaveBeenCalledWith( - `${dataStreamPath}/base-fields.yml`, - mockedTemplate - ); - expect(Utils.createSync).toHaveBeenCalledWith( - `${dataStreamPath}/fields/fields.yml`, - expectedFields - ); + expect(createSync).toHaveBeenCalledWith(`${dataStreamPath}/base-fields.yml`, mockedTemplate); + expect(createSync).toHaveBeenCalledWith(`${dataStreamPath}/fields/fields.yml`, expectedFields); }); it('Should create fields files even if docs value is empty', async () => { @@ -62,13 +56,7 @@ describe('createFieldMapping', () => { const expectedFields = `[] `; - expect(Utils.createSync).toHaveBeenCalledWith( - `${dataStreamPath}/base-fields.yml`, - mockedTemplate - ); - expect(Utils.createSync).toHaveBeenCalledWith( - `${dataStreamPath}/fields/fields.yml`, - expectedFields - ); + expect(createSync).toHaveBeenCalledWith(`${dataStreamPath}/base-fields.yml`, mockedTemplate); + expect(createSync).toHaveBeenCalledWith(`${dataStreamPath}/fields/fields.yml`, expectedFields); }); }); diff --git a/x-pack/plugins/integration_assistant/server/integration_builder/pipeline.test.ts b/x-pack/plugins/integration_assistant/server/integration_builder/pipeline.test.ts index 95d197ba2081b..a416add5f1048 100644 --- a/x-pack/plugins/integration_assistant/server/integration_builder/pipeline.test.ts +++ b/x-pack/plugins/integration_assistant/server/integration_builder/pipeline.test.ts @@ -6,7 +6,7 @@ */ import { Pipeline } from '../../common'; -import * as Utils from '../util'; +import { createSync } from '../util'; import { createPipeline } from './pipeline'; jest.mock('../util'); @@ -50,7 +50,7 @@ processors: ignore_missing: true if: ctx.event?.original == null `; - expect(Utils.createSync).toHaveBeenCalledWith( + expect(createSync).toHaveBeenCalledWith( `${dataStreamPath}/elasticsearch/ingest_pipeline/default.yml`, expectYamlContent ); @@ -62,7 +62,7 @@ processors: const expectYamlContent = `--- {} `; - expect(Utils.createSync).toHaveBeenCalledWith( + expect(createSync).toHaveBeenCalledWith( `${dataStreamPath}/elasticsearch/ingest_pipeline/default.yml`, expectYamlContent ); diff --git a/x-pack/plugins/integration_assistant/server/util/samples.ts b/x-pack/plugins/integration_assistant/server/util/samples.ts index 65b26ef0476d0..a29813c1643f8 100644 --- a/x-pack/plugins/integration_assistant/server/util/samples.ts +++ b/x-pack/plugins/integration_assistant/server/util/samples.ts @@ -5,7 +5,7 @@ * 2.0. */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import * as yaml from 'js-yaml'; +import { dump } from 'js-yaml'; import type { CategorizationState, EcsMappingState, RelatedState } from '../types'; interface SampleObj { @@ -160,7 +160,7 @@ export function generateFields(mergedDocs: string): string { .filter((key) => !ecsTopKeysSet.has(key)) .map((key) => recursiveParse(doc[key], [key])); - return yaml.dump(fieldsStructure, { sortKeys: false }); + return dump(fieldsStructure, { sortKeys: false }); } export function merge( diff --git a/x-pack/plugins/maps/public/api/start_api.ts b/x-pack/plugins/maps/public/api/start_api.ts index fcf9dab69e40a..4047c88268e04 100644 --- a/x-pack/plugins/maps/public/api/start_api.ts +++ b/x-pack/plugins/maps/public/api/start_api.ts @@ -9,7 +9,7 @@ import type { LayerDescriptor } from '../../common/descriptor_types'; import type { CreateLayerDescriptorParams } from '../classes/sources/es_search_source'; import type { SampleValuesConfig, EMSTermJoinConfig } from '../ems_autosuggest'; import type { Props as PassiveMapProps } from '../lens/passive_map'; -import type { Props as MapProps } from '../react_embeddable/map_renderer'; +import type { Props as MapProps } from '../react_embeddable/map_renderer/map_renderer'; export interface MapsStartApi { createLayerDescriptors: { diff --git a/x-pack/plugins/maps/public/legacy_visualizations/region_map/region_map_visualization.tsx b/x-pack/plugins/maps/public/legacy_visualizations/region_map/region_map_visualization.tsx index d8bc9a56f062a..09cf94a097767 100644 --- a/x-pack/plugins/maps/public/legacy_visualizations/region_map/region_map_visualization.tsx +++ b/x-pack/plugins/maps/public/legacy_visualizations/region_map/region_map_visualization.tsx @@ -11,7 +11,7 @@ import { first } from 'rxjs'; import type { Filter } from '@kbn/es-query'; import type { Query, TimeRange } from '@kbn/es-query'; import { RegionMapVisConfig } from './types'; -import { MapRenderer } from '../../react_embeddable/map_renderer'; +import { MapRenderer } from '../../react_embeddable/map_renderer/map_renderer'; import { createRegionMapLayerDescriptor } from '../../classes/layers/create_region_map_layer_descriptor'; interface Props { diff --git a/x-pack/plugins/maps/public/legacy_visualizations/tile_map/tile_map_visualization.tsx b/x-pack/plugins/maps/public/legacy_visualizations/tile_map/tile_map_visualization.tsx index f1cc8d437e082..35960c0c0dead 100644 --- a/x-pack/plugins/maps/public/legacy_visualizations/tile_map/tile_map_visualization.tsx +++ b/x-pack/plugins/maps/public/legacy_visualizations/tile_map/tile_map_visualization.tsx @@ -11,7 +11,7 @@ import { first } from 'rxjs'; import type { Filter } from '@kbn/es-query'; import type { Query, TimeRange } from '@kbn/es-query'; import type { TileMapVisConfig } from './types'; -import { MapRenderer } from '../../react_embeddable/map_renderer'; +import { MapRenderer } from '../../react_embeddable/map_renderer/map_renderer'; import { createTileMapLayerDescriptor } from '../../classes/layers/create_tile_map_layer_descriptor'; interface Props { diff --git a/x-pack/plugins/maps/public/lens/passive_map.tsx b/x-pack/plugins/maps/public/lens/passive_map.tsx index 30a4b8013b8ab..1be544a8cb423 100644 --- a/x-pack/plugins/maps/public/lens/passive_map.tsx +++ b/x-pack/plugins/maps/public/lens/passive_map.tsx @@ -53,6 +53,7 @@ export function PassiveMap(props: Props) { type={MAP_SAVED_OBJECT_TYPE} getParentApi={() => ({ + hideFilterActions: true, getSerializedStateForChild: () => { const basemapLayerDescriptor = createBasemapLayerDescriptor(); const intialLayers = basemapLayerDescriptor ? [basemapLayerDescriptor] : []; @@ -66,7 +67,6 @@ export function PassiveMap(props: Props) { hidePanelTitles: true, viewMode: ViewMode.VIEW, isLayerTOCOpen: false, - hideFilterActions: true, mapSettings: { disableInteractive: false, hideToolbarOverlay: false, diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index cc4cc4bcc91e8..fbdb2ef67d2ed 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -88,7 +88,7 @@ import { VectorTileInspectorView } from './inspector/vector_tile_adapter/vector_ import { PassiveMapLazy, setupLensChoroplethChart } from './lens'; import { CONTENT_ID, LATEST_VERSION } from '../common/content_management'; import { setupMapEmbeddable } from './react_embeddable/setup_map_embeddable'; -import { MapRendererLazy } from './react_embeddable/map_renderer_lazy'; +import { MapRendererLazy } from './react_embeddable/map_renderer/map_renderer_lazy'; export interface MapsPluginSetupDependencies { cloud?: CloudSetup; diff --git a/x-pack/plugins/maps/public/react_embeddable/map_react_embeddable.tsx b/x-pack/plugins/maps/public/react_embeddable/map_react_embeddable.tsx index 28a14592a8c32..b20c05dba244b 100644 --- a/x-pack/plugins/maps/public/react_embeddable/map_react_embeddable.tsx +++ b/x-pack/plugins/maps/public/react_embeddable/map_react_embeddable.tsx @@ -40,6 +40,9 @@ import { initializeDataViews } from './initialize_data_views'; import { initializeFetch } from './initialize_fetch'; import { initializeEditApi } from './initialize_edit_api'; import { extractReferences } from '../../common/migrations/references'; +import { MapAttributes } from '../../common/content_management'; +import { MapSettings } from '../../common/descriptor_types'; +import { isMapRendererApi } from './map_renderer/types'; export function getControlledBy(id: string) { return `mapEmbeddablePanel${id}`; @@ -65,6 +68,10 @@ export const mapEmbeddableFactory: ReactEmbeddableFactory< }); await savedMap.whenReady(); + const attributes$ = new BehaviorSubject(state.attributes); + const mapSettings$ = new BehaviorSubject | undefined>(state.mapSettings); + const savedObjectId$ = new BehaviorSubject(state.savedObjectId); + // eslint bug, eslint thinks api is never reassigned even though it is // eslint-disable-next-line prefer-const let api: MapApi | undefined; @@ -171,14 +178,14 @@ export const mapEmbeddableFactory: ReactEmbeddableFactory< }), ...crossPanelActions.comparators, ...reduxSync.comparators, + attributes: [attributes$, (next: MapAttributes | undefined) => attributes$.next(next)], + mapSettings: [ + mapSettings$, + (next: Partial | undefined) => mapSettings$.next(next), + ], + savedObjectId: [savedObjectId$, (next: string | undefined) => savedObjectId$.next(next)], // readonly comparators - attributes: getUnchangingComparator(), mapBuffer: getUnchangingComparator(), - savedObjectId: getUnchangingComparator(), - mapSettings: getUnchangingComparator(), - hideFilterActions: getUnchangingComparator(), - isSharable: getUnchangingComparator(), - tooltipRenderer: getUnchangingComparator(), } ); @@ -229,17 +236,28 @@ export const mapEmbeddableFactory: ReactEmbeddableFactory< ); diff --git a/x-pack/plugins/maps/public/react_embeddable/map_renderer.tsx b/x-pack/plugins/maps/public/react_embeddable/map_renderer/map_renderer.tsx similarity index 66% rename from x-pack/plugins/maps/public/react_embeddable/map_renderer.tsx rename to x-pack/plugins/maps/public/react_embeddable/map_renderer/map_renderer.tsx index 7701c7ebbe11b..459c99f9d981c 100644 --- a/x-pack/plugins/maps/public/react_embeddable/map_renderer.tsx +++ b/x-pack/plugins/maps/public/react_embeddable/map_renderer/map_renderer.tsx @@ -9,11 +9,16 @@ import React, { useEffect, useRef } from 'react'; import type { Filter, Query, TimeRange } from '@kbn/es-query'; import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; import { useSearchApi } from '@kbn/presentation-publishing'; -import type { LayerDescriptor, MapCenterAndZoom, MapSettings } from '../../common/descriptor_types'; -import { createBasemapLayerDescriptor } from '../classes/layers/create_basemap_layer_descriptor'; -import { MapApi, MapRuntimeState, MapSerializedState } from './types'; -import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; -import { RenderToolTipContent } from '../classes/tooltips/tooltip_property'; +import type { + LayerDescriptor, + MapCenterAndZoom, + MapSettings, +} from '../../../common/descriptor_types'; +import { createBasemapLayerDescriptor } from '../../classes/layers/create_basemap_layer_descriptor'; +import { MapApi, MapRuntimeState, MapSerializedState } from '../types'; +import { MAP_SAVED_OBJECT_TYPE } from '../../../common/constants'; +import { RenderToolTipContent } from '../../classes/tooltips/tooltip_property'; +import { MAP_RENDERER_TYPE } from './types'; function getLayers(layerList: LayerDescriptor[]) { const basemapLayer = createBasemapLayerDescriptor(); @@ -61,30 +66,27 @@ export function MapRenderer(props: Props) { type={MAP_SAVED_OBJECT_TYPE} getParentApi={() => ({ - ...searchApi, + type: MAP_RENDERER_TYPE, + getTooltipRenderer: props.getTooltipRenderer, + hideFilterActions: props.hideFilterActions, + isSharable: props.isSharable, getSerializedStateForChild: () => { - const rawState: MapSerializedState = { - attributes: { - title: props.title ?? '', - layerListJSON: JSON.stringify(getLayers(props.layerList)), - }, - hidePanelTitles: !Boolean(props.title), - isLayerTOCOpen: - typeof props.isLayerTOCOpen === 'boolean' ? props.isLayerTOCOpen : false, - hideFilterActions: - typeof props.hideFilterActions === 'boolean' ? props.hideFilterActions : false, - mapCenter: props.mapCenter, - mapSettings: props.mapSettings ?? {}, - isSharable: props.isSharable, - }; - if (props.getTooltipRenderer) { - rawState.tooltipRenderer = props.getTooltipRenderer(); - } return { - rawState, + rawState: { + attributes: { + title: props.title ?? '', + layerListJSON: JSON.stringify(getLayers(props.layerList)), + }, + hidePanelTitles: !Boolean(props.title), + isLayerTOCOpen: + typeof props.isLayerTOCOpen === 'boolean' ? props.isLayerTOCOpen : false, + mapCenter: props.mapCenter, + mapSettings: props.mapSettings ?? {}, + }, references: [], }; }, + ...searchApi, })} onApiAvailable={(api) => { mapApiRef.current = api; diff --git a/x-pack/plugins/maps/public/react_embeddable/map_renderer_lazy.tsx b/x-pack/plugins/maps/public/react_embeddable/map_renderer/map_renderer_lazy.tsx similarity index 100% rename from x-pack/plugins/maps/public/react_embeddable/map_renderer_lazy.tsx rename to x-pack/plugins/maps/public/react_embeddable/map_renderer/map_renderer_lazy.tsx diff --git a/x-pack/plugins/maps/public/react_embeddable/map_renderer/types.ts b/x-pack/plugins/maps/public/react_embeddable/map_renderer/types.ts new file mode 100644 index 0000000000000..0e92e551c821a --- /dev/null +++ b/x-pack/plugins/maps/public/react_embeddable/map_renderer/types.ts @@ -0,0 +1,21 @@ +/* + * 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 { HasType, apiIsOfType } from '@kbn/presentation-publishing'; +import { RenderToolTipContent } from '../../classes/tooltips/tooltip_property'; + +export const MAP_RENDERER_TYPE = 'mapRenderer'; + +export type MapRendererApi = HasType & { + getTooltipRenderer?: () => RenderToolTipContent; + hideFilterActions?: boolean; + isSharable?: boolean; +}; + +export function isMapRendererApi(api: unknown): api is MapRendererApi { + return Boolean(api && apiIsOfType(api, MAP_RENDERER_TYPE)); +} diff --git a/x-pack/plugins/maps/public/react_embeddable/types.ts b/x-pack/plugins/maps/public/react_embeddable/types.ts index f020e75723c6c..a61a7e1ce6a03 100644 --- a/x-pack/plugins/maps/public/react_embeddable/types.ts +++ b/x-pack/plugins/maps/public/react_embeddable/types.ts @@ -31,7 +31,6 @@ import { MapSettings, } from '../../common/descriptor_types'; import { ILayer } from '../classes/layers/layer'; -import { RenderToolTipContent } from '../classes/tooltips/tooltip_property'; import { EventHandlers } from '../reducers/non_serializable_instances'; export type MapSerializedState = SerializedTitles & @@ -47,15 +46,9 @@ export type MapSerializedState = SerializedTitles & mapBuffer?: MapExtent; mapSettings?: Partial; hiddenLayers?: string[]; - hideFilterActions?: boolean; timeRange?: TimeRange; filterByMapExtent?: boolean; isMovementSynchronized?: boolean; - - // Configuration item that are never persisted - // Putting in state as a temporary work around - isSharable?: boolean; - tooltipRenderer?: RenderToolTipContent; }; export type MapRuntimeState = MapSerializedState; diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.test.ts new file mode 100644 index 0000000000000..887d7c71564c2 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.test.ts @@ -0,0 +1,465 @@ +/* + * 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 { SanitizedRule } from '@kbn/alerting-types'; +import { omit } from 'lodash'; +import { + SYNTHETICS_STATUS_RULE, + SYNTHETICS_TLS_RULE, +} from '../../../common/constants/synthetics_alerts'; +import { DefaultAlertService } from './default_alert_service'; +import { DYNAMIC_SETTINGS_DEFAULTS } from '../../constants/settings'; + +describe('DefaultAlertService', () => { + describe('getSettings', () => { + const expectedSettings = { + certAgeThreshold: 50, + certExpirationThreshold: 10, + defaultConnectors: ['slack', 'email'], + }; + const soResponse = { attributes: { ...expectedSettings } }; + it('returns settings if already set', async () => { + const soClient = { get: jest.fn() } as any; + const service = new DefaultAlertService({} as any, {} as any, soClient); + service.settings = expectedSettings; + const settings = await service.getSettings(); + expect(settings).toEqual(expectedSettings); + expect(soClient.get).not.toHaveBeenCalled(); + }); + + it('fetches settings if not set', async () => { + const soClient = { get: jest.fn() } as any; + const service = new DefaultAlertService({} as any, {} as any, soClient); + soClient.get.mockResolvedValueOnce(soResponse); + const settings = await service.getSettings(); + expect(settings).toEqual({ + ...expectedSettings, + defaultEmail: undefined, + defaultStatusRuleEnabled: true, + defaultTLSRuleEnabled: true, + }); + expect(soClient.get).toHaveBeenCalledTimes(1); + }); + }); + + describe('setupDefaultAlerts', () => { + afterEach(() => jest.resetAllMocks()); + + it('sets up status and tls rules', async () => { + const soClient = { get: jest.fn() } as any; + const service = new DefaultAlertService({} as any, {} as any, soClient); + service.getSettings = jest.fn().mockResolvedValue({ + certAgeThreshold: 50, + certExpirationThreshold: 10, + defaultConnectors: ['slack', 'email'], + defaultEmail: undefined, + defaultStatusRuleEnabled: true, + defaultTLSRuleEnabled: true, + }); + const setupStatusRule = jest.fn(); + const setupTlsRule = jest.fn(); + service.setupStatusRule = setupStatusRule; + service.setupTlsRule = setupTlsRule; + setupStatusRule.mockResolvedValueOnce({ status: 'fulfilled', value: {} }); + setupTlsRule.mockResolvedValueOnce({ status: 'fulfilled', value: {} }); + const result = await service.setupDefaultAlerts(); + expect(setupStatusRule).toHaveBeenCalledTimes(1); + expect(setupTlsRule).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + statusRule: { status: 'fulfilled', value: {} }, + tlsRule: { status: 'fulfilled', value: {} }, + }); + }); + it('returns null rules if value is falsy', async () => { + const soClient = { get: jest.fn() } as any; + const service = new DefaultAlertService({} as any, {} as any, soClient); + service.getSettings = jest.fn().mockResolvedValue({ + certAgeThreshold: 50, + certExpirationThreshold: 10, + defaultConnectors: ['slack', 'email'], + defaultEmail: undefined, + defaultStatusRuleEnabled: true, + defaultTLSRuleEnabled: true, + }); + const setupStatusRule = jest.fn(); + const setupTlsRule = jest.fn(); + service.setupStatusRule = setupStatusRule; + service.setupTlsRule = setupTlsRule; + setupStatusRule.mockResolvedValueOnce(undefined); + setupTlsRule.mockResolvedValueOnce(undefined); + const result = await service.setupDefaultAlerts(); + expect(setupStatusRule).toHaveBeenCalledTimes(1); + expect(setupTlsRule).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + statusRule: null, + tlsRule: null, + }); + }); + }); + + describe('getMinimumRuleInterval', () => { + it('returns 1m if minimum interval is less than 1m', () => { + const server = { + alerting: { getConfig: () => ({ minimumScheduleInterval: { value: '30s' } }) }, + } as any; + const service = new DefaultAlertService({} as any, server, {} as any); + expect(service.getMinimumRuleInterval()).toBe('1m'); + }); + + it('returns minimum interval if greater than 1m', () => { + const server = { + alerting: { getConfig: () => ({ minimumScheduleInterval: { value: '5m' } }) }, + } as any; + const service = new DefaultAlertService({} as any, server, {} as any); + expect(service.getMinimumRuleInterval()).toBe('5m'); + }); + }); + + describe('setupStatusRule', () => { + it('creates status rule if enabled', async () => { + const service = new DefaultAlertService({} as any, {} as any, {} as any); + service.getMinimumRuleInterval = jest.fn().mockReturnValue('1m'); + service.createDefaultRuleIfNotExist = jest.fn(); + service.settings = { defaultStatusRuleEnabled: true } as any; + service.getSettings = jest.fn().mockResolvedValue({ + defaultStatusRuleEnabled: true, + }); + await service.setupStatusRule(); + expect(service.createDefaultRuleIfNotExist).toHaveBeenCalledWith( + SYNTHETICS_STATUS_RULE, + 'Synthetics status internal rule', + '1m' + ); + }); + + it('does not create status rule if disabled', async () => { + const service = new DefaultAlertService({} as any, {} as any, {} as any); + service.getMinimumRuleInterval = jest.fn().mockReturnValue('1m'); + service.createDefaultRuleIfNotExist = jest.fn(); + service.settings = { defaultStatusRuleEnabled: false } as any; + const result = await service.setupStatusRule(); + expect(service.createDefaultRuleIfNotExist).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + }); + + describe('setupTlsRule', () => { + it('creates tls rule if enabled', async () => { + const service = new DefaultAlertService({} as any, {} as any, {} as any); + service.getMinimumRuleInterval = jest.fn().mockReturnValue('1m'); + service.createDefaultRuleIfNotExist = jest.fn(); + service.settings = { defaultTlsRuleEnabled: true } as any; + service.getSettings = jest.fn().mockResolvedValue({ + defaultTlsRuleEnabled: true, + }); + await service.setupTlsRule(); + expect(service.createDefaultRuleIfNotExist).toHaveBeenCalledWith( + SYNTHETICS_TLS_RULE, + 'Synthetics internal TLS rule', + '1m' + ); + }); + + it('does not create tls rule if disabled', async () => { + const service = new DefaultAlertService({} as any, {} as any, {} as any); + service.getMinimumRuleInterval = jest.fn().mockReturnValue('1m'); + service.createDefaultRuleIfNotExist = jest.fn(); + service.settings = { defaultTLSRuleEnabled: false } as any; + const result = await service.setupTlsRule(); + expect(service.createDefaultRuleIfNotExist).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + }); + + describe('existing alerts', () => { + function setUpExistingRules>( + ruleOverride?: Partial>, + getRulesClientMocks = {} + ) { + const getRulesClient = jest.fn(); + const mockRule: any = ruleOverride ?? { + actions: [{ alertsFilter: { query: { kql: 'some kql', filters: [] } } }], + systemActions: [{ id: 'some system action', actionTypeId: 'actionTypeId', params: {} }], + ruleTypeId: 'xpack.synthetics.alerts.monitorStatus', + alertTypeId: 'xpack.synthetics.alerts.monitorStatus', + id: '123', + }; + const find = jest.fn(); + find.mockResolvedValue({ + data: [mockRule], + }); + getRulesClient.mockReturnValue({ find, ...getRulesClientMocks }); + + return { getRulesClient, mockRule }; + } + + function formatMockRuleResult(mockRule: any) { + return { + ...omit(mockRule, 'systemActions'), + actions: [...mockRule.actions, ...mockRule.systemActions], + }; + } + + afterEach(() => jest.resetAllMocks()); + + describe('getExistingAlert', () => { + it('returns rule if exists', async () => { + const { getRulesClient, mockRule } = setUpExistingRules(); + const service = new DefaultAlertService( + { alerting: { getRulesClient } } as any, + {} as any, + {} as any + ); + const result = await service.getExistingAlert('xpack.synthetics.alerts.monitorStatus'); + expect(result).toEqual(formatMockRuleResult(mockRule)); + }); + + it('returns undefined if rule does not exist', async () => { + const find = jest.fn().mockResolvedValue({ data: [] }); + const getRulesClient = jest.fn(); + getRulesClient.mockReturnValue({ find }); + const service = new DefaultAlertService( + { alerting: { getRulesClient } } as any, + {} as any, + {} as any + ); + const result = await service.getExistingAlert('xpack.synthetics.alerts.monitorStatus'); + expect(result).toBeUndefined(); + }); + }); + describe('createDefaultAlertIfNotExist', () => { + it('returns rule if exists', async () => { + const { getRulesClient, mockRule } = setUpExistingRules(); + const service = new DefaultAlertService( + { alerting: { getRulesClient } } as any, + {} as any, + {} as any + ); + const alert = await service.createDefaultRuleIfNotExist( + 'xpack.synthetics.alerts.monitorStatus', + 'name', + '1m' + ); + expect(alert).toEqual(formatMockRuleResult(mockRule)); + expect(getRulesClient).toHaveBeenCalled(); + }); + + it('creates rule if does not exist', async () => { + const sampleAction = { alertsFilter: { query: { kql: 'some kql', filters: [] } } }; + const find = jest.fn().mockResolvedValue({ data: [] }); + const create = jest.fn().mockResolvedValue({ + actions: [sampleAction], + systemActions: [], + id: '123', + alertTypeId: 'testalertid', + }); + const getActionsClient = jest.fn(); + getActionsClient.mockReturnValue({ + getAll: jest + .fn() + .mockResolvedValue([{ id: 'id', actionTypeId: 'actionTypeId', name: 'action name' }]), + }); + const getRulesClient = jest.fn(); + getRulesClient.mockReturnValue({ find, create }); + const service = new DefaultAlertService( + { actions: { getActionsClient }, alerting: { getRulesClient } } as any, + {} as any, + {} as any + ); + service.settings = { defaultConnectors: ['slack', 'email'] } as any; + const result = await service.createDefaultRuleIfNotExist( + 'xpack.synthetics.alerts.monitorStatus', + 'name', + '1m' + ); + expect(result).toEqual({ + actions: [sampleAction], + id: '123', + alertTypeId: 'testalertid', + ruleTypeId: 'testalertid', + }); + }); + }); + + function setUpUpdateTest>(mockRule?: Partial>) { + const update = jest.fn().mockResolvedValue({ + alertTypeId: 'test-alert-type-id', + actions: [{ id: 'id', actionTypeId: 'actionTypeId', name: 'action name' }], + systemActions: [{ id: 'sys-id', actionTypeId: 'actionTypeId', name: 'action name' }], + updatedAlertField: 'value', + }); + const { getRulesClient } = setUpExistingRules(mockRule ?? { schedule: { interval: '1m' } }, { + update, + }); + const getConfig = jest.fn().mockReturnValue({ minimumScheduleInterval: { value: '3m' } }); + const server = { + alerting: { + getConfig, + }, + } as any; + const getActionsClient = jest.fn(); + const getAll = jest + .fn() + .mockResolvedValue([{ id: 'id', actionTypeId: 'actionTypeId', name: 'action name' }]); + getActionsClient.mockReturnValue({ + getAll, + }); + const context = { actions: { getActionsClient }, alerting: { getRulesClient } }; + + return { + context, + server, + mocks: { update, getRulesClient, getConfig, getActionsClient, getAll }, + }; + } + + describe('updateStatusRule', () => { + it('updates the rule if it is enabled', async () => { + const { + context, + server, + mocks: { update, getAll }, + } = setUpUpdateTest({ + id: 'test-alert-id', + name: 'test-alert-name', + tags: ['test-alert-tags'], + schedule: { interval: '1m' }, + params: { param: 'value' }, + }); + const service = new DefaultAlertService(context as any, server as any, {} as any); + service.settings = { defaultConnectors: ['slack', 'email'] } as any; + const result = await service.updateStatusRule(true); + expect(result).toEqual({ + actions: [ + { actionTypeId: 'actionTypeId', id: 'id', name: 'action name' }, + { actionTypeId: 'actionTypeId', id: 'sys-id', name: 'action name' }, + ], + alertTypeId: 'test-alert-type-id', + ruleTypeId: 'test-alert-type-id', + updatedAlertField: 'value', + }); + expect(update).toHaveBeenCalledTimes(1); + expect(update.mock.calls[0][0]).toEqual({ + data: { + actions: [], + name: 'test-alert-name', + params: { param: 'value' }, + schedule: { interval: '3m' }, + tags: ['test-alert-tags'], + }, + id: 'test-alert-id', + }); + expect(getAll).toHaveBeenCalled(); + }); + + it('deletes the rule if it is disabled', async () => { + const server = { + alerting: { + getConfig: jest.fn().mockReturnValue({ minimumScheduleInterval: { value: '3m' } }), + }, + } as any; + const bulkDeleteRules = jest.fn(); + const { getRulesClient } = setUpExistingRules(undefined, { bulkDeleteRules }); + const service = new DefaultAlertService( + { alerting: { getRulesClient } } as any, + server as any, + {} as any + ); + await service.updateStatusRule(false); + expect(bulkDeleteRules).toHaveBeenCalled(); + expect(bulkDeleteRules.mock.calls[0][0]).toEqual({ + filter: + 'alert.attributes.alertTypeId:"xpack.synthetics.alerts.monitorStatus" AND alert.attributes.tags:"SYNTHETICS_DEFAULT_ALERT"', + }); + }); + }); + + describe('updateTlsRule', () => { + it('updates the rule if it is enabled', async () => { + const { context, server } = setUpUpdateTest(); + const service = new DefaultAlertService(context as any, server as any, {} as any); + service.settings = { defaultConnectors: ['slack', 'email'] } as any; + const result = await service.updateTlsRule(true); + expect(result).toEqual({ + actions: [ + { actionTypeId: 'actionTypeId', id: 'id', name: 'action name' }, + { actionTypeId: 'actionTypeId', id: 'sys-id', name: 'action name' }, + ], + alertTypeId: 'test-alert-type-id', + ruleTypeId: 'test-alert-type-id', + updatedAlertField: 'value', + }); + }); + + it('creates the rule if it does not exist', async () => { + const { context, server } = setUpUpdateTest(); + const service = new DefaultAlertService(context as any, server as any, {} as any); + service.settings = { defaultConnectors: ['slack', 'email'] } as any; + const getExistingAlertMock = jest.fn().mockResolvedValue(undefined); + service.getExistingAlert = getExistingAlertMock; + const createDefaultAlertIfNotExistMock = jest.fn(); + service.createDefaultRuleIfNotExist = createDefaultAlertIfNotExistMock; + const result = await service.updateTlsRule(true); + expect(result).toBeUndefined(); + expect(service.getExistingAlert).toHaveBeenCalled(); + expect(service.createDefaultRuleIfNotExist).toHaveBeenCalled(); + expect(getExistingAlertMock.mock.calls[0][0]).toBe('xpack.synthetics.alerts.tls'); + expect(createDefaultAlertIfNotExistMock.mock.calls[0]).toStrictEqual([ + 'xpack.synthetics.alerts.tls', + 'Synthetics internal TLS rule', + '3m', + ]); + }); + + it('deletes the rule if it is disabled', async () => { + const server = { + alerting: { + getConfig: jest.fn().mockReturnValue({ minimumScheduleInterval: { value: '3m' } }), + }, + } as any; + const bulkDeleteRules = jest.fn(); + const { getRulesClient } = setUpExistingRules(undefined, { bulkDeleteRules }); + const service = new DefaultAlertService( + { alerting: { getRulesClient } } as any, + server as any, + {} as any + ); + await service.updateTlsRule(false); + expect(bulkDeleteRules).toHaveBeenCalled(); + expect(bulkDeleteRules.mock.calls[0][0]).toEqual({ + filter: + 'alert.attributes.alertTypeId:"xpack.synthetics.alerts.tls" AND alert.attributes.tags:"SYNTHETICS_DEFAULT_ALERT"', + }); + }); + }); + }); + + describe('getActionConnectors', () => { + it('fetches settings if not set', async () => { + const getActionsClient = jest.fn(); + const getAll = jest.fn().mockResolvedValue([{ id: 'id', actionTypeId: 'actionTypeId' }]); + getActionsClient.mockReturnValue({ + getAll, + }); + const service = new DefaultAlertService( + { actions: { getActionsClient } } as any, + {} as any, + { get: jest.fn() } as any + ); + const connectors = await service.getActionConnectors(); + expect(connectors).toEqual({ + actionConnectors: [{ id: 'id', actionTypeId: 'actionTypeId' }], + settings: { + ...DYNAMIC_SETTINGS_DEFAULTS, + defaultStatusRuleEnabled: true, + defaultTLSRuleEnabled: true, + }, + }); + expect(getAll).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts index 4b7aeb5eed7e8..2e2263f6e3965 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts @@ -79,7 +79,7 @@ export class DefaultAlertService { if (this.settings?.defaultStatusRuleEnabled === false) { return; } - return this.createDefaultAlertIfNotExist( + return this.createDefaultRuleIfNotExist( SYNTHETICS_STATUS_RULE, `Synthetics status internal rule`, minimumRuleInterval @@ -91,7 +91,7 @@ export class DefaultAlertService { if (this.settings?.defaultTLSRuleEnabled === false) { return; } - return this.createDefaultAlertIfNotExist( + return this.createDefaultRuleIfNotExist( SYNTHETICS_TLS_RULE, `Synthetics internal TLS rule`, minimumRuleInterval @@ -116,7 +116,7 @@ export class DefaultAlertService { return { ...alert, actions: [...actions, ...systemActions], ruleTypeId: alert.alertTypeId }; } - async createDefaultAlertIfNotExist(ruleType: DefaultRuleType, name: string, interval: string) { + async createDefaultRuleIfNotExist(ruleType: DefaultRuleType, name: string, interval: string) { const alert = await this.getExistingAlert(ruleType); if (alert) { return alert; @@ -152,7 +152,7 @@ export class DefaultAlertService { async updateStatusRule(enabled?: boolean) { const minimumRuleInterval = this.getMinimumRuleInterval(); if (enabled) { - return this.updateDefaultAlert( + return this.upsertDefaultAlert( SYNTHETICS_STATUS_RULE, `Synthetics status internal rule`, minimumRuleInterval @@ -168,7 +168,7 @@ export class DefaultAlertService { async updateTlsRule(enabled?: boolean) { const minimumRuleInterval = this.getMinimumRuleInterval(); if (enabled) { - return this.updateDefaultAlert( + return this.upsertDefaultAlert( SYNTHETICS_TLS_RULE, `Synthetics internal TLS rule`, minimumRuleInterval @@ -181,7 +181,7 @@ export class DefaultAlertService { } } - async updateDefaultAlert(ruleType: DefaultRuleType, name: string, interval: string) { + async upsertDefaultAlert(ruleType: DefaultRuleType, name: string, interval: string) { const rulesClient = (await this.context.alerting)?.getRulesClient(); const alert = await this.getExistingAlert(ruleType); @@ -213,7 +213,7 @@ export class DefaultAlertService { }; } - return await this.createDefaultAlertIfNotExist(ruleType, name, interval); + return await this.createDefaultRuleIfNotExist(ruleType, name, interval); } async getAlertActions(ruleType: DefaultRuleType) { diff --git a/x-pack/plugins/observability_solution/synthetics/tsconfig.json b/x-pack/plugins/observability_solution/synthetics/tsconfig.json index 8446b7850d3a9..d0822a733baff 100644 --- a/x-pack/plugins/observability_solution/synthetics/tsconfig.json +++ b/x-pack/plugins/observability_solution/synthetics/tsconfig.json @@ -99,7 +99,8 @@ "@kbn/core-application-browser", "@kbn/dashboard-plugin", "@kbn/slo-plugin", - "@kbn/ebt-tools" + "@kbn/ebt-tools", + "@kbn/alerting-types" ], "exclude": ["target/**/*"] } 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/edit_role/edit_role_page.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx index 63229be3c8683..02812eda34c7b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx @@ -14,6 +14,7 @@ import { EuiFlexItem, EuiForm, EuiFormRow, + EuiIconTip, EuiPanel, EuiSpacer, EuiText, @@ -556,30 +557,27 @@ export const EditRolePage: FunctionComponent = ({ const getElasticsearchPrivileges = () => { return ( -
- - -
+ ); }; @@ -587,21 +585,18 @@ export const EditRolePage: FunctionComponent = ({ const getKibanaPrivileges = () => { return ( -
- - -
+ ); }; @@ -800,44 +795,89 @@ export const EditRolePage: FunctionComponent = ({ return (
- - {getFormTitle()} - - - - - {isRoleReserved && ( - - - -

+ + + + {getFormTitle()} + + + + + + + {isRoleReserved && ( + + +

+ +

+
+
+ )} + + + {isDeprecatedRole && ( + + + + + )} + + {getRoleNameAndDescription()} + + -

- - - )} - {isDeprecatedRole && ( - - - - - )} - - {getRoleNameAndDescription()} - {getElasticsearchPrivileges()} - {getKibanaPrivileges()} - - {getFormButtons()} + } + > + {getElasticsearchPrivileges()} +
+
+ + + + + + + + } + /> + + + } + > + {getKibanaPrivileges()} + + + + {getFormButtons()} + +
); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx index a37fd799a035e..8275a7b1203ab 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx @@ -107,10 +107,18 @@ export class PrivilegeSpaceForm extends Component {

+ +

+ +

+
{this.getForm()} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx index e5a4cb1494d77..2bb3292932870 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx @@ -206,7 +206,7 @@ export class SpaceAwarePrivilegeSection extends Component { > ); 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.test.ts b/x-pack/plugins/security/public/management/roles/roles_api_client.test.ts index e7f4839e56c5d..688aa78699769 100644 --- a/x-pack/plugins/security/public/management/roles/roles_api_client.test.ts +++ b/x-pack/plugins/security/public/management/roles/roles_api_client.test.ts @@ -11,260 +11,330 @@ import { RolesAPIClient } from './roles_api_client'; import type { Role } from '../../../common'; describe('RolesAPIClient', () => { - async function saveRole(role: Role) { - const httpMock = httpServiceMock.createStartContract(); - const rolesAPIClient = new RolesAPIClient(httpMock); - - await rolesAPIClient.saveRole({ role }); - expect(httpMock.put).toHaveBeenCalledTimes(1); - - return JSON.parse((httpMock.put.mock.calls[0] as any)[1]?.body as any); - } - - it('removes placeholder index privileges', async () => { - const role: Role = { - name: 'my role', - elasticsearch: { - cluster: [], - indices: [{ names: [], privileges: [] }], - remote_indices: [{ clusters: [], names: [], privileges: [] }], - run_as: [], - }, - kibana: [], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - elasticsearch: { - cluster: [], - indices: [], - remote_indices: [], - run_as: [], - }, - kibana: [], - }); - }); + describe('#saveRole', () => { + async function saveRole(role: Role) { + const httpMock = httpServiceMock.createStartContract(); + const rolesAPIClient = new RolesAPIClient(httpMock); + + await rolesAPIClient.saveRole({ role }); + expect(httpMock.put).toHaveBeenCalledTimes(1); + + return JSON.parse((httpMock.put.mock.calls[0] as any)[1]?.body as any); + } - it('removes placeholder query entries', async () => { - const role: Role = { - name: 'my role', - elasticsearch: { - cluster: [], - indices: [{ names: ['.kibana*'], privileges: ['all'], query: '' }], - run_as: [], - }, - kibana: [], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - elasticsearch: { - cluster: [], - indices: [{ names: ['.kibana*'], privileges: ['all'] }], - run_as: [], - }, - kibana: [], + it('removes placeholder index privileges', async () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [{ names: [], privileges: [] }], + remote_indices: [{ clusters: [], names: [], privileges: [] }], + run_as: [], + }, + kibana: [], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + remote_indices: [], + run_as: [], + }, + kibana: [], + }); }); - }); - it('removes transient fields not required for save', async () => { - const role: Role = { - name: 'my role', - transient_metadata: { - foo: 'bar', - }, - _transform_error: ['kibana'], - metadata: { - someOtherMetadata: true, - }, - _unrecognized_applications: ['foo'], - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - metadata: { - someOtherMetadata: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], + it('removes placeholder query entries', async () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'], query: '' }], + run_as: [], + }, + kibana: [], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'] }], + run_as: [], + }, + kibana: [], + }); }); - }); - it('does not remove actual query entries', async () => { - const role: Role = { - name: 'my role', - elasticsearch: { - cluster: [], - indices: [{ names: ['.kibana*'], privileges: ['all'], query: 'something' }], - remote_indices: [ - { clusters: ['cluster'], names: ['.kibana*'], privileges: ['all'], query: 'something' }, - ], - run_as: [], - }, - kibana: [], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - elasticsearch: { - cluster: [], - indices: [{ names: ['.kibana*'], privileges: ['all'], query: 'something' }], - remote_indices: [ - { clusters: ['cluster'], names: ['.kibana*'], privileges: ['all'], query: 'something' }, - ], - run_as: [], - }, - kibana: [], + it('removes transient fields not required for save', async () => { + const role: Role = { + name: 'my role', + transient_metadata: { + foo: 'bar', + }, + _transform_error: ['kibana'], + metadata: { + someOtherMetadata: true, + }, + _unrecognized_applications: ['foo'], + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + metadata: { + someOtherMetadata: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }); }); - }); - it('should remove feature privileges if a corresponding base privilege is defined', async () => { - const role: Role = { - name: 'my role', - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['foo'], - base: ['all'], - feature: { - feature1: ['read'], - feature2: ['write'], - }, + it('does not remove actual query entries', async () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'], query: 'something' }], + remote_indices: [ + { + clusters: ['cluster'], + names: ['.kibana*'], + privileges: ['all'], + query: 'something', + }, + ], + run_as: [], }, - ], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['foo'], - base: ['all'], - feature: {}, + kibana: [], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'], query: 'something' }], + remote_indices: [ + { + clusters: ['cluster'], + names: ['.kibana*'], + privileges: ['all'], + query: 'something', + }, + ], + run_as: [], }, - ], + kibana: [], + }); }); - }); - it('should not remove feature privileges if a corresponding base privilege is not defined', async () => { - const role: Role = { - name: 'my role', - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['foo'], - base: [], - feature: { - feature1: ['read'], - feature2: ['write'], - }, + it('should remove feature privileges if a corresponding base privilege is defined', async () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, - ], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['foo'], - base: [], - feature: { - feature1: ['read'], - feature2: ['write'], + kibana: [ + { + spaces: ['foo'], + base: ['all'], + feature: { + feature1: ['read'], + feature2: ['write'], + }, }, + ], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, - ], + kibana: [ + { + spaces: ['foo'], + base: ['all'], + feature: {}, + }, + ], + }); }); - }); - it('should not remove space privileges', async () => { - const role: Role = { - name: 'my role', - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['read'], - feature2: ['write'], - }, + it('should not remove feature privileges if a corresponding base privilege is not defined', async () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - feature2: ['write'], + kibana: [ + { + spaces: ['foo'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, }, + ], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, - ], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['read'], - feature2: ['write'], + kibana: [ + { + spaces: ['foo'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, }, + ], + }); + }); + + it('should not remove space privileges', async () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - feature2: ['write'], + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, - ], + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }); + }); + + describe('#bulkUpdateRoles', () => { + async function bulkUpdateRoles(roles: Role[]) { + const httpMock = httpServiceMock.createStartContract(); + const rolesAPIClient = new RolesAPIClient(httpMock); + + await rolesAPIClient.bulkUpdateRoles({ rolesUpdate: roles }); + expect(httpMock.post).toHaveBeenCalledTimes(1); + + return JSON.parse((httpMock.post.mock.calls[0] as any)[1]?.body as any); + } + + it('send payload in the accepted format', async () => { + const roles: Role[] = [ + { + name: 'role1', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }, + { + name: 'role2', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }, + ]; + + const result = await bulkUpdateRoles(roles); + + expect(result).toEqual({ + roles: { + role1: { + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }, + role2: { + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }, + }, + }); + }); }); }); }); 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..d6dcab658d21c 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 @@ -6,6 +6,7 @@ */ import type { HttpStart } from '@kbn/core/public'; +import type { BulkUpdatePayload, BulkUpdateRoleResponse } from '@kbn/security-plugin-types-public'; import type { Role, RoleIndexPrivilege, RoleRemoteIndexPrivilege } from '../../../common'; import { copyRole } from '../../../common/model'; @@ -32,6 +33,18 @@ export class RolesAPIClient { }); }; + public bulkUpdateRoles = async ({ + rolesUpdate, + }: BulkUpdatePayload): Promise => { + return await this.http.post('/api/security/roles', { + body: JSON.stringify({ + roles: Object.fromEntries( + rolesUpdate.map((role) => [role.name, this.transformRoleForSave(copyRole(role))]) + ), + }), + }); + }; + 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/common/constants.ts b/x-pack/plugins/spaces/common/constants.ts index d70c332fb62ab..bbbe38451fedf 100644 --- a/x-pack/plugins/spaces/common/constants.ts +++ b/x-pack/plugins/spaces/common/constants.ts @@ -31,3 +31,15 @@ export const MAX_SPACE_INITIALS = 2; * The path to enter a space. */ export const ENTER_SPACE_PATH = '/spaces/enter'; + +/** + * The 'classic' solution view is the default, non-project type of solution view + */ +export const SOLUTION_VIEW_CLASSIC = 'classic' as const; + +/** + * The feature privileges constants are used to identify the granularity of the configured feature visibility + */ +export const FEATURE_PRIVILEGES_ALL = 'all' as const; +export const FEATURE_PRIVILEGES_READ = 'read' as const; +export const FEATURE_PRIVILEGES_CUSTOM = 'custom' as const; diff --git a/x-pack/plugins/spaces/common/types/space/v1.ts b/x-pack/plugins/spaces/common/types/space/v1.ts index 9ba2deb09aaa2..ebd841e914e69 100644 --- a/x-pack/plugins/spaces/common/types/space/v1.ts +++ b/x-pack/plugins/spaces/common/types/space/v1.ts @@ -7,7 +7,9 @@ import type { OnBoardingDefaultSolution } from '@kbn/cloud-plugin/common'; -export type SolutionView = OnBoardingDefaultSolution | 'classic'; +import type { SOLUTION_VIEW_CLASSIC } from '../../constants'; + +export type SolutionView = OnBoardingDefaultSolution | typeof SOLUTION_VIEW_CLASSIC; /** * A Space. diff --git a/x-pack/plugins/spaces/public/constants.ts b/x-pack/plugins/spaces/public/constants.ts index 64781228d4f43..09bab1124f27d 100644 --- a/x-pack/plugins/spaces/public/constants.ts +++ b/x-pack/plugins/spaces/public/constants.ts @@ -13,7 +13,7 @@ export const getSpacesFeatureDescription = () => { if (!spacesFeatureDescription) { spacesFeatureDescription = i18n.translate('xpack.spaces.featureDescription', { defaultMessage: - 'Organize your dashboards and other saved objects into meaningful categories.', + 'Organize Kibana into spaces with dedicated navigation, privileges and objects.', }); } return spacesFeatureDescription; diff --git a/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space.test.tsx.snap b/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space.test.tsx.snap index 7eac1c7499919..d7527e300eece 100644 --- a/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space.test.tsx.snap @@ -5,7 +5,7 @@ exports[`renders correctly 1`] = ` dataTestSubj="generalPanel" > interface Props { validator: SpaceValidator; - space: FormValues; + space: CustomizeSpaceFormValues; editingExistingSpace: boolean; - onChange: (space: FormValues) => void; + onChange: (space: CustomizeSpaceFormValues) => void; title?: string; } @@ -71,7 +71,7 @@ export class CustomizeSpace extends Component { description={i18n.translate( 'xpack.spaces.management.manageSpacePage.describeSpaceDescription', { - defaultMessage: "Give your space a name that's memorable.", + defaultMessage: 'Give your space a meaningful name and description.', } )} fullWidth @@ -258,7 +258,7 @@ export class CustomizeSpace extends Component { }); }; - public onAvatarChange = (space: FormValues) => { + public onAvatarChange = (space: CustomizeSpaceFormValues) => { this.props.onChange(space); }; } diff --git a/x-pack/plugins/spaces/public/management/components/customize_space/customize_space_avatar.tsx b/x-pack/plugins/spaces/public/management/components/customize_space/customize_space_avatar.tsx index 4faa29d77e037..1a317a4fe9e9a 100644 --- a/x-pack/plugins/spaces/public/management/components/customize_space/customize_space_avatar.tsx +++ b/x-pack/plugins/spaces/public/management/components/customize_space/customize_space_avatar.tsx @@ -19,12 +19,12 @@ import { i18n } from '@kbn/i18n'; import { MAX_SPACE_INITIALS } from '../../../../common'; import { encode, imageTypes } from '../../../../common/lib/dataurl'; -import type { FormValues } from '../../edit_space/manage_space_page'; import type { SpaceValidator } from '../../lib'; +import type { CustomizeSpaceFormValues } from '../../types'; interface Props { - space: FormValues; - onChange: (space: FormValues) => void; + space: CustomizeSpaceFormValues; + onChange: (space: CustomizeSpaceFormValues) => void; validator: SpaceValidator; } @@ -127,7 +127,7 @@ export class CustomizeSpaceAvatar extends Component { onChange={(avatarType: string) => this.props.onChange({ ...space, - avatarType: avatarType as FormValues['avatarType'], + avatarType: avatarType as CustomizeSpaceFormValues['avatarType'], }) } buttonSize="m" diff --git a/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx b/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx index 9d7ca7140956c..c336791991df4 100644 --- a/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx +++ b/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx @@ -153,7 +153,7 @@ export const SolutionView: FunctionComponent = ({ placeholder={i18n.translate( 'xpack.spaces.management.navigation.solutionViewDefaultValue', { - defaultMessage: 'Classic (Default)', + defaultMessage: 'Select view', } )} isInvalid={validator.validateSolutionView(space, isEditing).isInvalid} diff --git a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx b/x-pack/plugins/spaces/public/management/create_space/create_space_page.test.tsx similarity index 98% rename from x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx rename to x-pack/plugins/spaces/public/management/create_space/create_space_page.test.tsx index 9fd90d739df41..4c8617ff007b8 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/create_space/create_space_page.test.tsx @@ -18,7 +18,7 @@ import { KibanaFeature } from '@kbn/features-plugin/public'; import { featuresPluginMock } from '@kbn/features-plugin/public/mocks'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; -import { ManageSpacePage } from './manage_space_page'; +import { CreateSpacePage } from './create_space_page'; import type { SolutionView, Space } from '../../../common/types/latest'; import { EventTracker } from '../../analytics'; import type { SpacesManager } from '../../spaces_manager'; @@ -70,7 +70,7 @@ describe('ManageSpacePage', () => { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { const spacesManager = spacesManagerMock.create(); const wrapper = mountWithIntl( - { const onLoadSpace = jest.fn(); const wrapper = mountWithIntl( - { const onLoadSpace = jest.fn(); const wrapper = mountWithIntl( - { const notifications = notificationServiceMock.createStartContract(); const wrapper = mountWithIntl( - Promise.reject(error)} notifications={notifications} @@ -542,7 +542,7 @@ describe('ManageSpacePage', () => { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { - customIdentifier?: boolean; - avatarType?: 'initials' | 'image'; - customAvatarInitials?: boolean; - customAvatarColor?: boolean; -} +import type { CustomizeSpaceFormValues } from '../types'; interface Props { getFeatures: FeaturesPluginStart['getFeatures']; @@ -62,7 +56,7 @@ interface Props { } interface State { - space: FormValues; + space: CustomizeSpaceFormValues; features: KibanaFeature[]; originalSpace?: Partial; showAlteringActiveSpaceDialog: boolean; @@ -77,7 +71,7 @@ interface State { }; } -export class ManageSpacePage extends Component { +export class CreateSpacePage extends Component { private readonly validator: SpaceValidator; constructor(props: Props) { @@ -189,7 +183,7 @@ export class ManageSpacePage extends Component { const { showAlteringActiveSpaceDialog } = this.state; return ( -
+
{ this.onSpaceChange(space); }; - public onSpaceChange = (updatedSpace: FormValues) => { + public onSpaceChange = (updatedSpace: CustomizeSpaceFormValues) => { this.setState({ space: updatedSpace, }); diff --git a/x-pack/plugins/spaces/public/management/create_space/index.ts b/x-pack/plugins/spaces/public/management/create_space/index.ts new file mode 100644 index 0000000000000..df9774f722dd3 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/create_space/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { CreateSpacePage } from './create_space_page'; diff --git a/x-pack/plugins/spaces/public/management/edit_space/constants.ts b/x-pack/plugins/spaces/public/management/edit_space/constants.ts new file mode 100644 index 0000000000000..21e10c547800f --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/constants.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export const TAB_ID_CONTENT = 'content'; +export const TAB_ID_ROLES = 'roles'; +export const TAB_ID_GENERAL = 'general'; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space.tsx new file mode 100644 index 0000000000000..cd2bd76a57928 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space.tsx @@ -0,0 +1,283 @@ +/* + * 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, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiSpacer, + EuiTab, + EuiTabs, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import React, { lazy, Suspense, useEffect, useState } from 'react'; +import type { FC } from 'react'; + +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'; +import type { Role } from '@kbn/security-plugin-types-common'; + +import { TAB_ID_CONTENT, TAB_ID_GENERAL, TAB_ID_ROLES } from './constants'; +import { handleApiError } from './handle_api_error'; +import { useTabs } from './hooks/use_tabs'; +import { useEditSpaceServices, useEditSpaceStore } from './provider'; +import { addSpaceIdToPath, ENTER_SPACE_PATH, type Space } from '../../../common'; +import { SOLUTION_VIEW_CLASSIC } from '../../../common/constants'; +import { getSpaceAvatarComponent } from '../../space_avatar'; +import { SpaceSolutionBadge } from '../../space_solution_badge'; + +// No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana. +const LazySpaceAvatar = lazy(() => + getSpaceAvatarComponent().then((component) => ({ default: component })) +); + +const getSelectedTabId = (canUserViewRoles: boolean, selectedTabId?: string) => { + // Validation of the selectedTabId routing parameter, default to the Content tab + return selectedTabId && + [TAB_ID_CONTENT, canUserViewRoles ? TAB_ID_ROLES : null].filter(Boolean).includes(selectedTabId) + ? selectedTabId + : TAB_ID_GENERAL; +}; + +interface PageProps { + spaceId?: string; + history: ScopedHistory; + selectedTabId?: string; + getFeatures: FeaturesPluginStart['getFeatures']; + onLoadSpace: (space: Space) => void; + allowFeatureVisibility: boolean; + allowSolutionVisibility: boolean; +} + +export const EditSpace: FC = ({ + spaceId, + getFeatures, + history, + onLoadSpace, + selectedTabId: _selectedTabId, + ...props +}) => { + const { state, dispatch } = useEditSpaceStore(); + const { invokeClient } = useEditSpaceServices(); + const { spacesManager, capabilities, serverBasePath, logger, notifications } = + useEditSpaceServices(); + const [space, setSpace] = useState(null); + const [userActiveSpace, setUserActiveSpace] = useState(null); + const [features, setFeatures] = useState(null); + const [isLoadingSpace, setIsLoadingSpace] = useState(true); + const [isLoadingFeatures, setIsLoadingFeatures] = useState(true); + const [isLoadingRoles, setIsLoadingRoles] = useState(true); + const selectedTabId = getSelectedTabId(Boolean(capabilities?.roles?.view), _selectedTabId); + const [tabs, selectedTabContent] = useTabs({ + space, + features, + rolesCount: state.roles.size, + capabilities, + history, + currentSelectedTabId: selectedTabId, + ...props, + }); + + useEffect(() => { + if (!spaceId) { + return; + } + + const getSpaceInfo = async () => { + // active space: the space that is active in the user's session + // current space: the space being edited by the user + const [activeSpace, currentSpace] = await Promise.all([ + spacesManager.getActiveSpace(), + spacesManager.getSpace(spaceId), + ]); + + setSpace(currentSpace); + setUserActiveSpace(activeSpace); + setIsLoadingSpace(false); + }; + + getSpaceInfo().catch((error) => + handleApiError(error, { logger, toasts: notifications.toasts }) + ); + }, [spaceId, spacesManager, logger, notifications.toasts]); + + // Load roles to show the count of assigned roles as a badge in the "Assigned roles" tab title + useEffect(() => { + if (!spaceId) { + return; + } + + const getRoles = async () => { + await invokeClient(async (clients) => { + let result: Role[] = []; + try { + result = await clients.spacesManager.getRolesForSpace(spaceId); + } catch (error) { + const message = error?.body?.message ?? error.toString(); + const statusCode = error?.body?.statusCode ?? null; + if (statusCode === 403) { + logger.error('Insufficient permissions to get list of roles for the space'); + logger.error(message); + } else { + logger.error('Encountered error while getting list of roles for space!'); + logger.error(error); + } + dispatch({ type: 'fetch_roles_error', payload: true }); + } + dispatch({ type: 'update_roles', payload: result }); + }); + + setIsLoadingRoles(false); + }; + + if (!state.roles.size && !state.fetchRolesError) { + getRoles(); + } + }, [dispatch, invokeClient, spaceId, state.roles, state.fetchRolesError, logger]); + + useEffect(() => { + const _getFeatures = async () => { + const result = await getFeatures(); + setFeatures(result); + setIsLoadingFeatures(false); + }; + _getFeatures().catch((error) => + handleApiError(error, { logger, toasts: notifications.toasts }) + ); + }, [getFeatures, logger, notifications.toasts]); + + useEffect(() => { + if (space) { + onLoadSpace?.(space); + } + }, [onLoadSpace, space]); + + if (!space) { + return null; + } + + if (isLoadingSpace || isLoadingFeatures || isLoadingRoles) { + return ( + + + + + + ); + } + + const HeaderAvatar = () => { + return ( + }> + + + ); + }; + + const { id, solution: spaceSolution } = space; + const solution = spaceSolution ?? SOLUTION_VIEW_CLASSIC; + const shouldShowSolutionBadge = + props.allowSolutionVisibility || solution !== SOLUTION_VIEW_CLASSIC; + + return ( +
+ + + + + + + + + + + + +

{space.name}

+
+
+ + + {userActiveSpace?.id !== id ? ( + + + + ) : null} + + +
+
+ +
+ {shouldShowSolutionBadge ? ( + + ) : null} + {userActiveSpace?.id === id ? ( + + + + ) : null} +
+
+
+
+
+
+ + +

{space.description}

+
+
+
+ + + + + {tabs.map((tab, index) => ( + + {tab.name} + + ))} + + + {selectedTabContent ?? null} + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.test.tsx new file mode 100644 index 0000000000000..209f65b9a6783 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.test.tsx @@ -0,0 +1,125 @@ +/* + * 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 React from 'react'; + +import { + httpServiceMock, + i18nServiceMock, + loggingSystemMock, + notificationServiceMock, + overlayServiceMock, + themeServiceMock, +} from '@kbn/core/public/mocks'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +import { EditSpaceContentTab } from './edit_space_content_tab'; +import { EditSpaceProvider } from './provider'; +import type { Space } from '../../../common'; +import { spacesManagerMock } from '../../spaces_manager/spaces_manager.mock'; +import type { SpaceContentTypeSummaryItem } from '../../types'; +import { getPrivilegeAPIClientMock } from '../privilege_api_client.mock'; +import { getRolesAPIClientMock } from '../roles_api_client.mock'; + +const getUrlForApp = (appId: string) => appId; +const navigateToUrl = jest.fn(); +const spacesManager = spacesManagerMock.create(); +const getRolesAPIClient = getRolesAPIClientMock; +const getPrivilegeAPIClient = getPrivilegeAPIClientMock; + +const http = httpServiceMock.createStartContract(); +const notifications = notificationServiceMock.createStartContract(); +const overlays = overlayServiceMock.createStartContract(); +const theme = themeServiceMock.createStartContract(); +const i18n = i18nServiceMock.createStartContract(); +const logger = loggingSystemMock.createLogger(); + +const TestComponent: React.FC = ({ children }) => { + return ( + + + {children} + + + ); +}; + +describe('EditSpaceContentTab', () => { + const space: Space = { + id: '1', + name: 'space1', + disabledFeatures: [], + }; + + const getSpaceContentSpy = jest.spyOn(spacesManager, 'getContentForSpace'); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render with a loading indicator initially', () => { + render( + + + + ); + + expect(screen.getByTestId('editSpaceContentTabLoadingIndicator')).toBeInTheDocument(); + }); + + it('should render the space content on resolving the saved objects within the space', async () => { + const spaceContentSummary: SpaceContentTypeSummaryItem[] = [ + { + type: 'dashboard', + count: 1, + displayName: 'Dashboard', + }, + ]; + + getSpaceContentSpy.mockResolvedValue({ + summary: spaceContentSummary, + total: spaceContentSummary.length, + }); + + render( + + + + ); + + await waitFor(() => null); + + expect(getSpaceContentSpy).toHaveBeenCalledTimes(1); + expect(getSpaceContentSpy).toHaveBeenCalledWith(space.id); + + expect(screen.queryByTestId('editSpaceContentTabLoadingIndicator')).not.toBeInTheDocument(); + + spaceContentSummary.forEach((item) => { + expect(screen.getByTestId(`space-content-row-${item.type}`)).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.tsx new file mode 100644 index 0000000000000..2c20e61800174 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.tsx @@ -0,0 +1,147 @@ +/* + * 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 { EuiBasicTableColumn, EuiTableFieldDataColumnType } from '@elastic/eui'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiLoadingSpinner, + EuiText, +} from '@elastic/eui'; +import { capitalize } from 'lodash'; +import type { FC } from 'react'; +import React, { useEffect, useState } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { handleApiError } from './handle_api_error'; +import { useEditSpaceServices } from './provider'; +import { addSpaceIdToPath, ENTER_SPACE_PATH, type Space } from '../../../common'; +import type { SpaceContentTypeSummaryItem } from '../../types'; + +export const EditSpaceContentTab: FC<{ space: Space }> = ({ space }) => { + const { id: spaceId } = space; + const { spacesManager, serverBasePath, logger, notifications } = useEditSpaceServices(); + const [isLoading, setIsLoading] = useState(true); + const [items, setItems] = useState(null); + + const columns: Array> = [ + { + field: 'type', + name: 'Type', + render: (_value: string, item: SpaceContentTypeSummaryItem) => { + const { icon, displayName } = item; + return ( + + + + + {capitalize(displayName)} + + ); + }, + }, + { + field: 'count', + name: 'Count', + render: (value: string, item: SpaceContentTypeSummaryItem) => { + const uriComponent = encodeURIComponent( + `/app/management/kibana/objects?initialQuery=type:(${item.type})` + ); + const href = addSpaceIdToPath( + serverBasePath, + space.id, + `${ENTER_SPACE_PATH}?next=${uriComponent}` + ); + return {value}; + }, + }, + ]; + + const getRowProps = (item: SpaceContentTypeSummaryItem) => { + const { type } = item; + return { + 'data-test-subj': `space-content-row-${type}`, + onClick: () => {}, + }; + }; + + const getCellProps = ( + item: SpaceContentTypeSummaryItem, + column: EuiTableFieldDataColumnType + ) => { + const { type } = item; + const { field } = column; + return { + 'data-test-subj': `space-content-cell-${type}-${String(field)}`, + textOnly: true, + }; + }; + + useEffect(() => { + const getItems = async () => { + const result = await spacesManager.getContentForSpace(spaceId); + const { summary } = result; + setItems(summary); + setIsLoading(false); + }; + + getItems().catch((error) => { + handleApiError(error, { logger, toasts: notifications.toasts }); + }); + }, [spaceId, spacesManager, logger, notifications.toasts]); + + if (isLoading) { + return ( + + + + + + ); + } + + if (!items) { + return null; + } + + return ( + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_features_tab.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_features_tab.tsx new file mode 100644 index 0000000000000..f5bfbe79ec2d4 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_features_tab.tsx @@ -0,0 +1,77 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import type { FC } from 'react'; +import React from 'react'; + +import type { KibanaFeature } from '@kbn/features-plugin/common'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { useEditSpaceServices } from './provider'; +import type { Space } from '../../../common'; +import { FeatureTable } from '../components/enabled_features/feature_table'; +import { SectionPanel } from '../components/section_panel'; + +interface Props { + space: Partial; + features: KibanaFeature[]; + onChange: (updatedSpace: Partial) => void; +} + +export const EditSpaceEnabledFeatures: FC = ({ features, space, onChange }) => { + const { capabilities, getUrlForApp } = useEditSpaceServices(); + const canManageRoles = capabilities.roles?.save === true; + + if (!features) { + return null; + } + + return ( + + + + +

+ +

+
+ + +

+ + + + ) : ( + + ), + }} + /> +

+
+
+ + + +
+
+ ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.test.tsx new file mode 100644 index 0000000000000..433362777f4d2 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.test.tsx @@ -0,0 +1,489 @@ +/* + * 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 { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { + httpServiceMock, + i18nServiceMock, + loggingSystemMock, + 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 { EditSpaceSettingsTab } from './edit_space_general_tab'; +import { EditSpaceProvider } from './provider/edit_space_provider'; +import type { SolutionView } from '../../../common'; +import { SOLUTION_VIEW_CLASSIC } from '../../../common/constants'; +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 }; +const history = scopedHistoryMock.create(); +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 logger = loggingSystemMock.createLogger(); + +const navigateSpy = jest.spyOn(history, 'push').mockImplementation(() => {}); +const updateSpaceSpy = jest + .spyOn(spacesManager, 'updateSpace') + .mockImplementation(() => Promise.resolve()); +const deleteSpaceSpy = jest + .spyOn(spacesManager, 'deleteSpace') + .mockImplementation(() => Promise.resolve()); + +describe('EditSpaceSettings', () => { + beforeEach(() => { + navigateSpy.mockReset(); + updateSpaceSpy.mockReset(); + deleteSpaceSpy.mockReset(); + }); + + const TestComponent: React.FC = ({ children }) => { + return ( + + + {children} + + + ); + }; + + it('should render controls for initial state of editing a space', () => { + render( + + + + ); + + expect(screen.getByTestId('addSpaceName')).toBeInTheDocument(); + expect(screen.getByTestId('descriptionSpaceText')).toBeInTheDocument(); + expect(screen.getByTestId('spaceLetterInitial')).toBeInTheDocument(); + expect(screen.getByTestId('euiColorPickerAnchor')).toBeInTheDocument(); + + expect(screen.queryByTestId('solutionViewSelect')).not.toBeInTheDocument(); // hides solution view when not not set to visible + expect(screen.queryByTestId('enabled-features-panel')).not.toBeInTheDocument(); // hides navigation features table when not set to visible + }); + + it('shows solution view select when visible', async () => { + render( + + + + ); + + expect(screen.getByTestId('solutionViewSelect')).toBeInTheDocument(); + expect(screen.queryByTestId('enabled-features-panel')).not.toBeInTheDocument(); // hides navigation features table when not set to visible + }); + + it('shows feature visibility controls when allowed', async () => { + const features = [ + new KibanaFeature({ + id: 'feature-1', + name: 'feature 1', + app: [], + category: DEFAULT_APP_CATEGORIES.kibana, + privileges: null, + }), + ]; + + render( + + + + ); + + expect(screen.getByTestId('enabled-features-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('solutionViewSelect')).not.toBeInTheDocument(); // hides solution view when not not set to visible + }); + + it('allows a space to be updated', async () => { + const spaceToUpdate = { + id: 'existing-space', + name: 'Existing Space', + description: 'hey an existing space', + color: '#aabbcc', + initials: 'AB', + disabledFeatures: [], + solution: 'es' as SolutionView, + }; + + render( + + + + ); + + await act(async () => { + // update the space name + const nameInput = screen.getByTestId('addSpaceName'); + fireEvent.change(nameInput, { target: { value: 'Updated Name Of Space' } }); + + expect(screen.queryByTestId('space-edit-page-user-impact-warning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('confirmModalTitleText')).not.toBeInTheDocument(); + + const updateButton = await screen.findByTestId('save-space-button'); // appears via re-render + await userEvent.click(updateButton); + + expect(updateSpaceSpy).toHaveBeenCalledWith({ + ...spaceToUpdate, + name: 'Updated Name Of Space', + initials: 'UN', + imageUrl: '', + color: '#D6BF57', + }); + }); + + expect(navigateSpy).toHaveBeenCalledTimes(1); + }); + + it('allows space to be deleted', async () => { + const spaceToDelete = { + id: 'delete-me-space', + name: 'Delete Me Space', + description: 'This is a very nice space... for me to DELETE!', + color: '#aabbcc', + initials: 'XX', + disabledFeatures: [], + }; + + render( + + + + ); + + await act(async () => { + const deleteButton = screen.getByTestId('delete-space-button'); + await userEvent.click(deleteButton); + + const confirmButton = await screen.findByTestId('confirmModalConfirmButton'); // click delete confirm + await userEvent.click(confirmButton); + + expect(deleteSpaceSpy).toHaveBeenCalledWith(spaceToDelete); + }); + }); + + it('sets calculated fields for existing spaces', async () => { + // The Spaces plugin provides functions to calculate the initials and color of a space if they have not been customized. The new space + // management page explicitly sets these fields when a new space is created, but it should also handle existing "legacy" spaces that do + // not already have these fields set. + const spaceToUpdate = { + id: 'existing-space', + name: 'Existing Space', + description: 'hey an existing space', + color: undefined, + initials: undefined, + imageUrl: undefined, + disabledFeatures: [], + }; + + render( + + + + ); + + await act(async () => { + // update the space name + const nameInput = screen.getByTestId('addSpaceName'); + fireEvent.change(nameInput, { target: { value: 'Updated Existing Space' } }); + + const updateButton = await screen.findByTestId('save-space-button'); // appears via re-render + await userEvent.click(updateButton); + + expect(updateSpaceSpy).toHaveBeenCalledWith({ + ...spaceToUpdate, + name: 'Updated Existing Space', + color: '#D6BF57', + initials: 'UE', + imageUrl: '', + }); + }); + }); + + it('warns when updating solution view', async () => { + const spaceToUpdate = { + id: 'existing-space', + name: 'Existing Space', + description: 'hey an existing space', + color: '#aabbcc', + initials: 'AB', + disabledFeatures: [], + solution: undefined, + }; + + render( + + + + ); + + // update the space solution view + await act(async () => { + const solutionViewPicker = screen.getByTestId('solutionViewSelect'); + await userEvent.click(solutionViewPicker); + + const esSolutionOption = await screen.findByTestId('solutionViewEsOption'); // appears via re-render + await userEvent.click(esSolutionOption); + + expect(screen.getByTestId('space-edit-page-user-impact-warning')).toBeInTheDocument(); + expect(screen.queryByTestId('confirmModalTitleText')).not.toBeInTheDocument(); + + const updateButton = screen.getByTestId('save-space-button'); + await userEvent.click(updateButton); + + expect(screen.getByTestId('confirmModalTitleText')).toBeInTheDocument(); + + const confirmButton = screen.getByTestId('confirmModalConfirmButton'); + await userEvent.click(confirmButton); + + await waitFor(() => { + expect(updateSpaceSpy).toHaveBeenCalledWith({ + ...spaceToUpdate, + imageUrl: '', + solution: 'es', + }); + }); + }); + + expect(navigateSpy).toHaveBeenCalledTimes(1); + }); + + it('warns when updating features in the active space', async () => { + const features = [ + new KibanaFeature({ + id: 'feature-1', + name: 'feature 1', + app: [], + category: DEFAULT_APP_CATEGORIES.kibana, + privileges: null, + }), + ]; + + const spaceToUpdate = { + id: 'existing-space', + name: 'Existing Space', + description: 'hey an existing space', + color: '#aabbcc', + initials: 'AB', + disabledFeatures: [], + solution: SOLUTION_VIEW_CLASSIC, + }; + + render( + + + + ); + + // update the space visible features + await act(async () => { + const feature1Checkbox = screen.getByTestId('featureCheckbox_feature-1'); + expect(feature1Checkbox).toBeChecked(); + + await userEvent.click(feature1Checkbox); + await waitFor(() => { + expect(feature1Checkbox).not.toBeChecked(); + }); + + expect(screen.getByTestId('space-edit-page-user-impact-warning')).toBeInTheDocument(); + expect(screen.queryByTestId('confirmModalTitleText')).not.toBeInTheDocument(); + + const updateButton = screen.getByTestId('save-space-button'); + await userEvent.click(updateButton); + + expect(screen.getByTestId('confirmModalTitleText')).toBeInTheDocument(); + + const confirmButton = screen.getByTestId('confirmModalConfirmButton'); + await userEvent.click(confirmButton); + + await waitFor(() => { + expect(updateSpaceSpy).toHaveBeenCalledWith({ + ...spaceToUpdate, + imageUrl: '', + disabledFeatures: ['feature-1'], + }); + }); + }); + + expect(navigateSpy).toHaveBeenCalledTimes(1); + }); + + it('empties the disabled features list when the solution view non-classic', async () => { + const features = [ + new KibanaFeature({ + id: 'feature-1', + name: 'feature 1', + app: [], + category: DEFAULT_APP_CATEGORIES.kibana, + privileges: null, + }), + ]; + + const spaceToUpdate = { + id: 'existing-space', + name: 'Existing Space', + description: 'hey an existing space', + color: '#aabbcc', + initials: 'AB', + disabledFeatures: [], + solution: SOLUTION_VIEW_CLASSIC, + }; + + render( + + + + ); + + // customize the space visible features to disable feature-1 + await act(async () => { + const feature1Checkbox = screen.getByTestId('featureCheckbox_feature-1'); + expect(feature1Checkbox).toBeChecked(); + + await userEvent.click(feature1Checkbox); + await waitFor(() => { + expect(feature1Checkbox).not.toBeChecked(); + }); + + expect(screen.getByTestId('space-edit-page-user-impact-warning')).toBeInTheDocument(); + expect(screen.queryByTestId('confirmModalTitleText')).not.toBeInTheDocument(); + }); + + // change the selected solution view to es + await act(async () => { + const solutionViewPicker = screen.getByTestId('solutionViewSelect'); + await userEvent.click(solutionViewPicker); + + const esSolutionOption = await screen.findByTestId('solutionViewEsOption'); // appears via re-render + await userEvent.click(esSolutionOption); + }); + + // perform the save + await act(async () => { + const updateButton = screen.getByTestId('save-space-button'); + await userEvent.click(updateButton); + + expect(screen.getByTestId('confirmModalTitleText')).toBeInTheDocument(); + + const confirmButton = screen.getByTestId('confirmModalConfirmButton'); + await userEvent.click(confirmButton); + + await waitFor(() => { + expect(updateSpaceSpy).toHaveBeenCalledWith({ + ...spaceToUpdate, + imageUrl: '', + solution: 'es', + disabledFeatures: [], // "feature-1" became deselected + }); + }); + }); + + expect(navigateSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.tsx new file mode 100644 index 0000000000000..2b7f04e4d9417 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.tsx @@ -0,0 +1,297 @@ +/* + * 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 { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; + +import type { ScopedHistory } from '@kbn/core-application-browser'; +import type { KibanaFeature } from '@kbn/features-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { useUnsavedChangesPrompt } from '@kbn/unsaved-changes-prompt'; + +import { EditSpaceEnabledFeatures } from './edit_space_features_tab'; +import { EditSpaceTabFooter } from './footer'; +import { useEditSpaceServices } from './provider'; +import type { Space } from '../../../common'; +import { SOLUTION_VIEW_CLASSIC } from '../../../common/constants'; +import { ConfirmDeleteModal } from '../components'; +import { ConfirmAlterActiveSpaceModal } from '../components/confirm_alter_active_space_modal'; +import { CustomizeSpace } from '../components/customize_space'; +import { SolutionView } from '../components/solution_view'; +import { SpaceValidator } from '../lib'; +import type { CustomizeSpaceFormValues } from '../types'; + +interface Props { + space: Space; + history: ScopedHistory; + features: KibanaFeature[]; + allowFeatureVisibility: boolean; + allowSolutionVisibility: boolean; + reloadWindow: () => void; +} + +export const EditSpaceSettingsTab: React.FC = ({ space, features, history, ...props }) => { + const imageAvatarSelected = Boolean(space.imageUrl); + const [formValues, setFormValues] = useState({ + ...space, + avatarType: imageAvatarSelected ? 'image' : 'initials', + imageUrl: imageAvatarSelected ? space.imageUrl : '', + }); + + const [isDirty, setIsDirty] = useState(false); // track if unsaved changes have been made + const [isLoading, setIsLoading] = useState(false); // track if user has just clicked the Update button + const [showUserImpactWarning, setShowUserImpactWarning] = useState(false); + const [showAlteringActiveSpaceDialog, setShowAlteringActiveSpaceDialog] = useState(false); + const [showConfirmDeleteModal, setShowConfirmDeleteModal] = useState(false); + const { http, overlays, logger, notifications, navigateToUrl, spacesManager } = + useEditSpaceServices(); + + const [solution, setSolution] = useState(space.solution); + + useUnsavedChangesPrompt({ + hasUnsavedChanges: isDirty, + http, + openConfirm: overlays.openConfirm, + navigateToUrl, + history, + titleText: i18n.translate('xpack.spaces.management.spaceDetails.unsavedChangesPromptTitle', { + defaultMessage: 'Leave without saving?', + }), + messageText: i18n.translate( + 'xpack.spaces.management.spaceDetails.unsavedChangesPromptMessage', + { + defaultMessage: "Unsaved changes won't be applied to the space and will be lost.", + } + ), + cancelButtonText: i18n.translate('xpack.spaces.management.spaceDetails.keepEditingButton', { + defaultMessage: 'Save before leaving', + }), + confirmButtonText: i18n.translate('xpack.spaces.management.spaceDetails.leavePageButton', { + defaultMessage: 'Leave', + }), + }); + + const onChangeSpaceSettings = useCallback( + (newFormValues: CustomizeSpaceFormValues) => { + setFormValues({ ...formValues, ...newFormValues }); + setIsDirty(true); + }, + [formValues] + ); + + const onChangeFeatures = useCallback( + (updatedSpace: Partial) => { + setFormValues({ ...formValues, ...updatedSpace }); + setIsDirty(true); + setShowUserImpactWarning(true); + }, + [formValues] + ); + + const onSolutionViewChange = useCallback( + (updatedSpace: Partial) => { + setSolution(updatedSpace.solution); + onChangeFeatures(updatedSpace); + }, + [onChangeFeatures] + ); + + const backToSpacesList = useCallback(() => { + history.push('/'); + }, [history]); + + const onClickCancel = useCallback(() => { + setShowAlteringActiveSpaceDialog(false); + setShowUserImpactWarning(false); + backToSpacesList(); + }, [backToSpacesList]); + + const onClickDeleteSpace = useCallback(() => { + setShowConfirmDeleteModal(true); + }, []); + + const performSave = useCallback( + async ({ requiresReload = false }) => { + const { + avatarType, + customIdentifier, + customAvatarColor, + customAvatarInitials, + ...partialSpace + } = formValues; + + const spaceClone = structuredClone(partialSpace as Partial); + const { id, name } = spaceClone; + + if (!id) { + throw new Error(`Can not update space without id field!`); + } + if (!name) { + throw new Error(`Can not update space without name field!`); + } + + setIsLoading(true); + + let disabledFeatures: string[] | undefined; + if (spaceClone.solution === SOLUTION_VIEW_CLASSIC) { + disabledFeatures = spaceClone.disabledFeatures; + } + + try { + await spacesManager.updateSpace({ + ...spaceClone, + id, + name, + disabledFeatures: disabledFeatures ?? [], + imageUrl: avatarType === 'image' ? spaceClone.imageUrl : '', + }); + + notifications.toasts.addSuccess( + i18n.translate( + 'xpack.spaces.management.spaceDetails.spaceSuccessfullySavedNotificationMessage', + { + defaultMessage: 'Space "{name}" was saved.', + values: { name }, + } + ) + ); + + setIsDirty(false); + backToSpacesList(); + if (requiresReload) { + props.reloadWindow(); + } + } catch (error) { + logger.error('Could not save changes to space!', error); + const message = error?.body?.message ?? error.toString(); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.spaces.management.spaceDetails.errorSavingSpaceTitle', { + defaultMessage: 'Error saving space: {message}', + values: { message }, + }), + }); + } finally { + setIsLoading(false); + } + }, + [backToSpacesList, notifications.toasts, formValues, spacesManager, logger, props] + ); + + const onClickSubmit = useCallback(() => { + if (showUserImpactWarning) { + setShowAlteringActiveSpaceDialog(true); + } else { + performSave({ requiresReload: false }); + } + }, [performSave, showUserImpactWarning]); + + const doShowAlteringActiveSpaceDialog = () => { + return ( + showAlteringActiveSpaceDialog && ( + performSave({ requiresReload: true })} + onCancel={() => { + setShowAlteringActiveSpaceDialog(false); + }} + /> + ) + ); + }; + + const doShowConfirmDeleteSpaceDialog = () => { + return ( + showConfirmDeleteModal && ( + { + setShowConfirmDeleteModal(false); + }} + onSuccess={() => { + setShowConfirmDeleteModal(false); + backToSpacesList(); + }} + /> + ) + ); + }; + + // Show if user has changed disabled features + // Show if user has changed solution view + const doShowUserImpactWarning = () => { + return ( + showUserImpactWarning && ( + <> + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.spaceChangesWarning.impactAllUsersInSpace', + { + defaultMessage: 'The changes made will impact all users in the space.', + } + )} + + + ) + ); + }; + + const validator = new SpaceValidator(); + + return ( + <> + {doShowAlteringActiveSpaceDialog()} + {doShowConfirmDeleteSpaceDialog()} + + + + {props.allowSolutionVisibility && ( + <> + + + + )} + + {props.allowFeatureVisibility && (solution == null || solution === SOLUTION_VIEW_CLASSIC) && ( + <> + + + + )} + + {doShowUserImpactWarning()} + + + + + ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_page.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_page.tsx new file mode 100644 index 0000000000000..882301d36459a --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_page.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 { EditSpace } from './edit_space'; +import { EditSpaceProvider, type EditSpaceProviderProps } from './provider'; + +type EditSpacePageProps = ComponentProps & EditSpaceProviderProps; + +export function EditSpacePage({ + spaceId, + getFeatures, + history, + onLoadSpace, + selectedTabId, + allowFeatureVisibility, + allowSolutionVisibility, + children, + ...editSpaceServicesProps +}: PropsWithChildren) { + return ( + + + + ); +} diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.test.tsx new file mode 100644 index 0000000000000..ca6d8e1c0767d --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.test.tsx @@ -0,0 +1,119 @@ +/* + * 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 { act, render, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { + httpServiceMock, + i18nServiceMock, + loggingSystemMock, + notificationServiceMock, + overlayServiceMock, + themeServiceMock, +} from '@kbn/core/public/mocks'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +import { EditSpaceAssignedRolesTab } from './edit_space_roles_tab'; +import { EditSpaceProvider } from './provider'; +import { spacesManagerMock } from '../../spaces_manager/spaces_manager.mock'; +import { getPrivilegeAPIClientMock } from '../privilege_api_client.mock'; +import { getRolesAPIClientMock } from '../roles_api_client.mock'; + +const getUrlForApp = (appId: string) => appId; +const navigateToUrl = jest.fn(); +const spacesManager = spacesManagerMock.create(); +const getRolesAPIClient = getRolesAPIClientMock; +const getPrivilegeAPIClient = getPrivilegeAPIClientMock; + +const http = httpServiceMock.createStartContract(); +const notifications = notificationServiceMock.createStartContract(); +const overlays = overlayServiceMock.createStartContract(); +const theme = themeServiceMock.createStartContract(); +const i18n = i18nServiceMock.createStartContract(); +const logger = loggingSystemMock.createLogger(); + +const space = { + id: 'space-a', + name: 'Space A', + disabledFeatures: [], + _reserved: false, +}; + +describe('EditSpaceAssignedRolesTab', () => { + const loadRolesSpy = jest.spyOn(spacesManager, 'getRolesForSpace'); + const toastErrorSpy = jest.spyOn(notifications.toasts, 'addError'); + + const TestComponent: React.FC = ({ children }) => { + return ( + + + {children} + + + ); + }; + + beforeEach(() => { + loadRolesSpy.mockReset(); + toastErrorSpy.mockReset(); + }); + + it('loads the assigned roles', async () => { + act(() => { + render( + + + + ); + }); + + await waitFor(() => { + expect(loadRolesSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('shows an error toast if there is an error loading the assigned roles', async () => { + loadRolesSpy.mockImplementation(() => { + throw new Error('test error'); + }); + + act(() => { + render( + + + + ); + }); + + await waitFor(() => { + expect(loadRolesSpy).toHaveBeenCalledTimes(1); + expect(toastErrorSpy).toHaveBeenCalledWith(new Error('test error'), { + title: 'Error: test error', + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx new file mode 100644 index 0000000000000..2733790d8de8b --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx @@ -0,0 +1,207 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import type { FC } from 'react'; +import React, { useCallback, useEffect } from 'react'; + +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 { handleApiError } from './handle_api_error'; +import { EditSpaceProvider, useEditSpaceServices, useEditSpaceStore } 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; + features: KibanaFeature[]; + isReadOnly: boolean; +} + +export const EditSpaceAssignedRolesTab: FC = ({ space, features, isReadOnly }) => { + const { dispatch, state } = useEditSpaceStore(); // no loading state because roles have already been loaded + const services = useEditSpaceServices(); + const { + getUrlForApp, + overlays, + theme, + i18n: i18nStart, + logger, + notifications, + invokeClient, + } = services; + + // Roles are already loaded in app state, refresh them when user navigates to this tab + useEffect(() => { + const getRoles = async () => { + await invokeClient(async (clients) => { + let result: Role[] = []; + try { + result = await clients.spacesManager.getRolesForSpace(space.id); + + dispatch({ type: 'update_roles', payload: result }); + } catch (error) { + handleApiError(error, { logger, toasts: notifications.toasts }); + } + }); + }; + + getRoles(); + }, [dispatch, invokeClient, space.id, logger, notifications.toasts]); + + const showRolesPrivilegeEditor = useCallback( + (defaultSelected?: Role[]) => { + const overlayRef = overlays.openFlyout( + toMountPoint( + + { + const { updated, errors } = response; + + if (updated) { + 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 }, + } + ) + ); + } + + for (const [roleName, error] of Object.entries(errors ?? {})) { + notifications.toasts.addError(new Error(JSON.stringify(error)), { + title: `Error updating ${roleName}`, + }); + } + overlayRef.close(); + }, + closeFlyout: () => overlayRef.close(), + defaultSelected, + storeDispatch: dispatch, + spacesClientsInvocator: invokeClient, + getUrlForApp, + }} + /> + , + { theme, i18n: i18nStart } + ), + { + size: 'm', + maxWidth: true, + maskProps: { headerZindexLocation: 'below' }, + } + ); + }, + [ + overlays, + services, + space, + features, + dispatch, + invokeClient, + getUrlForApp, + theme, + i18nStart, + notifications.toasts, + ] + ); + + const removeRole = useCallback( + async (payload: Role[]) => { + // To remove the role from the space in bulk-edit, we take the payload of roles to edit, loop over + // each role, and modify the kibana.spaces field of each role by stripping them of the space to + // disassociate + 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 invokeClient((clients) => { + return clients.rolesClient.bulkUpdateRoles({ rolesUpdate: updateDoc }).then((response) => { + const { updated, errors } = response; + + if (updated) { + 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, + }, + }) + ); + } + + for (const [roleName, error] of Object.entries(errors ?? {})) { + notifications.toasts.addError(new Error(JSON.stringify(error)), { + title: `Error updating ${roleName}`, + }); + } + }); + }); + + dispatch({ type: 'remove_roles', payload: updateDoc }); + }, + [dispatch, invokeClient, notifications.toasts, space.id, space.name] + ); + + return ( + + + + + + + + + showRolesPrivilegeEditor([rowRecord])} + onClickBulkRemove={async (selectedRoles) => { + await removeRole(selectedRoles); + }} + onClickRowRemoveAction={async (rowRecord) => { + await removeRole([rowRecord]); + }} + onClickAssignNewRole={async () => { + showRolesPrivilegeEditor(); + }} + /> + + + + ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_tabs.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_tabs.tsx new file mode 100644 index 0000000000000..48731de7af98c --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_tabs.tsx @@ -0,0 +1,125 @@ +/* + * 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 { EuiNotificationBadge } from '@elastic/eui'; +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 { withSuspense } from '@kbn/shared-ux-utility'; + +import { TAB_ID_CONTENT, TAB_ID_GENERAL, TAB_ID_ROLES } from './constants'; +import type { Space } from '../../../common'; + +export interface EditSpaceTab { + id: string; + name: string; + content: JSX.Element; + append?: JSX.Element; + href?: string; +} + +export interface GetTabsProps { + space: Space; + rolesCount: number; + features: KibanaFeature[]; + history: ScopedHistory; + capabilities: Capabilities & { + roles?: { view: boolean; save: boolean }; + }; + allowFeatureVisibility: boolean; + allowSolutionVisibility: boolean; +} + +const SuspenseEditSpaceSettingsTab = withSuspense( + React.lazy(() => + import('./edit_space_general_tab').then(({ EditSpaceSettingsTab }) => ({ + default: EditSpaceSettingsTab, + })) + ) +); + +const SuspenseEditSpaceAssignedRolesTab = withSuspense( + React.lazy(() => + import('./edit_space_roles_tab').then(({ EditSpaceAssignedRolesTab }) => ({ + default: EditSpaceAssignedRolesTab, + })) + ) +); + +const SuspenseEditSpaceContentTab = withSuspense( + React.lazy(() => + import('./edit_space_content_tab').then(({ EditSpaceContentTab }) => ({ + default: EditSpaceContentTab, + })) + ) +); + +export const getTabs = ({ + space, + features, + history, + capabilities, + rolesCount, + ...props +}: GetTabsProps): EditSpaceTab[] => { + const canUserViewRoles = Boolean(capabilities?.roles?.view); + const canUserModifyRoles = Boolean(capabilities?.roles?.save); + const reloadWindow = () => { + window.location.reload(); + }; + + const tabsDefinition: EditSpaceTab[] = [ + { + id: TAB_ID_GENERAL, + name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.general.heading', { + defaultMessage: 'General settings', + }), + content: ( + + ), + }, + ]; + + if (canUserViewRoles) { + tabsDefinition.push({ + id: TAB_ID_ROLES, + name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.roles.heading', { + defaultMessage: 'Permissions', + }), + append: ( + + {rolesCount} + + ), + content: ( + + ), + }); + } + + tabsDefinition.push({ + id: TAB_ID_CONTENT, + name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.content.heading', { + defaultMessage: 'Content', + }), + content: , + }); + + return tabsDefinition; +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/footer.tsx b/x-pack/plugins/spaces/public/management/edit_space/footer.tsx new file mode 100644 index 0000000000000..013a356f9b400 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/footer.tsx @@ -0,0 +1,87 @@ +/* + * 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, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, +} from '@elastic/eui'; +import React from 'react'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +interface Props { + isDirty: boolean; + isLoading: boolean; + onClickCancel: () => void; + onClickSubmit: () => void; + onClickDeleteSpace: () => void; +} + +export const EditSpaceTabFooter: React.FC = ({ + isDirty, + isLoading, + onClickCancel, + onClickSubmit, + onClickDeleteSpace, +}) => { + return ( + <> + {isLoading && ( + + + + + + )} + {!isLoading && ( + + + + + + + + + + + + + + + {isDirty && ( + + + + + + )} + + )} + + ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/handle_api_error.ts b/x-pack/plugins/spaces/public/management/edit_space/handle_api_error.ts new file mode 100644 index 0000000000000..2d40a1c34b990 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/handle_api_error.ts @@ -0,0 +1,27 @@ +/* + * 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 { NotificationsStart } from '@kbn/core-notifications-browser'; +import type { Logger } from '@kbn/logging'; + +interface HandleErrorDeps { + toasts: NotificationsStart['toasts']; + logger: Logger; +} + +export const handleApiError = (error: any, deps: HandleErrorDeps) => { + const { logger, toasts } = deps; + + const message = error?.body?.message ?? error.toString(); + + logger.error(message); + logger.error(error); + + toasts.addError(error, { + title: message, + }); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/hooks/use_tabs.ts b/x-pack/plugins/spaces/public/management/edit_space/hooks/use_tabs.ts new file mode 100644 index 0000000000000..fc583e54b0693 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/hooks/use_tabs.ts @@ -0,0 +1,41 @@ +/* + * 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 { useMemo } from 'react'; + +import type { ScopedHistory } from '@kbn/core-application-browser'; +import type { KibanaFeature } from '@kbn/features-plugin/public'; + +import type { Space } from '../../../../common'; +import { type EditSpaceTab, getTabs, type GetTabsProps } from '../edit_space_tabs'; + +type UseTabsProps = Pick & { + space: Space | null; + features: KibanaFeature[] | null; + currentSelectedTabId: string; + history: ScopedHistory; + allowFeatureVisibility: boolean; + allowSolutionVisibility: boolean; +}; + +export const useTabs = ({ + space, + features, + currentSelectedTabId, + ...getTabsArgs +}: UseTabsProps): [EditSpaceTab[], JSX.Element | undefined] => { + const [tabs, selectedTabContent] = useMemo(() => { + if (space === null || features === null) { + return [[]]; + } + + const _tabs = space != null ? getTabs({ space, features, ...getTabsArgs }) : []; + return [_tabs, _tabs.find((obj) => obj.id === currentSelectedTabId)?.content]; + }, [space, features, getTabsArgs, currentSelectedTabId]); + + return [tabs, selectedTabContent]; +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/index.ts b/x-pack/plugins/spaces/public/management/edit_space/index.ts index 78c3b0fc42e04..c85e8f1c2e499 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/index.ts +++ b/x-pack/plugins/spaces/public/management/edit_space/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { ManageSpacePage } from './manage_space_page'; +export { EditSpacePage } from './edit_space_page'; diff --git a/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.test.tsx new file mode 100644 index 0000000000000..bfd7d7b6059e8 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.test.tsx @@ -0,0 +1,113 @@ +/* + * 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, + loggingSystemMock, + 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 { EditSpaceProvider, useEditSpaceServices, useEditSpaceStore } from './edit_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 logger = loggingSystemMock.createLogger(); + +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('EditSpaceProvider', () => { + describe('useEditSpaceServices', () => { + it('returns an object of predefined properties', () => { + const { result } = renderHook(useEditSpaceServices, { 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(useEditSpaceServices); + expect(result.error).toBeDefined(); + expect(result.error?.message).toEqual( + expect.stringMatching('EditSpaceService Context is missing.') + ); + }); + }); + + describe('useEditSpaceStore', () => { + it('returns an object of predefined properties', () => { + const { result } = renderHook(useEditSpaceStore, { 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(useEditSpaceStore); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toEqual( + expect.stringMatching('EditSpaceStore Context is missing.') + ); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.tsx b/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.tsx new file mode 100644 index 0000000000000..75af2beea2108 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.tsx @@ -0,0 +1,149 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import type { + PrivilegesAPIClientPublicContract, + RolesAPIClient, +} from '@kbn/security-plugin-types-public'; + +import { + createSpaceRolesReducer, + type IDispatchAction, + type IEditSpaceStoreState, +} from './reducers'; +import type { SpacesManager } from '../../../spaces_manager'; + +export interface EditSpaceProviderProps + extends Pick { + logger: Logger; + capabilities: ApplicationStart['capabilities']; + getUrlForApp: ApplicationStart['getUrlForApp']; + navigateToUrl: ApplicationStart['navigateToUrl']; + serverBasePath: string; + spacesManager: SpacesManager; + getRolesAPIClient: () => Promise; + getPrivilegesAPIClient: () => Promise; +} + +export interface EditSpaceServices extends EditSpaceProviderProps { + invokeClient(arg: (clients: EditSpaceClients) => Promise): Promise; +} + +interface EditSpaceClients { + spacesManager: SpacesManager; + rolesClient: RolesAPIClient; + privilegesClient: PrivilegesAPIClientPublicContract; +} + +export interface EditSpaceStore { + state: IEditSpaceStoreState; + dispatch: Dispatch; +} + +const createSpaceRolesContext = once(() => createContext(null)); + +const createEditSpaceServicesContext = once(() => createContext(null)); + +export const EditSpaceProvider = ({ + children, + ...services +}: PropsWithChildren) => { + const EditSpaceStoreContext = createSpaceRolesContext(); + const EditSpaceServicesContext = createEditSpaceServicesContext(); + + const clients = useRef( + Promise.all([services.getRolesAPIClient(), services.getPrivilegesAPIClient()]) + ); + const rolesAPIClientRef = useRef(); + const privilegesClientRef = useRef(); + + const initialStoreState = useRef({ + roles: new Map(), + fetchRolesError: false, + }); + + const { logger } = services; + const resolveAPIClients = useCallback(async () => { + try { + [rolesAPIClientRef.current, privilegesClientRef.current] = await clients.current; + } catch (err) { + logger.error('Could not resolve API Clients!', err); + } + }, [logger]); + + useEffect(() => { + resolveAPIClients(); + }, [resolveAPIClients]); + + const createInitialState = useCallback((state: IEditSpaceStoreState) => { + return state; + }, []); + + const [state, dispatch] = useReducer( + createSpaceRolesReducer, + initialStoreState.current, + createInitialState + ); + + const invokeClient: EditSpaceServices['invokeClient'] = useCallback( + async (...args) => { + await resolveAPIClients(); + + return args[0]({ + spacesManager: services.spacesManager, + rolesClient: rolesAPIClientRef.current!, + privilegesClient: privilegesClientRef.current!, + }); + }, + [resolveAPIClients, services.spacesManager] + ); + + return ( + + + {children} + + + ); +}; + +export const useEditSpaceServices = (): EditSpaceServices => { + const context = useContext(createEditSpaceServicesContext()); + if (!context) { + throw new Error( + 'EditSpaceService Context is missing. Ensure the component or React root is wrapped with EditSpaceProvider' + ); + } + + return context; +}; + +export const useEditSpaceStore = () => { + const context = useContext(createSpaceRolesContext()); + if (!context) { + throw new Error( + 'EditSpaceStore Context is missing. Ensure the component or React root is wrapped with EditSpaceProvider' + ); + } + + return context; +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/provider/index.ts b/x-pack/plugins/spaces/public/management/edit_space/provider/index.ts new file mode 100644 index 0000000000000..7ae7301cd2c60 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/provider/index.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export { EditSpaceProvider, useEditSpaceServices, useEditSpaceStore } from './edit_space_provider'; +export type { + EditSpaceProviderProps, + EditSpaceServices, + EditSpaceStore, +} from './edit_space_provider'; diff --git a/x-pack/plugins/spaces/public/management/edit_space/provider/reducers/index.ts b/x-pack/plugins/spaces/public/management/edit_space/provider/reducers/index.ts new file mode 100644 index 0000000000000..66e66681fc759 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/provider/reducers/index.ts @@ -0,0 +1,66 @@ +/* + * 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 the records of roles for a space */ + type: 'update_roles' | 'remove_roles'; + payload: Role[]; + } + | { + /** @description updates to true if user does not have privilege to view roles */ + type: 'fetch_roles_error'; + payload: boolean; + } + | { + type: 'string'; + payload: unknown; + }; + +export interface IEditSpaceStoreState { + /** roles assigned to current space */ + roles: Map; + /** track if there was an error on the attempt to fetch roles **/ + fetchRolesError: boolean; +} + +export const createSpaceRolesReducer: Reducer = ( + state, + action +) => { + const clonedState = structuredClone(state); + + switch (action.type) { + case 'update_roles': { + if (action.payload) { + action.payload.forEach((role) => { + clonedState.roles.set(role.name, role); + }); + } + + return clonedState; + } + case 'remove_roles': { + action.payload.forEach((role) => { + clonedState.roles.delete(role.name); + }); + + return clonedState; + } + case 'fetch_roles_error': { + clonedState.fetchRolesError = action.payload; + return clonedState; + } + default: { + return clonedState; + } + } +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx new file mode 100644 index 0000000000000..9150f0c211adb --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx @@ -0,0 +1,308 @@ +/* + * 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 { + httpServiceMock, + i18nServiceMock, + loggingSystemMock, + notificationServiceMock, + overlayServiceMock, + themeServiceMock, +} from '@kbn/core/public/mocks'; +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 { FEATURE_PRIVILEGES_ALL, FEATURE_PRIVILEGES_READ } from '../../../../../common/constants'; +import { spacesManagerMock } from '../../../../spaces_manager/spaces_manager.mock'; +import { + createPrivilegeAPIClientMock, + getPrivilegeAPIClientMock, +} from '../../../privilege_api_client.mock'; +import { createRolesAPIClientMock, getRolesAPIClientMock } from '../../../roles_api_client.mock'; +import { EditSpaceProvider } from '../../provider'; + +const rolesAPIClient = createRolesAPIClientMock(); +const privilegeAPIClient = createPrivilegeAPIClientMock(); +const http = httpServiceMock.createStartContract(); +const notifications = notificationServiceMock.createStartContract(); +const overlays = overlayServiceMock.createStartContract(); +const theme = themeServiceMock.createStartContract(); +const i18n = i18nServiceMock.createStartContract(); +const logger = loggingSystemMock.createLogger(); +const spacesManager = spacesManagerMock.create(); + +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( + + _), + getRolesAPIClient: getRolesAPIClientMock, + getPrivilegesAPIClient: getPrivilegeAPIClientMock, + navigateToUrl: jest.fn(), + capabilities: { + navLinks: {}, + management: {}, + catalogue: {}, + spaces: { manage: true }, + }, + }} + > + _), + }} + /> + + + ); +}; + +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('does not display the privilege selection buttons or customization form when no role is selected', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + renderPrivilegeRolesForm(); + + await waitFor(() => null); + + ['all', 'read', 'custom'].forEach((privilege) => { + expect(screen.queryByTestId(`${privilege}-privilege-button`)).not.toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('space-assign-role-privilege-customization-form') + ).not.toBeInTheDocument(); + }); + + it('renders with the assign roles button disabled when no role is selected', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + renderPrivilegeRolesForm(); + + await waitFor(() => null); + + expect(screen.getByTestId('space-assign-role-create-roles-privilege-button')).toBeDisabled(); + }); + + it('preselects the privilege of the selected role when one is provided', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + renderPrivilegeRolesForm({ + preSelectedRoles: [ + createRole('test_role_1', [ + { base: [FEATURE_PRIVILEGES_ALL], feature: {}, spaces: [space.id] }, + ]), + ], + }); + + await waitFor(() => null); + + expect(screen.getByTestId(`${FEATURE_PRIVILEGES_ALL}-privilege-button`)).toHaveAttribute( + 'aria-pressed', + String(true) + ); + }); + + it('displays the privilege customization form, when there is a selected role', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + const roles: Role[] = [ + createRole('test_role_1', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + ]; + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await waitFor(() => null); + + expect(screen.getByTestId(`${FEATURE_PRIVILEGES_READ}-privilege-button`)).toHaveAttribute( + 'aria-pressed', + String(true) + ); + + expect( + screen.getByTestId('space-assign-role-privilege-customization-form') + ).toBeInTheDocument(); + + expect( + screen.getByTestId('space-update-role-create-roles-privilege-button') + ).not.toBeDisabled(); + }); + + describe('selecting multiple roles', () => { + 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: [FEATURE_PRIVILEGES_ALL], feature: {}, spaces: [space.id] }, + ]), + createRole('test_role_2', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + ]; + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await waitFor(() => null); + + expect(screen.getByTestId('privilege-conflict-callout')).toBeInTheDocument(); + }); + + it('does not display the permission conflict message when roles with the same privilege levels are selected', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + const roles: Role[] = [ + createRole('test_role_1', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + createRole('test_role_2', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + ]; + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await waitFor(() => null); + + expect(screen.queryByTestId('privilege-conflict-callout')).not.toBeInTheDocument(); + }); + }); + + describe('applying custom privileges', () => { + 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: [FEATURE_PRIVILEGES_ALL], feature: {}, spaces: [space.id] }, + ]), + createRole('test_role_2', [ + { base: [], feature: { [featureIds[0]]: [FEATURE_PRIVILEGES_ALL] }, spaces: [space.id] }, + ]), + createRole('test_role_3', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + createRole('test_role_4', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + // empty base denotes role with custom privilege + createRole('test_role_5', [ + { base: [], feature: { [featureIds[0]]: [FEATURE_PRIVILEGES_READ] }, spaces: [space.id] }, + ]), + ]; + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await waitFor(() => null); + + await userEvent.click(screen.getByTestId('custom-privilege-button')); + + expect( + screen.getByTestId('space-assign-role-privilege-customization-form') + ).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/edit_space/roles/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx new file mode 100644 index 0000000000000..658730a848a33 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx @@ -0,0 +1,610 @@ +/* + * 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, + EuiLink, + EuiLoadingSpinner, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import type { FC } 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'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { type RawKibanaPrivileges } from '@kbn/security-authorization-core'; +import type { Role } from '@kbn/security-plugin-types-common'; +import type { BulkUpdateRoleResponse } from '@kbn/security-plugin-types-public/src/roles/roles_api_client'; +import { KibanaPrivileges } from '@kbn/security-role-management-model'; +import { KibanaPrivilegeTable, PrivilegeFormCalculator } from '@kbn/security-ui-components'; + +import type { Space } from '../../../../../common'; +import { + FEATURE_PRIVILEGES_ALL, + FEATURE_PRIVILEGES_CUSTOM, + FEATURE_PRIVILEGES_READ, +} from '../../../../../common/constants'; +import { type EditSpaceServices, type EditSpaceStore, useEditSpaceServices } from '../../provider'; + +type KibanaRolePrivilege = + | keyof NonNullable + | typeof FEATURE_PRIVILEGES_CUSTOM; + +interface PrivilegesRolesFormProps { + space: Space; + features: KibanaFeature[]; + closeFlyout: () => void; + onSaveCompleted: (response: BulkUpdateRoleResponse) => void; + /** + * @description default roles that should be selected when the form is opened, + * this is useful when the form is opened in edit mode + */ + defaultSelected?: Role[]; + storeDispatch: EditSpaceStore['dispatch']; + spacesClientsInvocator: EditSpaceServices['invokeClient']; + getUrlForApp: EditSpaceServices['getUrlForApp']; +} + +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, + getUrlForApp, + } = props; + const { logger, notifications } = useEditSpaceServices(); + 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 isEditOperation = useRef(Boolean(defaultSelected.length)); + + 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 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 : [FEATURE_PRIVILEGES_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 + ? FEATURE_PRIVILEGES_ALL + : selectedRolesCombinedPrivileges[0] + ); + + const [roleCustomizationAnchor, setRoleCustomizationAnchor] = useState<{ + value: Role; + privilegeIndex: number; + }>(() => { + if (!selectedRoles.length) { + // return a skeleton anchor on init when no roles are selected + return { + value: { + name: 'placeholder', + elasticsearch: { + cluster: [], + run_as: [], + indices: [], + }, + kibana: [ + { + spaces: [space.id], + base: [roleSpacePrivilege === FEATURE_PRIVILEGES_CUSTOM ? '' : roleSpacePrivilege], + feature: {}, + }, + ], + }, + privilegeIndex: 0, + }; + } + + // 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, + }; + }); + + /** + * @description computes the value anchor role that will be used as the starting point for granular customizations + * on the selected roles. + */ + 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 === FEATURE_PRIVILEGES_CUSTOM) { + const _roleCustomizationAnchor = computeRoleCustomizationAnchor(space.id, selectedRoles); + if (_roleCustomizationAnchor) setRoleCustomizationAnchor(_roleCustomizationAnchor); + } else { + // opt for simple updates for customization anchor when privilege is not a custom one, especially that it's used only for visual treatment + setRoleCustomizationAnchor(({ value, privilegeIndex }) => { + value.kibana[privilegeIndex!] = { + spaces: [space.id], + base: [spacePrivilege], + feature: {}, + }; + + return { value, privilegeIndex }; + }); + } + + // persist selected privilege for UI + setRoleSpacePrivilege(spacePrivilege); + }, + [computeRoleCustomizationAnchor, selectedRoles, space.id] + ); + + const assignRolesToSpace = useCallback(async () => { + try { + setAssigningToRole(true); + + const newPrivileges = { + base: roleSpacePrivilege === FEATURE_PRIVILEGES_CUSTOM ? [] : [roleSpacePrivilege], + feature: + roleSpacePrivilege === FEATURE_PRIVILEGES_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) { + // account for instance where current space belongs to a collection of other spaces that share the same privileges that are grouped together, + // since we intend to apply the new privilege exclusively to the current space + // 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((response) => { + setAssigningToRole(false); + onSaveCompleted(response); + }) + ); + + storeDispatch({ + type: 'update_roles', + payload: updatedRoles, + }); + } catch (error) { + logger.error('Could not assign role to space!', error); + const message = error?.body?.message ?? error.toString(); + + notifications.toasts.addError(error, { + title: i18n.translate('xpack.spaces.management.spaceDetails.errorAssigningRoleTitle', { + defaultMessage: 'Error assigning role to space: {message}', + values: { message }, + }), + }); + } + }, [ + selectedRoles, + spacesClientsInvocator, + storeDispatch, + onSaveCompleted, + space.id, + roleSpacePrivilege, + roleCustomizationAnchor, + logger, + notifications.toasts, + ]); + + const getForm = () => { + return ( + + + {!isEditOperation.current && ( + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.selectRolesFormRowLabelAnchor', + { defaultMessage: 'Manage roles' } + )} + + } + helpText={i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.selectRolesHelp', + { + defaultMessage: 'Select Kibana spaces to which you wish to assign privileges.', + } + )} + > + setSelectedRoles(value)} + fullWidth + /> + + )} + + + {Boolean(selectedRoles.length) && ( + + + {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)} + isFullWidth + /> + + {Boolean(selectedRoles.length) && ( + + + {!kibanaPrivileges ? ( + + ) : ( + { + // apply selected changes only to the designated customization anchor, this way we delay reconciling the intending privileges + // to all 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) => { + // apply selected changes only to the designated customization anchor, this way we delay reconciling the intending privileges + // to all of the selected roles till we decide to commit the changes chosen + setRoleCustomizationAnchor(({ value, privilegeIndex }) => { + let privilege; + + if ((privilege = value!.kibana?.[privilegeIndex!])) { + privilege.base = _privilege; + } + + return { value, privilegeIndex }; + }); + }} + kibanaPrivileges={new KibanaPrivileges(kibanaPrivileges, features)} + privilegeCalculator={ + new PrivilegeFormCalculator( + new KibanaPrivileges(kibanaPrivileges, features), + roleCustomizationAnchor.value! + ) + } + allSpacesSelected={false} + canCustomizeSubFeaturePrivileges={false} + /> + )} + + + )} + + )} + + + ); + }; + + const getSaveButton = useCallback(() => { + return ( + assignRolesToSpace()} + data-test-subj={`space-${ + isEditOperation.current ? 'update' : 'assign' + }-role-create-roles-privilege-button`} + > + {isEditOperation.current + ? i18n.translate('xpack.spaces.management.spaceDetails.roles.updateRoleButton', { + defaultMessage: 'Update', + }) + : i18n.translate('xpack.spaces.management.spaceDetails.roles.assignRoleButton', { + defaultMessage: 'Assign', + })} + + ); + }, [assignRolesToSpace, assigningToRole, selectedRoles.length]); + + return ( + + + +

+ {isEditOperation.current + ? i18n.translate('xpack.spaces.management.spaceDetails.roles.assignRoleButton', { + defaultMessage: 'Edit role privileges', + }) + : i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.assign.privileges.custom', + { + defaultMessage: 'Assign roles to space', + } + )} +

+
+ + +

+ +

+
+
+ {getForm()} + + + + + {i18n.translate('xpack.spaces.management.spaceDetails.roles.cancelRoleButton', { + defaultMessage: 'Cancel', + })} + + + {getSaveButton()} + + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.test.tsx new file mode 100644 index 0000000000000..f909dba415c41 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.test.tsx @@ -0,0 +1,146 @@ +/* + * 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, within } from '@testing-library/react'; +import React, { type ComponentProps } from 'react'; + +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import type { Role } from '@kbn/security-plugin-types-common'; + +import { SpaceAssignedRolesTable } from './space_assigned_roles_table'; + +const defaultProps: Pick< + ComponentProps, + | 'onClickAssignNewRole' + | 'onClickBulkRemove' + | 'onClickRowEditAction' + | 'onClickRowRemoveAction' + | 'currentSpace' +> = { + currentSpace: { + id: 'odyssey', + name: 'Odyssey', + disabledFeatures: [], + }, + onClickBulkRemove: jest.fn(), + onClickRowEditAction: jest.fn(), + onClickAssignNewRole: jest.fn(), + onClickRowRemoveAction: jest.fn(), +}; + +const renderTestComponent = ( + props: Pick< + ComponentProps, + 'assignedRoles' | 'isReadOnly' | 'supportsBulkAction' + > +) => { + render( + + + + ); +}; + +describe('SpaceAssignedRolesTable', () => { + const spaceAssignedRoles = new Map( + [ + { + name: 'Odyssey', + description: 'Journey vs. Destination', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana: [ + { + spaces: [defaultProps.currentSpace.id], + base: ['all'], + feature: {}, + }, + ], + }, + { + name: 'Iliad', + description: '???', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana: [ + { + spaces: [defaultProps.currentSpace.id], + base: ['read'], + feature: {}, + }, + ], + }, + { + name: 'Trisolaris', + description: 'Dark Forest???', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + ], + metadata: { + _reserved: true, + }, + }, + ].map((role) => [role.name.toLocaleLowerCase(), role]) + ); + + it('renders the table', () => { + renderTestComponent({ + assignedRoles: spaceAssignedRoles, + }); + + expect(screen.getByTestId('spaceAssignedRolesTable')).toBeInTheDocument(); + }); + + it('does not render row selection and bulk actions context menu by default', () => { + renderTestComponent({ + assignedRoles: spaceAssignedRoles, + supportsBulkAction: false, + }); + + expect(screen.getByTestId('spaceAssignedRolesTable')).toBeInTheDocument(); + expect(screen.queryByTestId('bulkActionsContextMenuOpener')).toBeNull(); + expect(screen.queryByTestId('checkboxSelectAll')).toBeNull(); + }); + + it('renders with row selection and bulk actions context menu when bulk action are supported and table is not in readOnly mode', () => { + renderTestComponent({ + assignedRoles: spaceAssignedRoles, + supportsBulkAction: true, + }); + + expect(screen.getByTestId('spaceAssignedRolesTable')).toBeInTheDocument(); + expect(screen.getByTestId('bulkActionsContextMenuOpener')).toBeInTheDocument(); + expect(screen.getByTestId('checkboxSelectAll')).toBeInTheDocument(); + }); + + // it('will not render the bulk actions context menu when the table is in readOnly mode', () => {}) + + it('prevents modification of reserved roles', () => { + renderTestComponent({ + assignedRoles: spaceAssignedRoles, + supportsBulkAction: true, + }); + + expect(screen.getByTestId('spaceAssignedRolesTable')).toBeInTheDocument(); + + const trisolarisRow = screen.getByTestId('space-role-row-Trisolaris'); + + expect(trisolarisRow).toBeInTheDocument(); + + // We expect a length of 2 because EUI also adds a second node for screen readers + expect(within(trisolarisRow).getAllByText('Reserved')).toHaveLength(2); + expect(within(trisolarisRow).getByTestId('spaceRoleCellActionLocked')).toBeInTheDocument(); + expect(within(trisolarisRow).getByTestId('spaceRoleCellActionLocked')).toBeDisabled(); + expect( + within(trisolarisRow).queryByTestId('spaceRoleCellDeleteAction') + ).not.toBeInTheDocument(); + expect(within(trisolarisRow).queryByTestId('spaceRoleCellEditAction')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx new file mode 100644 index 0000000000000..6a1d9f24bc042 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx @@ -0,0 +1,509 @@ +/* + * 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'; +import { sortRolesForListing } from '../../../lib'; + +interface ISpaceAssignedRolesTableProps { + isReadOnly?: boolean; + currentSpace: Space; + assignedRoles: Map; + onClickAssignNewRole: () => Promise; + onClickRowEditAction: (role: Role) => void; + onClickRowRemoveAction: (role: Role) => void; + supportsBulkAction?: boolean; + onClickBulkRemove?: (selectedRoles: Role[]) => void; +} + +const isRoleReserved = (role: Role) => { + return role.metadata?._reserved; +}; +const isRoleAssignedToAll = (role: Role) => { + return role.kibana.reduce((acc, cur) => { + return cur.spaces.includes('*') || acc; + }, false); +}; + +/** + * @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 !(isRoleReserved(role) || isRoleAssignedToAll(role)); +}; + +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', + 'data-test-subj': 'spaceRoleCellActionLocked', + name: (role) => + isRoleReserved(role) + ? i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.notEditableTitle.isReserved', + { defaultMessage: 'Reserved' } + ) + : i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.notEditableTitle.isAssignedToAll', + { defaultMessage: 'Assigned to all spaces' } + ), + description: (role) => + isRoleReserved(role) + ? i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.notEditableDescription.isReserved', + { defaultMessage: `You can’t edit the access of reserved roles to this space.` } + ) + : i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.notEditableDescription.isAssignedToAll', + { + defaultMessage: `Can't perform actions on a role that is assigned to all spaces`, + } + ), + isPrimary: true, + enabled: () => false, + available: (rowRecord) => !isEditableRole(rowRecord), + }, + { + type: 'icon', + icon: 'pencil', + 'data-test-subj': 'spaceRoleCellEditAction', + 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', + 'data-test-subj': 'spaceRoleCellDeleteAction', + 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 = ({ + assignedRoles, + currentSpace, + onClickAssignNewRole, + onClickBulkRemove, + onClickRowEditAction, + onClickRowRemoveAction, + isReadOnly = false, + supportsBulkAction = false, +}: 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(() => { + const valuesFromMap = Array.from(assignedRoles.values()); + const sortedRoles = valuesFromMap.sort(sortRolesForListing); + setRolesInView(sortedRoles); + }, [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: { + fullWidth: false, + incremental: true, + 'data-test-subj': 'spaceAssignedRolesSearchBox', + placeholder: i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.searchField.placeholder', + { defaultMessage: 'Filter assigned roles...' } + ), + }, + onChange: onSearchQueryChange, + toolsRight: ( + <> + {!isReadOnly && ( + + + {i18n.translate('xpack.spaces.management.spaceDetails.roles.assign', { + defaultMessage: 'Assign new roles', + })} + + + )} + + ), + }; + }, [isReadOnly, onClickAssignNewRole, onSearchQueryChange]); + + const tableHeader = useMemo['childrenBetween']>(() => { + if (!supportsBulkAction) { + return null; + } + + const pageSize = pagination.size; + const pageIndex = pagination.index; + + const selectableRoles = rolesInView.filter((role) => isEditableRole(role) && !isReadOnly); + + return ( + + + + + + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectedStatusInfo', + { + defaultMessage: + 'Showing: {pageItemLength} of {rolesInViewCount} | Selected: {selectedCount, plural, one {one role} other {{selectedCount} roles}}', + values: { + pageItemLength: Math.floor( + rolesInView.length / (pageSize * (pageIndex + 1)) + ) + ? pageSize * (pageIndex + 1) + : rolesInView.length % pageSize, + rolesInViewCount: rolesInView.length, + selectedCount: selectedRoles.length, + }, + } + )} + + + + + + {!isReadOnly && ( + + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.contextMenuOpener', + { defaultMessage: 'Bulk actions' } + )} + + } + > + , + name: ( + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.remove', + { defaultMessage: 'Remove from space' } + )} + + ), + onClick: async () => { + 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 }, + } + ), + }), + })} + + + )} + + + + + + + + ); + }, [ + pagination.size, + pagination.index, + rolesInView, + selectedRoles, + isReadOnly, + supportsBulkAction, + isBulkActionContextOpen, + onClickBulkRemove, + ]); + + const onTableChange = ({ page }: CriteriaWithPagination) => { + setPagination(page); + }; + + const onSelectionChange = (selection: Role[]) => { + setSelectedRoles(selection); + }; + + const selection: EuiTableSelectionType | undefined = useMemo(() => { + if (!supportsBulkAction) { + return void 0; + } + + return { + selected: selectedRoles, + selectable: (role) => isEditableRole(role), + selectableMessage: (_selectable, role) => { + if (isRoleReserved(role)) { + return i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectableMessage.isReserved', + { defaultMessage: `You can't select a role that is reserved` } + ); + } + if (isRoleAssignedToAll(role)) { + return i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectableMessage.isRoleAssignedToAll', + { defaultMessage: `You can't select a role that is assigned to all spaces` } + ); + } + + return i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectableMessage.selectRole', + { defaultMessage: `Select {roleName}`, values: { roleName: role.name } } + ); + }, + onSelectionChange, + }; + }, [selectedRoles, supportsBulkAction]); + + return ( + + + + data-test-subj="spaceAssignedRolesTable" + 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/lib/index.ts b/x-pack/plugins/spaces/public/management/lib/index.ts index cbeb9036ec4ed..0507a79cb76a5 100644 --- a/x-pack/plugins/spaces/public/management/lib/index.ts +++ b/x-pack/plugins/spaces/public/management/lib/index.ts @@ -8,3 +8,5 @@ export { toSpaceIdentifier, isValidSpaceIdentifier } from './space_identifier_utils'; export { SpaceValidator } from './validate_space'; + +export { sortRolesForListing } from './sort_roles'; diff --git a/x-pack/plugins/spaces/public/management/lib/sort_roles.test.ts b/x-pack/plugins/spaces/public/management/lib/sort_roles.test.ts new file mode 100644 index 0000000000000..bba4e1e7430c9 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/lib/sort_roles.test.ts @@ -0,0 +1,171 @@ +/* + * 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 { sortRolesForListing } from './sort_roles'; + +const createCustom = (name: string): Role => { + return { + name, + metadata: { _reserved: false }, + } as unknown as Role; +}; + +const createReserved = (name: string): Role => { + return { + name, + metadata: { _reserved: true }, + } as unknown as Role; +}; + +const expected = [ + 'Apple', + 'Banana', + 'Cherry', + 'Date', + 'Elderberry', + 'Fig', + 'Grape', + 'Honeydew melon', + 'Indian fig', + 'Jackfruit', + 'Kiwi', + 'Lemon', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Quince', + 'Raspberry', + 'Strawberry', + 'Tangerine', + 'Artichoke', + 'Broccoli', + 'Carrot', + 'Daikon', + 'Eggplant', + 'Fennel', + 'Garlic', + 'Horseradish', + 'Iceberg lettuce', + 'Jalapeño', + 'Kale', + 'Leek', + 'Mushroom', + 'Napa cabbage', + 'Okra', + 'Parsnip', + 'Quinoa greens', + 'Radish', + 'Spinach', + 'Turnip', +]; + +describe('sortRolesForListing: sorts the roles correctly', () => { + it('when they are originally sorted alphabetically', () => { + const roles = [ + createCustom('Apple'), + createReserved('Artichoke'), + createCustom('Banana'), + createReserved('Broccoli'), + createReserved('Carrot'), + createCustom('Cherry'), + createReserved('Daikon'), + createCustom('Date'), + createReserved('Eggplant'), + createCustom('Elderberry'), + createReserved('Fennel'), + createCustom('Fig'), + createReserved('Garlic'), + createCustom('Grape'), + createCustom('Honeydew melon'), + createReserved('Horseradish'), + createReserved('Iceberg lettuce'), + createCustom('Indian fig'), + createCustom('Jackfruit'), + createReserved('Jalapeño'), + createReserved('Kale'), + createCustom('Kiwi'), + createReserved('Leek'), + createCustom('Lemon'), + createCustom('Mango'), + createReserved('Mushroom'), + createReserved('Napa cabbage'), + createCustom('Nectarine'), + createReserved('Okra'), + createCustom('Orange'), + createCustom('Papaya'), + createReserved('Parsnip'), + createCustom('Quince'), + createReserved('Quinoa greens'), + createReserved('Radish'), + createCustom('Raspberry'), + createReserved('Spinach'), + createCustom('Strawberry'), + createCustom('Tangerine'), + createReserved('Turnip'), + ]; + + const sortResult = roles.sort(sortRolesForListing); + const names = sortResult.map(({ name }) => name); + + // expect fruits to be at the top, otherwise sorted alphabetically + expect(names).toEqual(expected); + }); + + it('when they are originally sorted randomly', () => { + const roles = [ + createReserved('Iceberg lettuce'), + createCustom('Nectarine'), + createCustom('Strawberry'), + createReserved('Jalapeño'), + createCustom('Papaya'), + createReserved('Fennel'), + createCustom('Lemon'), + createCustom('Grape'), + createReserved('Artichoke'), + createCustom('Apple'), + createReserved('Quinoa greens'), + createCustom('Quince'), + createCustom('Raspberry'), + createReserved('Leek'), + createReserved('Radish'), + createReserved('Daikon'), + createReserved('Turnip'), + createCustom('Elderberry'), + createCustom('Tangerine'), + createReserved('Broccoli'), + createReserved('Mushroom'), + createCustom('Honeydew melon'), + createCustom('Kiwi'), + createCustom('Fig'), + createCustom('Mango'), + createCustom('Banana'), + createCustom('Jackfruit'), + createReserved('Napa cabbage'), + createReserved('Spinach'), + createCustom('Orange'), + createReserved('Okra'), + createReserved('Eggplant'), + createReserved('Kale'), + createCustom('Cherry'), + createReserved('Horseradish'), + createReserved('Garlic'), + createReserved('Carrot'), + createCustom('Date'), + createReserved('Parsnip'), + createCustom('Indian fig'), + ]; + + const sortResult = roles.sort(sortRolesForListing); + const names = sortResult.map(({ name }) => name); + + // expect fruits to be at the top, otherwise sorted alphabetically + expect(names).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/lib/sort_roles.ts b/x-pack/plugins/spaces/public/management/lib/sort_roles.ts new file mode 100644 index 0000000000000..952502fcc777b --- /dev/null +++ b/x-pack/plugins/spaces/public/management/lib/sort_roles.ts @@ -0,0 +1,28 @@ +/* + * 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'; + +/** + * Roles in the listing must be sorted so that custom roles appear in the beginning + * and reserved roles appear at the end + */ +export function sortRolesForListing(aRole: Role, bRole: Role) { + const { name: aName, metadata: aMeta } = aRole; + const { name: bName, metadata: bMeta } = bRole; + const aReserved = aMeta?._reserved ?? false; + const bReserved = bMeta?._reserved ?? false; + + if (aReserved && !bReserved) { + return 1; + } + if (!aReserved && bReserved) { + return -1; + } + + return aName.localeCompare(bName); +} diff --git a/x-pack/plugins/spaces/public/management/lib/validate_space.ts b/x-pack/plugins/spaces/public/management/lib/validate_space.ts index 9a9ae0cbe98fd..7a7980028dad0 100644 --- a/x-pack/plugins/spaces/public/management/lib/validate_space.ts +++ b/x-pack/plugins/spaces/public/management/lib/validate_space.ts @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { isValidSpaceIdentifier } from './space_identifier_utils'; import { isReservedSpace } from '../../../common/is_reserved_space'; -import type { FormValues } from '../edit_space/manage_space_page'; +import type { CustomizeSpaceFormValues } from '../types'; interface SpaceValidatorOptions { shouldValidate?: boolean; @@ -32,7 +32,7 @@ export class SpaceValidator { this.shouldValidate = false; } - public validateSpaceName(space: FormValues) { + public validateSpaceName(space: CustomizeSpaceFormValues) { if (!this.shouldValidate) { return valid(); } @@ -56,7 +56,7 @@ export class SpaceValidator { return valid(); } - public validateSpaceDescription(space: FormValues) { + public validateSpaceDescription(space: CustomizeSpaceFormValues) { if (!this.shouldValidate) { return valid(); } @@ -72,7 +72,7 @@ export class SpaceValidator { return valid(); } - public validateURLIdentifier(space: FormValues) { + public validateURLIdentifier(space: CustomizeSpaceFormValues) { if (!this.shouldValidate) { return valid(); } @@ -104,7 +104,7 @@ export class SpaceValidator { return valid(); } - public validateAvatarInitials(space: FormValues) { + public validateAvatarInitials(space: CustomizeSpaceFormValues) { if (!this.shouldValidate) { return valid(); } @@ -129,7 +129,7 @@ export class SpaceValidator { return valid(); } - public validateAvatarColor(space: FormValues) { + public validateAvatarColor(space: CustomizeSpaceFormValues) { if (!this.shouldValidate) { return valid(); } @@ -153,7 +153,7 @@ export class SpaceValidator { return valid(); } - public validateAvatarImage(space: FormValues) { + public validateAvatarImage(space: CustomizeSpaceFormValues) { if (!this.shouldValidate) { return valid(); } @@ -170,7 +170,7 @@ export class SpaceValidator { } public validateSolutionView( - space: FormValues, + space: CustomizeSpaceFormValues, isEditing: boolean, allowSolutionVisibility = true ) { @@ -189,11 +189,15 @@ export class SpaceValidator { return valid(); } - public validateEnabledFeatures(space: FormValues) { + public validateEnabledFeatures(space: CustomizeSpaceFormValues) { return valid(); } - public validateForSave(space: FormValues, isEditing: boolean, allowSolutionVisibility: boolean) { + public validateForSave( + space: CustomizeSpaceFormValues, + isEditing: boolean, + allowSolutionVisibility: boolean + ) { const { isInvalid: isNameInvalid } = this.validateSpaceName(space); const { isInvalid: isDescriptionInvalid } = this.validateSpaceDescription(space); const { isInvalid: isIdentifierInvalid } = this.validateURLIdentifier(space); diff --git a/x-pack/plugins/spaces/public/management/management_service.test.ts b/x-pack/plugins/spaces/public/management/management_service.test.ts index e5438c0cf5e9c..40a61397e286f 100644 --- a/x-pack/plugins/spaces/public/management/management_service.test.ts +++ b/x-pack/plugins/spaces/public/management/management_service.test.ts @@ -7,6 +7,7 @@ import type { CoreSetup } from '@kbn/core/public'; import { coreMock } from '@kbn/core/public/mocks'; +import { loggingSystemMock } from '@kbn/core-logging-browser-mocks'; import type { ManagementSection } from '@kbn/management-plugin/public'; import { managementPluginMock } from '@kbn/management-plugin/public/mocks'; @@ -18,6 +19,7 @@ import type { PluginsStart } from '../plugin'; import { spacesManagerMock } from '../spaces_manager/mocks'; const eventTracker = new EventTracker({ reportEvent: jest.fn() }); +const logger = loggingSystemMock.createLogger(); describe('ManagementService', () => { const config: ConfigType = { @@ -44,6 +46,7 @@ describe('ManagementService', () => { .getStartServices as CoreSetup['getStartServices'], spacesManager: spacesManagerMock.create(), config, + logger, getRolesAPIClient: getRolesAPIClientMock, getPrivilegesAPIClient: jest.fn(), eventTracker, @@ -66,6 +69,7 @@ describe('ManagementService', () => { .getStartServices as CoreSetup['getStartServices'], spacesManager: spacesManagerMock.create(), config, + logger, getRolesAPIClient: getRolesAPIClientMock, getPrivilegesAPIClient: jest.fn(), eventTracker, @@ -89,6 +93,7 @@ describe('ManagementService', () => { .getStartServices as CoreSetup['getStartServices'], spacesManager: spacesManagerMock.create(), config, + logger, getRolesAPIClient: jest.fn(), getPrivilegesAPIClient: jest.fn(), eventTracker, diff --git a/x-pack/plugins/spaces/public/management/management_service.tsx b/x-pack/plugins/spaces/public/management/management_service.tsx index b186135d88e05..0379189e192c3 100644 --- a/x-pack/plugins/spaces/public/management/management_service.tsx +++ b/x-pack/plugins/spaces/public/management/management_service.tsx @@ -6,6 +6,7 @@ */ import type { StartServicesAccessor } from '@kbn/core/public'; +import type { Logger } from '@kbn/logging'; import type { ManagementApp, ManagementSetup } from '@kbn/management-plugin/public'; import type { PrivilegesAPIClientPublicContract, @@ -26,6 +27,7 @@ interface SetupDeps { getRolesAPIClient: () => Promise; eventTracker: EventTracker; getPrivilegesAPIClient: () => Promise; + logger: Logger; } export class ManagementService { @@ -36,6 +38,7 @@ export class ManagementService { management, spacesManager, config, + logger, getRolesAPIClient, eventTracker, getPrivilegesAPIClient, @@ -45,6 +48,7 @@ export class ManagementService { getStartServices, spacesManager, config, + logger, getRolesAPIClient, eventTracker, getPrivilegesAPIClient, 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_grid/spaces_grid_page.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx index 2bbcc290a8a83..5ac3ecf0ca687 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx @@ -35,7 +35,11 @@ import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; import { addSpaceIdToPath, type Space } from '../../../common'; import { isReservedSpace } from '../../../common'; -import { DEFAULT_SPACE_ID, ENTER_SPACE_PATH } from '../../../common/constants'; +import { + DEFAULT_SPACE_ID, + ENTER_SPACE_PATH, + SOLUTION_VIEW_CLASSIC, +} from '../../../common/constants'; import { getSpacesFeatureDescription } from '../../constants'; import { getSpaceAvatarComponent } from '../../space_avatar'; import { SpaceSolutionBadge } from '../../space_solution_badge'; @@ -251,6 +255,9 @@ export class SpacesGridPage extends Component { }; public getColumnConfig() { + const { activeSpace, features } = this.state; + const { solution: activeSolution } = activeSpace ?? {}; + const config: Array> = [ { field: 'initials', @@ -306,17 +313,21 @@ export class SpacesGridPage extends Component { truncateText: true, width: '30%', }, - { + ]; + + const shouldShowFeaturesColumn = !activeSolution || activeSolution === SOLUTION_VIEW_CLASSIC; + if (shouldShowFeaturesColumn) { + config.push({ field: 'disabledFeatures', name: i18n.translate('xpack.spaces.management.spacesGridPage.featuresColumnName', { defaultMessage: 'Features visible', }), sortable: (space: Space) => { - return getEnabledFeatures(this.state.features, space).length; + return getEnabledFeatures(features, space).length; }, render: (_disabledFeatures: string[], rowRecord: Space) => { - const enabledFeatureCount = getEnabledFeatures(this.state.features, rowRecord).length; - if (enabledFeatureCount === this.state.features.length) { + const enabledFeatureCount = getEnabledFeatures(features, rowRecord).length; + if (enabledFeatureCount === features.length) { return ( { } if (enabledFeatureCount === 0) { return ( - + { defaultMessage="{enabledFeatureCount} / {totalFeatureCount}" values={{ enabledFeatureCount, - totalFeatureCount: this.state.features.length, + totalFeatureCount: features.length, }} /> ); }, + }); + } + + config.push({ + field: 'id', + name: i18n.translate('xpack.spaces.management.spacesGridPage.identifierColumnName', { + defaultMessage: 'Identifier', + }), + sortable: true, + render(id: string) { + if (id === DEFAULT_SPACE_ID) { + return ''; + } + return id; }, - { - field: 'id', - name: i18n.translate('xpack.spaces.management.spacesGridPage.identifierColumnName', { - defaultMessage: 'Identifier', - }), - sortable: true, - render(id: string) { - if (id === DEFAULT_SPACE_ID) { - return ''; - } - return id; - }, - }, - ]; + }); if (this.props.allowSolutionVisibility) { config.push({ @@ -404,7 +416,7 @@ export class SpacesGridPage extends Component { defaultMessage: 'Switch', }), description: (rowRecord) => - this.state.activeSpace?.name !== rowRecord.name + activeSpace?.name !== rowRecord.name ? i18n.translate( 'xpack.spaces.management.spacesGridPage.switchSpaceActionDescription', { @@ -428,7 +440,7 @@ export class SpacesGridPage extends Component { rowRecord.id, `${ENTER_SPACE_PATH}?next=/app/management/kibana/spaces/` ), - enabled: (rowRecord) => this.state.activeSpace?.name !== rowRecord.name, + enabled: (rowRecord) => activeSpace?.name !== rowRecord.name, 'data-test-subj': (rowRecord) => `${rowRecord.name}-switchSpace`, }, { @@ -440,7 +452,7 @@ export class SpacesGridPage extends Component { ? i18n.translate( 'xpack.spaces.management.spacesGridPage.deleteActionDisabledDescription', { - defaultMessage: `{spaceName} is reserved`, + defaultMessage: `You can't delete the {spaceName} space`, values: { spaceName: rowRecord.name }, } ) 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 d9852a82f8259..a04335613e59b 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 @@ -9,8 +9,17 @@ jest.mock('./spaces_grid', () => ({ SpacesGridPage: (props: any) => `Spaces Page: ${JSON.stringify(props)}`, })); +jest.mock('./create_space', () => ({ + CreateSpacePage: (props: any) => { + if (props.spacesManager && props.onLoadSpace) { + props.spacesManager.getSpace().then((space: any) => props.onLoadSpace(space)); + } + return `Spaces Create Page: ${JSON.stringify(props)}`; + }, +})); + jest.mock('./edit_space', () => ({ - ManageSpacePage: (props: any) => { + EditSpacePage: (props: any) => { if (props.spacesManager && props.onLoadSpace) { props.spacesManager.getSpace().then((space: any) => props.onLoadSpace(space)); } @@ -18,7 +27,12 @@ jest.mock('./edit_space', () => ({ }, })); -import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; +import { + coreMock, + loggingSystemMock, + scopedHistoryMock, + themeServiceMock, +} from '@kbn/core/public/mocks'; import { featuresPluginMock } from '@kbn/features-plugin/public/mocks'; import { spacesManagementApp } from './spaces_management_app'; @@ -37,6 +51,7 @@ const config: ConfigType = { }; const eventTracker = new EventTracker({ reportEvent: jest.fn() }); +const logger = loggingSystemMock.createLogger(); async function mountApp(basePath: string, pathname: string, spaceId?: string) { const container = document.createElement('div'); @@ -59,6 +74,7 @@ async function mountApp(basePath: string, pathname: string, spaceId?: string) { spacesManager, getStartServices: async () => [coreStart, pluginsStart as PluginsStart, {}], config, + logger, getRolesAPIClient: jest.fn(), getPrivilegesAPIClient: jest.fn(), eventTracker, @@ -82,6 +98,7 @@ describe('spacesManagementApp', () => { spacesManager: spacesManagerMock.create(), getStartServices: coreMock.createSetup().getStartServices as any, config, + logger, getRolesAPIClient: jest.fn(), getPrivilegesAPIClient: jest.fn(), eventTracker, @@ -136,7 +153,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 Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/create","search":"","hash":""}},"allowFeatureVisibility":true,"allowSolutionVisibility":true,"eventTracker":{"analytics":{}}} + Spaces Create Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/create","search":"","hash":""}},"allowFeatureVisibility":true,"allowSolutionVisibility":true,"eventTracker":{"analytics":{}}}
`); @@ -159,7 +176,7 @@ describe('spacesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([ { href: `/`, text: 'Spaces' }, - { text: `space with id some-space` }, + { text: `Edit "space with id some-space"` }, ]); expect(docTitle.change).toHaveBeenCalledWith('Spaces'); expect(docTitle.reset).not.toHaveBeenCalled(); @@ -169,7 +186,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 Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"spaceId":"some-space","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/some-space","search":"","hash":""}},"allowFeatureVisibility":true,"allowSolutionVisibility":true,"eventTracker":{"analytics":{}}} + Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"serverBasePath":"","http":{"basePath":{"basePath":"","serverBasePath":"","assetsHrefBase":""},"anonymousPaths":{},"externalUrl":{},"staticAssets":{}},"overlays":{"banners":{}},"notifications":{"toasts":{}},"theme":{"theme$":{}},"i18n":{},"logger":{"context":[]},"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 40532a7259521..fa74316779a7e 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx @@ -12,6 +12,7 @@ import { useParams } from 'react-router-dom'; import type { StartServicesAccessor } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import type { Logger } from '@kbn/logging'; import type { RegisterManagementAppArgs } from '@kbn/management-plugin/public'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import type { @@ -31,6 +32,7 @@ interface CreateParams { getStartServices: StartServicesAccessor; spacesManager: SpacesManager; config: ConfigType; + logger: Logger; getRolesAPIClient: () => Promise; eventTracker: EventTracker; getPrivilegesAPIClient: () => Promise; @@ -38,7 +40,15 @@ interface CreateParams { export const spacesManagementApp = Object.freeze({ id: 'spaces', - create({ getStartServices, spacesManager, config, eventTracker }: CreateParams) { + create({ + getStartServices, + spacesManager, + config, + logger, + eventTracker, + getRolesAPIClient, + getPrivilegesAPIClient, + }: CreateParams) { const title = i18n.translate('xpack.spaces.displayName', { defaultMessage: 'Spaces', }); @@ -49,14 +59,23 @@ export const spacesManagementApp = Object.freeze({ title, async mount({ element, setBreadcrumbs, history }) { - const [[coreStart, { features }], { SpacesGridPage }, { ManageSpacePage }] = - await Promise.all([getStartServices(), import('./spaces_grid'), import('./edit_space')]); + const [ + [coreStart, { features }], + { SpacesGridPage }, + { CreateSpacePage }, + { EditSpacePage }, + ] = await Promise.all([ + getStartServices(), + import('./spaces_grid'), + import('./create_space'), + import('./edit_space'), + ]); const spacesFirstBreadcrumb = { text: title, href: `/`, }; - const { notifications, application, chrome, http } = coreStart; + const { notifications, application, chrome, http, overlays, theme } = coreStart; chrome.docTitle.change(title); @@ -88,7 +107,7 @@ export const spacesManagementApp = Object.freeze({ ]); return ( - { - const { spaceId } = useParams<{ spaceId: string }>(); + const { spaceId, selectedTabId } = useParams<{ + spaceId: string; + selectedTabId?: string; + }>(); + + const breadcrumbText = (space: Space) => + i18n.translate('xpack.spaces.management.editSpaceBreadcrumb', { + defaultMessage: 'Edit "{space}"', + values: { space: space.name }, + }); const onLoadSpace = (space: Space) => { setBreadcrumbs([ spacesFirstBreadcrumb, { - text: space.name, + text: breadcrumbText(space), }, ]); }; return ( - ); }; @@ -141,7 +179,7 @@ export const spacesManagementApp = Object.freeze({ - + diff --git a/x-pack/plugins/spaces/public/management/types.ts b/x-pack/plugins/spaces/public/management/types.ts new file mode 100644 index 0000000000000..dbf839256e43e --- /dev/null +++ b/x-pack/plugins/spaces/public/management/types.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 { Space } from '../../common'; + +/** + * Values used in the "Customize Space" form + */ +export interface CustomizeSpaceFormValues extends Partial { + customIdentifier?: boolean; + avatarType?: 'initials' | 'image'; + customAvatarInitials?: boolean; + customAvatarColor?: boolean; +} diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 6d545cdb70e61..86196333c0883 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -125,6 +125,7 @@ export class SpacesPlugin implements Plugin; } diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts index bed49326efea5..021620b41dc55 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts @@ -183,4 +183,32 @@ describe('SpacesManager', () => { }); }); }); + + describe('#getRolesForSpace', () => { + it('retrieves roles for the specified space', async () => { + const coreStart = coreMock.createStart(); + const rolesForSpace = [Symbol()]; + coreStart.http.get.mockResolvedValue(rolesForSpace); + const spacesManager = new SpacesManager(coreStart.http); + + const result = await spacesManager.getRolesForSpace('foo'); + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.http.get).toHaveBeenLastCalledWith('/internal/security/roles/foo'); + expect(result).toEqual(rolesForSpace); + }); + }); + + describe('#getContentForSpace', () => { + it('retrieves content for the specified space', async () => { + const coreStart = coreMock.createStart(); + const spaceContent = [Symbol()]; + coreStart.http.get.mockResolvedValue({ summary: spaceContent, total: spaceContent.length }); + const spacesManager = new SpacesManager(coreStart.http); + + const result = await spacesManager.getContentForSpace('foo'); + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.http.get).toHaveBeenLastCalledWith('/internal/spaces/foo/content_summary'); + expect(result).toEqual({ summary: spaceContent, total: spaceContent.length }); + }); + }); }); diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts index 7762158a4379b..962f02ca2bd79 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts @@ -11,9 +11,11 @@ import { BehaviorSubject, skipWhile } from 'rxjs'; import type { HttpSetup } from '@kbn/core/public'; import type { SavedObjectsCollectMultiNamespaceReferencesResponse } from '@kbn/core-saved-objects-api-server'; import type { LegacyUrlAliasTarget } from '@kbn/core-saved-objects-common'; +import type { Role } from '@kbn/security-plugin-types-common'; import type { GetAllSpacesOptions, GetSpaceResult, Space } from '../../common'; import type { CopySavedObjectsToSpaceResponse } from '../copy_saved_objects_to_space/types'; +import type { SpaceContentTypeSummaryItem } from '../types'; interface SavedObjectTarget { type: string; @@ -192,4 +194,14 @@ export class SpacesManager { private isAnonymousPath() { return this.http.anonymousPaths.isAnonymous(window.location.pathname); } + + public getContentForSpace( + id: string + ): Promise<{ summary: SpaceContentTypeSummaryItem[]; total: number }> { + return this.http.get(`/internal/spaces/${id}/content_summary`); + } + + public getRolesForSpace(id: string): Promise { + return this.http.get(`/internal/security/roles/${id}`); + } } diff --git a/x-pack/plugins/spaces/public/types.ts b/x-pack/plugins/spaces/public/types.ts index b5540df1fdd5a..874ed9e8fd7d3 100644 --- a/x-pack/plugins/spaces/public/types.ts +++ b/x-pack/plugins/spaces/public/types.ts @@ -66,3 +66,14 @@ export interface SpacesApi { */ isSolutionViewEnabled: boolean; } + +/** + * The API for retrieving content associated with a space returns an array of summary data for each type of + * saved object content. SpaceContentTypeSummaryItem is the format of the items included in this summary data. + */ +export interface SpaceContentTypeSummaryItem { + displayName: string; + icon?: string; + count: number; + type: string; // the type of saved object content (dashboard, search, config, etc) +} diff --git a/x-pack/plugins/spaces/tsconfig.json b/x-pack/plugins/spaces/tsconfig.json index ba13f984ef66d..20d3f7d3175d8 100644 --- a/x-pack/plugins/spaces/tsconfig.json +++ b/x-pack/plugins/spaces/tsconfig.json @@ -37,7 +37,21 @@ "@kbn/utility-types-jest", "@kbn/security-plugin-types-public", "@kbn/cloud-plugin", - "@kbn/core-analytics-browser" + "@kbn/core-analytics-browser", + "@kbn/core-analytics-browser", + "@kbn/security-plugin-types-common", + "@kbn/core-application-browser", + "@kbn/unsaved-changes-prompt", + "@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", + "@kbn/core-notifications-browser", + "@kbn/logging", + "@kbn/core-logging-browser-mocks", ], "exclude": [ "target/**/*", diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/monitoring.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/monitoring.ts index a86db4a8c27e4..480150b9a97a6 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/monitoring.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/monitoring.ts @@ -13,18 +13,28 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function monitoringAlertTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const retry = getService('retry'); - // Failing: See https://github.com/elastic/kibana/issues/193072 - describe.skip('monitoring', () => { + describe('monitoring', () => { const objectRemover = new ObjectRemover(supertest); + const run = async (id: string) => { + await retry.try(async () => { + // Sometimes the rule may already be running, which returns a 200. Try until it isn't + const response = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${id}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + }); + }; + after(async () => await objectRemover.removeAll()); it('should return an accurate history for a single success', async () => { const createResponse = await supertest .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) .set('kbn-xsrf', 'foo') - .send(getTestRuleData({ schedule: { interval: '3s' } })); + .send(getTestRuleData({ schedule: { interval: '1h' } })); expect(createResponse.status).to.eql(200); objectRemover.add(Spaces.space1.id, createResponse.body.id, 'rule', 'alerting'); @@ -45,15 +55,21 @@ export default function monitoringAlertTests({ getService }: FtrProviderContext) const createResponse = await supertest .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) .set('kbn-xsrf', 'foo') - .send(getTestRuleData({ schedule: { interval: '3s' } })); + .send(getTestRuleData({ schedule: { interval: '1h' } })); expect(createResponse.status).to.eql(200); - objectRemover.add(Spaces.space1.id, createResponse.body.id, 'rule', 'alerting'); + const ruleId = createResponse.body.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); + + for (let i = 1; i < 3; i++) { + await waitForExecutionCount(i, ruleId); + await run(ruleId); + } // Allow at least three executions - await waitForExecutionCount(3, createResponse.body.id); + await waitForExecutionCount(3, ruleId); const getResponse = await supertest.get( - `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createResponse.body.id}` + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}` ); expect(getResponse.status).to.eql(200); @@ -72,20 +88,26 @@ export default function monitoringAlertTests({ getService }: FtrProviderContext) .send( getTestRuleData({ rule_type_id: 'test.patternSuccessOrFailure', - schedule: { interval: '3s' }, + schedule: { interval: '1h' }, params: { pattern, }, }) ); expect(createResponse.status).to.eql(200); - objectRemover.add(Spaces.space1.id, createResponse.body.id, 'rule', 'alerting'); - // Allow at least three executions - await waitForExecutionCount(5, createResponse.body.id); + const ruleId = createResponse.body.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); + + for (let i = 1; i < 5; i++) { + await waitForExecutionCount(i, ruleId); + await run(ruleId); + } + // Allow at least five executions + await waitForExecutionCount(5, ruleId); const getResponse = await supertest.get( - `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createResponse.body.id}` + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}` ); expect(getResponse.status).to.eql(200); diff --git a/x-pack/test/functional/apps/spaces/create_edit_space.ts b/x-pack/test/functional/apps/spaces/create_edit_space.ts deleted file mode 100644 index cfffc752cca0c..0000000000000 --- a/x-pack/test/functional/apps/spaces/create_edit_space.ts +++ /dev/null @@ -1,36 +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 { FtrProviderContext } from '../../ftr_provider_context'; - -export default function ({ getPageObjects, getService }: FtrProviderContext) { - const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects(['common', 'settings', 'security', 'spaceSelector']); - const testSubjects = getService('testSubjects'); - - describe('edit space', () => { - before(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - }); - - after(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - }); - - describe('solution view', () => { - it('does not show solution view panel', async () => { - await PageObjects.common.navigateToUrl('management', 'kibana/spaces/edit/default', { - shouldUseHashForSubUrl: false, - }); - - await testSubjects.existOrFail('spaces-edit-page'); - await testSubjects.existOrFail('spaces-edit-page > generalPanel'); - await testSubjects.missingOrFail('spaces-edit-page > navigationPanel'); - }); - }); - }); -} diff --git a/x-pack/test/functional/apps/spaces/create_edit_space/acme_logo.png b/x-pack/test/functional/apps/spaces/create_edit_space/acme_logo.png new file mode 100644 index 0000000000000..8e8ed078b8b61 Binary files /dev/null and b/x-pack/test/functional/apps/spaces/create_edit_space/acme_logo.png differ diff --git a/x-pack/test/functional/apps/spaces/create_edit_space/create_edit_space.ts b/x-pack/test/functional/apps/spaces/create_edit_space/create_edit_space.ts new file mode 100644 index 0000000000000..4b100595a38c6 --- /dev/null +++ b/x-pack/test/functional/apps/spaces/create_edit_space/create_edit_space.ts @@ -0,0 +1,117 @@ +/* + * 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 expect from '@kbn/expect'; +import { faker } from '@faker-js/faker'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects(['common', 'settings', 'security', 'spaceSelector']); + const testSubjects = getService('testSubjects'); + const spacesServices = getService('spaces'); + const log = getService('log'); + + describe('Spaces Management: Create and Edit', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + + describe('create space', () => { + const spaceName = `${faker.word.adjective()} space`; + const spaceId = spaceName.replace(' ', '-'); + + before(async () => { + await PageObjects.common.navigateToApp('spacesManagement'); + await testSubjects.existOrFail('spaces-grid-page'); + + await PageObjects.spaceSelector.clickCreateSpace(); + await testSubjects.existOrFail('spaces-create-page'); + }); + + after(async () => { + await spacesServices.delete(spaceId); + }); + + it('create a space with a given name', async () => { + await PageObjects.spaceSelector.addSpaceName(spaceName); + await PageObjects.spaceSelector.clickSaveSpaceCreation(); + await testSubjects.existOrFail(`spacesListTableRow-${spaceId}`); + }); + }); + + describe('edit space', () => { + const spaceName = `${faker.word.adjective()} space`; + const spaceId = spaceName.replace(' ', '-'); + + before(async () => { + log.debug(`Creating space named "${spaceName}" with ID "${spaceId}"`); + + await spacesServices.create({ + id: spaceId, + name: spaceName, + disabledFeatures: [], + color: '#AABBCC', + }); + + await PageObjects.common.navigateToApp('spacesManagement'); + await testSubjects.existOrFail('spaces-grid-page'); + }); + + after(async () => { + await spacesServices.delete(spaceId); + }); + + it('allows changing space initials', async () => { + const spaceInitials = faker.string.alpha(2); + + await testSubjects.click(`${spaceId}-hyperlink`); + await testSubjects.existOrFail('spaces-view-page > generalPanel'); + + await testSubjects.setValue('spaceLetterInitial', spaceInitials); + await testSubjects.click('save-space-button'); + + await testSubjects.existOrFail('spaces-grid-page'); // wait for grid page to reload + await testSubjects.existOrFail(`space-avatar-${spaceId}`); + expect(await testSubjects.getVisibleText(`space-avatar-${spaceId}`)).to.be(spaceInitials); + }); + + it('allows changing space avatar', async () => { + await testSubjects.click(`${spaceId}-hyperlink`); + await testSubjects.existOrFail('spaces-view-page > generalPanel'); + + await testSubjects.click('image'); + + const avatarPath = require.resolve('./acme_logo.png'); + log.debug(`Importing file '${avatarPath}' ...`); + await PageObjects.common.setFileInputPath(avatarPath); + + await testSubjects.click('save-space-button'); + await testSubjects.existOrFail('spaces-grid-page'); // wait for grid page to reload + await testSubjects.existOrFail(`space-avatar-${spaceId}`); + const avatarEl = await testSubjects.find(`space-avatar-${spaceId}`); + expect(await avatarEl.getAttribute('role')).to.be('img'); // expect that the space uses image avatar + }); + }); + + describe('solution view', () => { + it('does not show solution view panel', async () => { + await PageObjects.common.navigateToUrl('management', 'kibana/spaces/edit/default', { + shouldUseHashForSubUrl: false, + }); + + await testSubjects.existOrFail('spaces-view-page'); + await testSubjects.existOrFail('spaces-view-page > generalPanel'); + await testSubjects.missingOrFail('spaces-view-page > navigationPanel'); // xpack.spaces.allowSolutionVisibility is not enabled, so the solution view picker should not appear + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/spaces/create_edit_space/index.ts b/x-pack/test/functional/apps/spaces/create_edit_space/index.ts new file mode 100644 index 0000000000000..dc96179e1cb7c --- /dev/null +++ b/x-pack/test/functional/apps/spaces/create_edit_space/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function spacesApp({ loadTestFile }: FtrProviderContext) { + describe('Spaces app', function spacesAppTestSuite() { + loadTestFile(require.resolve('./create_edit_space')); + }); +} diff --git a/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts b/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts index be03af7c896a0..66d5eb280d613 100644 --- a/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts +++ b/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts @@ -92,7 +92,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, }); - await testSubjects.existOrFail('spaces-edit-page'); + await testSubjects.existOrFail('spaces-create-page'); }); it(`can navigate to edit space page`, async () => { @@ -102,7 +102,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, }); - await testSubjects.existOrFail('spaces-edit-page'); + await testSubjects.existOrFail('spaces-view-page'); }); }); diff --git a/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/create_edit_space.ts b/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/create_edit_space.ts index 3f00dda32c878..f6f69ada3c0c1 100644 --- a/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/create_edit_space.ts +++ b/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/create_edit_space.ts @@ -28,9 +28,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, }); - await testSubjects.existOrFail('spaces-edit-page'); - await testSubjects.existOrFail('spaces-edit-page > generalPanel'); - await testSubjects.existOrFail('spaces-edit-page > navigationPanel'); + await testSubjects.existOrFail('spaces-view-page'); + await testSubjects.existOrFail('spaces-view-page > generalPanel'); + await testSubjects.existOrFail('spaces-view-page > navigationPanel'); }); it('changes the space solution and updates the side navigation', async () => { @@ -58,9 +58,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, }); - await testSubjects.missingOrFail('userImpactWarning'); + await testSubjects.missingOrFail('space-edit-page-user-impact-warning'); await PageObjects.spaceSelector.changeSolutionView('classic'); - await testSubjects.existOrFail('userImpactWarning'); // Warn that the change will impact other users + await testSubjects.existOrFail('space-edit-page-user-impact-warning'); // Warn that the change will impact other users await PageObjects.spaceSelector.clickSaveSpaceCreation(); await PageObjects.spaceSelector.confirmModal(); diff --git a/x-pack/test/functional/apps/spaces/spaces_grid.ts b/x-pack/test/functional/apps/spaces/spaces_grid.ts index 62363802db98a..bcb04f45b87cb 100644 --- a/x-pack/test/functional/apps/spaces/spaces_grid.ts +++ b/x-pack/test/functional/apps/spaces/spaces_grid.ts @@ -5,43 +5,120 @@ * 2.0. */ -import { FtrProviderContext } from '../../ftr_provider_context'; +import crypto from 'crypto'; +import expect from '@kbn/expect'; +import { type FtrProviderContext } from '../../ftr_provider_context'; -export default function enterSpaceFunctionalTests({ +export default function spaceDetailsViewFunctionalTests({ getService, getPageObjects, }: FtrProviderContext) { - const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects(['security', 'spaceSelector', 'common']); + const PageObjects = getPageObjects(['common', 'settings', 'spaceSelector']); const spacesService = getService('spaces'); const testSubjects = getService('testSubjects'); + const retry = getService('retry'); - const anotherSpace = { - id: 'space2', - name: 'space2', - disabledFeatures: [], - }; + const testSpacesIds = [ + 'odyssey', + // this number is chosen intentionally to not exceed the default 10 items displayed by spaces table + ...Array.from(new Array(5)).map((_) => `space-${crypto.randomUUID()}`), + ]; - describe('Spaces grid', function () { + describe('Spaces Management: List of Spaces', function () { before(async () => { - await spacesService.create(anotherSpace); + for (const testSpaceId of testSpacesIds) { + await spacesService.create({ id: testSpaceId, name: `${testSpaceId}-name` }); + } + + await PageObjects.settings.navigateTo(); + await testSubjects.existOrFail('spaces'); + }); + + beforeEach(async () => { + await PageObjects.common.navigateToUrl('management', 'kibana/spaces', { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + shouldUseHashForSubUrl: false, + }); - await PageObjects.common.navigateToApp('spacesManagement'); await testSubjects.existOrFail('spaces-grid-page'); }); after(async () => { - await spacesService.delete('another-space'); - await kibanaServer.savedObjects.cleanStandardList(); + for (const testSpaceId of testSpacesIds) { + await spacesService.delete(testSpaceId); + } + }); + + it('should list all the spaces populated', async () => { + const renderedSpaceRow = await testSubjects.findAll('*spacesListTableRow-'); + + expect(renderedSpaceRow.length).to.equal(testSpacesIds.length + 1); }); - it('can switch to a space from the row in the grid', async () => { - // use the "current" badge confirm that Default is the current space - await testSubjects.existOrFail('spacesListCurrentBadge-default'); - // click the switch button of "another space" - await PageObjects.spaceSelector.clickSwitchSpaceButton('space2'); - // use the "current" badge confirm that "Another Space" is now the current space - await testSubjects.existOrFail('spacesListCurrentBadge-space2'); + it('does not display the space switcher button when viewing the details page for the current selected space', async () => { + const currentSpaceTitle = ( + await PageObjects.spaceSelector.currentSelectedSpaceTitle() + )?.toLowerCase(); + + expect(currentSpaceTitle).to.equal('default'); + + await testSubjects.click('default-hyperlink'); + await testSubjects.existOrFail('space-view-page-details-header'); + expect( + (await testSubjects.getVisibleText('space-view-page-details-header')) + .toLowerCase() + .includes('default') + ).to.be(true); + await testSubjects.missingOrFail('spaces-view-page-switcher-button'); + }); + + it("displays the space switcher button when viewing the details page of the space that's not the current selected one", async () => { + const testSpaceId = testSpacesIds[Math.floor(Math.random() * testSpacesIds.length)]; + + const currentSpaceTitle = ( + await PageObjects.spaceSelector.currentSelectedSpaceTitle() + )?.toLowerCase(); + + expect(currentSpaceTitle).to.equal('default'); + + await testSubjects.click(`${testSpaceId}-hyperlink`); + await testSubjects.existOrFail('space-view-page-details-header'); + expect( + (await testSubjects.getVisibleText('space-view-page-details-header')) + .toLowerCase() + .includes(`${testSpaceId}-name`) + ).to.be(true); + await testSubjects.existOrFail('spaces-view-page-switcher-button'); + }); + + it('switches to a new space using the space switcher button', async () => { + const currentSpaceTitle = ( + await PageObjects.spaceSelector.currentSelectedSpaceTitle() + )?.toLowerCase(); + + expect(currentSpaceTitle).to.equal('default'); + + const testSpaceId = testSpacesIds[Math.floor(Math.random() * testSpacesIds.length)]; + + await testSubjects.click(`${testSpaceId}-hyperlink`); + await testSubjects.click('spaces-view-page-switcher-button'); + + await retry.try(async () => { + const detailsTitle = ( + await testSubjects.getVisibleText('space-view-page-details-header') + ).toLowerCase(); + + const currentSwitchSpaceTitle = ( + await PageObjects.spaceSelector.currentSelectedSpaceTitle() + )?.toLocaleLowerCase(); + + return ( + currentSwitchSpaceTitle && + currentSwitchSpaceTitle === `${testSpaceId}-name` && + detailsTitle.includes(currentSwitchSpaceTitle) + ); + }); }); }); } diff --git a/x-pack/test/functional/page_objects/space_selector_page.ts b/x-pack/test/functional/page_objects/space_selector_page.ts index 5dce6ed2d7c94..e5afbd78fe767 100644 --- a/x-pack/test/functional/page_objects/space_selector_page.ts +++ b/x-pack/test/functional/page_objects/space_selector_page.ts @@ -288,4 +288,9 @@ export class SpaceSelectorPageObject extends FtrService { ); expect(await msgElem.getVisibleText()).to.be('no spaces found'); } + + async currentSelectedSpaceTitle() { + const spacesNavSelector = await this.testSubjects.find('spacesNavSelector'); + return spacesNavSelector.getAttribute('title'); + } } diff --git a/x-pack/test/security_solution_cypress/runner.ts b/x-pack/test/security_solution_cypress/runner.ts index fb58d22a40cdc..0b815e2a14033 100644 --- a/x-pack/test/security_solution_cypress/runner.ts +++ b/x-pack/test/security_solution_cypress/runner.ts @@ -7,6 +7,7 @@ import Url from 'url'; +import { createEsClientForFtrConfig } from '@kbn/test'; import { TransportResult } from '@elastic/elasticsearch'; import { FtrProviderContext } from '../common/ftr_provider_context'; import { tiAbusechMalware } from './pipelines/ti_abusech_malware'; @@ -20,7 +21,7 @@ export async function SecuritySolutionConfigurableCypressTestRunner({ }: FtrProviderContext) { const log = getService('log'); const config = getService('config'); - const es = getService('es'); + const es = createEsClientForFtrConfig(config); const pipelines = [tiAbusechMalware, tiAbusechMalwareBazaar, tiAbusechUrl];