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 ccdc71d119f08..c69dc0d7ca821 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, @@ -555,30 +556,27 @@ export const EditRolePage: FunctionComponent<Props> = ({ const getElasticsearchPrivileges = () => { return ( - <div> - <EuiSpacer /> - <ElasticsearchPrivileges - role={role} - editable={!isRoleReadOnly} - indicesAPIClient={indicesAPIClient} - onChange={onRoleChange} - runAsUsers={runAsUsers} - validator={validator} - indexPatterns={indexPatternsTitles} - remoteClusters={remoteClustersState.value} - builtinESPrivileges={builtInESPrivileges} - license={license} - docLinks={docLinks} - canUseRemoteIndices={ - buildFlavor === 'traditional' && featureCheckState.value?.canUseRemoteIndices - } - canUseRemoteClusters={ - buildFlavor === 'traditional' && featureCheckState.value?.canUseRemoteClusters - } - isDarkMode={isDarkMode} - buildFlavor={buildFlavor} - /> - </div> + <ElasticsearchPrivileges + role={role} + editable={!isRoleReadOnly} + indicesAPIClient={indicesAPIClient} + onChange={onRoleChange} + runAsUsers={runAsUsers} + validator={validator} + indexPatterns={indexPatternsTitles} + remoteClusters={remoteClustersState.value} + builtinESPrivileges={builtInESPrivileges} + license={license} + docLinks={docLinks} + canUseRemoteIndices={ + buildFlavor === 'traditional' && featureCheckState.value?.canUseRemoteIndices + } + canUseRemoteClusters={ + buildFlavor === 'traditional' && featureCheckState.value?.canUseRemoteClusters + } + isDarkMode={isDarkMode} + buildFlavor={buildFlavor} + /> ); }; @@ -586,21 +584,18 @@ export const EditRolePage: FunctionComponent<Props> = ({ const getKibanaPrivileges = () => { return ( - <div> - <EuiSpacer /> - <KibanaPrivilegesRegion - kibanaPrivileges={new KibanaPrivileges(kibanaPrivileges, features)} - spaces={spaces.list} - spacesEnabled={spaces.enabled} - uiCapabilities={uiCapabilities} - canCustomizeSubFeaturePrivileges={license.getFeatures().allowSubFeaturePrivileges} - editable={!isRoleReadOnly} - role={role} - onChange={onRoleChange} - validator={validator} - spacesApiUi={spacesApiUi} - /> - </div> + <KibanaPrivilegesRegion + kibanaPrivileges={new KibanaPrivileges(kibanaPrivileges, features)} + spaces={spaces.list} + spacesEnabled={spaces.enabled} + uiCapabilities={uiCapabilities} + canCustomizeSubFeaturePrivileges={license.getFeatures().allowSubFeaturePrivileges} + editable={!isRoleReadOnly} + role={role} + onChange={onRoleChange} + validator={validator} + spacesApiUi={spacesApiUi} + /> ); }; @@ -797,44 +792,89 @@ export const EditRolePage: FunctionComponent<Props> = ({ return ( <div className="editRolePage"> - <EuiForm {...formError}> - {getFormTitle()} - <EuiSpacer /> - <EuiText size="s"> - <FormattedMessage - id="xpack.security.management.editRole.setPrivilegesToKibanaSpacesDescription" - defaultMessage="Set privileges on your Elasticsearch data and control access to your Project spaces." - /> - </EuiText> - {isRoleReserved && ( - <Fragment> - <EuiSpacer size="s" /> - <EuiText size="s" color="subdued"> - <p id="reservedRoleDescription" tabIndex={0}> + <EuiForm {...formError} fullWidth> + <EuiFlexGroup direction="column"> + <EuiFlexItem> + {getFormTitle()} + <EuiSpacer /> + <EuiText size="s"> + <FormattedMessage + id="xpack.security.management.editRole.setPrivilegesToKibanaSpacesDescription" + defaultMessage="Set privileges on your Elasticsearch data and control access to your Project spaces." + /> + </EuiText> + </EuiFlexItem> + <EuiFlexItem> + {isRoleReserved && ( + <Fragment> + <EuiText size="s" color="subdued"> + <p id="reservedRoleDescription" tabIndex={0}> + <FormattedMessage + id="xpack.security.management.editRole.modifyingReversedRolesDescription" + defaultMessage="Reserved roles are built-in and cannot be removed or modified." + /> + </p> + </EuiText> + </Fragment> + )} + </EuiFlexItem> + <EuiFlexItem> + {isDeprecatedRole && ( + <Fragment> + <EuiSpacer size="s" /> + <EuiCallOut + title={getExtendedRoleDeprecationNotice(role)} + color="warning" + iconType="warning" + /> + </Fragment> + )} + </EuiFlexItem> + <EuiFlexItem>{getRoleNameAndDescription()}</EuiFlexItem> + <EuiFlexItem> + <EuiFormRow + label={ <FormattedMessage - id="xpack.security.management.editRole.modifyingReversedRolesDescription" - defaultMessage="Reserved roles are built-in and cannot be removed or modified." + id="xpack.security.management.editRole.dataLayerLabel" + defaultMessage="Data Layer" /> - </p> - </EuiText> - </Fragment> - )} - {isDeprecatedRole && ( - <Fragment> - <EuiSpacer size="s" /> - <EuiCallOut - title={getExtendedRoleDeprecationNotice(role)} - color="warning" - iconType="warning" - /> - </Fragment> - )} - <EuiSpacer /> - {getRoleNameAndDescription()} - {getElasticsearchPrivileges()} - {getKibanaPrivileges()} - <EuiSpacer /> - {getFormButtons()} + } + > + {getElasticsearchPrivileges()} + </EuiFormRow> + </EuiFlexItem> + <EuiFlexItem> + <EuiFormRow + label={ + <EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}> + <EuiFlexItem grow={false}> + <FormattedMessage + id="xpack.security.management.editRole.appLayerLabel" + defaultMessage="Application layer" + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiIconTip + type="iInCircle" + color="subdued" + content={ + <FormattedMessage + id="xpack.security.management.editRole.appLayerTooltipText" + defaultMessage="Feature access is granted on a per space basis for all features. Feature visibility is set on the space. Both must be enabled for this role to use a feature" + /> + } + /> + </EuiFlexItem> + </EuiFlexGroup> + } + > + {getKibanaPrivileges()} + </EuiFormRow> + </EuiFlexItem> + <EuiFlexItem> + <EuiFormRow fullWidth={false}>{getFormButtons()}</EuiFormRow> + </EuiFlexItem> + </EuiFlexGroup> </EuiForm> </div> ); 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 6abf5a04ae5c6..16f76a1e7a59d 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 @@ -105,10 +105,18 @@ export class PrivilegeSpaceForm extends Component<Props, State> { <h2> <FormattedMessage id="xpack.security.management.editRole.spacePrivilegeForm.modalTitle" - defaultMessage="Kibana privileges" + defaultMessage="Assign role to space" /> </h2> </EuiTitle> + <EuiText size="s"> + <p> + <FormattedMessage + id="xpack.security.management.editRole.spacePrivilegeForm.modalHeadline" + defaultMessage="This role will be granted access to the following spaces" + /> + </p> + </EuiText> </EuiFlyoutHeader> <EuiFlyoutBody> <EuiErrorBoundary>{this.getForm()}</EuiErrorBoundary> 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 f499da5c6973c..bb9430e3d873a 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<Props, State> { > <FormattedMessage id="xpack.security.management.editRole.spacePrivilegeSection.addSpacePrivilegeButton" - defaultMessage="Add Kibana privilege" + defaultMessage="Assign to space" /> </EuiButton> ); diff --git a/x-pack/plugins/spaces/common/index.ts b/x-pack/plugins/spaces/common/index.ts index 4a767fb403ee2..0a0a84886647e 100644 --- a/x-pack/plugins/spaces/common/index.ts +++ b/x-pack/plugins/spaces/common/index.ts @@ -12,7 +12,11 @@ export { ENTER_SPACE_PATH, DEFAULT_SPACE_ID, } from './constants'; -export { addSpaceIdToPath, getSpaceIdFromPath } from './lib/spaces_url_parser'; +export { + addSpaceIdToPath, + getSpaceIdFromPath, + getSpaceNavigationURL, +} from './lib/spaces_url_parser'; export type { Space, GetAllSpacesOptions, diff --git a/x-pack/plugins/spaces/common/lib/spaces_url_parser.ts b/x-pack/plugins/spaces/common/lib/spaces_url_parser.ts index 9b24a70792030..b847655aa9a87 100644 --- a/x-pack/plugins/spaces/common/lib/spaces_url_parser.ts +++ b/x-pack/plugins/spaces/common/lib/spaces_url_parser.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { DEFAULT_SPACE_ID } from '../constants'; +import { DEFAULT_SPACE_ID, ENTER_SPACE_PATH } from '../constants'; const spaceContextRegex = /^\/s\/([a-z0-9_\-]+)/; @@ -75,6 +75,23 @@ export function addSpaceIdToPath( return `${normalizedBasePath}${requestedPath}` || '/'; } +/** + * Builds URL that will navigate a user to the space for the spaceId provided + */ +export function getSpaceNavigationURL({ + serverBasePath, + spaceId, +}: { + serverBasePath: string; + spaceId: string; +}) { + return addSpaceIdToPath( + serverBasePath, + spaceId, + `${ENTER_SPACE_PATH}?next=/app/management/kibana/spaces/view/${spaceId}` + ); +} + function stripServerBasePath(requestBasePath: string, serverBasePath: string) { if (serverBasePath && serverBasePath !== '/' && requestBasePath.startsWith(serverBasePath)) { return requestBasePath.substr(serverBasePath.length); diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap index 4bf010004cbef..fa2cf4f8c3f80 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap @@ -70,6 +70,15 @@ exports[`EnabledFeatures renders as expected 1`] = ` }, ] } + headerText={ + <EuiText + size="xs" + > + <b> + Feature visibility + </b> + </EuiText> + } onChange={[MockFunction]} space={ Object { diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx index 19e41b7ca9d03..1fec0ad8d3f2f 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx @@ -256,9 +256,7 @@ export class FeatureTable extends Component<Props, {}> { } updatedSpace.disabledFeatures = disabledFeatures; - if (this.props.onChange) { - this.props.onChange(updatedSpace); - } + this.props.onChange?.(updatedSpace); }; private getAllFeatureIds = () => @@ -287,9 +285,7 @@ export class FeatureTable extends Component<Props, {}> { ); } - if (this.props.onChange) { - this.props.onChange(updatedSpace); - } + this.props.onChange?.(updatedSpace); }; private getCategoryHelpText = (category: AppCategory) => { 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 0c5b360398223..5721de2d20647 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 @@ -6,6 +6,7 @@ */ import { + type EuiBasicTableColumn, EuiButton, EuiButtonIcon, EuiCallOut, @@ -30,7 +31,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; -import type { Space } from '../../../common'; +import { getSpaceNavigationURL, type Space } from '../../../common'; import { isReservedSpace } from '../../../common'; import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { getSpacesFeatureDescription } from '../../constants'; @@ -47,6 +48,7 @@ const LazySpaceAvatar = lazy(() => interface Props { spacesManager: SpacesManager; notifications: NotificationsStart; + serverBasePath: string; getFeatures: FeaturesPluginStart['getFeatures']; capabilities: Capabilities; history: ScopedHistory; @@ -56,6 +58,7 @@ interface Props { interface State { spaces: Space[]; + activeSpace: Space | null; features: KibanaFeature[]; loading: boolean; showConfirmDeleteModal: boolean; @@ -67,6 +70,7 @@ export class SpacesGridPage extends Component<Props, State> { super(props); this.state = { spaces: [], + activeSpace: null, features: [], loading: true, showConfirmDeleteModal: false, @@ -124,12 +128,16 @@ export class SpacesGridPage extends Component<Props, State> { ) : undefined} <EuiInMemoryTable itemId={'id'} + data-test-subj={'spacesListTable'} items={this.state.spaces} tableCaption={i18n.translate('xpack.spaces.management.spacesGridPage.tableCaption', { defaultMessage: 'Kibana spaces', })} rowHeader="name" - columns={this.getColumnConfig()} + rowProps={(item) => ({ + 'data-test-subj': `spacesListTableRow-${item.id}`, + })} + columns={this.getColumnConfig({ serverBasePath: this.props.serverBasePath })} pagination={true} sorting={true} search={{ @@ -212,12 +220,18 @@ export class SpacesGridPage extends Component<Props, State> { }); const getSpaces = spacesManager.getSpaces(); + const getActiveSpace = spacesManager.getActiveSpace(); try { - const [spaces, features] = await Promise.all([getSpaces, getFeatures()]); + const [spaces, activeSpace, features] = await Promise.all([ + getSpaces, + getActiveSpace, + getFeatures(), + ]); this.setState({ loading: false, spaces, + activeSpace, features, }); } catch (error) { @@ -232,17 +246,23 @@ export class SpacesGridPage extends Component<Props, State> { } }; - public getColumnConfig() { + public getColumnConfig({ + serverBasePath, + }: { + serverBasePath: string; + }): Array<EuiBasicTableColumn<Space>> { return [ { field: 'initials', name: '', width: '50px', - render: (_value: string, record: Space) => { + render: (_value: string, rowRecord) => { return ( <Suspense fallback={<EuiLoadingSpinner />}> - <EuiLink {...reactRouterNavigate(this.props.history, this.getViewSpacePath(record))}> - <LazySpaceAvatar space={record} size="s" /> + <EuiLink + {...reactRouterNavigate(this.props.history, this.getViewSpacePath(rowRecord))} + > + <LazySpaceAvatar space={rowRecord} size="s" /> </EuiLink> </Suspense> ); @@ -254,8 +274,11 @@ export class SpacesGridPage extends Component<Props, State> { defaultMessage: 'Space', }), sortable: true, - render: (value: string, record: Space) => ( - <EuiLink {...reactRouterNavigate(this.props.history, this.getViewSpacePath(record))}> + render: (value: string, rowRecord) => ( + <EuiLink + {...reactRouterNavigate(this.props.history, this.getViewSpacePath(rowRecord))} + data-test-subj={`${rowRecord.id}-hyperlink`} + > {value} </EuiLink> ), @@ -275,8 +298,8 @@ export class SpacesGridPage extends Component<Props, State> { sortable: (space: Space) => { return getEnabledFeatures(this.state.features, space).length; }, - render: (_disabledFeatures: string[], record: Space) => { - const enabledFeatureCount = getEnabledFeatures(this.state.features, record).length; + render: (_disabledFeatures: string[], rowRecord) => { + const enabledFeatureCount = getEnabledFeatures(this.state.features, rowRecord).length; if (enabledFeatureCount === this.state.features.length) { return ( <FormattedMessage @@ -326,37 +349,57 @@ export class SpacesGridPage extends Component<Props, State> { }), actions: [ { - render: (record: Space) => ( + isPrimary: true, + available: (rowRecord) => this.state.activeSpace?.name !== rowRecord.name, + render: (rowRecord: Space) => { + return ( + <EuiButtonIcon + href={getSpaceNavigationURL({ serverBasePath, spaceId: rowRecord.id })} + iconType="merge" + data-test-subj={`${rowRecord.name}-switchSpace`} + aria-label={i18n.translate( + 'xpack.spaces.management.spacesGridPage.switchSpaceActionName', + { + defaultMessage: 'Switch to {spaceName} space', + values: { spaceName: rowRecord.name }, + } + )} + /> + ); + }, + }, + { + render: (rowRecord: Space) => ( <EuiButtonIcon - data-test-subj={`${record.name}-editSpace`} + data-test-subj={`${rowRecord.name}-editSpace`} aria-label={i18n.translate( 'xpack.spaces.management.spacesGridPage.editSpaceActionName', { defaultMessage: `Edit {spaceName}.`, - values: { spaceName: record.name }, + values: { spaceName: rowRecord.name }, } )} color={'primary'} iconType={'pencil'} - {...reactRouterNavigate(this.props.history, this.getEditSpacePath(record))} + {...reactRouterNavigate(this.props.history, this.getEditSpacePath(rowRecord))} /> ), }, { - available: (record: Space) => !isReservedSpace(record), - render: (record: Space) => ( + available: (rowRecord: Space) => !isReservedSpace(rowRecord), + render: (rowRecord: Space) => ( <EuiButtonIcon - data-test-subj={`${record.name}-deleteSpace`} + data-test-subj={`${rowRecord.name}-deleteSpace`} aria-label={i18n.translate( 'xpack.spaces.management.spacesGridPage.deleteActionName', { defaultMessage: `Delete {spaceName}.`, - values: { spaceName: record.name }, + values: { spaceName: rowRecord.name }, } )} color={'danger'} iconType={'trash'} - onClick={() => this.onDeleteSpaceClick(record)} + onClick={() => this.onDeleteSpaceClick(rowRecord)} /> ), }, 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 5741006d19bd6..d068df15e6fb8 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx @@ -71,6 +71,7 @@ export const spacesManagementApp = Object.freeze({ getFeatures={features.getFeatures} notifications={notifications} spacesManager={spacesManager} + serverBasePath={http.basePath.serverBasePath} history={history} getUrlForApp={application.getUrlForApp} maxSpaces={config.maxSpaces} diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space.tsx index f15bae1c36dfe..4491393d373e3 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space.tsx @@ -16,19 +16,21 @@ import { EuiTab, EuiTabs, EuiText, + EuiTitle, } from '@elastic/eui'; import React, { lazy, Suspense, useEffect, useState } from 'react'; import type { FC } from 'react'; import type { ApplicationStart, Capabilities, 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_FEATURES, TAB_ID_ROLES } from './constants'; import { useTabs } from './hooks/use_tabs'; import { ViewSpaceContextProvider } from './hooks/view_space_context_provider'; -import { addSpaceIdToPath, ENTER_SPACE_PATH, type Space } from '../../../common'; +import { getSpaceNavigationURL, type Space } from '../../../common'; import { getSpaceAvatarComponent } from '../../space_avatar'; import type { SpacesManager } from '../../spaces_manager'; @@ -76,6 +78,7 @@ export const ViewSpacePage: FC<PageProps> = (props) => { const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null); const selectedTabId = getSelectedTabId(_selectedTabId); const [space, setSpace] = useState<Space | null>(null); + const [userActiveSpace, setUserActiveSpace] = useState<Space | null>(null); const [features, setFeatures] = useState<KibanaFeature[] | null>(null); const [roles, setRoles] = useState<Role[]>([]); const [isLoadingSpace, setIsLoadingSpace] = useState(true); @@ -94,27 +97,21 @@ export const ViewSpacePage: FC<PageProps> = (props) => { if (!spaceId) { return; } - const getSpace = async () => { - const result = await spacesManager.getSpace(spaceId); - if (!result) { - throw new Error(`Could not get resulting space by id ${spaceId}`); - } - setSpace(result); + + const getSpaceInfo = async () => { + const [activeSpace, currentSpace] = await Promise.all([ + spacesManager.getActiveSpace(), + spacesManager.getSpace(spaceId), + ]); + + setSpace(currentSpace); + setUserActiveSpace(activeSpace); setIsLoadingSpace(false); }; - getSpace().catch(handleApiError); + getSpaceInfo().catch(handleApiError); }, [spaceId, spacesManager]); - useEffect(() => { - const _getFeatures = async () => { - const result = await getFeatures(); - setFeatures(result); - setIsLoadingFeatures(false); - }; - _getFeatures().catch(handleApiError); - }, [getFeatures]); - useEffect(() => { if (spaceId) { const getRoles = async () => { @@ -127,14 +124,25 @@ export const ViewSpacePage: FC<PageProps> = (props) => { } }, [spaceId, spacesManager]); + useEffect(() => { + const _getFeatures = async () => { + const result = await getFeatures(); + setFeatures(result); + setIsLoadingFeatures(false); + }; + _getFeatures().catch(handleApiError); + }, [getFeatures]); + + useEffect(() => { + if (space) { + onLoadSpace?.(space); + } + }, [onLoadSpace, space]); + if (!space) { return null; } - if (onLoadSpace) { - onLoadSpace(space); - } - if (isLoadingSpace || isLoadingFeatures || isLoadingRoles) { return ( <EuiFlexGroup justifyContent="spaceAround"> @@ -146,27 +154,9 @@ export const ViewSpacePage: FC<PageProps> = (props) => { } const HeaderAvatar = () => { - return space.imageUrl != null ? ( - <Suspense fallback={<EuiLoadingSpinner />}> - <LazySpaceAvatar - space={{ - ...space, - initials: space.initials ?? 'X', - name: undefined, - }} - size="xl" - /> - </Suspense> - ) : ( + return ( <Suspense fallback={<EuiLoadingSpinner />}> - <LazySpaceAvatar - space={{ - ...space, - name: space.name ?? 'Y', - imageUrl: undefined, - }} - size="xl" - /> + <LazySpaceAvatar space={space} size="xl" /> </Suspense> ); }; @@ -184,7 +174,9 @@ export const ViewSpacePage: FC<PageProps> = (props) => { navigateToUrl(href); }} > - <EuiButtonEmpty iconType="gear">Settings</EuiButtonEmpty> + <EuiButtonEmpty iconType="gear"> + <FormattedMessage id="viewSpace.spaceSettingsButton.label" defaultMessage="Settings" /> + </EuiButtonEmpty> </a> ) : null; }; @@ -195,16 +187,22 @@ export const ViewSpacePage: FC<PageProps> = (props) => { } const { serverBasePath } = props; - const urlToSelectedSpace = addSpaceIdToPath( - serverBasePath, - space.id, - `${ENTER_SPACE_PATH}?next=/app/management/kibana/spaces/view/${space.id}` - ); + + if (userActiveSpace?.id === space.id) { + return null; + } // use href to force full page reload (needed in order to change spaces) return ( - <EuiButton iconType="merge" href={urlToSelectedSpace}> - Switch to this space + <EuiButton + iconType="merge" + href={getSpaceNavigationURL({ serverBasePath, spaceId: space.id })} + data-test-subj="spaceSwitcherButton" + > + <FormattedMessage + id="xpack.spaces.management.spaceDetails.space.switchToSpaceButton.label" + defaultMessage="Switch to this space" + /> </EuiButton> ); }; @@ -217,18 +215,24 @@ export const ViewSpacePage: FC<PageProps> = (props) => { getUrlForApp={getUrlForApp} > <EuiText> - <EuiFlexGroup> + <EuiFlexGroup data-test-subj="spaceDetailsHeader"> <EuiFlexItem grow={false}> <HeaderAvatar /> </EuiFlexItem> <EuiFlexItem> - <h1>{space.name}</h1> - <p> - <small> - {space.description ?? - 'Organize your saved objects and show related features for creating new content.'} - </small> - </p> + <EuiTitle size="l"> + <h1 data-test-subj="spaceTitle">{space.name}</h1> + </EuiTitle> + <EuiText size="s"> + <p> + {space.description ?? ( + <FormattedMessage + id="xpack.spaces.management.spaceDetails.space.description" + defaultMessage="Organize your saved objects and show related features for creating new content." + /> + )} + </p> + </EuiText> </EuiFlexItem> <EuiFlexItem grow={false}> <EuiFlexGroup alignItems="center"> @@ -244,22 +248,26 @@ export const ViewSpacePage: FC<PageProps> = (props) => { <EuiSpacer /> - <EuiTabs> - {tabs.map((tab, index) => ( - <EuiTab - key={index} - isSelected={tab.id === selectedTabId} - append={tab.append} - {...reactRouterNavigate(history, `/view/${encodeURIComponent(space.id)}/${tab.id}`)} - > - {tab.name} - </EuiTab> - ))} - </EuiTabs> - - <EuiSpacer /> - - {selectedTabContent ?? null} + <EuiFlexGroup direction="column"> + <EuiFlexItem> + <EuiTabs> + {tabs.map((tab, index) => ( + <EuiTab + key={index} + isSelected={tab.id === selectedTabId} + append={tab.append} + {...reactRouterNavigate( + history, + `/view/${encodeURIComponent(space.id)}/${tab.id}` + )} + > + {tab.name} + </EuiTab> + ))} + </EuiTabs> + </EuiFlexItem> + <EuiFlexItem>{selectedTabContent ?? null}</EuiFlexItem> + </EuiFlexGroup> </EuiText> </ViewSpaceContextProvider> ); diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx index a3119f775c4fc..9f398543b8882 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx @@ -5,32 +5,60 @@ * 2.0. */ -import type { EuiBasicTableColumn, EuiTableFieldDataColumnType } from '@elastic/eui'; import { EuiBasicTable, EuiButton, EuiButtonEmpty, + EuiComboBox, + EuiFilterButton, + EuiFilterGroup, EuiFlexGroup, EuiFlexItem, EuiFlyout, EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiText, EuiTitle, } from '@elastic/eui'; +import type { + EuiBasicTableColumn, + EuiComboBoxOptionOption, + EuiTableFieldDataColumnType, +} from '@elastic/eui'; import type { FC } from 'react'; import React, { useState } from 'react'; +import type { KibanaFeature } from '@kbn/features-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import type { Role } from '@kbn/security-plugin-types-common'; import type { Space } from '../../../common'; +import { FeatureTable } from '../edit_space/enabled_features/feature_table'; interface Props { space: Space; roles: Role[]; + features: KibanaFeature[]; } -export const ViewSpaceAssignedRoles: FC<Props> = ({ space, roles }) => { +const filterRolesAssignedToSpace = (roles: Role[], space: Space) => { + return roles.filter((role) => + role.kibana.reduce((acc, cur) => { + return ( + (cur.spaces.includes(space.name) || cur.spaces.includes('*')) && + Boolean(cur.base.length) && + acc + ); + }, true) + ); +}; + +export const ViewSpaceAssignedRoles: FC<Props> = ({ space, roles, features }) => { const [showRolesPrivilegeEditor, setShowRolesPrivilegeEditor] = useState(false); const getRowProps = (item: Role) => { const { name } = item; @@ -52,11 +80,15 @@ export const ViewSpaceAssignedRoles: FC<Props> = ({ space, roles }) => { const columns: Array<EuiBasicTableColumn<Role>> = [ { field: 'name', - name: 'Role', + name: i18n.translate('xpack.spaces.management.spaceDetails.roles.column.name.title', { + defaultMessage: 'Role', + }), }, { field: 'privileges', - name: 'Privileges', + name: i18n.translate('xpack.spaces.management.spaceDetails.roles.column.privileges.title', { + defaultMessage: 'Privileges', + }), render: (_value, record) => { return record.kibana.map((kibanaPrivilege) => { return kibanaPrivilege.base.join(', '); @@ -67,7 +99,12 @@ export const ViewSpaceAssignedRoles: FC<Props> = ({ space, roles }) => { name: 'Actions', actions: [ { - name: 'Remove from space', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.column.actions.remove.title', + { + defaultMessage: 'Remove from space', + } + ), description: 'Click this action to remove the role privileges from this space.', onClick: () => { window.alert('Not yet implemented.'); @@ -77,12 +114,7 @@ export const ViewSpaceAssignedRoles: FC<Props> = ({ space, roles }) => { }, ]; - const rolesInUse = roles.filter((role) => { - const privilegesSum = role.kibana.reduce((sum, privilege) => { - return sum + privilege.base.length; - }, 0); - return privilegesSum > 0; - }); + const rolesInUse = filterRolesAssignedToSpace(roles, space); if (!rolesInUse) { return null; @@ -92,6 +124,7 @@ export const ViewSpaceAssignedRoles: FC<Props> = ({ space, roles }) => { <> {showRolesPrivilegeEditor && ( <PrivilegesRolesForm + features={features} space={space} roles={roles} closeFlyout={() => { @@ -103,51 +136,151 @@ export const ViewSpaceAssignedRoles: FC<Props> = ({ space, roles }) => { }} /> )} - <EuiFlexGroup> + <EuiFlexGroup direction="column"> <EuiFlexItem> - <p>Roles that can access this space. Privileges are managed at the role level.</p> + <EuiFlexGroup> + <EuiFlexItem> + <EuiText> + <p> + {i18n.translate('xpack.spaces.management.spaceDetails.roles.heading', { + defaultMessage: + 'Roles that can access this space. Privileges are managed at the role level.', + })} + </p> + </EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false} color="primary"> + <EuiButton + onClick={() => { + setShowRolesPrivilegeEditor(true); + }} + > + {i18n.translate('xpack.spaces.management.spaceDetails.roles.assign', { + defaultMessage: 'Assign role', + })} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> </EuiFlexItem> - <EuiFlexItem grow={false} color="primary"> - <EuiButton - onClick={() => { - setShowRolesPrivilegeEditor(true); - }} - > - Assign role - </EuiButton> + <EuiFlexItem> + <EuiBasicTable + rowHeader="firstName" + columns={columns} + items={rolesInUse} + rowProps={getRowProps} + cellProps={getCellProps} + /> </EuiFlexItem> </EuiFlexGroup> - - <EuiBasicTable - tableCaption="Demo of EuiBasicTable" - rowHeader="firstName" - columns={columns} - items={rolesInUse} - rowProps={getRowProps} - cellProps={getCellProps} - /> </> ); }; -interface PrivilegesRolesFormProps { - space: Space; - roles: Role[]; +interface PrivilegesRolesFormProps extends Props { closeFlyout: () => void; onSaveClick: () => void; } export const PrivilegesRolesForm: FC<PrivilegesRolesFormProps> = (props) => { - const { space, roles, onSaveClick, closeFlyout } = props; + const { space, roles, onSaveClick, closeFlyout, features } = props; + + const [selectedRoles, setSelectedRoles] = useState<Array<EuiComboBoxOptionOption<string>>>([]); + const [spacePrivilege, setSpacePrivilege] = useState<'all' | 'read' | 'custom'>('all'); const getForm = () => { - return <textarea>{JSON.stringify(roles)}</textarea>; + return ( + <EuiForm component="form" fullWidth> + <EuiFormRow label="Select a role(s)"> + <EuiComboBox + aria-label={i18n.translate('xpack.spaces.management.spaceDetails.roles.selectRoles', { + defaultMessage: 'Select role to assign to the {spaceName} space', + values: { spaceName: space.name }, + })} + placeholder="Select roles" + options={roles.map((role) => ({ + label: role.name, + }))} + selectedOptions={selectedRoles} + onChange={(value) => { + setSelectedRoles(value); + }} + isClearable={true} + data-test-subj="roleSelectionComboBox" + autoFocus + fullWidth + /> + </EuiFormRow> + <EuiFormRow + helpText={i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.assign.privilegesHelpText', + { + defaultMessage: + 'Assign the privilege you wish to grant to all present and future features across this space', + } + )} + > + <EuiFilterGroup fullWidth> + <EuiFilterButton + hasActiveFilters={spacePrivilege === 'all'} + onClick={() => setSpacePrivilege('all')} + > + <FormattedMessage + id="xpack.spaces.management.spaceDetails.roles.assign.privileges.all" + defaultMessage="All" + /> + </EuiFilterButton> + <EuiFilterButton + hasActiveFilters={spacePrivilege === 'read'} + onClick={() => setSpacePrivilege('read')} + > + <FormattedMessage + id="xpack.spaces.management.spaceDetails.roles.assign.privileges.read" + defaultMessage="Read" + /> + </EuiFilterButton> + <EuiFilterButton + hasActiveFilters={spacePrivilege === 'custom'} + onClick={() => setSpacePrivilege('custom')} + > + <FormattedMessage + id="xpack.spaces.management.spaceDetails.roles.assign.privileges.customize" + defaultMessage="Customize" + /> + </EuiFilterButton> + </EuiFilterGroup> + </EuiFormRow> + {spacePrivilege === 'custom' && ( + <EuiFormRow + label={i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.assign.privileges.customizeLabelText', + { defaultMessage: 'Customize by feature' } + )} + > + <> + <EuiText size="xs"> + <p> + <FormattedMessage + id="xpack.spaces.management.spaceDetails.roles.assign.privileges.customizeDescriptionText" + defaultMessage="Increase privilege levels per feature basis. Some features might be hidden by the + space or affected by a global space privilege" + /> + </p> + </EuiText> + <EuiSpacer /> + <FeatureTable space={space} features={features} /> + </> + </EuiFormRow> + )} + </EuiForm> + ); }; const getSaveButton = () => { return ( <EuiButton onClick={onSaveClick} fill data-test-subj={'createRolesPrivilegeButton'}> - Assign roles + {i18n.translate('xpack.spaces.management.spaceDetails.roles.assignRoleButton', { + defaultMessage: 'Assign roles', + })} </EuiButton> ); }; @@ -158,14 +291,17 @@ export const PrivilegesRolesForm: FC<PrivilegesRolesFormProps> = (props) => { <EuiTitle size="m"> <h2>Assign role to {space.name}</h2> </EuiTitle> + <EuiSpacer size="s" /> + <EuiText size="s"> + <p> + <FormattedMessage + id="xpack.spaces.management.spaceDetails.privilegeForm.heading" + defaultMessage="Roles will be granted access to the current space according to their default privileges. Use the ‘Customize’ option to override default privileges." + /> + </p> + </EuiText> </EuiFlyoutHeader> - <EuiFlyoutBody> - <p> - Roles will be granted access to the current space according to their default privileges. - Use the ‘Customize’ option to override default privileges. - </p> - {getForm()} - </EuiFlyoutBody> + <EuiFlyoutBody>{getForm()}</EuiFlyoutBody> <EuiFlyoutFooter> <EuiFlexGroup justifyContent="spaceBetween"> <EuiFlexItem grow={false}> @@ -175,7 +311,9 @@ export const PrivilegesRolesForm: FC<PrivilegesRolesFormProps> = (props) => { flush="left" data-test-subj={'cancelRolesPrivilegeButton'} > - Cancel + {i18n.translate('xpack.spaces.management.spaceDetails.roles.cancelRoleButton', { + defaultMessage: 'Cancel', + })} </EuiButtonEmpty> </EuiFlexItem> <EuiFlexItem grow={false}>{getSaveButton()}</EuiFlexItem> diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx index 51e44f6a4b68a..d4cd9dc47dca2 100644 --- a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx +++ b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx @@ -9,6 +9,7 @@ import { EuiNotificationBadge } from '@elastic/eui'; import React from 'react'; import type { KibanaFeature } from '@kbn/features-plugin/common'; +import { i18n } from '@kbn/i18n'; import type { Role } from '@kbn/security-plugin-types-common'; import { TAB_ID_CONTENT, TAB_ID_FEATURES, TAB_ID_ROLES } from './constants'; @@ -33,12 +34,16 @@ export const getTabs = (space: Space, features: KibanaFeature[], roles: Role[]): return [ { id: TAB_ID_CONTENT, - name: 'Content', + name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.feature.heading', { + defaultMessage: 'Content', + }), content: <ViewSpaceContent space={space} />, }, { id: TAB_ID_FEATURES, - name: 'Features', + name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.feature.heading', { + defaultMessage: 'Feature visibility', + }), append: ( <EuiNotificationBadge className="eui-alignCenter" size="m"> {enabledFeatureCount} / {totalFeatureCount} @@ -48,13 +53,15 @@ export const getTabs = (space: Space, features: KibanaFeature[], roles: Role[]): }, { id: TAB_ID_ROLES, - name: 'Assigned roles', + name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.roles.heading', { + defaultMessage: 'Assigned roles', + }), append: ( <EuiNotificationBadge className="eui-alignCenter" size="m"> {roles.length} </EuiNotificationBadge> ), - content: <ViewSpaceAssignedRoles space={space} roles={roles} />, + content: <ViewSpaceAssignedRoles space={space} roles={roles} features={features} />, }, ]; }; diff --git a/x-pack/test/functional/apps/spaces/details_view/spaces_details_view.ts b/x-pack/test/functional/apps/spaces/details_view/spaces_details_view.ts new file mode 100644 index 0000000000000..56fe87e6eed20 --- /dev/null +++ b/x-pack/test/functional/apps/spaces/details_view/spaces_details_view.ts @@ -0,0 +1,132 @@ +/* + * 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 crypto from 'crypto'; +import expect from '@kbn/expect'; +import { type FtrProviderContext } from '../../../ftr_provider_context'; + +export default function spaceDetailsViewFunctionalTests({ + getService, + getPageObjects, +}: FtrProviderContext) { + const PageObjects = getPageObjects(['common', 'settings', 'spaceSelector']); + + const find = getService('find'); + const retry = getService('retry'); + const spacesServices = getService('spaces'); + const testSubjects = getService('testSubjects'); + + describe('Spaces', function () { + 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()}`), + ]; + + before(async () => { + for (const testSpaceId of testSpacesIds) { + await spacesServices.create({ id: testSpaceId, name: `${testSpaceId}-name` }); + } + }); + + after(async () => { + for (const testSpaceId of testSpacesIds) { + await spacesServices.delete(testSpaceId); + } + }); + + describe('Space listing', () => { + before(async () => { + await PageObjects.settings.navigateTo(); + await testSubjects.existOrFail('spaces'); + }); + + beforeEach(async () => { + await PageObjects.common.navigateToUrl('management', 'kibana/spaces', { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + shouldUseHashForSubUrl: false, + }); + + await testSubjects.existOrFail('spaces-grid-page'); + }); + + it('should list all the spaces populated', async () => { + const renderedSpaceRow = await find.allByCssSelector( + '[data-test-subj*=spacesListTableRow-]' + ); + + expect(renderedSpaceRow.length).to.equal(testSpacesIds.length + 1); + }); + + 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('spaceDetailsHeader'); + expect( + (await testSubjects.getVisibleText('spaceDetailsHeader')) + .toLowerCase() + .includes('default') + ).to.be(true); + await testSubjects.missingOrFail('spaceSwitcherButton'); + }); + + 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('spaceDetailsHeader'); + expect( + (await testSubjects.getVisibleText('spaceDetailsHeader')) + .toLowerCase() + .includes(`${testSpaceId}-name`) + ).to.be(true); + await testSubjects.existOrFail('spaceSwitcherButton'); + }); + + 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('spaceSwitcherButton'); + + await retry.try(async () => { + const detailsTitle = ( + await testSubjects.getVisibleText('spaceDetailsHeader') + ).toLowerCase(); + + const currentSwitchSpaceTitle = ( + await PageObjects.spaceSelector.currentSelectedSpaceTitle() + )?.toLocaleLowerCase(); + + return ( + currentSwitchSpaceTitle && + currentSwitchSpaceTitle === `${testSpaceId}-name` && + detailsTitle.includes(currentSwitchSpaceTitle) + ); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/spaces/index.ts b/x-pack/test/functional/apps/spaces/index.ts index c951609d6a33f..0bcefafebc2d9 100644 --- a/x-pack/test/functional/apps/spaces/index.ts +++ b/x-pack/test/functional/apps/spaces/index.ts @@ -13,5 +13,6 @@ export default function spacesApp({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./feature_controls/spaces_security')); loadTestFile(require.resolve('./spaces_selection')); loadTestFile(require.resolve('./enter_space')); + loadTestFile(require.resolve('./details_view/spaces_details_view')); }); } 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 8c56dee81f435..e4879b1c3e5be 100644 --- a/x-pack/test/functional/page_objects/space_selector_page.ts +++ b/x-pack/test/functional/page_objects/space_selector_page.ts @@ -253,4 +253,9 @@ export class SpaceSelectorPageObject extends FtrService { ); expect(await msgElem.getVisibleText()).to.be('no spaces found'); } + + async currentSelectedSpaceTitle() { + const spacesNavSelector = await this.find.byCssSelector('[data-test-subj="spacesNavSelector"]'); + return spacesNavSelector.getAttribute('title'); + } }