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; };