From a50d94908c3202ba462aac8421b3f9acb333f4d2 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Thu, 24 Jun 2021 07:57:49 -0500 Subject: [PATCH] [Enterprise Search] Add User management feature (#103173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename method to close both flyouts This is shared with the forthcoming user flyouts closeRoleMappingFlyout -> closeUsersAndRolesFlyout * Add logic for elasticsearch users and single user role mappings * Add logic for various form states - Showing and hiding flyouts - Select and text input values - User created state to turn flyout into a success message state * Add User server routes * Add logic for saving a user * Add User components * Add User list and User flyout to RoleMappings view * Fix path * Rename things - Users & roles -> Users and roles - roleId -> roleMappingId (matches other places in code) - also added a missing prop to the actions col * Set default group when modal closed The UI sets the default group on page load but did not cover the case where the user has chosen a group in a previous interaction and the closed the flyout. This commit adds a method that resets that state when the flyout is closed Part of porting of https://github.com/elastic/ent-search/pull/3865 Specifically: https://github.com/elastic/ent-search/commit/a4131b95dab7c0df97bd78e660f25e09ac3e7cec * Adds tooltip for external attribute This was missed from the design Part of porting of https://github.com/elastic/ent-search/pull/3865 Specifically: https://github.com/elastic/ent-search/commit/03aa349cab4fb32069b64ab8c51a7252ba52e805 * Fix invitations link * Fix incorrect role type Role-> RoleTypes 🤷🏽‍♀️ * Add EuiPortal to Flyout Wasn’t needed in ent-search; already done for RomeMappingFlyout. Hide whitespace changes plskthx * Auth provider deprecation warning in mapping UI Since we're moving fully into Kibana, we're losing our concept of auth providers. In 8.0, role mappings the specify an auth provider will no longer work, so this adds a small deprecation warning in the role mappings table. https://github.com/elastic/ent-search/pull/3885 * Email is no longer required After a slack discussion, it was determined that email should be optional. This commit also fixes another instance of the App Search role type being wrong. * Existing users’ usernames should not be editable * Use EuiLink instead of anchor * Add validation tests * Change URL for users_and_roles Need to change folder and file names but will punt until after 7.14FF I did throw in updating the logic file path * Remove unused import Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../app_search/components/layout/nav.test.tsx | 4 +- .../app_search/components/layout/nav.tsx | 4 +- .../components/role_mappings/role_mapping.tsx | 4 +- .../role_mappings/role_mappings.test.tsx | 42 ++- .../role_mappings/role_mappings.tsx | 31 ++ .../role_mappings/role_mappings_logic.test.ts | 274 +++++++++++++++- .../role_mappings/role_mappings_logic.ts | 181 ++++++++++- .../components/role_mappings/user.test.tsx | 124 +++++++ .../components/role_mappings/user.tsx | 106 ++++++ .../applications/app_search/index.test.tsx | 2 +- .../public/applications/app_search/index.tsx | 6 +- .../public/applications/app_search/routes.ts | 2 +- .../shared/role_mapping/constants.ts | 18 +- .../role_mapping/role_mapping_flyout.test.tsx | 4 +- .../role_mapping/role_mapping_flyout.tsx | 10 +- .../role_mapping/role_mappings_table.test.tsx | 12 +- .../role_mapping/role_mappings_table.tsx | 42 ++- .../role_mapping/user_added_info.test.tsx | 100 +++++- .../shared/role_mapping/user_added_info.tsx | 6 +- .../shared/role_mapping/user_flyout.tsx | 39 ++- .../role_mapping/user_invitation_callout.tsx | 2 +- .../role_mapping/user_selector.test.tsx | 3 +- .../shared/role_mapping/user_selector.tsx | 5 +- .../shared/role_mapping/users_table.tsx | 5 +- .../components/layout/nav.test.tsx | 4 +- .../components/layout/nav.tsx | 6 +- .../workplace_search/constants.ts | 2 +- .../applications/workplace_search/index.tsx | 4 +- .../applications/workplace_search/routes.ts | 2 +- .../views/role_mappings/role_mapping.tsx | 4 +- .../role_mappings/role_mappings.test.tsx | 40 ++- .../views/role_mappings/role_mappings.tsx | 31 ++ .../role_mappings/role_mappings_logic.test.ts | 302 ++++++++++++++++-- .../role_mappings/role_mappings_logic.ts | 192 ++++++++++- .../views/role_mappings/user.test.tsx | 123 +++++++ .../views/role_mappings/user.tsx | 103 ++++++ .../routes/app_search/role_mappings.test.ts | 49 +++ .../server/routes/app_search/role_mappings.ts | 26 ++ .../workplace_search/role_mappings.test.ts | 49 +++ .../routes/workplace_search/role_mappings.ts | 29 ++ 40 files changed, 1868 insertions(+), 124 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx index c9f5452e254e1..ce4a118bef095 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.test.tsx @@ -100,8 +100,8 @@ describe('useAppSearchNav', () => { }, { id: 'usersRoles', - name: 'Users & roles', - href: '/role_mappings', + name: 'Users and roles', + href: '/users_and_roles', }, ], }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx index c3b8ec642233b..793a36f48fe82 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/nav.tsx @@ -13,7 +13,7 @@ import { generateNavLink } from '../../../shared/layout'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; import { AppLogic } from '../../app_logic'; -import { ENGINES_PATH, SETTINGS_PATH, CREDENTIALS_PATH, ROLE_MAPPINGS_PATH } from '../../routes'; +import { ENGINES_PATH, SETTINGS_PATH, CREDENTIALS_PATH, USERS_AND_ROLES_PATH } from '../../routes'; import { CREDENTIALS_TITLE } from '../credentials'; import { useEngineNav } from '../engine/engine_nav'; import { ENGINES_TITLE } from '../engines'; @@ -57,7 +57,7 @@ export const useAppSearchNav = () => { navItems.push({ id: 'usersRoles', name: ROLE_MAPPINGS_TITLE, - ...generateNavLink({ to: ROLE_MAPPINGS_PATH }), + ...generateNavLink({ to: USERS_AND_ROLES_PATH }), }); } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx index b6a9dd72cfd05..dbebd8e46a219 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mapping.tsx @@ -28,7 +28,7 @@ export const RoleMapping: React.FC = () => { handleAuthProviderChange, handleRoleChange, handleSaveMapping, - closeRoleMappingFlyout, + closeUsersAndRolesFlyout, } = useActions(RoleMappingsLogic); const { @@ -68,7 +68,7 @@ export const RoleMapping: React.FC = () => { 0} error={roleMappingErrors}> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx index 308022ccb2e5a..64bf41a50a2f0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.test.tsx @@ -12,26 +12,39 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { RoleMappingsTable, RoleMappingsHeading } from '../../../shared/role_mapping'; -import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { + RoleMappingsTable, + RoleMappingsHeading, + UsersHeading, + UsersEmptyPrompt, +} from '../../../shared/role_mapping'; +import { + asRoleMapping, + asSingleUserRoleMapping, +} from '../../../shared/role_mapping/__mocks__/roles'; import { RoleMapping } from './role_mapping'; import { RoleMappings } from './role_mappings'; +import { User } from './user'; describe('RoleMappings', () => { const initializeRoleMappings = jest.fn(); const initializeRoleMapping = jest.fn(); + const initializeSingleUserRoleMapping = jest.fn(); const handleDeleteMapping = jest.fn(); const mockValues = { - roleMappings: [wsRoleMapping], + roleMappings: [asRoleMapping], dataLoading: false, multipleAuthProvidersConfig: false, + singleUserRoleMappings: [asSingleUserRoleMapping], + singleUserRoleMappingFlyoutOpen: false, }; beforeEach(() => { setMockActions({ initializeRoleMappings, initializeRoleMapping, + initializeSingleUserRoleMapping, handleDeleteMapping, }); setMockValues(mockValues); @@ -50,10 +63,31 @@ describe('RoleMappings', () => { expect(wrapper.find(RoleMapping)).toHaveLength(1); }); - it('handles onClick', () => { + it('renders User flyout', () => { + setMockValues({ ...mockValues, singleUserRoleMappingFlyoutOpen: true }); + const wrapper = shallow(); + + expect(wrapper.find(User)).toHaveLength(1); + }); + + it('handles RoleMappingsHeading onClick', () => { const wrapper = shallow(); wrapper.find(RoleMappingsHeading).prop('onClick')(); expect(initializeRoleMapping).toHaveBeenCalled(); }); + + it('handles UsersHeading onClick', () => { + const wrapper = shallow(); + wrapper.find(UsersHeading).prop('onClick')(); + + expect(initializeSingleUserRoleMapping).toHaveBeenCalled(); + }); + + it('handles empty users state', () => { + setMockValues({ ...mockValues, singleUserRoleMappings: [] }); + const wrapper = shallow(); + + expect(wrapper.find(UsersEmptyPrompt)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx index 03e2ae67eca9e..3e692aa48623e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings.tsx @@ -9,11 +9,16 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; +import { EuiSpacer } from '@elastic/eui'; + import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; import { RoleMappingsTable, RoleMappingsHeading, RolesEmptyPrompt, + UsersTable, + UsersHeading, + UsersEmptyPrompt, } from '../../../shared/role_mapping'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; @@ -23,6 +28,7 @@ import { AppSearchPageTemplate } from '../layout'; import { ROLE_MAPPINGS_ENGINE_ACCESS_HEADING } from './constants'; import { RoleMapping } from './role_mapping'; import { RoleMappingsLogic } from './role_mappings_logic'; +import { User } from './user'; const ROLES_DOCS_LINK = `${DOCS_PREFIX}/security-and-users.html`; @@ -31,14 +37,17 @@ export const RoleMappings: React.FC = () => { enableRoleBasedAccess, initializeRoleMappings, initializeRoleMapping, + initializeSingleUserRoleMapping, handleDeleteMapping, resetState, } = useActions(RoleMappingsLogic); const { roleMappings, + singleUserRoleMappings, multipleAuthProvidersConfig, dataLoading, roleMappingFlyoutOpen, + singleUserRoleMappingFlyoutOpen, } = useValues(RoleMappingsLogic); useEffect(() => { @@ -46,6 +55,8 @@ export const RoleMappings: React.FC = () => { return resetState; }, []); + const hasUsers = singleUserRoleMappings.length > 0; + const rolesEmptyState = ( { ); + const usersTable = ( + + ); + + const usersSection = ( + <> + initializeSingleUserRoleMapping()} /> + + {hasUsers ? usersTable : } + + ); + return ( { emptyState={rolesEmptyState} > {roleMappingFlyoutOpen && } + {singleUserRoleMappingFlyoutOpen && } {roleMappingsSection} + + {usersSection} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts index 6985f213d1dd5..16b44e9ec1f11 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.test.ts @@ -15,11 +15,18 @@ import { engines } from '../../__mocks__/engines.mock'; import { nextTick } from '@kbn/test/jest'; -import { asRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { elasticsearchUsers } from '../../../shared/role_mapping/__mocks__/elasticsearch_users'; + +import { + asRoleMapping, + asSingleUserRoleMapping, +} from '../../../shared/role_mapping/__mocks__/roles'; import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; import { RoleMappingsLogic } from './role_mappings_logic'; +const emptyUser = { username: '', email: '' }; + describe('RoleMappingsLogic', () => { const { http } = mockHttpValues; const { clearFlashMessages, flashAPIErrors, setSuccessMessage } = mockFlashMessageHelpers; @@ -28,6 +35,8 @@ describe('RoleMappingsLogic', () => { attributes: [], availableAuthProviders: [], elasticsearchRoles: [], + elasticsearchUser: emptyUser, + elasticsearchUsers: [], roleMapping: null, roleMappingFlyoutOpen: false, roleMappings: [], @@ -43,6 +52,12 @@ describe('RoleMappingsLogic', () => { selectedAuthProviders: [ANY_AUTH_PROVIDER], selectedOptions: [], roleMappingErrors: [], + singleUserRoleMapping: null, + singleUserRoleMappings: [], + singleUserRoleMappingFlyoutOpen: false, + userCreated: false, + userFormIsNewUser: true, + userFormUserIsExisting: true, }; const mappingsServerProps = { @@ -53,6 +68,8 @@ describe('RoleMappingsLogic', () => { availableEngines: engines, elasticsearchRoles: [], hasAdvancedRoles: false, + singleUserRoleMappings: [asSingleUserRoleMapping], + elasticsearchUsers, }; beforeEach(() => { @@ -83,7 +100,19 @@ describe('RoleMappingsLogic', () => { elasticsearchRoles: mappingsServerProps.elasticsearchRoles, selectedEngines: new Set(), selectedOptions: [], + elasticsearchUsers, + elasticsearchUser: elasticsearchUsers[0], + singleUserRoleMappings: [asSingleUserRoleMapping], + }); + }); + + it('handles fallback if no elasticsearch users present', () => { + RoleMappingsLogic.actions.setRoleMappingsData({ + ...mappingsServerProps, + elasticsearchUsers: [], }); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual(emptyUser); }); }); @@ -94,6 +123,26 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values.dataLoading).toEqual(false); }); + describe('setElasticsearchUser', () => { + it('sets user', () => { + RoleMappingsLogic.actions.setElasticsearchUser(elasticsearchUsers[0]); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual(elasticsearchUsers[0]); + }); + + it('handles fallback if no user present', () => { + RoleMappingsLogic.actions.setElasticsearchUser(undefined); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual(emptyUser); + }); + }); + + it('setSingleUserRoleMapping', () => { + RoleMappingsLogic.actions.setSingleUserRoleMapping(asSingleUserRoleMapping); + + expect(RoleMappingsLogic.values.singleUserRoleMapping).toEqual(asSingleUserRoleMapping); + }); + it('handleRoleChange', () => { RoleMappingsLogic.actions.handleRoleChange('dev'); @@ -152,6 +201,12 @@ describe('RoleMappingsLogic', () => { }); }); + it('setUserExistingRadioValue', () => { + RoleMappingsLogic.actions.setUserExistingRadioValue(false); + + expect(RoleMappingsLogic.values.userFormUserIsExisting).toEqual(false); + }); + describe('handleAttributeSelectorChange', () => { const elasticsearchRoles = ['foo', 'bar']; @@ -174,6 +229,8 @@ describe('RoleMappingsLogic', () => { attributeName: 'role', elasticsearchRoles, selectedEngines: new Set(), + elasticsearchUsers, + singleUserRoleMappings: [asSingleUserRoleMapping], }); }); @@ -260,16 +317,59 @@ describe('RoleMappingsLogic', () => { expect(clearFlashMessages).toHaveBeenCalled(); }); - it('closeRoleMappingFlyout', () => { + it('openSingleUserRoleMappingFlyout', () => { + mount(mappingsServerProps); + RoleMappingsLogic.actions.openSingleUserRoleMappingFlyout(); + + expect(RoleMappingsLogic.values.singleUserRoleMappingFlyoutOpen).toEqual(true); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + + it('closeUsersAndRolesFlyout', () => { mount({ ...mappingsServerProps, roleMappingFlyoutOpen: true, }); - RoleMappingsLogic.actions.closeRoleMappingFlyout(); + RoleMappingsLogic.actions.closeUsersAndRolesFlyout(); expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(false); expect(clearFlashMessages).toHaveBeenCalled(); }); + + it('setElasticsearchUsernameValue', () => { + const username = 'newName'; + RoleMappingsLogic.actions.setElasticsearchUsernameValue(username); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + elasticsearchUser: { + ...RoleMappingsLogic.values.elasticsearchUser, + username, + }, + }); + }); + + it('setElasticsearchEmailValue', () => { + const email = 'newEmail@foo.cats'; + RoleMappingsLogic.actions.setElasticsearchEmailValue(email); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + elasticsearchUser: { + ...RoleMappingsLogic.values.elasticsearchUser, + email, + }, + }); + }); + + it('setUserCreated', () => { + RoleMappingsLogic.actions.setUserCreated(); + + expect(RoleMappingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + userCreated: true, + }); + }); }); describe('listeners', () => { @@ -335,6 +435,39 @@ describe('RoleMappingsLogic', () => { }); }); + describe('initializeSingleUserRoleMapping', () => { + let setElasticsearchUserSpy: jest.MockedFunction; + let setRoleMappingSpy: jest.MockedFunction; + let setSingleUserRoleMappingSpy: jest.MockedFunction; + beforeEach(() => { + setElasticsearchUserSpy = jest.spyOn(RoleMappingsLogic.actions, 'setElasticsearchUser'); + setRoleMappingSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMapping'); + setSingleUserRoleMappingSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setSingleUserRoleMapping' + ); + }); + + it('should handle the new user state and only set an empty mapping', () => { + RoleMappingsLogic.actions.initializeSingleUserRoleMapping(); + + expect(setElasticsearchUserSpy).not.toHaveBeenCalled(); + expect(setRoleMappingSpy).not.toHaveBeenCalled(); + expect(setSingleUserRoleMappingSpy).toHaveBeenCalledWith(undefined); + }); + + it('should handle an existing user state and set mapping', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + RoleMappingsLogic.actions.initializeSingleUserRoleMapping( + asSingleUserRoleMapping.roleMapping.id + ); + + expect(setElasticsearchUserSpy).toHaveBeenCalled(); + expect(setRoleMappingSpy).toHaveBeenCalled(); + expect(setSingleUserRoleMappingSpy).toHaveBeenCalledWith(asSingleUserRoleMapping); + }); + }); + describe('handleSaveMapping', () => { const body = { roleType: 'owner', @@ -430,6 +563,94 @@ describe('RoleMappingsLogic', () => { }); }); + describe('handleSaveUser', () => { + it('calls API and refreshes list when new mapping', async () => { + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); + const setUserCreatedSpy = jest.spyOn(RoleMappingsLogic.actions, 'setUserCreated'); + const setSingleUserRoleMappingSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setSingleUserRoleMapping' + ); + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + + http.post.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.handleSaveUser(); + + expect(http.post).toHaveBeenCalledWith('/api/app_search/single_user_role_mapping', { + body: JSON.stringify({ + roleMapping: { + engines: [], + roleType: 'owner', + accessAllEngines: true, + }, + elasticsearchUser: { + username: elasticsearchUsers[0].username, + email: elasticsearchUsers[0].email, + }, + }), + }); + await nextTick(); + + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); + expect(setUserCreatedSpy).toHaveBeenCalled(); + expect(setSingleUserRoleMappingSpy).toHaveBeenCalled(); + }); + + it('calls API and refreshes list when existing mapping', async () => { + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); + RoleMappingsLogic.actions.setSingleUserRoleMapping(asSingleUserRoleMapping); + RoleMappingsLogic.actions.handleAccessAllEnginesChange(false); + + http.put.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.handleSaveUser(); + + expect(http.post).toHaveBeenCalledWith('/api/app_search/single_user_role_mapping', { + body: JSON.stringify({ + roleMapping: { + engines: [], + roleType: 'owner', + accessAllEngines: false, + id: asSingleUserRoleMapping.roleMapping.id, + }, + elasticsearchUser: { + username: '', + email: '', + }, + }), + }); + await nextTick(); + + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); + }); + + it('handles error', async () => { + const setRoleMappingErrorsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setRoleMappingErrors' + ); + + http.post.mockReturnValue( + Promise.reject({ + body: { + attributes: { + errors: ['this is an error'], + }, + }, + }) + ); + RoleMappingsLogic.actions.handleSaveUser(); + await nextTick(); + + expect(setRoleMappingErrorsSpy).toHaveBeenCalledWith(['this is an error']); + }); + }); + describe('handleDeleteMapping', () => { const roleMappingId = 'r1'; @@ -458,5 +679,52 @@ describe('RoleMappingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); }); + + describe('handleUsernameSelectChange', () => { + it('sets elasticsearchUser when match found', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.handleUsernameSelectChange(elasticsearchUsers[0].username); + + expect(setElasticsearchUserSpy).toHaveBeenCalledWith(elasticsearchUsers[0]); + }); + + it('does not set elasticsearchUser when no match found', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.handleUsernameSelectChange('bogus'); + + expect(setElasticsearchUserSpy).not.toHaveBeenCalled(); + }); + }); + + describe('setUserExistingRadioValue', () => { + it('handles existing user', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.setUserExistingRadioValue(true); + + expect(setElasticsearchUserSpy).toHaveBeenCalledWith(elasticsearchUsers[0]); + }); + + it('handles new user', () => { + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.setUserExistingRadioValue(false); + + expect(setElasticsearchUserSpy).toHaveBeenCalledWith(emptyUser); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts index e2ef75897528c..0b57e1d08a294 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/role_mappings_logic.ts @@ -16,7 +16,7 @@ import { } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; -import { AttributeName } from '../../../shared/types'; +import { AttributeName, SingleUserRoleMapping, ElasticsearchUser } from '../../../shared/types'; import { ASRoleMapping, RoleTypes } from '../../types'; import { roleHasScopedEngines } from '../../utils/role/has_scoped_engines'; import { Engine } from '../engine/types'; @@ -27,20 +27,25 @@ import { ROLE_MAPPING_UPDATED_MESSAGE, } from './constants'; +type UserMapping = SingleUserRoleMapping; + interface RoleMappingsServerDetails { roleMappings: ASRoleMapping[]; attributes: string[]; authProviders: string[]; availableEngines: Engine[]; elasticsearchRoles: string[]; + elasticsearchUsers: ElasticsearchUser[]; hasAdvancedRoles: boolean; multipleAuthProvidersConfig: boolean; + singleUserRoleMappings: UserMapping[]; } const getFirstAttributeName = (roleMapping: ASRoleMapping) => Object.entries(roleMapping.rules)[0][0] as AttributeName; const getFirstAttributeValue = (roleMapping: ASRoleMapping) => Object.entries(roleMapping.rules)[0][1] as AttributeName; +const emptyUser = { username: '', email: '' } as ElasticsearchUser; interface RoleMappingsActions { handleAccessAllEnginesChange(selected: boolean): { selected: boolean }; @@ -53,21 +58,34 @@ interface RoleMappingsActions { handleDeleteMapping(roleMappingId: string): { roleMappingId: string }; handleEngineSelectionChange(engineNames: string[]): { engineNames: string[] }; handleRoleChange(roleType: RoleTypes): { roleType: RoleTypes }; + handleUsernameSelectChange(username: string): { username: string }; handleSaveMapping(): void; + handleSaveUser(): void; initializeRoleMapping(roleMappingId?: string): { roleMappingId?: string }; + initializeSingleUserRoleMapping(roleMappingId?: string): { roleMappingId?: string }; initializeRoleMappings(): void; resetState(): void; setRoleMapping(roleMapping: ASRoleMapping): { roleMapping: ASRoleMapping }; + setSingleUserRoleMapping(data?: UserMapping): { singleUserRoleMapping: UserMapping }; setRoleMappings({ roleMappings, }: { roleMappings: ASRoleMapping[]; }): { roleMappings: ASRoleMapping[] }; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; + setElasticsearchUser( + elasticsearchUser?: ElasticsearchUser + ): { elasticsearchUser: ElasticsearchUser }; openRoleMappingFlyout(): void; - closeRoleMappingFlyout(): void; + openSingleUserRoleMappingFlyout(): void; + closeUsersAndRolesFlyout(): void; setRoleMappingErrors(errors: string[]): { errors: string[] }; enableRoleBasedAccess(): void; + setUserExistingRadioValue(userFormUserIsExisting: boolean): { userFormUserIsExisting: boolean }; + setElasticsearchUsernameValue(username: string): { username: string }; + setElasticsearchEmailValue(email: string): { email: string }; + setUserCreated(): void; + setUserFormIsNewUser(userFormIsNewUser: boolean): { userFormIsNewUser: boolean }; } interface RoleMappingsValues { @@ -79,27 +97,38 @@ interface RoleMappingsValues { availableEngines: Engine[]; dataLoading: boolean; elasticsearchRoles: string[]; + elasticsearchUsers: ElasticsearchUser[]; + elasticsearchUser: ElasticsearchUser; hasAdvancedRoles: boolean; multipleAuthProvidersConfig: boolean; roleMapping: ASRoleMapping | null; roleMappings: ASRoleMapping[]; + singleUserRoleMapping: UserMapping | null; + singleUserRoleMappings: UserMapping[]; roleType: RoleTypes; selectedAuthProviders: string[]; selectedEngines: Set; roleMappingFlyoutOpen: boolean; + singleUserRoleMappingFlyoutOpen: boolean; selectedOptions: EuiComboBoxOptionOption[]; roleMappingErrors: string[]; + userFormUserIsExisting: boolean; + userCreated: boolean; + userFormIsNewUser: boolean; } export const RoleMappingsLogic = kea>({ - path: ['enterprise_search', 'app_search', 'role_mappings'], + path: ['enterprise_search', 'app_search', 'users_and_roles'], actions: { setRoleMappingsData: (data: RoleMappingsServerDetails) => data, setRoleMapping: (roleMapping: ASRoleMapping) => ({ roleMapping }), + setElasticsearchUser: (elasticsearchUser: ElasticsearchUser) => ({ elasticsearchUser }), + setSingleUserRoleMapping: (singleUserRoleMapping: UserMapping) => ({ singleUserRoleMapping }), setRoleMappings: ({ roleMappings }: { roleMappings: ASRoleMapping[] }) => ({ roleMappings }), setRoleMappingErrors: (errors: string[]) => ({ errors }), handleAuthProviderChange: (value: string) => ({ value }), handleRoleChange: (roleType: RoleTypes) => ({ roleType }), + handleUsernameSelectChange: (username: string) => ({ username }), handleEngineSelectionChange: (engineNames: string[]) => ({ engineNames }), handleAttributeSelectorChange: (value: string, firstElasticsearchRole: string) => ({ value, @@ -108,13 +137,21 @@ export const RoleMappingsLogic = kea ({ value }), handleAccessAllEnginesChange: (selected: boolean) => ({ selected }), enableRoleBasedAccess: true, + openSingleUserRoleMappingFlyout: true, + setUserExistingRadioValue: (userFormUserIsExisting: boolean) => ({ userFormUserIsExisting }), resetState: true, initializeRoleMappings: true, + initializeSingleUserRoleMapping: (roleMappingId?: string) => ({ roleMappingId }), initializeRoleMapping: (roleMappingId) => ({ roleMappingId }), handleDeleteMapping: (roleMappingId: string) => ({ roleMappingId }), handleSaveMapping: true, + handleSaveUser: true, openRoleMappingFlyout: true, - closeRoleMappingFlyout: false, + closeUsersAndRolesFlyout: false, + setElasticsearchUsernameValue: (username: string) => ({ username }), + setElasticsearchEmailValue: (email: string) => ({ email }), + setUserCreated: true, + setUserFormIsNewUser: (userFormIsNewUser: boolean) => ({ userFormIsNewUser }), }, reducers: { dataLoading: [ @@ -134,6 +171,13 @@ export const RoleMappingsLogic = kea [], }, ], + singleUserRoleMappings: [ + [], + { + setRoleMappingsData: (_, { singleUserRoleMappings }) => singleUserRoleMappings, + resetState: () => [], + }, + ], multipleAuthProvidersConfig: [ false, { @@ -165,6 +209,14 @@ export const RoleMappingsLogic = kea elasticsearchRoles, + closeUsersAndRolesFlyout: () => [ANY_AUTH_PROVIDER], + }, + ], + elasticsearchUsers: [ + [], + { + setRoleMappingsData: (_, { elasticsearchUsers }) => elasticsearchUsers, + resetState: () => [], }, ], roleMapping: [ @@ -172,7 +224,7 @@ export const RoleMappingsLogic = kea roleMapping, resetState: () => null, - closeRoleMappingFlyout: () => null, + closeUsersAndRolesFlyout: () => null, }, ], roleType: [ @@ -188,6 +240,7 @@ export const RoleMappingsLogic = kea roleMapping.accessAllEngines, handleRoleChange: (_, { roleType }) => !roleHasScopedEngines(roleType), handleAccessAllEnginesChange: (_, { selected }) => selected, + closeUsersAndRolesFlyout: () => true, }, ], attributeValue: [ @@ -198,7 +251,7 @@ export const RoleMappingsLogic = kea value, resetState: () => '', - closeRoleMappingFlyout: () => '', + closeUsersAndRolesFlyout: () => '', }, ], attributeName: [ @@ -207,7 +260,7 @@ export const RoleMappingsLogic = kea getFirstAttributeName(roleMapping), handleAttributeSelectorChange: (_, { value }) => value, resetState: () => 'username', - closeRoleMappingFlyout: () => 'username', + closeUsersAndRolesFlyout: () => 'username', }, ], selectedEngines: [ @@ -222,6 +275,7 @@ export const RoleMappingsLogic = kea new Set(), }, ], availableAuthProviders: [ @@ -251,17 +305,68 @@ export const RoleMappingsLogic = kea true, - closeRoleMappingFlyout: () => false, + closeUsersAndRolesFlyout: () => false, initializeRoleMappings: () => false, initializeRoleMapping: () => true, }, ], + singleUserRoleMappingFlyoutOpen: [ + false, + { + openSingleUserRoleMappingFlyout: () => true, + closeUsersAndRolesFlyout: () => false, + initializeSingleUserRoleMapping: () => true, + }, + ], + singleUserRoleMapping: [ + null, + { + setSingleUserRoleMapping: (_, { singleUserRoleMapping }) => singleUserRoleMapping || null, + closeUsersAndRolesFlyout: () => null, + }, + ], roleMappingErrors: [ [], { setRoleMappingErrors: (_, { errors }) => errors, handleSaveMapping: () => [], - closeRoleMappingFlyout: () => [], + closeUsersAndRolesFlyout: () => [], + }, + ], + userFormUserIsExisting: [ + true, + { + setUserExistingRadioValue: (_, { userFormUserIsExisting }) => userFormUserIsExisting, + closeUsersAndRolesFlyout: () => true, + }, + ], + elasticsearchUser: [ + emptyUser, + { + setRoleMappingsData: (_, { elasticsearchUsers }) => elasticsearchUsers[0] || emptyUser, + setElasticsearchUser: (_, { elasticsearchUser }) => elasticsearchUser || emptyUser, + setElasticsearchUsernameValue: (state, { username }) => ({ + ...state, + username, + }), + setElasticsearchEmailValue: (state, { email }) => ({ + ...state, + email, + }), + closeUsersAndRolesFlyout: () => emptyUser, + }, + ], + userCreated: [ + false, + { + setUserCreated: () => true, + closeUsersAndRolesFlyout: () => false, + }, + ], + userFormIsNewUser: [ + true, + { + setUserFormIsNewUser: (_, { userFormIsNewUser }) => userFormIsNewUser, }, ], }, @@ -303,6 +408,17 @@ export const RoleMappingsLogic = kea id === roleMappingId); if (roleMapping) actions.setRoleMapping(roleMapping); }, + initializeSingleUserRoleMapping: ({ roleMappingId }) => { + const singleUserRoleMapping = values.singleUserRoleMappings.find( + ({ roleMapping }) => roleMapping.id === roleMappingId + ); + if (singleUserRoleMapping) { + actions.setElasticsearchUser(singleUserRoleMapping.elasticsearchUser); + actions.setRoleMapping(singleUserRoleMapping.roleMapping); + } + actions.setSingleUserRoleMapping(singleUserRoleMapping); + actions.setUserFormIsNewUser(!singleUserRoleMapping); + }, handleDeleteMapping: async ({ roleMappingId }) => { const { http } = HttpLogic.values; const route = `/api/app_search/role_mappings/${roleMappingId}`; @@ -357,11 +473,56 @@ export const RoleMappingsLogic = kea { clearFlashMessages(); }, - closeRoleMappingFlyout: () => { + handleSaveUser: async () => { + const { http } = HttpLogic.values; + const { + roleType, + singleUserRoleMapping, + accessAllEngines, + selectedEngines, + elasticsearchUser: { email, username }, + } = values; + + const body = JSON.stringify({ + roleMapping: { + engines: accessAllEngines ? [] : Array.from(selectedEngines), + roleType, + accessAllEngines, + id: singleUserRoleMapping?.roleMapping?.id, + }, + elasticsearchUser: { + username, + email, + }, + }); + + try { + const response = await http.post('/api/app_search/single_user_role_mapping', { body }); + actions.setSingleUserRoleMapping(response); + actions.setUserCreated(); + actions.initializeRoleMappings(); + } catch (e) { + actions.setRoleMappingErrors(e?.body?.attributes?.errors); + } + }, + closeUsersAndRolesFlyout: () => { clearFlashMessages(); + const firstUser = values.elasticsearchUsers[0]; + actions.setElasticsearchUser(firstUser); }, openRoleMappingFlyout: () => { clearFlashMessages(); }, + openSingleUserRoleMappingFlyout: () => { + clearFlashMessages(); + }, + setUserExistingRadioValue: ({ userFormUserIsExisting }) => { + const firstUser = values.elasticsearchUsers[0]; + actions.setElasticsearchUser(userFormUserIsExisting ? firstUser : emptyUser); + }, + handleUsernameSelectChange: ({ username }) => { + const user = values.elasticsearchUsers.find((u) => u.username === username); + if (user) actions.setElasticsearchUser(user); + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.test.tsx new file mode 100644 index 0000000000000..88103532bd149 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/react_router'; +import '../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../__mocks__/kea_logic'; +import { engines } from '../../__mocks__/engines.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { UserFlyout, UserAddedInfo, UserInvitationCallout } from '../../../shared/role_mapping'; +import { elasticsearchUsers } from '../../../shared/role_mapping/__mocks__/elasticsearch_users'; +import { wsSingleUserRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; + +import { EngineAssignmentSelector } from './engine_assignment_selector'; +import { User } from './user'; + +describe('User', () => { + const handleSaveUser = jest.fn(); + const closeUsersAndRolesFlyout = jest.fn(); + const setUserExistingRadioValue = jest.fn(); + const setElasticsearchUsernameValue = jest.fn(); + const setElasticsearchEmailValue = jest.fn(); + const handleRoleChange = jest.fn(); + const handleUsernameSelectChange = jest.fn(); + + const mockValues = { + availableEngines: [], + singleUserRoleMapping: null, + userFormUserIsExisting: false, + elasticsearchUsers: [], + elasticsearchUser: {}, + roleType: 'admin', + roleMappingErrors: [], + userCreated: false, + userFormIsNewUser: false, + hasAdvancedRoles: false, + }; + + beforeEach(() => { + setMockActions({ + handleSaveUser, + closeUsersAndRolesFlyout, + setUserExistingRadioValue, + setElasticsearchUsernameValue, + setElasticsearchEmailValue, + handleRoleChange, + handleUsernameSelectChange, + }); + + setMockValues(mockValues); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(UserFlyout)).toHaveLength(1); + }); + + it('renders engine assignment selector when groups present', () => { + setMockValues({ ...mockValues, availableEngines: engines, hasAdvancedRoles: true }); + const wrapper = shallow(); + + expect(wrapper.find(EngineAssignmentSelector)).toHaveLength(1); + }); + + it('renders userInvitationCallout', () => { + setMockValues({ + ...mockValues, + singleUserRoleMapping: wsSingleUserRoleMapping, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserInvitationCallout)).toHaveLength(1); + }); + + it('renders user added info when user created', () => { + setMockValues({ + ...mockValues, + singleUserRoleMapping: wsSingleUserRoleMapping, + userCreated: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserAddedInfo)).toHaveLength(1); + }); + + it('disables form when username value not present', () => { + setMockValues({ + ...mockValues, + singleUserRoleMapping: wsSingleUserRoleMapping, + elasticsearchUsers, + elasticsearchUser: { + username: null, + email: 'email@user.com', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserFlyout).prop('disabled')).toEqual(true); + }); + + it('enables form when userFormUserIsExisting', () => { + setMockValues({ + ...mockValues, + userFormUserIsExisting: true.valueOf, + singleUserRoleMapping: wsSingleUserRoleMapping, + elasticsearchUsers, + elasticsearchUser: { + username: null, + email: 'email@user.com', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserFlyout).prop('disabled')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.tsx new file mode 100644 index 0000000000000..df231fac64df7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/role_mappings/user.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiForm } from '@elastic/eui'; + +import { getAppSearchUrl } from '../../../shared/enterprise_search_url'; +import { + UserFlyout, + UserSelector, + UserAddedInfo, + UserInvitationCallout, +} from '../../../shared/role_mapping'; +import { RoleTypes } from '../../types'; + +import { EngineAssignmentSelector } from './engine_assignment_selector'; +import { RoleMappingsLogic } from './role_mappings_logic'; + +const standardRoles = (['owner', 'admin'] as unknown) as RoleTypes[]; +const advancedRoles = (['dev', 'editor', 'analyst'] as unknown) as RoleTypes[]; + +export const User: React.FC = () => { + const { + handleSaveUser, + closeUsersAndRolesFlyout, + setUserExistingRadioValue, + setElasticsearchUsernameValue, + setElasticsearchEmailValue, + handleRoleChange, + handleUsernameSelectChange, + } = useActions(RoleMappingsLogic); + + const { + availableEngines, + singleUserRoleMapping, + hasAdvancedRoles, + userFormUserIsExisting, + elasticsearchUsers, + elasticsearchUser, + roleType, + roleMappingErrors, + userCreated, + userFormIsNewUser, + } = useValues(RoleMappingsLogic); + + const roleTypes = hasAdvancedRoles ? [...standardRoles, ...advancedRoles] : standardRoles; + const hasEngines = availableEngines.length > 0; + const showEngineAssignmentSelector = hasEngines && hasAdvancedRoles; + const flyoutDisabled = + !userFormUserIsExisting && (!elasticsearchUser.email || !elasticsearchUser.username); + + const userAddedInfo = singleUserRoleMapping && ( + + ); + + const userInvitationCallout = singleUserRoleMapping?.invitation && ( + + ); + + const createUserForm = ( + 0} error={roleMappingErrors}> + + {showEngineAssignmentSelector && } + + ); + + return ( + + {userCreated ? userAddedInfo : createUserForm} + {userInvitationCallout} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 2402a6ecc6401..00acea945177a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -196,6 +196,6 @@ describe('AppSearchNav', () => { setMockValues({ myRole: { canViewRoleMappings: true } }); const wrapper = shallow(); - expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('/role_mappings'); + expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('/users_and_roles'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 191758af26758..d7ddad5683f38 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -37,7 +37,7 @@ import { SETUP_GUIDE_PATH, SETTINGS_PATH, CREDENTIALS_PATH, - ROLE_MAPPINGS_PATH, + USERS_AND_ROLES_PATH, ENGINES_PATH, ENGINE_PATH, LIBRARY_PATH, @@ -128,7 +128,7 @@ export const AppSearchConfigured: React.FC> = (props) = )} {canViewRoleMappings && ( - + )} @@ -162,7 +162,7 @@ export const AppSearchNav: React.FC = () => { {CREDENTIALS_TITLE} )} {canViewRoleMappings && ( - + {ROLE_MAPPINGS_TITLE} )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index d9d1935c648f7..f086a32bbf590 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -15,7 +15,7 @@ export const LIBRARY_PATH = '/library'; export const SETTINGS_PATH = '/settings'; export const CREDENTIALS_PATH = '/credentials'; -export const ROLE_MAPPINGS_PATH = '/role_mappings'; +export const USERS_AND_ROLES_PATH = '/users_and_roles'; export const ENGINES_PATH = '/engines'; export const ENGINE_CREATION_PATH = `${ENGINES_PATH}/new`; // This is safe from conflicting with an :engineName path because new is a reserved name diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts index 45cab32b67e08..215c76ffb7ef4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -136,7 +136,7 @@ export const FILTER_ROLE_MAPPINGS_PLACEHOLDER = i18n.translate( export const ROLE_MAPPINGS_TITLE = i18n.translate( 'xpack.enterpriseSearch.roleMapping.roleMappingsTitle', { - defaultMessage: 'Users & roles', + defaultMessage: 'Users and roles', } ); @@ -406,3 +406,19 @@ export const FILTER_USERS_LABEL = i18n.translate( export const NO_USERS_LABEL = i18n.translate('xpack.enterpriseSearch.roleMapping.noUsersLabel', { defaultMessage: 'No matching users found', }); + +export const EXTERNAL_ATTRIBUTE_TOOLTIP = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.externalAttributeTooltip', + { + defaultMessage: + 'External attributes are defined by the identity provider, and varies from service to service.', + } +); + +export const AUTH_PROVIDER_TOOLTIP = i18n.translate( + 'xpack.enterpriseSearch.roleMapping.authProviderTooltip', + { + defaultMessage: + 'Provider-specific role mapping is still applied, but configuration is now deprecated.', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx index c0973bb2c9504..ffcf5508233fc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.test.tsx @@ -20,13 +20,13 @@ import { import { RoleMappingFlyout } from './role_mapping_flyout'; describe('RoleMappingFlyout', () => { - const closeRoleMappingFlyout = jest.fn(); + const closeUsersAndRolesFlyout = jest.fn(); const handleSaveMapping = jest.fn(); const props = { isNew: true, disabled: false, - closeRoleMappingFlyout, + closeUsersAndRolesFlyout, handleSaveMapping, }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx index bae991fef3655..4416a2de28011 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mapping_flyout.tsx @@ -36,7 +36,7 @@ interface Props { children: React.ReactNode; isNew: boolean; disabled: boolean; - closeRoleMappingFlyout(): void; + closeUsersAndRolesFlyout(): void; handleSaveMapping(): void; } @@ -44,13 +44,13 @@ export const RoleMappingFlyout: React.FC = ({ children, isNew, disabled, - closeRoleMappingFlyout, + closeUsersAndRolesFlyout, handleSaveMapping, }) => ( @@ -71,7 +71,9 @@ export const RoleMappingFlyout: React.FC = ({ - {CANCEL_BUTTON_LABEL} + + {CANCEL_BUTTON_LABEL} + { }); it('renders auth provider display names', () => { - const wrapper = mount(); + const roleMappingWithAuths = { + ...wsRoleMapping, + authProvider: ['saml', 'native'], + }; + const wrapper = mount(); - expect(wrapper.find('[data-test-subj="AuthProviderDisplayValue"]').prop('children')).toEqual( - `${ANY_AUTH_PROVIDER_OPTION_LABEL}, other_auth` - ); + expect(wrapper.find('[data-test-subj="ProviderSpecificList"]')).toHaveLength(1); }); it('handles manage click', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx index eb9621c7a242c..4136d114d3420 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/role_mappings_table.tsx @@ -7,14 +7,17 @@ import React from 'react'; -import { EuiIconTip, EuiInMemoryTable, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiIconTip, EuiInMemoryTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui'; import { ASRoleMapping } from '../../app_search/types'; import { WSRoleMapping } from '../../workplace_search/types'; +import { docLinks } from '../doc_links'; import { RoleRules } from '../types'; import './role_mappings_table.scss'; +const AUTH_PROVIDER_DOCUMENTATION_URL = `${docLinks.enterpriseSearchBase}/users-access.html`; + import { ANY_AUTH_PROVIDER, ANY_AUTH_PROVIDER_OPTION_LABEL, @@ -25,6 +28,8 @@ import { ATTRIBUTE_VALUE_LABEL, FILTER_ROLE_MAPPINGS_PLACEHOLDER, ROLE_MAPPINGS_NO_RESULTS_MESSAGE, + EXTERNAL_ATTRIBUTE_TOOLTIP, + AUTH_PROVIDER_TOOLTIP, } from './constants'; import { UsersAndRolesRowActions } from './users_and_roles_row_actions'; @@ -46,9 +51,6 @@ interface Props { handleDeleteMapping(roleMappingId: string): void; } -const getAuthProviderDisplayValue = (authProvider: string) => - authProvider === ANY_AUTH_PROVIDER ? ANY_AUTH_PROVIDER_OPTION_LABEL : authProvider; - export const RoleMappingsTable: React.FC = ({ accessItemKey, accessHeader, @@ -69,7 +71,19 @@ export const RoleMappingsTable: React.FC = ({ const attributeNameCol: EuiBasicTableColumn = { field: 'attribute', - name: EXTERNAL_ATTRIBUTE_LABEL, + name: ( + + {EXTERNAL_ATTRIBUTE_LABEL}{' '} + + + ), render: (_, { rules }: SharedRoleMapping) => getFirstAttributeName(rules), }; @@ -105,11 +119,19 @@ export const RoleMappingsTable: React.FC = ({ const authProviderCol: EuiBasicTableColumn = { field: 'authProvider', name: AUTH_PROVIDER_LABEL, - render: (_, { authProvider }: SharedRoleMapping) => ( - - {authProvider.map(getAuthProviderDisplayValue).join(', ')} - - ), + render: (_, { authProvider }: SharedRoleMapping) => { + if (authProvider[0] === ANY_AUTH_PROVIDER) { + return ANY_AUTH_PROVIDER_OPTION_LABEL; + } + return ( + + {authProvider.join(', ')}{' '} + + + + + ); + }, }; const actionsCol: EuiBasicTableColumn = { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx index 30bdaa0010b58..57200b389591d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.test.tsx @@ -9,8 +9,6 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiText } from '@elastic/eui'; - import { UserAddedInfo } from './'; describe('UserAddedInfo', () => { @@ -20,9 +18,103 @@ describe('UserAddedInfo', () => { roleType: 'user', }; - it('renders', () => { + it('renders with email', () => { const wrapper = shallow(); - expect(wrapper.find(EuiText)).toHaveLength(6); + expect(wrapper).toMatchInlineSnapshot(` + + + + Username + + + + user1 + + + + + Email + + + + test@test.com + + + + + Role + + + + user + + + + `); + }); + + it('renders without email', () => { + const wrapper = shallow(); + + expect(wrapper).toMatchInlineSnapshot(` + + + + Username + + + + user1 + + + + + Email + + + + + — + + + + + + Role + + + + user + + + + `); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx index a12eae66262a0..37804414a94a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_added_info.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiSpacer, EuiText, EuiTextColor } from '@elastic/eui'; import { USERNAME_LABEL, EMAIL_LABEL } from '../constants'; @@ -19,6 +19,8 @@ interface Props { roleType: string; } +const noItemsPlaceholder = ; + export const UserAddedInfo: React.FC = ({ username, email, roleType }) => ( <> @@ -29,7 +31,7 @@ export const UserAddedInfo: React.FC = ({ username, email, roleType }) => {EMAIL_LABEL} - {email} + {email || noItemsPlaceholder} {ROLE_LABEL} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx index e13a56a716929..a3be5e295ddfe 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_flyout.tsx @@ -17,6 +17,7 @@ import { EuiFlyoutFooter, EuiFlyoutHeader, EuiIcon, + EuiPortal, EuiText, EuiTitle, EuiSpacer, @@ -92,22 +93,26 @@ export const UserFlyout: React.FC = ({ ); return ( - - - -

{isComplete ? IS_COMPLETE_HEADING : IS_EDITING_HEADING}

-
- {!isComplete && ( - -

{IS_EDITING_DESCRIPTION}

-
- )} -
- - {children} - - - {isComplete ? completedFooterAction : editingFooterActions} -
+ + + + +

{isComplete ? IS_COMPLETE_HEADING : IS_EDITING_HEADING}

+
+ {!isComplete && ( + +

{IS_EDITING_DESCRIPTION}

+
+ )} +
+ + {children} + + + + {isComplete ? completedFooterAction : editingFooterActions} + +
+
); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx index 8310077ad6f2e..d6d0ce7b050ab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_invitation_callout.tsx @@ -23,7 +23,7 @@ interface Props { } export const UserInvitationCallout: React.FC = ({ isNew, invitationCode, urlPrefix }) => { - const link = urlPrefix + invitationCode; + const link = `${urlPrefix}/invitations/${invitationCode}`; const label = isNew ? NEW_INVITATION_LABEL : EXISTING_INVITATION_LABEL; return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx index 08ddc7ba5427f..60bac97d09835 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.test.tsx @@ -13,7 +13,7 @@ import { shallow } from 'enzyme'; import { EuiFormRow } from '@elastic/eui'; -import { Role as ASRole } from '../../app_search/types'; +import { RoleTypes as ASRole } from '../../app_search/types'; import { REQUIRED_LABEL, USERNAME_NO_USERS_TEXT } from './constants'; @@ -107,6 +107,5 @@ describe('UserSelector', () => { expect(wrapper.find(EuiFormRow).at(0).prop('helpText')).toEqual(USERNAME_NO_USERS_TEXT); expect(wrapper.find(EuiFormRow).at(1).prop('helpText')).toEqual(REQUIRED_LABEL); - expect(wrapper.find(EuiFormRow).at(2).prop('helpText')).toEqual(REQUIRED_LABEL); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx index 70348bf29894a..d65f97265f6a3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/user_selector.tsx @@ -16,7 +16,7 @@ import { EuiSpacer, } from '@elastic/eui'; -import { Role as ASRole } from '../../app_search/types'; +import { RoleTypes as ASRole } from '../../app_search/types'; import { ElasticsearchUser } from '../../shared/types'; import { Role as WSRole } from '../../workplace_search/types'; @@ -80,7 +80,7 @@ export const UserSelector: React.FC = ({ ); const emailInput = ( - + = ({ setElasticsearchUsernameValue(e.target.value)} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx index 86dc2c2626229..674796775b1d3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/users_table.tsx @@ -46,8 +46,8 @@ interface SharedRoleMapping extends ASRoleMapping, WSRoleMapping { interface Props { accessItemKey: 'groups' | 'engines'; singleUserRoleMappings: Array>; - initializeSingleUserRoleMapping(roleId: string): string; - handleDeleteMapping(roleId: string): string; + initializeSingleUserRoleMapping(roleMappingId: string): void; + handleDeleteMapping(roleMappingId: string): void; } const noItemsPlaceholder = ; @@ -110,6 +110,7 @@ export const UsersTable: React.FC = ({ { field: 'id', name: '', + align: 'right', render: (_, { id, username }: SharedUser) => ( { }, { id: 'usersRoles', - name: 'Users & roles', - href: '/role_mappings', + name: 'Users and roles', + href: '/users_and_roles', }, { id: 'security', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index ce2f8bf7ef7e4..c8d821dcdae2e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -15,7 +15,7 @@ import { NAV } from '../../constants'; import { SOURCES_PATH, SECURITY_PATH, - ROLE_MAPPINGS_PATH, + USERS_AND_ROLES_PATH, GROUPS_PATH, ORG_SETTINGS_PATH, } from '../../routes'; @@ -48,7 +48,7 @@ export const useWorkplaceSearchNav = () => { { id: 'usersRoles', name: NAV.ROLE_MAPPINGS, - ...generateNavLink({ to: ROLE_MAPPINGS_PATH }), + ...generateNavLink({ to: USERS_AND_ROLES_PATH }), }, { id: 'security', @@ -92,7 +92,7 @@ export const WorkplaceSearchNav: React.FC = ({ {NAV.GROUPS} - + {NAV.ROLE_MAPPINGS} {NAV.SECURITY} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index aa5419f12c7f3..cf459171a808a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -40,7 +40,7 @@ export const NAV = { defaultMessage: 'Content', }), ROLE_MAPPINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.roleMappings', { - defaultMessage: 'Users & roles', + defaultMessage: 'Users and roles', }), SECURITY: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.security', { defaultMessage: 'Security', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 8a1e9c0275322..05018be2934b4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -26,7 +26,7 @@ import { SOURCE_ADDED_PATH, PERSONAL_SOURCES_PATH, ORG_SETTINGS_PATH, - ROLE_MAPPINGS_PATH, + USERS_AND_ROLES_PATH, SECURITY_PATH, PERSONAL_SETTINGS_PATH, PERSONAL_PATH, @@ -103,7 +103,7 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 3c564c1f912ec..b9309ffd94809 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -48,7 +48,7 @@ export const ENT_SEARCH_LICENSE_MANAGEMENT = `${docLinks.enterpriseSearchBase}/l export const PERSONAL_PATH = '/p'; -export const ROLE_MAPPINGS_PATH = '/role_mappings'; +export const USERS_AND_ROLES_PATH = '/users_and_roles'; export const USERS_PATH = '/users'; export const SECURITY_PATH = '/security'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx index cc773895bff1c..20211d40d7010 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mapping.tsx @@ -43,7 +43,7 @@ export const RoleMapping: React.FC = () => { handleAttributeSelectorChange, handleRoleChange, handleAuthProviderChange, - closeRoleMappingFlyout, + closeUsersAndRolesFlyout, } = useActions(RoleMappingsLogic); const { @@ -70,7 +70,7 @@ export const RoleMapping: React.FC = () => { 0} error={roleMappingErrors}> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx index 308022ccb2e5a..2e13f24a13eee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.test.tsx @@ -12,26 +12,39 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { RoleMappingsTable, RoleMappingsHeading } from '../../../shared/role_mapping'; -import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { + RoleMappingsTable, + RoleMappingsHeading, + UsersHeading, + UsersEmptyPrompt, +} from '../../../shared/role_mapping'; +import { + wsRoleMapping, + wsSingleUserRoleMapping, +} from '../../../shared/role_mapping/__mocks__/roles'; import { RoleMapping } from './role_mapping'; import { RoleMappings } from './role_mappings'; +import { User } from './user'; describe('RoleMappings', () => { const initializeRoleMappings = jest.fn(); const initializeRoleMapping = jest.fn(); + const initializeSingleUserRoleMapping = jest.fn(); const handleDeleteMapping = jest.fn(); const mockValues = { roleMappings: [wsRoleMapping], dataLoading: false, multipleAuthProvidersConfig: false, + singleUserRoleMappings: [wsSingleUserRoleMapping], + singleUserRoleMappingFlyoutOpen: false, }; beforeEach(() => { setMockActions({ initializeRoleMappings, initializeRoleMapping, + initializeSingleUserRoleMapping, handleDeleteMapping, }); setMockValues(mockValues); @@ -50,10 +63,31 @@ describe('RoleMappings', () => { expect(wrapper.find(RoleMapping)).toHaveLength(1); }); - it('handles onClick', () => { + it('renders User flyout', () => { + setMockValues({ ...mockValues, singleUserRoleMappingFlyoutOpen: true }); + const wrapper = shallow(); + + expect(wrapper.find(User)).toHaveLength(1); + }); + + it('handles RoleMappingsHeading onClick', () => { const wrapper = shallow(); wrapper.find(RoleMappingsHeading).prop('onClick')(); expect(initializeRoleMapping).toHaveBeenCalled(); }); + + it('handles UsersHeading onClick', () => { + const wrapper = shallow(); + wrapper.find(UsersHeading).prop('onClick')(); + + expect(initializeSingleUserRoleMapping).toHaveBeenCalled(); + }); + + it('handles empty users state', () => { + setMockValues({ ...mockValues, singleUserRoleMappings: [] }); + const wrapper = shallow(); + + expect(wrapper.find(UsersEmptyPrompt)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx index 01d32bec14ebd..df5d7e4267690 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings.tsx @@ -9,11 +9,16 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; +import { EuiSpacer } from '@elastic/eui'; + import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; import { RoleMappingsTable, RoleMappingsHeading, RolesEmptyPrompt, + UsersTable, + UsersHeading, + UsersEmptyPrompt, } from '../../../shared/role_mapping'; import { ROLE_MAPPINGS_TITLE } from '../../../shared/role_mapping/constants'; import { WorkplaceSearchPageTemplate } from '../../components/layout'; @@ -23,26 +28,32 @@ import { ROLE_MAPPINGS_TABLE_HEADER } from './constants'; import { RoleMapping } from './role_mapping'; import { RoleMappingsLogic } from './role_mappings_logic'; +import { User } from './user'; export const RoleMappings: React.FC = () => { const { enableRoleBasedAccess, initializeRoleMappings, initializeRoleMapping, + initializeSingleUserRoleMapping, handleDeleteMapping, } = useActions(RoleMappingsLogic); const { roleMappings, + singleUserRoleMappings, dataLoading, multipleAuthProvidersConfig, roleMappingFlyoutOpen, + singleUserRoleMappingFlyoutOpen, } = useValues(RoleMappingsLogic); useEffect(() => { initializeRoleMappings(); }, []); + const hasUsers = singleUserRoleMappings.length > 0; + const rolesEmptyState = ( { ); + const usersTable = ( + + ); + + const usersSection = ( + <> + initializeSingleUserRoleMapping()} /> + + {hasUsers ? usersTable : } + + ); + return ( { emptyState={rolesEmptyState} > {roleMappingFlyoutOpen && } + {singleUserRoleMappingFlyoutOpen && } {roleMappingsSection} + + {usersSection} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts index a4bbddbd23b49..c85e86ebcca2c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.test.ts @@ -15,11 +15,18 @@ import { groups } from '../../__mocks__/groups.mock'; import { nextTick } from '@kbn/test/jest'; -import { wsRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; +import { elasticsearchUsers } from '../../../shared/role_mapping/__mocks__/elasticsearch_users'; + +import { + wsRoleMapping, + wsSingleUserRoleMapping, +} from '../../../shared/role_mapping/__mocks__/roles'; import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; import { RoleMappingsLogic } from './role_mappings_logic'; +const emptyUser = { username: '', email: '' }; + describe('RoleMappingsLogic', () => { const { http } = mockHttpValues; const { clearFlashMessages, flashAPIErrors } = mockFlashMessageHelpers; @@ -28,6 +35,8 @@ describe('RoleMappingsLogic', () => { attributes: [], availableAuthProviders: [], elasticsearchRoles: [], + elasticsearchUser: emptyUser, + elasticsearchUsers: [], roleMapping: null, roleMappingFlyoutOpen: false, roleMappings: [], @@ -42,6 +51,12 @@ describe('RoleMappingsLogic', () => { selectedAuthProviders: [ANY_AUTH_PROVIDER], selectedOptions: [], roleMappingErrors: [], + singleUserRoleMapping: null, + singleUserRoleMappings: [], + singleUserRoleMappingFlyoutOpen: false, + userCreated: false, + userFormIsNewUser: true, + userFormUserIsExisting: true, }; const roleGroup = { id: '123', @@ -59,6 +74,8 @@ describe('RoleMappingsLogic', () => { authProviders: [], availableGroups: [roleGroup, defaultGroup], elasticsearchRoles: [], + singleUserRoleMappings: [wsSingleUserRoleMapping], + elasticsearchUsers, }; beforeEach(() => { @@ -71,23 +88,36 @@ describe('RoleMappingsLogic', () => { }); describe('actions', () => { - it('setRoleMappingsData', () => { - RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + describe('setRoleMappingsData', () => { + it('sets data based on server response from the `mappings` (plural) endpoint', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); - expect(RoleMappingsLogic.values.roleMappings).toEqual([wsRoleMapping]); - expect(RoleMappingsLogic.values.dataLoading).toEqual(false); - expect(RoleMappingsLogic.values.multipleAuthProvidersConfig).toEqual(true); - expect(RoleMappingsLogic.values.dataLoading).toEqual(false); - expect(RoleMappingsLogic.values.attributes).toEqual(mappingsServerProps.attributes); - expect(RoleMappingsLogic.values.availableGroups).toEqual(mappingsServerProps.availableGroups); - expect(RoleMappingsLogic.values.includeInAllGroups).toEqual(false); - expect(RoleMappingsLogic.values.elasticsearchRoles).toEqual( - mappingsServerProps.elasticsearchRoles - ); - expect(RoleMappingsLogic.values.selectedOptions).toEqual([ - { label: defaultGroup.name, value: defaultGroup.id }, - ]); - expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([defaultGroup.id])); + expect(RoleMappingsLogic.values.roleMappings).toEqual([wsRoleMapping]); + expect(RoleMappingsLogic.values.dataLoading).toEqual(false); + expect(RoleMappingsLogic.values.multipleAuthProvidersConfig).toEqual(true); + expect(RoleMappingsLogic.values.dataLoading).toEqual(false); + expect(RoleMappingsLogic.values.attributes).toEqual(mappingsServerProps.attributes); + expect(RoleMappingsLogic.values.availableGroups).toEqual( + mappingsServerProps.availableGroups + ); + expect(RoleMappingsLogic.values.includeInAllGroups).toEqual(false); + expect(RoleMappingsLogic.values.elasticsearchRoles).toEqual( + mappingsServerProps.elasticsearchRoles + ); + expect(RoleMappingsLogic.values.selectedOptions).toEqual([ + { label: defaultGroup.name, value: defaultGroup.id }, + ]); + expect(RoleMappingsLogic.values.selectedGroups).toEqual(new Set([defaultGroup.id])); + }); + + it('handles fallback if no elasticsearch users present', () => { + RoleMappingsLogic.actions.setRoleMappingsData({ + ...mappingsServerProps, + elasticsearchUsers: [], + }); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual(emptyUser); + }); }); it('setRoleMappings', () => { @@ -97,6 +127,26 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values.dataLoading).toEqual(false); }); + describe('setElasticsearchUser', () => { + it('sets user', () => { + RoleMappingsLogic.actions.setElasticsearchUser(elasticsearchUsers[0]); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual(elasticsearchUsers[0]); + }); + + it('handles fallback if no user present', () => { + RoleMappingsLogic.actions.setElasticsearchUser(undefined); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual(emptyUser); + }); + }); + + it('setSingleUserRoleMapping', () => { + RoleMappingsLogic.actions.setSingleUserRoleMapping(wsSingleUserRoleMapping); + + expect(RoleMappingsLogic.values.singleUserRoleMapping).toEqual(wsSingleUserRoleMapping); + }); + it('handleRoleChange', () => { RoleMappingsLogic.actions.handleRoleChange('user'); @@ -133,6 +183,12 @@ describe('RoleMappingsLogic', () => { expect(RoleMappingsLogic.values.includeInAllGroups).toEqual(true); }); + it('setUserExistingRadioValue', () => { + RoleMappingsLogic.actions.setUserExistingRadioValue(false); + + expect(RoleMappingsLogic.values.userFormUserIsExisting).toEqual(false); + }); + describe('handleAttributeSelectorChange', () => { const elasticsearchRoles = ['foo', 'bar']; @@ -228,16 +284,50 @@ describe('RoleMappingsLogic', () => { expect(clearFlashMessages).toHaveBeenCalled(); }); - it('closeRoleMappingFlyout', () => { + it('openSingleUserRoleMappingFlyout', () => { + mount(mappingsServerProps); + RoleMappingsLogic.actions.openSingleUserRoleMappingFlyout(); + + expect(RoleMappingsLogic.values.singleUserRoleMappingFlyoutOpen).toEqual(true); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + + it('closeUsersAndRolesFlyout', () => { mount({ ...mappingsServerProps, roleMappingFlyoutOpen: true, }); - RoleMappingsLogic.actions.closeRoleMappingFlyout(); + RoleMappingsLogic.actions.closeUsersAndRolesFlyout(); expect(RoleMappingsLogic.values.roleMappingFlyoutOpen).toEqual(false); expect(clearFlashMessages).toHaveBeenCalled(); }); + + it('setElasticsearchUsernameValue', () => { + const username = 'newName'; + RoleMappingsLogic.actions.setElasticsearchUsernameValue(username); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual({ + ...RoleMappingsLogic.values.elasticsearchUser, + username, + }); + }); + + it('setElasticsearchEmailValue', () => { + const email = 'newEmail@foo.cats'; + RoleMappingsLogic.actions.setElasticsearchEmailValue(email); + + expect(RoleMappingsLogic.values.elasticsearchUser).toEqual({ + ...RoleMappingsLogic.values.elasticsearchUser, + email, + }); + }); + + it('setUserCreated', () => { + RoleMappingsLogic.actions.setUserCreated(); + + expect(RoleMappingsLogic.values.userCreated).toEqual(true); + }); }); describe('listeners', () => { @@ -303,6 +393,39 @@ describe('RoleMappingsLogic', () => { }); }); + describe('initializeSingleUserRoleMapping', () => { + let setElasticsearchUserSpy: jest.MockedFunction; + let setRoleMappingSpy: jest.MockedFunction; + let setSingleUserRoleMappingSpy: jest.MockedFunction; + beforeEach(() => { + setElasticsearchUserSpy = jest.spyOn(RoleMappingsLogic.actions, 'setElasticsearchUser'); + setRoleMappingSpy = jest.spyOn(RoleMappingsLogic.actions, 'setRoleMapping'); + setSingleUserRoleMappingSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setSingleUserRoleMapping' + ); + }); + + it('should handle the new user state and only set an empty mapping', () => { + RoleMappingsLogic.actions.initializeSingleUserRoleMapping(); + + expect(setElasticsearchUserSpy).not.toHaveBeenCalled(); + expect(setRoleMappingSpy).not.toHaveBeenCalled(); + expect(setSingleUserRoleMappingSpy).toHaveBeenCalledWith(undefined); + }); + + it('should handle an existing user state and set mapping', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + RoleMappingsLogic.actions.initializeSingleUserRoleMapping( + wsSingleUserRoleMapping.roleMapping.id + ); + + expect(setElasticsearchUserSpy).toHaveBeenCalled(); + expect(setRoleMappingSpy).toHaveBeenCalled(); + expect(setSingleUserRoleMappingSpy).toHaveBeenCalledWith(wsSingleUserRoleMapping); + }); + }); + describe('handleSaveMapping', () => { it('calls API and refreshes list when new mapping', async () => { const initializeRoleMappingsSpy = jest.spyOn( @@ -381,6 +504,100 @@ describe('RoleMappingsLogic', () => { }); }); + describe('handleSaveUser', () => { + it('calls API and refreshes list when new mapping', async () => { + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); + const setUserCreatedSpy = jest.spyOn(RoleMappingsLogic.actions, 'setUserCreated'); + const setSingleUserRoleMappingSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setSingleUserRoleMapping' + ); + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + + http.post.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.handleSaveUser(); + + expect(http.post).toHaveBeenCalledWith( + '/api/workplace_search/org/single_user_role_mapping', + { + body: JSON.stringify({ + roleMapping: { + groups: [defaultGroup.id], + roleType: 'admin', + allGroups: false, + }, + elasticsearchUser: { + username: elasticsearchUsers[0].username, + email: elasticsearchUsers[0].email, + }, + }), + } + ); + await nextTick(); + + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); + expect(setUserCreatedSpy).toHaveBeenCalled(); + expect(setSingleUserRoleMappingSpy).toHaveBeenCalled(); + }); + + it('calls API and refreshes list when existing mapping', async () => { + const initializeRoleMappingsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'initializeRoleMappings' + ); + RoleMappingsLogic.actions.setSingleUserRoleMapping(wsSingleUserRoleMapping); + RoleMappingsLogic.actions.handleAllGroupsSelectionChange(true); + + http.put.mockReturnValue(Promise.resolve(mappingsServerProps)); + RoleMappingsLogic.actions.handleSaveUser(); + + expect(http.post).toHaveBeenCalledWith( + '/api/workplace_search/org/single_user_role_mapping', + { + body: JSON.stringify({ + roleMapping: { + groups: [], + roleType: 'admin', + allGroups: true, + id: wsSingleUserRoleMapping.roleMapping.id, + }, + elasticsearchUser: { + username: '', + email: '', + }, + }), + } + ); + await nextTick(); + + expect(initializeRoleMappingsSpy).toHaveBeenCalled(); + }); + + it('handles error', async () => { + const setRoleMappingErrorsSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setRoleMappingErrors' + ); + + http.post.mockReturnValue( + Promise.reject({ + body: { + attributes: { + errors: ['this is an error'], + }, + }, + }) + ); + RoleMappingsLogic.actions.handleSaveUser(); + await nextTick(); + + expect(setRoleMappingErrorsSpy).toHaveBeenCalledWith(['this is an error']); + }); + }); + describe('handleDeleteMapping', () => { const roleMappingId = 'r1'; @@ -410,5 +627,52 @@ describe('RoleMappingsLogic', () => { expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); }); + + describe('handleUsernameSelectChange', () => { + it('sets elasticsearchUser when match found', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.handleUsernameSelectChange(elasticsearchUsers[0].username); + + expect(setElasticsearchUserSpy).toHaveBeenCalledWith(elasticsearchUsers[0]); + }); + + it('does not set elasticsearchUser when no match found', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.handleUsernameSelectChange('bogus'); + + expect(setElasticsearchUserSpy).not.toHaveBeenCalled(); + }); + }); + + describe('setUserExistingRadioValue', () => { + it('handles existing user', () => { + RoleMappingsLogic.actions.setRoleMappingsData(mappingsServerProps); + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.setUserExistingRadioValue(true); + + expect(setElasticsearchUserSpy).toHaveBeenCalledWith(elasticsearchUsers[0]); + }); + + it('handles new user', () => { + const setElasticsearchUserSpy = jest.spyOn( + RoleMappingsLogic.actions, + 'setElasticsearchUser' + ); + RoleMappingsLogic.actions.setUserExistingRadioValue(false); + + expect(setElasticsearchUserSpy).toHaveBeenCalledWith(emptyUser); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts index 76b41b2f383eb..7f26c8738786c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/role_mappings_logic.ts @@ -16,7 +16,7 @@ import { } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { ANY_AUTH_PROVIDER } from '../../../shared/role_mapping/constants'; -import { AttributeName } from '../../../shared/types'; +import { AttributeName, SingleUserRoleMapping, ElasticsearchUser } from '../../../shared/types'; import { RoleGroup, WSRoleMapping, Role } from '../../types'; import { @@ -26,19 +26,24 @@ import { DEFAULT_GROUP_NAME, } from './constants'; +type UserMapping = SingleUserRoleMapping; + interface RoleMappingsServerDetails { roleMappings: WSRoleMapping[]; attributes: string[]; authProviders: string[]; availableGroups: RoleGroup[]; + elasticsearchUsers: ElasticsearchUser[]; elasticsearchRoles: string[]; multipleAuthProvidersConfig: boolean; + singleUserRoleMappings: UserMapping[]; } const getFirstAttributeName = (roleMapping: WSRoleMapping): AttributeName => Object.entries(roleMapping.rules)[0][0] as AttributeName; const getFirstAttributeValue = (roleMapping: WSRoleMapping): string => Object.entries(roleMapping.rules)[0][1] as string; +const emptyUser = { username: '', email: '' } as ElasticsearchUser; interface RoleMappingsActions { handleAllGroupsSelectionChange(selected: boolean): { selected: boolean }; @@ -51,21 +56,35 @@ interface RoleMappingsActions { handleDeleteMapping(roleMappingId: string): { roleMappingId: string }; handleGroupSelectionChange(groupIds: string[]): { groupIds: string[] }; handleRoleChange(roleType: Role): { roleType: Role }; + handleUsernameSelectChange(username: string): { username: string }; handleSaveMapping(): void; + handleSaveUser(): void; initializeRoleMapping(roleMappingId?: string): { roleMappingId?: string }; + initializeSingleUserRoleMapping(roleMappingId?: string): { roleMappingId?: string }; initializeRoleMappings(): void; resetState(): void; setRoleMapping(roleMapping: WSRoleMapping): { roleMapping: WSRoleMapping }; + setSingleUserRoleMapping(data?: UserMapping): { singleUserRoleMapping: UserMapping }; setRoleMappings({ roleMappings, }: { roleMappings: WSRoleMapping[]; }): { roleMappings: WSRoleMapping[] }; setRoleMappingsData(data: RoleMappingsServerDetails): RoleMappingsServerDetails; + setElasticsearchUser( + elasticsearchUser?: ElasticsearchUser + ): { elasticsearchUser: ElasticsearchUser }; + setDefaultGroup(availableGroups: RoleGroup[]): { availableGroups: RoleGroup[] }; openRoleMappingFlyout(): void; - closeRoleMappingFlyout(): void; + openSingleUserRoleMappingFlyout(): void; + closeUsersAndRolesFlyout(): void; setRoleMappingErrors(errors: string[]): { errors: string[] }; enableRoleBasedAccess(): void; + setUserExistingRadioValue(userFormUserIsExisting: boolean): { userFormUserIsExisting: boolean }; + setElasticsearchUsernameValue(username: string): { username: string }; + setElasticsearchEmailValue(email: string): { email: string }; + setUserCreated(): void; + setUserFormIsNewUser(userFormIsNewUser: boolean): { userFormIsNewUser: boolean }; } interface RoleMappingsValues { @@ -77,26 +96,37 @@ interface RoleMappingsValues { availableGroups: RoleGroup[]; dataLoading: boolean; elasticsearchRoles: string[]; + elasticsearchUsers: ElasticsearchUser[]; + elasticsearchUser: ElasticsearchUser; multipleAuthProvidersConfig: boolean; roleMapping: WSRoleMapping | null; roleMappings: WSRoleMapping[]; + singleUserRoleMapping: UserMapping | null; + singleUserRoleMappings: UserMapping[]; roleType: Role; selectedAuthProviders: string[]; selectedGroups: Set; roleMappingFlyoutOpen: boolean; + singleUserRoleMappingFlyoutOpen: boolean; selectedOptions: EuiComboBoxOptionOption[]; roleMappingErrors: string[]; + userFormUserIsExisting: boolean; + userCreated: boolean; + userFormIsNewUser: boolean; } export const RoleMappingsLogic = kea>({ - path: ['enterprise_search', 'workplace_search', 'role_mappings'], + path: ['enterprise_search', 'workplace_search', 'users_and_roles'], actions: { setRoleMappingsData: (data: RoleMappingsServerDetails) => data, setRoleMapping: (roleMapping: WSRoleMapping) => ({ roleMapping }), + setElasticsearchUser: (elasticsearchUser: ElasticsearchUser) => ({ elasticsearchUser }), + setSingleUserRoleMapping: (singleUserRoleMapping: UserMapping) => ({ singleUserRoleMapping }), setRoleMappings: ({ roleMappings }: { roleMappings: WSRoleMapping[] }) => ({ roleMappings }), setRoleMappingErrors: (errors: string[]) => ({ errors }), handleAuthProviderChange: (value: string[]) => ({ value }), handleRoleChange: (roleType: Role) => ({ roleType }), + handleUsernameSelectChange: (username: string) => ({ username }), handleGroupSelectionChange: (groupIds: string[]) => ({ groupIds }), handleAttributeSelectorChange: (value: string, firstElasticsearchRole: string) => ({ value, @@ -105,13 +135,22 @@ export const RoleMappingsLogic = kea ({ value }), handleAllGroupsSelectionChange: (selected: boolean) => ({ selected }), enableRoleBasedAccess: true, + openSingleUserRoleMappingFlyout: true, + setUserExistingRadioValue: (userFormUserIsExisting: boolean) => ({ userFormUserIsExisting }), resetState: true, initializeRoleMappings: true, + initializeSingleUserRoleMapping: (roleMappingId?: string) => ({ roleMappingId }), initializeRoleMapping: (roleMappingId?: string) => ({ roleMappingId }), handleDeleteMapping: (roleMappingId: string) => ({ roleMappingId }), handleSaveMapping: true, + handleSaveUser: true, + setDefaultGroup: (availableGroups: RoleGroup[]) => ({ availableGroups }), openRoleMappingFlyout: true, - closeRoleMappingFlyout: false, + closeUsersAndRolesFlyout: false, + setElasticsearchUsernameValue: (username: string) => ({ username }), + setElasticsearchEmailValue: (email: string) => ({ email }), + setUserCreated: true, + setUserFormIsNewUser: (userFormIsNewUser: boolean) => ({ userFormIsNewUser }), }, reducers: { dataLoading: [ @@ -131,6 +170,13 @@ export const RoleMappingsLogic = kea [], }, ], + singleUserRoleMappings: [ + [], + { + setRoleMappingsData: (_, { singleUserRoleMappings }) => singleUserRoleMappings, + resetState: () => [], + }, + ], multipleAuthProvidersConfig: [ false, { @@ -154,6 +200,13 @@ export const RoleMappingsLogic = kea elasticsearchRoles, + closeUsersAndRolesFlyout: () => [ANY_AUTH_PROVIDER], + }, + ], + elasticsearchUsers: [ + [], + { + setRoleMappingsData: (_, { elasticsearchUsers }) => elasticsearchUsers, }, ], roleMapping: [ @@ -161,7 +214,14 @@ export const RoleMappingsLogic = kea roleMapping, resetState: () => null, - closeRoleMappingFlyout: () => null, + closeUsersAndRolesFlyout: () => null, + }, + ], + singleUserRoleMapping: [ + null, + { + setSingleUserRoleMapping: (_, { singleUserRoleMapping }) => singleUserRoleMapping || null, + closeUsersAndRolesFlyout: () => null, }, ], roleType: [ @@ -176,6 +236,7 @@ export const RoleMappingsLogic = kea roleMapping.allGroups, handleAllGroupsSelectionChange: (_, { selected }) => selected, + closeUsersAndRolesFlyout: () => false, }, ], attributeValue: [ @@ -186,7 +247,7 @@ export const RoleMappingsLogic = kea value, resetState: () => '', - closeRoleMappingFlyout: () => '', + closeUsersAndRolesFlyout: () => '', }, ], attributeName: [ @@ -195,7 +256,7 @@ export const RoleMappingsLogic = kea getFirstAttributeName(roleMapping), handleAttributeSelectorChange: (_, { value }) => value, resetState: () => 'username', - closeRoleMappingFlyout: () => 'username', + closeUsersAndRolesFlyout: () => 'username', }, ], selectedGroups: [ @@ -207,6 +268,12 @@ export const RoleMappingsLogic = kea group.name === DEFAULT_GROUP_NAME) .map((group) => group.id) ), + setDefaultGroup: (_, { availableGroups }) => + new Set( + availableGroups + .filter((group) => group.name === DEFAULT_GROUP_NAME) + .map((group) => group.id) + ), setRoleMapping: (_, { roleMapping }) => new Set(roleMapping.groups.map((group: RoleGroup) => group.id)), handleGroupSelectionChange: (_, { groupIds }) => { @@ -215,6 +282,7 @@ export const RoleMappingsLogic = kea new Set(), }, ], availableAuthProviders: [ @@ -244,17 +312,61 @@ export const RoleMappingsLogic = kea true, - closeRoleMappingFlyout: () => false, + closeUsersAndRolesFlyout: () => false, initializeRoleMappings: () => false, initializeRoleMapping: () => true, }, ], + singleUserRoleMappingFlyoutOpen: [ + false, + { + openSingleUserRoleMappingFlyout: () => true, + closeUsersAndRolesFlyout: () => false, + initializeSingleUserRoleMapping: () => true, + }, + ], roleMappingErrors: [ [], { setRoleMappingErrors: (_, { errors }) => errors, handleSaveMapping: () => [], - closeRoleMappingFlyout: () => [], + closeUsersAndRolesFlyout: () => [], + }, + ], + userFormUserIsExisting: [ + true, + { + setUserExistingRadioValue: (_, { userFormUserIsExisting }) => userFormUserIsExisting, + closeUsersAndRolesFlyout: () => true, + }, + ], + elasticsearchUser: [ + emptyUser, + { + setRoleMappingsData: (_, { elasticsearchUsers }) => elasticsearchUsers[0] || emptyUser, + setElasticsearchUser: (_, { elasticsearchUser }) => elasticsearchUser || emptyUser, + setElasticsearchUsernameValue: (state, { username }) => ({ + ...state, + username, + }), + setElasticsearchEmailValue: (state, { email }) => ({ + ...state, + email, + }), + closeUsersAndRolesFlyout: () => emptyUser, + }, + ], + userCreated: [ + false, + { + setUserCreated: () => true, + closeUsersAndRolesFlyout: () => false, + }, + ], + userFormIsNewUser: [ + true, + { + setUserFormIsNewUser: (_, { userFormIsNewUser }) => userFormIsNewUser, }, ], }, @@ -296,6 +408,18 @@ export const RoleMappingsLogic = kea id === roleMappingId); if (roleMapping) actions.setRoleMapping(roleMapping); }, + initializeSingleUserRoleMapping: ({ roleMappingId }) => { + const singleUserRoleMapping = values.singleUserRoleMappings.find( + ({ roleMapping }) => roleMapping.id === roleMappingId + ); + + if (singleUserRoleMapping) { + actions.setElasticsearchUser(singleUserRoleMapping.elasticsearchUser); + actions.setRoleMapping(singleUserRoleMapping.roleMapping); + } + actions.setSingleUserRoleMapping(singleUserRoleMapping); + actions.setUserFormIsNewUser(!singleUserRoleMapping); + }, handleDeleteMapping: async ({ roleMappingId }) => { const { http } = HttpLogic.values; const route = `/api/workplace_search/org/role_mappings/${roleMappingId}`; @@ -349,11 +473,59 @@ export const RoleMappingsLogic = kea { clearFlashMessages(); }, - closeRoleMappingFlyout: () => { + handleSaveUser: async () => { + const { http } = HttpLogic.values; + const { + roleType, + singleUserRoleMapping, + includeInAllGroups, + selectedGroups, + elasticsearchUser: { email, username }, + } = values; + + const body = JSON.stringify({ + roleMapping: { + groups: includeInAllGroups ? [] : Array.from(selectedGroups), + roleType, + allGroups: includeInAllGroups, + id: singleUserRoleMapping?.roleMapping?.id, + }, + elasticsearchUser: { + username, + email, + }, + }); + + try { + const response = await http.post('/api/workplace_search/org/single_user_role_mapping', { + body, + }); + actions.setSingleUserRoleMapping(response); + actions.setUserCreated(); + actions.initializeRoleMappings(); + } catch (e) { + actions.setRoleMappingErrors(e?.body?.attributes?.errors); + } + }, + closeUsersAndRolesFlyout: () => { clearFlashMessages(); + const firstUser = values.elasticsearchUsers[0]; + actions.setElasticsearchUser(firstUser); + actions.setDefaultGroup(values.availableGroups); }, openRoleMappingFlyout: () => { clearFlashMessages(); }, + openSingleUserRoleMappingFlyout: () => { + clearFlashMessages(); + }, + setUserExistingRadioValue: ({ userFormUserIsExisting }) => { + const firstUser = values.elasticsearchUsers[0]; + actions.setElasticsearchUser(userFormUserIsExisting ? firstUser : emptyUser); + }, + handleUsernameSelectChange: ({ username }) => { + const user = values.elasticsearchUsers.find((u) => u.username === username); + if (user) actions.setElasticsearchUser(user); + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.test.tsx new file mode 100644 index 0000000000000..32ee1a7f22875 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/react_router'; +import '../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../__mocks__/kea_logic'; +import { groups } from '../../__mocks__/groups.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { UserFlyout, UserAddedInfo, UserInvitationCallout } from '../../../shared/role_mapping'; +import { elasticsearchUsers } from '../../../shared/role_mapping/__mocks__/elasticsearch_users'; +import { wsSingleUserRoleMapping } from '../../../shared/role_mapping/__mocks__/roles'; + +import { GroupAssignmentSelector } from './group_assignment_selector'; +import { User } from './user'; + +describe('User', () => { + const handleSaveUser = jest.fn(); + const closeUsersAndRolesFlyout = jest.fn(); + const setUserExistingRadioValue = jest.fn(); + const setElasticsearchUsernameValue = jest.fn(); + const setElasticsearchEmailValue = jest.fn(); + const handleRoleChange = jest.fn(); + const handleUsernameSelectChange = jest.fn(); + + const mockValues = { + availableGroups: [], + singleUserRoleMapping: null, + userFormUserIsExisting: false, + elasticsearchUsers: [], + elasticsearchUser: {}, + roleType: 'admin', + roleMappingErrors: [], + userCreated: false, + userFormIsNewUser: false, + }; + + beforeEach(() => { + setMockActions({ + handleSaveUser, + closeUsersAndRolesFlyout, + setUserExistingRadioValue, + setElasticsearchUsernameValue, + setElasticsearchEmailValue, + handleRoleChange, + handleUsernameSelectChange, + }); + + setMockValues(mockValues); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(UserFlyout)).toHaveLength(1); + }); + + it('renders group assignment selector when groups present', () => { + setMockValues({ ...mockValues, availableGroups: groups }); + const wrapper = shallow(); + + expect(wrapper.find(GroupAssignmentSelector)).toHaveLength(1); + }); + + it('renders userInvitationCallout', () => { + setMockValues({ + ...mockValues, + singleUserRoleMapping: wsSingleUserRoleMapping, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserInvitationCallout)).toHaveLength(1); + }); + + it('renders user added info when user created', () => { + setMockValues({ + ...mockValues, + singleUserRoleMapping: wsSingleUserRoleMapping, + userCreated: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserAddedInfo)).toHaveLength(1); + }); + + it('disables form when username value not present', () => { + setMockValues({ + ...mockValues, + singleUserRoleMapping: wsSingleUserRoleMapping, + elasticsearchUsers, + elasticsearchUser: { + username: null, + email: 'email@user.com', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserFlyout).prop('disabled')).toEqual(true); + }); + + it('enables form when userFormUserIsExisting', () => { + setMockValues({ + ...mockValues, + userFormUserIsExisting: true.valueOf, + singleUserRoleMapping: wsSingleUserRoleMapping, + elasticsearchUsers, + elasticsearchUser: { + username: null, + email: 'email@user.com', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(UserFlyout).prop('disabled')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.tsx new file mode 100644 index 0000000000000..bfb32ee31c121 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/role_mappings/user.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiForm } from '@elastic/eui'; + +import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; +import { + UserFlyout, + UserSelector, + UserAddedInfo, + UserInvitationCallout, +} from '../../../shared/role_mapping'; +import { Role } from '../../types'; + +import { GroupAssignmentSelector } from './group_assignment_selector'; +import { RoleMappingsLogic } from './role_mappings_logic'; + +const roleTypes = (['admin', 'user'] as unknown) as Role[]; + +export const User: React.FC = () => { + const { + handleSaveUser, + closeUsersAndRolesFlyout, + setUserExistingRadioValue, + setElasticsearchUsernameValue, + setElasticsearchEmailValue, + handleRoleChange, + handleUsernameSelectChange, + } = useActions(RoleMappingsLogic); + + const { + availableGroups, + singleUserRoleMapping, + userFormUserIsExisting, + elasticsearchUsers, + elasticsearchUser, + roleType, + roleMappingErrors, + userCreated, + userFormIsNewUser, + } = useValues(RoleMappingsLogic); + + const showGroupAssignmentSelector = availableGroups.length > 0; + const hasAvailableUsers = elasticsearchUsers.length > 0; + const flyoutDisabled = + (!userFormUserIsExisting || !hasAvailableUsers) && !elasticsearchUser.username; + + const userAddedInfo = singleUserRoleMapping && ( + + ); + + const userInvitationCallout = singleUserRoleMapping?.invitation && ( + + ); + + const createUserForm = ( + 0} error={roleMappingErrors}> + + {showGroupAssignmentSelector && } + + ); + + return ( + + {userCreated ? userAddedInfo : createUserForm} + {userInvitationCallout} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts index 7d9f08627516b..dfb9765f834b6 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.test.ts @@ -11,6 +11,7 @@ import { registerEnableRoleMappingsRoute, registerRoleMappingsRoute, registerRoleMappingRoute, + registerUserRoute, } from './role_mappings'; const roleMappingBaseSchema = { @@ -160,4 +161,52 @@ describe('role mappings routes', () => { }); }); }); + + describe('POST /api/app_search/single_user_role_mapping', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/single_user_role_mapping', + }); + + registerUserRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { + body: { + roleMapping: { + engines: ['foo', 'bar'], + roleType: 'admin', + accessAllEngines: true, + id: '123asf', + }, + elasticsearchUser: { + username: 'user2@elastic.co', + email: 'user2', + }, + }, + }; + mockRouter.shouldValidate(request); + }); + + it('missing required fields', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/role_mappings/upsert_single_user_role_mapping', + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts index da620be2ea950..d90a005cb2532 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/role_mappings.ts @@ -93,8 +93,34 @@ export function registerRoleMappingRoute({ ); } +export function registerUserRoute({ router, enterpriseSearchRequestHandler }: RouteDependencies) { + router.post( + { + path: '/api/app_search/single_user_role_mapping', + validate: { + body: schema.object({ + roleMapping: schema.object({ + engines: schema.arrayOf(schema.string()), + roleType: schema.string(), + accessAllEngines: schema.boolean(), + id: schema.maybe(schema.string()), + }), + elasticsearchUser: schema.object({ + username: schema.string(), + email: schema.string(), + }), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/role_mappings/upsert_single_user_role_mapping', + }) + ); +} + export const registerRoleMappingsRoutes = (dependencies: RouteDependencies) => { registerEnableRoleMappingsRoute(dependencies); registerRoleMappingsRoute(dependencies); registerRoleMappingRoute(dependencies); + registerUserRoute(dependencies); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts index aa0e9983166c0..ef8f1bd63f5d3 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.test.ts @@ -11,6 +11,7 @@ import { registerOrgEnableRoleMappingsRoute, registerOrgRoleMappingsRoute, registerOrgRoleMappingRoute, + registerOrgUserRoute, } from './role_mappings'; describe('role mappings routes', () => { @@ -128,4 +129,52 @@ describe('role mappings routes', () => { }); }); }); + + describe('POST /api/workplace_search/org/single_user_role_mapping', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/org/single_user_role_mapping', + }); + + registerOrgUserRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { + body: { + roleMapping: { + groups: ['foo', 'bar'], + roleType: 'admin', + allGroups: true, + id: '123asf', + }, + elasticsearchUser: { + username: 'user2@elastic.co', + email: 'user2', + }, + }, + }; + mockRouter.shouldValidate(request); + }); + + it('missing required fields', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/role_mappings/upsert_single_user_role_mapping', + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts index cea7bcb311ce8..e6f4919ed2a2f 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/role_mappings.ts @@ -93,8 +93,37 @@ export function registerOrgRoleMappingRoute({ ); } +export function registerOrgUserRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.post( + { + path: '/api/workplace_search/org/single_user_role_mapping', + validate: { + body: schema.object({ + roleMapping: schema.object({ + groups: schema.arrayOf(schema.string()), + roleType: schema.string(), + allGroups: schema.boolean(), + id: schema.maybe(schema.string()), + }), + elasticsearchUser: schema.object({ + username: schema.string(), + email: schema.string(), + }), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/role_mappings/upsert_single_user_role_mapping', + }) + ); +} + export const registerRoleMappingsRoutes = (dependencies: RouteDependencies) => { registerOrgEnableRoleMappingsRoute(dependencies); registerOrgRoleMappingsRoute(dependencies); registerOrgRoleMappingRoute(dependencies); + registerOrgUserRoute(dependencies); };