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 64f5765938584..3103e1ad46f9e 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,9 +6,12 @@
*/
import {
+ EuiBadge,
type EuiBasicTableColumn,
EuiButton,
EuiCallOut,
+ EuiFlexGroup,
+ EuiFlexItem,
EuiInMemoryTable,
EuiLink,
EuiLoadingSpinner,
@@ -278,12 +281,25 @@ export class SpacesGridPage extends Component {
}),
sortable: true,
render: (value: string, rowRecord) => (
-
- {value}
-
+
+
+
+ {value}
+
+
+ {this.state.activeSpace?.name === rowRecord.name && (
+
+
+ {i18n.translate('xpack.spaces.management.spacesGridPage.currentSpaceMarkerText', {
+ defaultMessage: 'current',
+ })}
+
+
+ )}
+
),
},
{
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 24792a1ef26b7..c5c0d9179f059 100644
--- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx
+++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx
@@ -33,7 +33,13 @@ interface CreateParams {
export const spacesManagementApp = Object.freeze({
id: 'spaces',
- create({ getStartServices, spacesManager, config, solutionNavExperiment }: CreateParams) {
+ create({
+ getStartServices,
+ spacesManager,
+ config,
+ solutionNavExperiment,
+ getRolesAPIClient,
+ }: CreateParams) {
const title = i18n.translate('xpack.spaces.displayName', {
defaultMessage: 'Spaces',
});
@@ -160,6 +166,7 @@ export const spacesManagementApp = Object.freeze({
onLoadSpace={onLoadSpace}
spaceId={spaceId}
selectedTabId={selectedTabId}
+ getRolesAPIClient={getRolesAPIClient}
/>
);
};
diff --git a/x-pack/plugins/spaces/public/management/view_space/hooks/use_tabs.ts b/x-pack/plugins/spaces/public/management/view_space/hooks/use_tabs.ts
index 3c518c7250dc6..176d4be754458 100644
--- a/x-pack/plugins/spaces/public/management/view_space/hooks/use_tabs.ts
+++ b/x-pack/plugins/spaces/public/management/view_space/hooks/use_tabs.ts
@@ -8,25 +8,30 @@
import { useMemo } from 'react';
import type { KibanaFeature } from '@kbn/features-plugin/public';
-import type { Role } from '@kbn/security-plugin-types-common';
import type { Space } from '../../../../common';
-import type { ViewSpaceTab } from '../view_space_tabs';
-import { getTabs } from '../view_space_tabs';
+import { getTabs, type GetTabsProps, type ViewSpaceTab } from '../view_space_tabs';
-export const useTabs = (
- space: Space | null,
- features: KibanaFeature[] | null,
- roles: Role[],
- currentSelectedTabId: string
-): [ViewSpaceTab[], JSX.Element | undefined] => {
+type UseTabsProps = Omit & {
+ space: Space | null;
+ features: KibanaFeature[] | null;
+ currentSelectedTabId: string;
+};
+
+export const useTabs = ({
+ space,
+ features,
+ currentSelectedTabId,
+ ...getTabsArgs
+}: UseTabsProps): [ViewSpaceTab[], JSX.Element | undefined] => {
const [tabs, selectedTabContent] = useMemo(() => {
- if (space == null || features == null) {
+ if (space === null || features === null) {
return [[]];
}
- const _tabs = space != null ? getTabs(space, features, roles) : [];
+
+ const _tabs = space != null ? getTabs({ space, features, ...getTabsArgs }) : [];
return [_tabs, _tabs.find((obj) => obj.id === currentSelectedTabId)?.content];
- }, [space, currentSelectedTabId, features, roles]);
+ }, [space, features, getTabsArgs, currentSelectedTabId]);
return [tabs, selectedTabContent];
};
diff --git a/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx b/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx
index c37b9329f942e..77f246d570948 100644
--- a/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx
+++ b/x-pack/plugins/spaces/public/management/view_space/hooks/view_space_context_provider.tsx
@@ -9,14 +9,16 @@ import type { FC, PropsWithChildren } from 'react';
import React, { createContext, useContext } from 'react';
import type { ApplicationStart } from '@kbn/core-application-browser';
+import type { RolesAPIClient } from '@kbn/security-plugin-types-public';
import type { SpacesManager } from '../../../spaces_manager';
-interface ViewSpaceServices {
+export interface ViewSpaceServices {
serverBasePath: string;
getUrlForApp: ApplicationStart['getUrlForApp'];
navigateToUrl: ApplicationStart['navigateToUrl'];
spacesManager: SpacesManager;
+ getRolesAPIClient: () => Promise;
}
const ViewSpaceContext = createContext(null);
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 452ca57b3163f..1578cf6c19b82 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
@@ -21,7 +21,7 @@ import {
import React, { lazy, Suspense, useEffect, useState } from 'react';
import type { FC } from 'react';
-import type { ApplicationStart, Capabilities, ScopedHistory } from '@kbn/core/public';
+import type { 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';
@@ -29,37 +29,38 @@ 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 {
+ ViewSpaceContextProvider,
+ type ViewSpaceServices,
+} from './hooks/view_space_context_provider';
import { addSpaceIdToPath, ENTER_SPACE_PATH, type Space } from '../../../common';
import { getSpaceAvatarComponent } from '../../space_avatar';
import { SpaceSolutionBadge } from '../../space_solution_badge';
-import type { SpacesManager } from '../../spaces_manager';
// 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 = (selectedTabId?: string) => {
+const getSelectedTabId = (canUserViewRoles: boolean, selectedTabId?: string) => {
// Validation of the selectedTabId routing parameter, default to the Content tab
- return selectedTabId && [TAB_ID_FEATURES, TAB_ID_ROLES].includes(selectedTabId)
+ return selectedTabId &&
+ [TAB_ID_FEATURES, canUserViewRoles ? TAB_ID_ROLES : null]
+ .filter(Boolean)
+ .includes(selectedTabId)
? selectedTabId
: TAB_ID_CONTENT;
};
-interface PageProps {
+interface PageProps extends ViewSpaceServices {
+ spaceId?: string;
+ history: ScopedHistory;
+ selectedTabId?: string;
capabilities: Capabilities;
allowFeatureVisibility: boolean; // FIXME: handle this
solutionNavExperiment?: Promise;
getFeatures: FeaturesPluginStart['getFeatures'];
- getUrlForApp: ApplicationStart['getUrlForApp'];
- navigateToUrl: ApplicationStart['navigateToUrl'];
- serverBasePath: string;
- spacesManager: SpacesManager;
- history: ScopedHistory;
onLoadSpace: (space: Space) => void;
- spaceId?: string;
- selectedTabId?: string;
}
const handleApiError = (error: Error) => {
@@ -80,9 +81,9 @@ export const ViewSpacePage: FC = (props) => {
capabilities,
getUrlForApp,
navigateToUrl,
+ getRolesAPIClient,
} = props;
- const selectedTabId = getSelectedTabId(_selectedTabId);
const [space, setSpace] = useState(null);
const [userActiveSpace, setUserActiveSpace] = useState(null);
const [features, setFeatures] = useState(null);
@@ -90,8 +91,15 @@ export const ViewSpacePage: FC = (props) => {
const [isLoadingSpace, setIsLoadingSpace] = useState(true);
const [isLoadingFeatures, setIsLoadingFeatures] = useState(true);
const [isLoadingRoles, setIsLoadingRoles] = useState(true);
- const [tabs, selectedTabContent] = useTabs(space, features, roles, selectedTabId);
const [isSolutionNavEnabled, setIsSolutionNavEnabled] = useState(false);
+ const selectedTabId = getSelectedTabId(Boolean(capabilities?.roles?.view), _selectedTabId);
+ const [tabs, selectedTabContent] = useTabs({
+ space,
+ features,
+ roles,
+ capabilities,
+ currentSelectedTabId: selectedTabId,
+ });
useEffect(() => {
if (!spaceId) {
@@ -123,6 +131,7 @@ export const ViewSpacePage: FC = (props) => {
setIsLoadingRoles(false);
};
+ // maybe we do not make this call if user can't view roles? 🤔
getRoles().catch(handleApiError);
}, [spaceId, spacesManager]);
@@ -192,41 +201,16 @@ export const ViewSpacePage: FC = (props) => {
) : null;
};
- const SwitchButton = () => {
- if (userActiveSpace?.id === space.id) {
- return null;
- }
-
- const { serverBasePath } = props;
-
- // use href to force full page reload (needed in order to change spaces)
- return (
-
-
-
- );
- };
-
return (
-
+
@@ -270,13 +254,28 @@ export const ViewSpacePage: FC = (props) => {
-
+
-
-
-
+ {userActiveSpace?.id !== space.id ? (
+
+
+
+
+
+ ) : null}
diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx
index 9f398543b8882..8909bd175bcc6 100644
--- a/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx
+++ b/x-pack/plugins/spaces/public/management/view_space/view_space_roles.tsx
@@ -9,9 +9,8 @@ import {
EuiBasicTable,
EuiButton,
EuiButtonEmpty,
+ EuiButtonGroup,
EuiComboBox,
- EuiFilterButton,
- EuiFilterGroup,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
@@ -30,20 +29,28 @@ import type {
EuiTableFieldDataColumnType,
} from '@elastic/eui';
import type { FC } from 'react';
-import React, { useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
-import type { KibanaFeature } from '@kbn/features-plugin/common';
+import type { KibanaFeature, KibanaFeatureConfig } from '@kbn/features-plugin/common';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Role } from '@kbn/security-plugin-types-common';
+import { useViewSpaceServices, type ViewSpaceServices } from './hooks/view_space_context_provider';
import type { Space } from '../../../common';
import { FeatureTable } from '../edit_space/enabled_features/feature_table';
+type RolesAPIClient = ReturnType extends Promise
+ ? R
+ : never;
+
+type KibanaPrivilegeBase = keyof NonNullable;
+
interface Props {
space: Space;
roles: Role[];
features: KibanaFeature[];
+ isReadOnly: boolean;
}
const filterRolesAssignedToSpace = (roles: Role[], space: Space) => {
@@ -58,8 +65,40 @@ const filterRolesAssignedToSpace = (roles: Role[], space: Space) => {
);
};
-export const ViewSpaceAssignedRoles: FC = ({ space, roles, features }) => {
+export const ViewSpaceAssignedRoles: FC = ({ space, roles, features, isReadOnly }) => {
const [showRolesPrivilegeEditor, setShowRolesPrivilegeEditor] = useState(false);
+ const [roleAPIClientInitialized, setRoleAPIClientInitialized] = useState(false);
+ const [systemRoles, setSystemRoles] = useState([]);
+
+ const rolesAPIClient = useRef();
+
+ const { getRolesAPIClient } = useViewSpaceServices();
+
+ const resolveRolesAPIClient = useCallback(async () => {
+ try {
+ rolesAPIClient.current = await getRolesAPIClient();
+ setRoleAPIClientInitialized(true);
+ } catch {
+ //
+ }
+ }, [getRolesAPIClient]);
+
+ useEffect(() => {
+ if (!isReadOnly) {
+ resolveRolesAPIClient();
+ }
+ }, [isReadOnly, resolveRolesAPIClient]);
+
+ useEffect(() => {
+ async function fetchAllSystemRoles() {
+ setSystemRoles((await rolesAPIClient.current?.getRoles()) ?? []);
+ }
+
+ if (roleAPIClientInitialized) {
+ fetchAllSystemRoles?.();
+ }
+ }, [roleAPIClientInitialized]);
+
const getRowProps = (item: Role) => {
const { name } = item;
return {
@@ -95,7 +134,10 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features }) =>
});
},
},
- {
+ ];
+
+ if (!isReadOnly) {
+ columns.push({
name: 'Actions',
actions: [
{
@@ -111,8 +153,8 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features }) =>
},
},
],
- },
- ];
+ });
+ }
const rolesInUse = filterRolesAssignedToSpace(roles, space);
@@ -126,14 +168,15 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features }) =>
{
setShowRolesPrivilegeEditor(false);
}}
onSaveClick={() => {
- window.alert('your wish is granted');
setShowRolesPrivilegeEditor(false);
}}
+ systemRoles={systemRoles}
+ // rolesAPIClient would have been initialized before the privilege editor is displayed
+ roleAPIClient={rolesAPIClient.current!}
/>
)}
@@ -149,17 +192,22 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features }) =>
-
- {
- setShowRolesPrivilegeEditor(true);
- }}
- >
- {i18n.translate('xpack.spaces.management.spaceDetails.roles.assign', {
- defaultMessage: 'Assign role',
- })}
-
-
+ {!isReadOnly && (
+
+ {
+ if (!roleAPIClientInitialized) {
+ await resolveRolesAPIClient();
+ }
+ setShowRolesPrivilegeEditor(true);
+ }}
+ >
+ {i18n.translate('xpack.spaces.management.spaceDetails.roles.assign', {
+ defaultMessage: 'Assign role',
+ })}
+
+
+ )}
@@ -176,37 +224,77 @@ export const ViewSpaceAssignedRoles: FC = ({ space, roles, features }) =>
);
};
-interface PrivilegesRolesFormProps extends Props {
+interface PrivilegesRolesFormProps extends Omit {
closeFlyout: () => void;
onSaveClick: () => void;
+ systemRoles: Role[];
+ roleAPIClient: RolesAPIClient;
}
+const createRolesComboBoxOptions = (roles: Role[]): Array> =>
+ roles.map((role) => ({
+ label: role.name,
+ value: role,
+ }));
+
export const PrivilegesRolesForm: FC = (props) => {
- const { space, roles, onSaveClick, closeFlyout, features } = props;
+ const { onSaveClick, closeFlyout, features, roleAPIClient, systemRoles } = props;
+
+ const [space, setSpaceState] = useState(props.space);
+ const [spacePrivilege, setSpacePrivilege] = useState('all');
+ const [selectedRoles, setSelectedRoles] = useState>(
+ []
+ );
+
+ const [assigningToRole, setAssigningToRole] = useState(false);
- const [selectedRoles, setSelectedRoles] = useState>>([]);
- const [spacePrivilege, setSpacePrivilege] = useState<'all' | 'read' | 'custom'>('all');
+ const assignRolesToSpace = useCallback(async () => {
+ try {
+ setAssigningToRole(true);
+
+ await Promise.all(
+ selectedRoles.map((selectedRole) => {
+ roleAPIClient.saveRole({ role: selectedRole.value! });
+ })
+ ).then(setAssigningToRole.bind(null, false));
+
+ onSaveClick();
+ } catch {
+ // Handle resulting error
+ }
+ }, [onSaveClick, roleAPIClient, selectedRoles]);
const getForm = () => {
return (
({
- label: role.name,
- }))}
+ options={createRolesComboBoxOptions(systemRoles)}
selectedOptions={selectedRoles}
onChange={(value) => {
- setSelectedRoles(value);
+ setSelectedRoles((prevRoles) => {
+ if (prevRoles.length < value.length) {
+ const newlyAdded = value[value.length - 1];
+
+ // Add kibana space privilege definition to role
+ newlyAdded.value!.kibana.push({
+ spaces: [space.name],
+ base: spacePrivilege === 'custom' ? [] : [spacePrivilege],
+ feature: {},
+ });
+
+ return prevRoles.concat(newlyAdded);
+ } else {
+ return value;
+ }
+ });
}}
- isClearable={true}
- data-test-subj="roleSelectionComboBox"
- autoFocus
fullWidth
/>
@@ -219,35 +307,43 @@ export const PrivilegesRolesForm: FC = (props) => {
}
)}
>
-
- setSpacePrivilege('all')}
- >
-
-
- setSpacePrivilege('read')}
- >
-
-
- setSpacePrivilege('custom')}
- >
-
-
-
+ ({
+ ...privilege,
+ 'data-test-subj': `${privilege.id}-privilege-button`,
+ }))}
+ color="primary"
+ idSelected={spacePrivilege}
+ onChange={(id) => setSpacePrivilege(id)}
+ buttonSize="compressed"
+ isFullWidth
+ />
{spacePrivilege === 'custom' && (
= (props) => {
-
+
>
)}
@@ -277,7 +373,12 @@ export const PrivilegesRolesForm: FC = (props) => {
const getSaveButton = () => {
return (
-
+ assignRolesToSpace()}
+ data-test-subj={'createRolesPrivilegeButton'}
+ >
{i18n.translate('xpack.spaces.management.spaceDetails.roles.assignRoleButton', {
defaultMessage: 'Assign roles',
})}
@@ -286,7 +387,7 @@ export const PrivilegesRolesForm: FC = (props) => {
};
return (
-
+
Assign role to {space.name}
diff --git a/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx b/x-pack/plugins/spaces/public/management/view_space/view_space_tabs.tsx
index 9a41d2a08bea6..19f165bb558ca 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
@@ -8,6 +8,7 @@
import { EuiNotificationBadge } from '@elastic/eui';
import React from 'react';
+import type { Capabilities } from '@kbn/core/public';
import type { KibanaFeature } from '@kbn/features-plugin/common';
import { i18n } from '@kbn/i18n';
import type { Role } from '@kbn/security-plugin-types-common';
@@ -27,11 +28,28 @@ export interface ViewSpaceTab {
href?: string;
}
-export const getTabs = (space: Space, features: KibanaFeature[], roles: Role[]): ViewSpaceTab[] => {
+export interface GetTabsProps {
+ space: Space;
+ roles: Role[];
+ features: KibanaFeature[];
+ capabilities: Capabilities & {
+ roles?: { view: boolean; save: boolean };
+ };
+}
+
+export const getTabs = ({
+ space,
+ features,
+ capabilities,
+ ...rest
+}: GetTabsProps): ViewSpaceTab[] => {
const enabledFeatureCount = getEnabledFeatures(features, space).length;
const totalFeatureCount = features.length;
- return [
+ const canUserViewRoles = Boolean(capabilities?.roles?.view);
+ const canUserModifyRoles = Boolean(capabilities?.roles?.save);
+
+ const tabsDefinition = [
{
id: TAB_ID_CONTENT,
name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.content.heading', {
@@ -51,17 +69,29 @@ export const getTabs = (space: Space, features: KibanaFeature[], roles: Role[]):
),
content: ,
},
- {
+ ];
+
+ if (canUserViewRoles) {
+ tabsDefinition.push({
id: TAB_ID_ROLES,
name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.roles.heading', {
defaultMessage: 'Assigned roles',
}),
append: (
- {roles.length}
+ {rest.roles.length}
),
- content: ,
- },
- ];
+ content: (
+
+ ),
+ });
+ }
+
+ return tabsDefinition;
};