From 78bf7cfdf9c2a51515a6653acb27c7f9c61745cc Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Mon, 22 Apr 2024 13:49:07 +0800 Subject: [PATCH] [Workspace] Add permission tab to workspace create update page (#6378) (#6578) * Allow workspace update with partial attirbutes Signed-off-by: Lin Wang * Add permissions tab for workspace creator and update page Signed-off-by: Lin Wang * Add change log for adding permission tab Signed-off-by: Lin Wang * Optimize permissions to permissions settings convertation Signed-off-by: Lin Wang * Address PR comments Signed-off-by: Lin Wang * Update comments for getPermissionModeId Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang (cherry picked from commit d911fa7144fec19b9409a8dea6b7d4d06af9a2b8) Signed-off-by: github-actions[bot] # Conflicts: # CHANGELOG.md Co-authored-by: github-actions[bot] --- src/core/types/saved_objects.ts | 2 + src/core/types/workspace.ts | 6 + .../workspace_creator.test.tsx | 50 +++- .../workspace_creator/workspace_creator.tsx | 10 +- .../components/workspace_form/constants.ts | 50 ++++ .../public/components/workspace_form/index.ts | 4 + .../public/components/workspace_form/types.ts | 26 +- .../workspace_form/use_workspace_form.ts | 51 ++-- .../components/workspace_form/utils.test.ts | 266 +++++++++++++++++- .../public/components/workspace_form/utils.ts | 237 +++++++++++++++- .../workspace_form/workspace_form.tsx | 41 ++- ...orkspace_permission_setting_input.test.tsx | 114 ++++++++ .../workspace_permission_setting_input.tsx | 128 +++++++++ ...orkspace_permission_setting_panel.test.tsx | 199 +++++++++++++ .../workspace_permission_setting_panel.tsx | 235 ++++++++++++++++ .../workspace_updater.test.tsx | 24 +- .../workspace_updater/workspace_updater.tsx | 48 +++- .../workspace/public/workspace_client.ts | 23 +- .../server/integration_tests/routes.test.ts | 26 ++ src/plugins/workspace/server/routes/index.ts | 17 +- src/plugins/workspace/server/types.ts | 2 +- .../workspace/server/workspace_client.ts | 21 +- 22 files changed, 1502 insertions(+), 78 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 06d03f5f24c4..e63f380d9fb0 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -126,3 +126,5 @@ export interface SavedObjectError { statusCode: number; metadata?: Record; } + +export type SavedObjectPermissions = Permissions; diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index c95b993edc74..92888b845257 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Permissions } from '../server/saved_objects'; + export interface WorkspaceAttribute { id: string; name: string; @@ -12,3 +14,7 @@ export interface WorkspaceAttribute { icon?: string; reserved?: boolean; } + +export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { + permissions?: Permissions; +} diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx index 9cc4f9b53f69..f5a12df2e43d 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -34,6 +34,12 @@ const WorkspaceCreator = (props: any) => { ...{ application: { ...mockCoreStart.application, + capabilities: { + ...mockCoreStart.application.capabilities, + workspaces: { + permissionEnabled: true, + }, + }, navigateToApp, getUrlForApp: jest.fn(() => '/app/workspace_overview'), applications$: new BehaviorSubject>(PublicAPPInfoMap as any), @@ -145,7 +151,8 @@ describe('WorkspaceCreator', () => { name: 'test workspace name', color: '#000000', description: 'test workspace description', - }) + }), + undefined ); await waitFor(() => { expect(notificationToastsAddSuccess).toHaveBeenCalled(); @@ -168,7 +175,8 @@ describe('WorkspaceCreator', () => { expect.objectContaining({ name: 'test workspace name', features: expect.arrayContaining(['app1', 'app2', 'app3']), - }) + }), + undefined ); await waitFor(() => { expect(notificationToastsAddSuccess).toHaveBeenCalled(); @@ -180,7 +188,7 @@ describe('WorkspaceCreator', () => { }); it('should show danger toasts after create workspace failed', async () => { - workspaceClientCreate.mockReturnValue({ result: { id: 'failResult' }, success: false }); + workspaceClientCreate.mockReturnValueOnce({ result: { id: 'failResult' }, success: false }); const { getByTestId } = render(); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { @@ -195,7 +203,7 @@ describe('WorkspaceCreator', () => { }); it('should show danger toasts after call create workspace API failed', async () => { - workspaceClientCreate.mockImplementation(async () => { + workspaceClientCreate.mockImplementationOnce(async () => { throw new Error(); }); const { getByTestId } = render(); @@ -210,4 +218,38 @@ describe('WorkspaceCreator', () => { }); expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); }); + + it('create workspace with customized permissions', async () => { + const { getByTestId, getByText, getAllByText } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByText('Users & Permissions')); + fireEvent.click(getByTestId('workspaceForm-permissionSettingPanel-user-addNew')); + const userIdInput = getAllByText('Select')[0]; + fireEvent.click(userIdInput); + fireEvent.input(getByTestId('comboBoxSearchInput'), { + target: { value: 'test user id' }, + }); + fireEvent.blur(getByTestId('comboBoxSearchInput')); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + }), + { + read: { + users: ['test user id'], + }, + library_read: { + users: ['test user id'], + }, + } + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); }); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index 0ba7ca9947df..75b598d7aa6d 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -11,17 +11,23 @@ import { WorkspaceForm, WorkspaceFormSubmitData, WorkspaceOperationType } from ' import { WORKSPACE_OVERVIEW_APP_ID } from '../../../common/constants'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { WorkspaceClient } from '../../workspace_client'; +import { convertPermissionSettingsToPermissions } from '../workspace_form'; export const WorkspaceCreator = () => { const { services: { application, notifications, http, workspaceClient }, } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); + const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; const handleWorkspaceFormSubmit = useCallback( async (data: WorkspaceFormSubmitData) => { let result; try { - result = await workspaceClient.create(data); + const { permissionSettings, ...attributes } = data; + result = await workspaceClient.create( + attributes, + convertPermissionSettingsToPermissions(permissionSettings) + ); if (result?.success) { notifications?.toasts.addSuccess({ title: i18n.translate('workspace.create.success', { @@ -80,6 +86,8 @@ export const WorkspaceCreator = () => { application={application} onSubmit={handleWorkspaceFormSubmit} operationType={WorkspaceOperationType.Create} + permissionEnabled={isPermissionEnabled} + permissionLastAdminItemDeletable /> )} diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts index 83ae111e9c20..693f0cdce141 100644 --- a/src/plugins/workspace/public/components/workspace_form/constants.ts +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -3,6 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { i18n } from '@osd/i18n'; +import { WorkspacePermissionMode } from '../../../common/constants'; + export enum WorkspaceOperationType { Create = 'create', Update = 'update', @@ -11,4 +14,51 @@ export enum WorkspaceOperationType { export enum WorkspaceFormTabs { NotSelected, FeatureVisibility, + UsersAndPermissions, +} + +export enum WorkspacePermissionItemType { + User = 'user', + Group = 'group', } + +export enum PermissionModeId { + Read = 'read', + ReadAndWrite = 'read+write', + Admin = 'admin', +} + +export const permissionModeOptions = [ + { + id: PermissionModeId.Read, + label: i18n.translate('workspace.form.permissionSettingPanel.permissionModeOptions.read', { + defaultMessage: 'Read', + }), + }, + { + id: PermissionModeId.ReadAndWrite, + label: i18n.translate( + 'workspace.form.permissionSettingPanel.permissionModeOptions.readAndWrite', + { + defaultMessage: 'Read & Write', + } + ), + }, + { + id: PermissionModeId.Admin, + label: i18n.translate('workspace.form.permissionSettingPanel.permissionModeOptions.admin', { + defaultMessage: 'Admin', + }), + }, +]; + +export const optionIdToWorkspacePermissionModesMap: { + [key: string]: WorkspacePermissionMode[]; +} = { + [PermissionModeId.Read]: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + [PermissionModeId.ReadAndWrite]: [ + WorkspacePermissionMode.LibraryWrite, + WorkspacePermissionMode.Read, + ], + [PermissionModeId.Admin]: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], +}; diff --git a/src/plugins/workspace/public/components/workspace_form/index.ts b/src/plugins/workspace/public/components/workspace_form/index.ts index 6531d4a1c6f7..416592ff2006 100644 --- a/src/plugins/workspace/public/components/workspace_form/index.ts +++ b/src/plugins/workspace/public/components/workspace_form/index.ts @@ -6,3 +6,7 @@ export { WorkspaceForm } from './workspace_form'; export { WorkspaceFormSubmitData } from './types'; export { WorkspaceOperationType } from './constants'; +export { + convertPermissionsToPermissionSettings, + convertPermissionSettingsToPermissions, +} from './utils'; diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index 8014c2321ad5..95c96fa3e353 100644 --- a/src/plugins/workspace/public/components/workspace_form/types.ts +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -3,14 +3,30 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { WorkspaceOperationType } from './constants'; import type { ApplicationStart } from '../../../../../core/public'; +import type { WorkspacePermissionMode } from '../../../common/constants'; +import type { WorkspaceOperationType, WorkspacePermissionItemType } from './constants'; + +export type WorkspacePermissionSetting = + | { + id: number; + type: WorkspacePermissionItemType.User; + userId: string; + modes: WorkspacePermissionMode[]; + } + | { + id: number; + type: WorkspacePermissionItemType.Group; + group: string; + modes: WorkspacePermissionMode[]; + }; export interface WorkspaceFormSubmitData { name: string; description?: string; features?: string[]; color?: string; + permissionSettings?: WorkspacePermissionSetting[]; } export interface WorkspaceFormData extends WorkspaceFormSubmitData { @@ -28,11 +44,17 @@ export interface WorkspaceFeatureGroup { features: WorkspaceFeature[]; } -export type WorkspaceFormErrors = { [key in keyof WorkspaceFormData]?: string }; +export type WorkspaceFormErrors = { + [key in keyof Omit]?: string; +} & { + permissionSettings?: { [key: number]: string }; +}; export interface WorkspaceFormProps { application: ApplicationStart; onSubmit?: (formData: WorkspaceFormSubmitData) => void; defaultValues?: WorkspaceFormData; operationType?: WorkspaceOperationType; + permissionEnabled?: boolean; + permissionLastAdminItemDeletable?: boolean; } diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts index 315f3486f83e..cded7c4bcb71 100644 --- a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -5,13 +5,12 @@ import { useCallback, useState, FormEventHandler, useRef, useMemo, useEffect } from 'react'; import { htmlIdGenerator, EuiFieldTextProps, EuiColorPickerProps } from '@elastic/eui'; -import { i18n } from '@osd/i18n'; import { useApplications } from '../../hooks'; import { featureMatchesConfig } from '../../utils'; import { WorkspaceFormTabs } from './constants'; -import { WorkspaceFormProps, WorkspaceFormErrors } from './types'; -import { appendDefaultFeatureIds, getNumberOfErrors, isValidFormTextInput } from './utils'; +import { WorkspaceFormProps, WorkspaceFormErrors, WorkspacePermissionSetting } from './types'; +import { appendDefaultFeatureIds, getNumberOfErrors, validateWorkspaceForm } from './utils'; const workspaceHtmlIdGenerator = htmlIdGenerator(); @@ -36,6 +35,13 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works const [selectedFeatureIds, setSelectedFeatureIds] = useState( appendDefaultFeatureIds(defaultFeatures) ); + const [permissionSettings, setPermissionSettings] = useState< + Array & Partial> + >( + defaultValues?.permissionSettings && defaultValues.permissionSettings.length > 0 + ? defaultValues.permissionSettings + : [] + ); const [formErrors, setFormErrors] = useState({}); const numberOfErrors = useMemo(() => getNumberOfErrors(formErrors), [formErrors]); @@ -45,6 +51,7 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works description, features: selectedFeatureIds, color, + permissionSettings, }); const getFormDataRef = useRef(getFormData); getFormDataRef.current = getFormData; @@ -56,32 +63,8 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works const handleFormSubmit = useCallback( (e) => { e.preventDefault(); - let currentFormErrors: WorkspaceFormErrors = {}; const formData = getFormDataRef.current(); - if (!formData.name) { - currentFormErrors = { - ...currentFormErrors, - name: i18n.translate('workspace.form.detail.name.empty', { - defaultMessage: "Name can't be empty.", - }), - }; - } - if (formData.name && !isValidFormTextInput(formData.name)) { - currentFormErrors = { - ...currentFormErrors, - name: i18n.translate('workspace.form.detail.name.invalid', { - defaultMessage: 'Invalid workspace name', - }), - }; - } - if (formData.description && !isValidFormTextInput(formData.description)) { - currentFormErrors = { - ...currentFormErrors, - description: i18n.translate('workspace.form.detail.description.invalid', { - defaultMessage: 'Invalid workspace description', - }), - }; - } + const currentFormErrors: WorkspaceFormErrors = validateWorkspaceForm(formData); setFormErrors(currentFormErrors); if (getNumberOfErrors(currentFormErrors) > 0) { return; @@ -101,7 +84,11 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works formData.features = defaultValues?.features ?? []; } - onSubmit?.({ ...formData, name: formData.name! }); + onSubmit?.({ + ...formData, + name: formData.name!, + permissionSettings: formData.permissionSettings as WorkspacePermissionSetting[], + }); }, [defaultFeatures, onSubmit, defaultValues?.features] ); @@ -122,6 +109,10 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works setSelectedTab(WorkspaceFormTabs.FeatureVisibility); }, []); + const handleTabPermissionClick = useCallback(() => { + setSelectedTab(WorkspaceFormTabs.UsersAndPermissions); + }, []); + const handleFeaturesChange = useCallback((featureIds: string[]) => { setSelectedFeatureIds(featureIds); }, []); @@ -143,6 +134,8 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works handleFeaturesChange, handleNameInputChange, handleTabFeatureClick, + setPermissionSettings, + handleTabPermissionClick, handleDescriptionInputChange, }; }; diff --git a/src/plugins/workspace/public/components/workspace_form/utils.test.ts b/src/plugins/workspace/public/components/workspace_form/utils.test.ts index 6101bd078831..4531b2979bc1 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.test.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.test.ts @@ -4,7 +4,14 @@ */ import { AppNavLinkStatus, DEFAULT_APP_CATEGORIES } from '../../../../../core/public'; -import { convertApplicationsToFeaturesOrGroups } from './utils'; +import { + validateWorkspaceForm, + convertApplicationsToFeaturesOrGroups, + convertPermissionSettingsToPermissions, + convertPermissionsToPermissionSettings, +} from './utils'; +import { WorkspacePermissionMode } from '../../../common/constants'; +import { WorkspacePermissionItemType } from './constants'; describe('convertApplicationsToFeaturesOrGroups', () => { it('should filter out invisible features', () => { @@ -124,3 +131,260 @@ describe('convertApplicationsToFeaturesOrGroups', () => { ]); }); }); + +describe('convertPermissionSettingsToPermissions', () => { + it('should return undefined if permission items not provided', () => { + expect(convertPermissionSettingsToPermissions(undefined)).toBeUndefined(); + expect(convertPermissionSettingsToPermissions([])).toBeUndefined(); + }); + + it('should not add duplicate users and groups', () => { + expect( + convertPermissionSettingsToPermissions([ + { + id: 0, + type: WorkspacePermissionItemType.User, + userId: 'duplicate-user', + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + { + id: 1, + type: WorkspacePermissionItemType.User, + userId: 'duplicate-user', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Read], + }, + { + id: 2, + type: WorkspacePermissionItemType.Group, + group: 'duplicate-group', + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + { + id: 3, + type: WorkspacePermissionItemType.Group, + group: 'duplicate-group', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Read], + }, + ]) + ).toEqual({ + library_read: { users: ['duplicate-user'], groups: ['duplicate-group'] }, + library_write: { users: ['duplicate-user'], groups: ['duplicate-group'] }, + read: { users: ['duplicate-user'], groups: ['duplicate-group'] }, + }); + }); + + it('should return consistent permissions', () => { + expect( + convertPermissionSettingsToPermissions([ + { + id: 0, + type: WorkspacePermissionItemType.User, + userId: 'foo', + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + { + id: 1, + type: WorkspacePermissionItemType.Group, + group: 'bar', + modes: [WorkspacePermissionMode.LibraryWrite], + }, + ]) + ).toEqual({ + library_read: { users: ['foo'] }, + library_write: { groups: ['bar'] }, + read: { users: ['foo'] }, + }); + }); +}); + +describe('convertPermissionsToPermissionSettings', () => { + it('should return consistent permission settings', () => { + expect( + convertPermissionsToPermissionSettings({ + library_read: { users: ['foo'] }, + library_write: { groups: ['bar'] }, + read: { users: ['foo'] }, + write: { groups: ['bar'] }, + }) + ).toEqual([ + { + id: 0, + type: WorkspacePermissionItemType.User, + userId: 'foo', + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + { + id: 1, + type: WorkspacePermissionItemType.Group, + group: 'bar', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + }, + ]); + }); + it('should separate to multi permission settings', () => { + expect( + convertPermissionsToPermissionSettings({ + library_read: { users: ['foo'] }, + library_write: { users: ['foo'] }, + read: { users: ['foo'] }, + }) + ).toEqual([ + { + id: 0, + type: WorkspacePermissionItemType.User, + userId: 'foo', + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + { + id: 1, + type: WorkspacePermissionItemType.User, + userId: 'foo', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Read], + }, + ]); + expect( + convertPermissionsToPermissionSettings({ + library_read: { groups: ['bar'] }, + library_write: { groups: ['bar'] }, + read: { groups: ['bar'] }, + }) + ).toEqual([ + { + id: 0, + type: WorkspacePermissionItemType.Group, + group: 'bar', + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + { + id: 1, + type: WorkspacePermissionItemType.Group, + group: 'bar', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Read], + }, + ]); + }); + it('should only convert workspace supported permissions', () => { + expect( + convertPermissionsToPermissionSettings({ + another_read: { users: ['foo'] }, + }) + ).toEqual([]); + }); +}); + +describe('validateWorkspaceForm', () => { + it('should return error if name is empty', () => { + expect(validateWorkspaceForm({}).name).toEqual("Name can't be empty."); + }); + it('should return error if name is invalid', () => { + expect(validateWorkspaceForm({ name: '~' }).name).toEqual('Invalid workspace name'); + }); + it('should return error if description is invalid', () => { + expect(validateWorkspaceForm({ description: '~' }).description).toEqual( + 'Invalid workspace description' + ); + }); + it('should return error if permission setting type is invalid', () => { + expect( + validateWorkspaceForm({ + name: 'test', + permissionSettings: [{ id: 0 }], + }).permissionSettings + ).toEqual({ 0: 'Invalid type' }); + }); + it('should return error if permission setting modes is invalid', () => { + expect( + validateWorkspaceForm({ + name: 'test', + permissionSettings: [{ id: 0, type: WorkspacePermissionItemType.User, modes: [] }], + }).permissionSettings + ).toEqual({ 0: 'Invalid permission modes' }); + }); + it('should return error if permission setting user id is invalid', () => { + expect( + validateWorkspaceForm({ + name: 'test', + permissionSettings: [ + { + id: 0, + type: WorkspacePermissionItemType.User, + modes: [WorkspacePermissionMode.LibraryRead], + userId: '', + }, + ], + }).permissionSettings + ).toEqual({ 0: 'Invalid user id' }); + }); + it('should return error if permission setting group is invalid', () => { + expect( + validateWorkspaceForm({ + name: 'test', + permissionSettings: [ + { + id: 0, + type: WorkspacePermissionItemType.Group, + modes: [WorkspacePermissionMode.LibraryRead], + group: '', + }, + ], + }).permissionSettings + ).toEqual({ 0: 'Invalid user group' }); + }); + + it('should return error if permission setting is duplicate', () => { + expect( + validateWorkspaceForm({ + name: 'test', + permissionSettings: [ + { + id: 0, + type: WorkspacePermissionItemType.User, + modes: [WorkspacePermissionMode.LibraryRead], + userId: 'foo', + }, + { + id: 1, + type: WorkspacePermissionItemType.User, + modes: [WorkspacePermissionMode.LibraryRead], + userId: 'foo', + }, + ], + }).permissionSettings + ).toEqual({ 1: 'Duplicate permission setting' }); + expect( + validateWorkspaceForm({ + name: 'test', + permissionSettings: [ + { + id: 0, + type: WorkspacePermissionItemType.Group, + modes: [WorkspacePermissionMode.LibraryRead], + group: 'foo', + }, + { + id: 1, + type: WorkspacePermissionItemType.Group, + modes: [WorkspacePermissionMode.LibraryRead], + group: 'foo', + }, + ], + }).permissionSettings + ).toEqual({ 1: 'Duplicate permission setting' }); + }); + + it('should return empty object for valid for data', () => { + expect( + validateWorkspaceForm({ + name: 'test', + permissionSettings: [ + { + id: 0, + type: WorkspacePermissionItemType.Group, + modes: [WorkspacePermissionMode.LibraryRead], + group: 'foo', + }, + ], + }) + ).toEqual({}); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts index 5514e6a8fb9c..a00be1ceaa4e 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -3,14 +3,28 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { i18n } from '@osd/i18n'; + import { AppNavLinkStatus, DEFAULT_APP_CATEGORIES, PublicAppInfo, } from '../../../../../core/public'; -import { DEFAULT_SELECTED_FEATURES_IDS } from '../../../common/constants'; +import type { SavedObjectPermissions } from '../../../../../core/types'; +import { DEFAULT_SELECTED_FEATURES_IDS, WorkspacePermissionMode } from '../../../common/constants'; +import { + optionIdToWorkspacePermissionModesMap, + PermissionModeId, + WorkspacePermissionItemType, +} from './constants'; -import { WorkspaceFeature, WorkspaceFeatureGroup, WorkspaceFormErrors } from './types'; +import { + WorkspaceFeature, + WorkspaceFeatureGroup, + WorkspaceFormErrors, + WorkspaceFormSubmitData, + WorkspacePermissionSetting, +} from './types'; export const isWorkspaceFeatureGroup = ( featureOrGroup: WorkspaceFeature | WorkspaceFeatureGroup @@ -38,6 +52,9 @@ export const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { if (formErrors.description) { numberOfErrors += 1; } + if (formErrors.permissionSettings) { + numberOfErrors += Object.keys(formErrors.permissionSettings).length; + } return numberOfErrors; }; @@ -98,3 +115,219 @@ export const convertApplicationsToFeaturesOrGroups = ( ]; }, []); }; + +export const isUserOrGroupPermissionSettingDuplicated = ( + permissionSettings: Array>, + permissionSettingToCheck: WorkspacePermissionSetting +) => + permissionSettings.some( + (permissionSetting) => + (permissionSettingToCheck.type === WorkspacePermissionItemType.User && + permissionSetting.type === WorkspacePermissionItemType.User && + permissionSettingToCheck.userId === permissionSetting.userId) || + (permissionSettingToCheck.type === WorkspacePermissionItemType.Group && + permissionSetting.type === WorkspacePermissionItemType.Group && + permissionSettingToCheck.group === permissionSetting.group) + ); + +/** + * This function is for converting passed permission modes to permission option id, + * it will return Read as default if permission modes not matched. + * + * @param modes permission modes + * @returns permission option id + */ +export const getPermissionModeId = (modes: WorkspacePermissionMode[]) => { + for (const key in optionIdToWorkspacePermissionModesMap) { + if (optionIdToWorkspacePermissionModesMap[key].every((mode) => modes?.includes(mode))) { + return key; + } + } + return PermissionModeId.Read; +}; + +export const convertPermissionSettingsToPermissions = ( + permissionItems: WorkspacePermissionSetting[] | undefined +) => { + if (!permissionItems || permissionItems.length === 0) { + return undefined; + } + return permissionItems.reduce((previous, current) => { + current.modes.forEach((mode) => { + if (!previous[mode]) { + previous[mode] = {}; + } + switch (current.type) { + case WorkspacePermissionItemType.User: + previous[mode].users = previous[mode].users?.includes(current.userId) + ? previous[mode].users + : [...(previous[mode].users || []), current.userId]; + break; + case WorkspacePermissionItemType.Group: + previous[mode].groups = previous[mode].groups?.includes(current.group) + ? previous[mode].groups + : [...(previous[mode].groups || []), current.group]; + break; + } + }); + return previous; + }, {}); +}; + +const isWorkspacePermissionMode = (test: string): test is WorkspacePermissionMode => + test === WorkspacePermissionMode.LibraryRead || + test === WorkspacePermissionMode.LibraryWrite || + test === WorkspacePermissionMode.Read || + test === WorkspacePermissionMode.Write; + +export const convertPermissionsToPermissionSettings = (permissions: SavedObjectPermissions) => { + const permissionSettings: WorkspacePermissionSetting[] = []; + const finalPermissionSettings: WorkspacePermissionSetting[] = []; + const settingType2Modes: { [key: string]: WorkspacePermissionMode[] } = {}; + + const processUsersOrGroups = ( + usersOrGroups: string[] | undefined, + type: WorkspacePermissionItemType, + mode: WorkspacePermissionMode + ) => { + usersOrGroups?.forEach((userOrGroup) => { + const settingTypeKey = `${type}-${userOrGroup}`; + const modes = settingType2Modes[settingTypeKey] ?? []; + + modes.push(mode); + if (modes.length === 1) { + permissionSettings.push({ + // This id is for type safe, and will be overwrite in below. + id: 0, + modes, + ...(type === WorkspacePermissionItemType.User + ? { type: WorkspacePermissionItemType.User, userId: userOrGroup } + : { type: WorkspacePermissionItemType.Group, group: userOrGroup }), + }); + settingType2Modes[settingTypeKey] = modes; + } + }); + }; + + Object.keys(permissions).forEach((mode) => { + if (isWorkspacePermissionMode(mode)) { + processUsersOrGroups(permissions[mode].users, WorkspacePermissionItemType.User, mode); + processUsersOrGroups(permissions[mode].groups, WorkspacePermissionItemType.Group, mode); + } + }); + + let id = 0; + /** + * One workspace permission setting may include multi setting options, + * for loop the workspace permission setting array to separate it to multi rows. + **/ + permissionSettings.forEach((currentPermissionSettings) => { + /** + * For loop the option id to workspace permission modes map, + * if one settings includes all permission modes in a specific option, + * add these permission modes to the result array. + */ + for (const key in optionIdToWorkspacePermissionModesMap) { + if (!Object.prototype.hasOwnProperty.call(optionIdToWorkspacePermissionModesMap, key)) { + continue; + } + const modesForCertainPermissionId = optionIdToWorkspacePermissionModesMap[key]; + if ( + modesForCertainPermissionId.every((mode) => currentPermissionSettings.modes?.includes(mode)) + ) { + finalPermissionSettings.push({ + ...currentPermissionSettings, + id, + modes: modesForCertainPermissionId, + }); + id++; + } + } + }); + + return finalPermissionSettings; +}; + +export const validateWorkspaceForm = ( + formData: Omit, 'permissionSettings'> & { + permissionSettings?: Array< + Pick & Partial + >; + } +) => { + const formErrors: WorkspaceFormErrors = {}; + const { name, description, permissionSettings } = formData; + if (name) { + if (!isValidFormTextInput(name)) { + formErrors.name = i18n.translate('workspace.form.detail.name.invalid', { + defaultMessage: 'Invalid workspace name', + }); + } + } else { + formErrors.name = i18n.translate('workspace.form.detail.name.empty', { + defaultMessage: "Name can't be empty.", + }); + } + if (description && !isValidFormTextInput(description)) { + formErrors.description = i18n.translate('workspace.form.detail.description.invalid', { + defaultMessage: 'Invalid workspace description', + }); + } + if (permissionSettings) { + const permissionSettingsErrors: { [key: number]: string } = {}; + for (let i = 0; i < permissionSettings.length; i++) { + const setting = permissionSettings[i]; + if (!setting.type) { + permissionSettingsErrors[setting.id] = i18n.translate( + 'workspace.form.permission.invalidate.type', + { + defaultMessage: 'Invalid type', + } + ); + } else if (!setting.modes || setting.modes.length === 0) { + permissionSettingsErrors[setting.id] = i18n.translate( + 'workspace.form.permission.invalidate.modes', + { + defaultMessage: 'Invalid permission modes', + } + ); + } else if (setting.type === WorkspacePermissionItemType.User && !setting.userId) { + permissionSettingsErrors[setting.id] = i18n.translate( + 'workspace.form.permission.invalidate.userId', + { + defaultMessage: 'Invalid user id', + } + ); + } else if (setting.type === WorkspacePermissionItemType.Group && !setting.group) { + permissionSettingsErrors[setting.id] = i18n.translate( + 'workspace.form.permission.invalidate.group', + { + defaultMessage: 'Invalid user group', + } + ); + } else if ( + isUserOrGroupPermissionSettingDuplicated( + permissionSettings.slice(0, i), + setting as WorkspacePermissionSetting + ) + ) { + permissionSettingsErrors[setting.id] = i18n.translate( + 'workspace.form.permission.invalidate.group', + { + defaultMessage: 'Duplicate permission setting', + } + ); + } + } + if (Object.keys(permissionSettingsErrors).length > 0) { + formErrors.permissionSettings = permissionSettingsErrors; + } + } + return formErrors; +}; + +export const generateNextPermissionSettingsId = (permissionSettings: Array<{ id: number }>) => { + return permissionSettings.length === 0 + ? 0 + : Math.max(...permissionSettings.map(({ id }) => id)) + 1; +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx index 69793c75395d..274cee6d07d3 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -24,9 +24,16 @@ import { WorkspaceFormProps } from './types'; import { WorkspaceFormTabs } from './constants'; import { useWorkspaceForm } from './use_workspace_form'; import { WorkspaceFeatureSelector } from './workspace_feature_selector'; +import { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; export const WorkspaceForm = (props: WorkspaceFormProps) => { - const { application, defaultValues, operationType } = props; + const { + application, + defaultValues, + operationType, + permissionEnabled, + permissionLastAdminItemDeletable, + } = props; const { formId, formData, @@ -39,6 +46,8 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { handleFeaturesChange, handleNameInputChange, handleTabFeatureClick, + setPermissionSettings, + handleTabPermissionClick, handleDescriptionInputChange, } = useWorkspaceForm(props); const workspaceDetailsTitle = i18n.translate('workspace.form.workspaceDetails.title', { @@ -47,6 +56,9 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { const featureVisibilityTitle = i18n.translate('workspace.form.featureVisibility.title', { defaultMessage: 'Feature Visibility', }); + const usersAndPermissionsTitle = i18n.translate('workspace.form.usersAndPermissions.title', { + defaultMessage: 'Users & Permissions', + }); return ( @@ -124,6 +136,14 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { > {featureVisibilityTitle} + {permissionEnabled && ( + + {usersAndPermissionsTitle} + + )} {selectedTab === WorkspaceFormTabs.FeatureVisibility && ( @@ -139,6 +159,25 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { /> )} + {selectedTab === WorkspaceFormTabs.UsersAndPermissions && ( + + +

+ {i18n.translate('workspace.form.usersAndPermissions.title', { + defaultMessage: 'Users & Permissions', + })} +

+
+ + +
+ )} ) => { + const onGroupOrUserIdChangeMock = jest.fn(); + const onPermissionModesChangeMock = jest.fn(); + const onDeleteMock = jest.fn(); + const renderResult = render( + + ); + return { + renderResult, + onGroupOrUserIdChangeMock, + onPermissionModesChangeMock, + onDeleteMock, + }; +}; + +describe('WorkspacePermissionSettingInput', () => { + it('should render consistent user id and permission modes', () => { + const { renderResult } = setup({ + userId: 'foo', + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }); + + expect(renderResult.getByText('foo')).toBeInTheDocument(); + expect(renderResult.getByText('Read')).toBeInTheDocument(); + expect( + renderResult.getByText('Read').closest('.euiButtonGroupButton-isSelected') + ).toBeInTheDocument(); + }); + it('should render consistent group id and permission modes', () => { + const { renderResult } = setup({ + type: WorkspacePermissionItemType.Group, + group: 'bar', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Read], + }); + + expect(renderResult.getByText('bar')).toBeInTheDocument(); + expect(renderResult.getByText('Read & Write')).toBeInTheDocument(); + expect( + renderResult.getByText('Read & Write').closest('.euiButtonGroupButton-isSelected') + ).toBeInTheDocument(); + }); + it('should call onGroupOrUserIdChange with user id', () => { + const { renderResult, onGroupOrUserIdChangeMock } = setup(); + + expect(onGroupOrUserIdChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByText('Select')); + fireEvent.input(renderResult.getByTestId('comboBoxSearchInput'), { + target: { value: 'user1' }, + }); + fireEvent.blur(renderResult.getByTestId('comboBoxSearchInput')); + expect(onGroupOrUserIdChangeMock).toHaveBeenCalledWith({ type: 'user', userId: 'user1' }, 0); + }); + it('should call onGroupOrUserIdChange with group', () => { + const { renderResult, onGroupOrUserIdChangeMock } = setup({ + type: WorkspacePermissionItemType.Group, + }); + + expect(onGroupOrUserIdChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByText('Select')); + fireEvent.input(renderResult.getByTestId('comboBoxSearchInput'), { + target: { value: 'group' }, + }); + fireEvent.blur(renderResult.getByTestId('comboBoxSearchInput')); + expect(onGroupOrUserIdChangeMock).toHaveBeenCalledWith({ type: 'group', group: 'group' }, 0); + }); + + it('should call onGroupOrUserIdChange without user id after clear button clicked', () => { + const { renderResult, onGroupOrUserIdChangeMock } = setup({ + userId: 'foo', + }); + + expect(onGroupOrUserIdChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByTestId('comboBoxClearButton')); + expect(onGroupOrUserIdChangeMock).toHaveBeenCalledWith({ type: 'user' }, 0); + }); + + it('should call onPermissionModesChange with permission modes after permission modes changed', () => { + const { renderResult, onPermissionModesChangeMock } = setup({}); + + expect(onPermissionModesChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByText('Admin')); + expect(onPermissionModesChangeMock).toHaveBeenCalledWith(['library_write', 'write'], 0); + }); + + it('should call onDelete with index after delete button clicked', () => { + const { renderResult, onDeleteMock } = setup(); + + expect(onDeleteMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByLabelText('Delete permission setting')); + expect(onDeleteMock).toHaveBeenCalledWith(0); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx new file mode 100644 index 000000000000..099ae33bc23c --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx @@ -0,0 +1,128 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo } from 'react'; +import { + EuiFlexGroup, + EuiComboBox, + EuiFlexItem, + EuiButtonIcon, + EuiButtonGroup, +} from '@elastic/eui'; +import { WorkspacePermissionMode } from '../../../common/constants'; +import { + WorkspacePermissionItemType, + optionIdToWorkspacePermissionModesMap, + permissionModeOptions, +} from './constants'; +import { getPermissionModeId } from './utils'; + +export interface WorkspacePermissionSettingInputProps { + index: number; + deletable: boolean; + type: WorkspacePermissionItemType; + userId?: string; + group?: string; + modes?: WorkspacePermissionMode[]; + onGroupOrUserIdChange: ( + groupOrUserId: + | { type: WorkspacePermissionItemType.User; userId?: string } + | { type: WorkspacePermissionItemType.Group; group?: string }, + index: number + ) => void; + onPermissionModesChange: ( + WorkspacePermissionMode: WorkspacePermissionMode[], + index: number + ) => void; + onDelete: (index: number) => void; +} + +export const WorkspacePermissionSettingInput = ({ + index, + type, + userId, + group, + modes, + deletable, + onDelete, + onGroupOrUserIdChange, + onPermissionModesChange, +}: WorkspacePermissionSettingInputProps) => { + const groupOrUserIdSelectedOptions = useMemo( + () => (group || userId ? [{ label: (group || userId) as string }] : []), + [group, userId] + ); + + const permissionModesSelectedId = useMemo(() => getPermissionModeId(modes ?? []), [modes]); + const handleGroupOrUserIdCreate = useCallback( + (groupOrUserId) => { + onGroupOrUserIdChange( + type === WorkspacePermissionItemType.Group + ? { type, group: groupOrUserId } + : { type, userId: groupOrUserId }, + index + ); + }, + [index, type, onGroupOrUserIdChange] + ); + + const handleGroupOrUserIdChange = useCallback( + (options) => { + if (options.length === 0) { + onGroupOrUserIdChange({ type }, index); + } + }, + [index, type, onGroupOrUserIdChange] + ); + + const handlePermissionModeOptionChange = useCallback( + (id: string) => { + if (optionIdToWorkspacePermissionModesMap[id]) { + onPermissionModesChange([...optionIdToWorkspacePermissionModesMap[id]], index); + } + }, + [index, onPermissionModesChange] + ); + + const handleDelete = useCallback(() => { + onDelete(index); + }, [index, onDelete]); + + return ( + + + + + + + + + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.test.tsx new file mode 100644 index 000000000000..8415405e8e98 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.test.tsx @@ -0,0 +1,199 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { + WorkspacePermissionSettingPanel, + WorkspacePermissionSettingPanelProps, +} from './workspace_permission_setting_panel'; +import { WorkspacePermissionItemType } from './constants'; +import { WorkspacePermissionMode } from '../../../common/constants'; + +const setup = (options?: Partial) => { + const onChangeMock = jest.fn(); + const permissionSettings = [ + { + id: 0, + type: WorkspacePermissionItemType.User, + userId: 'foo', + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + { + id: 1, + type: WorkspacePermissionItemType.Group, + group: 'bar', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Read], + }, + ]; + const renderResult = render( + + ); + return { + renderResult, + onChangeMock, + }; +}; + +describe('WorkspacePermissionSettingInput', () => { + it('should render consistent user and group permissions', () => { + const { renderResult } = setup(); + + expect(renderResult.getByText('foo')).toBeInTheDocument(); + expect( + renderResult.getAllByText('Read')[0].closest('.euiButtonGroupButton-isSelected') + ).toBeInTheDocument(); + + expect(renderResult.getByText('bar')).toBeInTheDocument(); + expect( + renderResult.getAllByText('Read & Write')[1].closest('.euiButtonGroupButton-isSelected') + ).toBeInTheDocument(); + }); + + it('should call onChange with new user permission modes', () => { + const { renderResult, onChangeMock } = setup(); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getAllByText('Read & Write')[0]); + expect(onChangeMock).toHaveBeenCalledWith([ + { + id: 0, + type: WorkspacePermissionItemType.User, + userId: 'foo', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Read], + }, + { + id: 1, + type: WorkspacePermissionItemType.Group, + group: 'bar', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Read], + }, + ]); + }); + it('should call onChange with new group permission modes', () => { + const { renderResult, onChangeMock } = setup(); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getAllByText('Admin')[1]); + expect(onChangeMock).toHaveBeenCalledWith([ + { + id: 0, + type: WorkspacePermissionItemType.User, + userId: 'foo', + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + { + id: 1, + type: WorkspacePermissionItemType.Group, + group: 'bar', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + }, + ]); + }); + + it('should call onChange with new user permission setting after add new button click', () => { + const { renderResult, onChangeMock } = setup({ + permissionSettings: [], + }); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByTestId('workspaceForm-permissionSettingPanel-user-addNew')); + expect(onChangeMock).toHaveBeenCalledWith([ + { + id: 0, + type: WorkspacePermissionItemType.User, + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + ]); + }); + + it('should call onChange with new group permission setting after add new button click', () => { + const { renderResult, onChangeMock } = setup({ + permissionSettings: [], + }); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByTestId('workspaceForm-permissionSettingPanel-group-addNew')); + expect(onChangeMock).toHaveBeenCalledWith([ + { + id: 0, + type: WorkspacePermissionItemType.Group, + modes: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + }, + ]); + }); + + it('should not able to delete last user admin permission setting', () => { + const { renderResult } = setup({ + lastAdminItemDeletable: false, + permissionSettings: [ + { + id: 0, + type: WorkspacePermissionItemType.User, + userId: 'foo', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + }, + ], + }); + + expect(renderResult.getByLabelText('Delete permission setting')).toBeDisabled(); + }); + + it('should not able to delete last group admin permission setting', () => { + const { renderResult } = setup({ + lastAdminItemDeletable: false, + permissionSettings: [ + { + id: 0, + type: WorkspacePermissionItemType.Group, + group: 'bar', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + }, + ], + }); + + expect(renderResult.getByLabelText('Delete permission setting')).toBeDisabled(); + }); + + it('should able to delete permission setting if more than one admin permission', () => { + const { renderResult } = setup({ + lastAdminItemDeletable: false, + permissionSettings: [ + { + id: 0, + type: WorkspacePermissionItemType.User, + userId: 'foo', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + }, + { + id: 0, + type: WorkspacePermissionItemType.Group, + group: 'bar', + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + }, + ], + }); + + expect(renderResult.getAllByLabelText('Delete permission setting')[0]).not.toBeDisabled(); + expect(renderResult.getAllByLabelText('Delete permission setting')[1]).not.toBeDisabled(); + }); + + it('should render consistent errors', () => { + const { renderResult } = setup({ + errors: { '0': 'User permission setting error', '1': 'Group permission setting error' }, + }); + expect(renderResult.container.querySelectorAll('.euiFormRow')[0]).toHaveTextContent( + 'User permission setting error' + ); + expect(renderResult.container.querySelectorAll('.euiFormRow')[1]).toHaveTextContent( + 'Group permission setting error' + ); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx new file mode 100644 index 000000000000..a4cb3b83f52b --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx @@ -0,0 +1,235 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { EuiButton, EuiFormRow, EuiText, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { WorkspacePermissionSetting } from './types'; +import { + WorkspacePermissionItemType, + optionIdToWorkspacePermissionModesMap, + PermissionModeId, +} from './constants'; +import { + WorkspacePermissionSettingInput, + WorkspacePermissionSettingInputProps, +} from './workspace_permission_setting_input'; +import { generateNextPermissionSettingsId, getPermissionModeId } from './utils'; + +export interface WorkspacePermissionSettingPanelProps { + errors?: { [key: number]: string }; + lastAdminItemDeletable: boolean; + permissionSettings: Array< + Pick & Partial + >; + onChange?: ( + value: Array & Partial> + ) => void; +} + +interface UserOrGroupSectionProps + extends Omit { + title: string; + nonDeletableIndex: number; + type: WorkspacePermissionItemType; + nextIdGenerator: () => number; +} + +const UserOrGroupSection = ({ + type, + title, + errors, + onChange, + nextIdGenerator, + permissionSettings, + nonDeletableIndex, +}: UserOrGroupSectionProps) => { + // default permission mode is read + const handleAddNewOne = useCallback(() => { + onChange?.([ + ...permissionSettings, + { + id: nextIdGenerator(), + type, + modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Read], + }, + ]); + }, [onChange, type, permissionSettings, nextIdGenerator]); + + const handleDelete = useCallback( + (index: number) => { + onChange?.(permissionSettings.filter((_item, itemIndex) => itemIndex !== index)); + }, + [onChange, permissionSettings] + ); + + const handlePermissionModesChange = useCallback< + WorkspacePermissionSettingInputProps['onPermissionModesChange'] + >( + (modes, index) => { + onChange?.( + permissionSettings.map((item, itemIndex) => + index === itemIndex ? { ...item, modes } : item + ) + ); + }, + [onChange, permissionSettings] + ); + + const handleGroupOrUserIdChange = useCallback< + WorkspacePermissionSettingInputProps['onGroupOrUserIdChange'] + >( + (userOrGroupIdWithType, index) => { + onChange?.( + permissionSettings.map((item, itemIndex) => + index === itemIndex + ? { + id: item.id, + ...userOrGroupIdWithType, + ...(item.modes ? { modes: item.modes } : {}), + } + : item + ) + ); + }, + [onChange, permissionSettings] + ); + + return ( +
+ + {title} + + + {permissionSettings.map((item, index) => ( + + + + + + ))} + + {i18n.translate('workspace.form.permissionSettingPanel.addNew', { + defaultMessage: 'Add New', + })} + +
+ ); +}; + +export const WorkspacePermissionSettingPanel = ({ + errors, + onChange, + permissionSettings, + lastAdminItemDeletable, +}: WorkspacePermissionSettingPanelProps) => { + const userPermissionSettings = useMemo( + () => + permissionSettings?.filter( + (permissionSettingItem) => permissionSettingItem.type === WorkspacePermissionItemType.User + ) ?? [], + [permissionSettings] + ); + const groupPermissionSettings = useMemo( + () => + permissionSettings?.filter( + (permissionSettingItem) => permissionSettingItem.type === WorkspacePermissionItemType.Group + ) ?? [], + [permissionSettings] + ); + + const { userNonDeletableIndex, groupNonDeletableIndex } = useMemo(() => { + if ( + lastAdminItemDeletable || + // Permission setting can be deleted if there are more than one admin setting + [...userPermissionSettings, ...groupPermissionSettings].filter( + (permission) => + permission.modes && getPermissionModeId(permission.modes) === PermissionModeId.Admin + ).length > 1 + ) { + return { userNonDeletableIndex: -1, groupNonDeletableIndex: -1 }; + } + return { + userNonDeletableIndex: userPermissionSettings.findIndex( + (permission) => + permission.modes && getPermissionModeId(permission.modes) === PermissionModeId.Admin + ), + groupNonDeletableIndex: groupPermissionSettings.findIndex( + (permission) => + permission.modes && getPermissionModeId(permission.modes) === PermissionModeId.Admin + ), + }; + }, [userPermissionSettings, groupPermissionSettings, lastAdminItemDeletable]); + + const nextIdRef = useRef(generateNextPermissionSettingsId(permissionSettings)); + + const handleUserPermissionSettingsChange = useCallback( + (newSettings) => { + onChange?.([...newSettings, ...groupPermissionSettings]); + }, + [groupPermissionSettings, onChange] + ); + + const handleGroupPermissionSettingsChange = useCallback( + (newSettings) => { + onChange?.([...userPermissionSettings, ...newSettings]); + }, + [userPermissionSettings, onChange] + ); + + const nextIdGenerator = useCallback(() => { + const nextId = nextIdRef.current; + nextIdRef.current++; + return nextId; + }, []); + + useEffect(() => { + nextIdRef.current = Math.max( + nextIdRef.current, + generateNextPermissionSettingsId(permissionSettings) + ); + }, [permissionSettings]); + + return ( +
+ + + +
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx index d829154426dd..142fcfaa61ea 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx @@ -56,6 +56,9 @@ const WorkspaceUpdater = (props: any) => { ...mockCoreStart.application, capabilities: { ...mockCoreStart.application.capabilities, + workspaces: { + permissionEnabled: true, + }, }, navigateToApp, getUrlForApp: jest.fn(() => '/app/workspace_overview'), @@ -148,7 +151,7 @@ describe('WorkspaceUpdater', () => { }); it('update workspace successfully', async () => { - const { getByTestId } = render(); + const { getByTestId, getByText, getAllByText } = render(); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -169,6 +172,15 @@ describe('WorkspaceUpdater', () => { fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-app1')); fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-category1')); + fireEvent.click(getByText('Users & Permissions')); + fireEvent.click(getByTestId('workspaceForm-permissionSettingPanel-user-addNew')); + const userIdInput = getAllByText('Select')[0]; + fireEvent.click(userIdInput); + fireEvent.input(getByTestId('comboBoxSearchInput'), { + target: { value: 'test user id' }, + }); + fireEvent.blur(getByTestId('comboBoxSearchInput')); + fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); expect(workspaceClientUpdate).toHaveBeenCalledWith( expect.any(String), @@ -177,7 +189,15 @@ describe('WorkspaceUpdater', () => { color: '#000000', description: 'test workspace description', features: expect.arrayContaining(['app1', 'app2', 'app3']), - }) + }), + { + read: { + users: ['test user id'], + }, + library_read: { + users: ['test user id'], + }, + } ); await waitFor(() => { expect(notificationToastsAddSuccess).toHaveBeenCalled(); diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx index d39ddd650360..201175c11c8e 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -6,36 +6,46 @@ import React, { useCallback, useEffect, useState } from 'react'; import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent } from '@elastic/eui'; import { i18n } from '@osd/i18n'; -import { WorkspaceAttribute } from 'opensearch-dashboards/public'; import { useObservable } from 'react-use'; import { of } from 'rxjs'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; -import { WorkspaceForm, WorkspaceFormSubmitData, WorkspaceOperationType } from '../workspace_form'; import { WORKSPACE_OVERVIEW_APP_ID } from '../../../common/constants'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { WorkspaceAttributeWithPermission } from '../../../../../core/types'; import { WorkspaceClient } from '../../workspace_client'; -import { WorkspaceFormData } from '../workspace_form/types'; +import { + WorkspaceForm, + WorkspaceFormSubmitData, + WorkspaceOperationType, + convertPermissionsToPermissionSettings, + convertPermissionSettingsToPermissions, +} from '../workspace_form'; function getFormDataFromWorkspace( - currentWorkspace: WorkspaceAttribute | null | undefined -): WorkspaceFormData { - return (currentWorkspace || {}) as WorkspaceFormData; + currentWorkspace: WorkspaceAttributeWithPermission | null | undefined +) { + if (!currentWorkspace) { + return null; + } + return { + ...currentWorkspace, + permissionSettings: currentWorkspace.permissions + ? convertPermissionsToPermissionSettings(currentWorkspace.permissions) + : currentWorkspace.permissions, + }; } export const WorkspaceUpdater = () => { const { services: { application, workspaces, notifications, http, workspaceClient }, } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); + const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); - const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState( + const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState( getFormDataFromWorkspace(currentWorkspace) ); - useEffect(() => { - setCurrentWorkspaceFormData(getFormDataFromWorkspace(currentWorkspace)); - }, [currentWorkspace]); - const handleWorkspaceFormSubmit = useCallback( async (data: WorkspaceFormSubmitData) => { let result; @@ -49,8 +59,12 @@ export const WorkspaceUpdater = () => { } try { - const { ...attributes } = data; - result = await workspaceClient.update(currentWorkspace.id, attributes); + const { permissionSettings, ...attributes } = data; + result = await workspaceClient.update( + currentWorkspace.id, + attributes, + convertPermissionSettingsToPermissions(permissionSettings) + ); if (result?.success) { notifications?.toasts.addSuccess({ title: i18n.translate('workspace.update.success', { @@ -86,7 +100,11 @@ export const WorkspaceUpdater = () => { [notifications?.toasts, currentWorkspace, http, application, workspaceClient] ); - if (!currentWorkspaceFormData.name) { + useEffect(() => { + setCurrentWorkspaceFormData(getFormDataFromWorkspace(currentWorkspace)); + }, [currentWorkspace]); + + if (!currentWorkspaceFormData) { return null; } @@ -108,6 +126,8 @@ export const WorkspaceUpdater = () => { defaultValues={currentWorkspaceFormData} onSubmit={handleWorkspaceFormSubmit} operationType={WorkspaceOperationType.Update} + permissionEnabled={isPermissionEnabled} + permissionLastAdminItemDeletable={false} /> )} diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts index 3e988f38b265..892fcc52cef1 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -11,6 +11,7 @@ import { WorkspaceAttribute, WorkspacesSetup, } from '../../../core/public'; +import { SavedObjectPermissions, WorkspaceAttributeWithPermission } from '../../../core/types'; const WORKSPACES_API_BASE_URL = '/api/workspaces'; @@ -146,7 +147,7 @@ export class WorkspaceClient { /** * A bypass layer to get current workspace id */ - public getCurrentWorkspaceId(): IResponse { + public getCurrentWorkspaceId(): IResponse { const currentWorkspaceId = this.workspaces.currentWorkspaceId$.getValue(); if (!currentWorkspaceId) { return { @@ -166,7 +167,7 @@ export class WorkspaceClient { /** * Do a find in the latest workspace list with current workspace id */ - public async getCurrentWorkspace(): Promise> { + public async getCurrentWorkspace(): Promise> { const currentWorkspaceIdResp = this.getCurrentWorkspaceId(); if (currentWorkspaceIdResp.success) { const currentWorkspaceResp = await this.get(currentWorkspaceIdResp.result); @@ -183,14 +184,16 @@ export class WorkspaceClient { * @returns {Promise>>} id of the new created workspace */ public async create( - attributes: Omit - ): Promise>> { + attributes: Omit, + permissions?: SavedObjectPermissions + ): Promise>> { const path = this.getPath(); - const result = await this.safeFetch(path, { + const result = await this.safeFetch(path, { method: 'POST', body: JSON.stringify({ attributes, + permissions, }), }); @@ -233,7 +236,7 @@ export class WorkspaceClient { options?: WorkspaceFindOptions ): Promise< IResponse<{ - workspaces: WorkspaceAttribute[]; + workspaces: WorkspaceAttributeWithPermission[]; total: number; per_page: number; page: number; @@ -250,9 +253,9 @@ export class WorkspaceClient { * Fetches a single workspace by a workspace id * * @param {string} id - * @returns {Promise>} The metadata of the workspace for the given id. + * @returns {Promise>} The metadata of the workspace for the given id. */ - public get(id: string): Promise> { + public get(id: string): Promise> { const path = this.getPath(id); return this.safeFetch(path, { method: 'GET', @@ -268,11 +271,13 @@ export class WorkspaceClient { */ public async update( id: string, - attributes: Partial + attributes: Partial, + permissions?: SavedObjectPermissions ): Promise> { const path = this.getPath(id); const body = { attributes, + permissions, }; const result = await this.safeFetch(path, { diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 66b7032a003a..cc1ef5f756b3 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -263,6 +263,32 @@ describe('workspace service api integration test', () => { expect(findResult.body.total).toEqual(0); expect(listResult.body.result.total).toEqual(1); }); + it('should able to update workspace with partial attributes', async () => { + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + await osdTestServer.request + .put(root, `/api/workspaces/${result.body.result.id}`) + .send({ + attributes: { + name: 'updated', + }, + }) + .expect(200); + + const getResult = await osdTestServer.request.get( + root, + `/api/workspaces/${result.body.result.id}` + ); + + expect(getResult.body.success).toEqual(true); + expect(getResult.body.result.name).toEqual('updated'); + expect(getResult.body.result.description).toEqual(testWorkspace.description); + }); }); describe('Duplicate saved objects APIs', () => { diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index b49bb2893575..2f7c6d8969fd 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -29,14 +29,23 @@ const workspacePermissions = schema.recordOf( schema.recordOf(principalType, schema.arrayOf(schema.string()), {}) ); -const workspaceAttributesSchema = schema.object({ +const workspaceOptionalAttributesSchema = { description: schema.maybe(schema.string()), - name: schema.string(), features: schema.maybe(schema.arrayOf(schema.string())), color: schema.maybe(schema.string()), icon: schema.maybe(schema.string()), defaultVISTheme: schema.maybe(schema.string()), reserved: schema.maybe(schema.boolean()), +}; + +const createWorkspaceAttributesSchema = schema.object({ + name: schema.string(), + ...workspaceOptionalAttributesSchema, +}); + +const updateWorkspaceAttributesSchema = schema.object({ + name: schema.maybe(schema.string()), + ...workspaceOptionalAttributesSchema, }); export function registerRoutes({ @@ -117,7 +126,7 @@ export function registerRoutes({ path: `${WORKSPACES_API_BASE_URL}`, validate: { body: schema.object({ - attributes: workspaceAttributesSchema, + attributes: createWorkspaceAttributesSchema, permissions: schema.maybe(workspacePermissions), }), }, @@ -163,7 +172,7 @@ export function registerRoutes({ id: schema.string(), }), body: schema.object({ - attributes: workspaceAttributesSchema, + attributes: updateWorkspaceAttributesSchema, permissions: schema.maybe(workspacePermissions), }), }, diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index 2973ea4dbc31..10a7649c87da 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -95,7 +95,7 @@ export interface IWorkspaceClientImpl { update( requestDetail: IRequestDetail, id: string, - payload: Omit + payload: Partial> ): Promise>; /** * Delete a given workspace diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index c86c9e9a67e1..72609519d2f7 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -67,10 +67,11 @@ export class WorkspaceClient implements IWorkspaceClientImpl { } private getFlattenedResultWithSavedObject( savedObject: SavedObject - ): WorkspaceAttribute { + ): WorkspaceAttributeWithPermission { return { ...savedObject.attributes, id: savedObject.id, + permissions: savedObject.permissions, }; } private formatError(error: Error | any): string { @@ -172,7 +173,7 @@ export class WorkspaceClient implements IWorkspaceClientImpl { public async update( requestDetail: IRequestDetail, id: string, - payload: Omit + payload: Partial> ): Promise> { const { permissions, ...attributes } = payload; try { @@ -191,12 +192,16 @@ export class WorkspaceClient implements IWorkspaceClientImpl { throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); } } - await client.create>(WORKSPACE_TYPE, attributes, { - id, - permissions, - overwrite: true, - version: workspaceInDB.version, - }); + await client.create>( + WORKSPACE_TYPE, + { ...workspaceInDB.attributes, ...attributes }, + { + id, + permissions, + overwrite: true, + version: workspaceInDB.version, + } + ); return { success: true, result: true,