diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_useeffect.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_useeffect.mock.ts index 732786b5f924..1e3a45a83853 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_useeffect.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_useeffect.mock.ts @@ -9,6 +9,10 @@ jest.mock('react', () => ({ useEffect: jest.fn((fn) => fn()), // Calls on mount/every update - use mount for more complex behavior })); +// Helper for calling the returned useEffect unmount handler +import { useEffect } from 'react'; +export const unmountHandler = () => (useEffect as jest.Mock).mock.calls[0][0]()(); + /** * Example usage within a component test using shallow(): * @@ -19,3 +23,14 @@ jest.mock('react', () => ({ * * // ... etc. */ +/** + * Example unmount() usage: + * + * import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock'; + * + * it('unmounts', () => { + * shallow(SomeComponent); + * unmountHandler(); + * // expect something to have been done on unmount (NOTE: the component is not actually unmounted) + * }); + */ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/constants.ts index 92d14f727518..374a2420f5ba 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/constants.ts @@ -5,37 +5,79 @@ */ import { i18n } from '@kbn/i18n'; -export const ADMIN = 'admin'; -export const PRIVATE = 'private'; -export const SEARCH = 'search'; +export enum ApiTokenTypes { + Admin = 'admin', + Private = 'private', + Search = 'search', +} -export const TOKEN_TYPE_DESCRIPTION = { - [SEARCH]: i18n.translate('xpack.enterpriseSearch.appSearch.tokens.search.description', { - defaultMessage: 'Public Search Keys are used for search endpoints only.', - }), - [PRIVATE]: i18n.translate('xpack.enterpriseSearch.appSearch.tokens.private.description', { - defaultMessage: - 'Private API Keys are used for read and/or write access on one or more Engines.', - }), - [ADMIN]: i18n.translate('xpack.enterpriseSearch.appSearch.tokens.admin.description', { - defaultMessage: 'Private Admin Keys are used to interact with the Credentials API.', - }), +export const SEARCH_DISPLAY = i18n.translate( + 'xpack.enterpriseSearch.appSearch.tokens.permissions.display.search', + { + defaultMessage: 'search', + } +); +export const ALL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.tokens.permissions.display.all', + { + defaultMessage: 'all', + } +); +export const READ_WRITE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.tokens.permissions.display.readwrite', + { + defaultMessage: 'read/write', + } +); +export const READ_ONLY = i18n.translate( + 'xpack.enterpriseSearch.appSearch.tokens.permissions.display.readonly', + { + defaultMessage: 'read-only', + } +); +export const WRITE_ONLY = i18n.translate( + 'xpack.enterpriseSearch.appSearch.tokens.permissions.display.writeonly', + { + defaultMessage: 'write-only', + } +); + +export const TOKEN_TYPE_DESCRIPTION: { [key: string]: string } = { + [ApiTokenTypes.Search]: i18n.translate( + 'xpack.enterpriseSearch.appSearch.tokens.search.description', + { + defaultMessage: 'Public Search Keys are used for search endpoints only.', + } + ), + [ApiTokenTypes.Private]: i18n.translate( + 'xpack.enterpriseSearch.appSearch.tokens.private.description', + { + defaultMessage: + 'Private API Keys are used for read and/or write access on one or more Engines.', + } + ), + [ApiTokenTypes.Admin]: i18n.translate( + 'xpack.enterpriseSearch.appSearch.tokens.admin.description', + { + defaultMessage: 'Private Admin Keys are used to interact with the Credentials API.', + } + ), }; -export const TOKEN_TYPE_DISPLAY_NAMES = { - [SEARCH]: i18n.translate('xpack.enterpriseSearch.appSearch.tokens.search.name', { +export const TOKEN_TYPE_DISPLAY_NAMES: { [key: string]: string } = { + [ApiTokenTypes.Search]: i18n.translate('xpack.enterpriseSearch.appSearch.tokens.search.name', { defaultMessage: 'Public Search Key', }), - [PRIVATE]: i18n.translate('xpack.enterpriseSearch.appSearch.tokens.private.name', { + [ApiTokenTypes.Private]: i18n.translate('xpack.enterpriseSearch.appSearch.tokens.private.name', { defaultMessage: 'Private API Key', }), - [ADMIN]: i18n.translate('xpack.enterpriseSearch.appSearch.tokens.admin.name', { + [ApiTokenTypes.Admin]: i18n.translate('xpack.enterpriseSearch.appSearch.tokens.admin.name', { defaultMessage: 'Private Admin Key', }), }; export const TOKEN_TYPE_INFO = [ - { value: SEARCH, text: TOKEN_TYPE_DISPLAY_NAMES[SEARCH] }, - { value: PRIVATE, text: TOKEN_TYPE_DISPLAY_NAMES[PRIVATE] }, - { value: ADMIN, text: TOKEN_TYPE_DISPLAY_NAMES[ADMIN] }, + { value: ApiTokenTypes.Search, text: TOKEN_TYPE_DISPLAY_NAMES[ApiTokenTypes.Search] }, + { value: ApiTokenTypes.Private, text: TOKEN_TYPE_DISPLAY_NAMES[ApiTokenTypes.Private] }, + { value: ApiTokenTypes.Admin, text: TOKEN_TYPE_DISPLAY_NAMES[ApiTokenTypes.Admin] }, ]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.test.tsx new file mode 100644 index 000000000000..7b24f6d20a58 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setMockValues, setMockActions } from '../../../__mocks__/kea.mock'; +import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Credentials } from './credentials'; +import { EuiCopy, EuiPageContentBody } from '@elastic/eui'; + +import { externalUrl } from '../../../shared/enterprise_search_url'; + +describe('Credentials', () => { + // Kea mocks + const values = { + dataLoading: false, + }; + const actions = { + initializeCredentialsData: jest.fn(), + resetCredentials: jest.fn(), + showCredentialsForm: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiPageContentBody)).toHaveLength(1); + }); + + it('initializes data on mount', () => { + shallow(); + expect(actions.initializeCredentialsData).toHaveBeenCalledTimes(1); + }); + + it('calls resetCredentials on unmount', () => { + shallow(); + unmountHandler(); + expect(actions.resetCredentials).toHaveBeenCalledTimes(1); + }); + + it('renders nothing if data is still loading', () => { + setMockValues({ dataLoading: true }); + const wrapper = shallow(); + expect(wrapper.find(EuiPageContentBody)).toHaveLength(0); + }); + + it('renders the API endpoint and a button to copy it', () => { + externalUrl.enterpriseSearchUrl = 'http://localhost:3002'; + const copyMock = jest.fn(); + const wrapper = shallow(); + // We wrap children in a div so that `shallow` can render it. + const copyEl = shallow(
{wrapper.find(EuiCopy).props().children(copyMock)}
); + expect(copyEl.find('EuiButtonIcon').props().onClick).toEqual(copyMock); + expect(copyEl.text().replace('', '')).toEqual('http://localhost:3002'); + }); + + it('will show the Crendentials Flyout when the Create API Key button is pressed', () => { + const wrapper = shallow(); + const button: any = wrapper.find('[data-test-subj="CreateAPIKeyButton"]'); + button.props().onClick(); + expect(actions.showCredentialsForm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx new file mode 100644 index 000000000000..ae95482e0f85 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; +import { useActions, useValues } from 'kea'; + +import { + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, + EuiPageContentBody, + EuiPanel, + EuiCopy, + EuiButtonIcon, + EuiSpacer, + EuiButton, + EuiPageContentHeader, + EuiPageContentHeaderSection, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { CredentialsLogic } from './credentials_logic'; +import { externalUrl } from '../../../shared/enterprise_search_url/external_url'; +import { CredentialsList } from './credentials_list'; + +export const Credentials: React.FC = () => { + const { initializeCredentialsData, resetCredentials, showCredentialsForm } = useActions( + CredentialsLogic + ); + + const { dataLoading } = useValues(CredentialsLogic); + + useEffect(() => { + initializeCredentialsData(); + return () => { + resetCredentials(); + }; + }, []); + + // TODO + // if (dataLoading) { return } + if (dataLoading) { + return null; + } + return ( + <> + + + + +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.title', { + defaultMessage: 'Credentials', + })} +

+
+
+
+ + + +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.apiEndpoint', { + defaultMessage: 'Endpoint', + })} +

+
+ + {(copy) => ( + <> + + {externalUrl.enterpriseSearchUrl} + + )} + +
+ + + + +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.apiKeys', { + defaultMessage: 'API Keys', + })} +

+
+
+ + showCredentialsForm()} + > + {i18n.translate('xpack.enterpriseSearch.appSearch.credentials.createKey', { + defaultMessage: 'Create a key', + })} + + +
+ + + + +
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx new file mode 100644 index 000000000000..7b7d89164662 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.test.tsx @@ -0,0 +1,243 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setMockValues, setMockActions } from '../../../../__mocks__/kea.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { CredentialsList } from './credentials_list'; +import { EuiBasicTable, EuiCopy } from '@elastic/eui'; +import { IApiToken } from '../types'; +import { ApiTokenTypes } from '../constants'; + +describe('Credentials', () => { + const apiToken: IApiToken = { + name: '', + type: ApiTokenTypes.Private, + read: true, + write: true, + access_all_engines: true, + key: 'abc-1234', + }; + + // Kea mocks + const values = { + apiTokens: [], + meta: { + page: { + current: 1, + size: 10, + total_pages: 1, + total_results: 1, + }, + }, + }; + const actions = { + deleteApiKey: jest.fn(), + fetchCredentials: jest.fn(), + showCredentialsForm: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiBasicTable)).toHaveLength(1); + }); + + describe('items', () => { + it('sorts items by id', () => { + setMockValues({ + ...values, + apiTokens: [ + { + ...apiToken, + id: 2, + }, + { + ...apiToken, + id: undefined, + }, + { + ...apiToken, + id: 1, + }, + ], + }); + const wrapper = shallow(); + const { items } = wrapper.find(EuiBasicTable).props(); + expect(items.map((i: IApiToken) => i.id)).toEqual([undefined, 1, 2]); + }); + }); + + describe('pagination', () => { + it('derives pagination from meta object', () => { + setMockValues({ + ...values, + meta: { + page: { + current: 6, + size: 55, + total_pages: 1, + total_results: 1004, + }, + }, + }); + const wrapper = shallow(); + const { pagination } = wrapper.find(EuiBasicTable).props(); + expect(pagination).toEqual({ + pageIndex: 5, + pageSize: 55, + totalItemCount: 1004, + hidePerPageOptions: true, + }); + }); + + it('will default pagination values if `page` is not available', () => { + setMockValues({ ...values, meta: {} }); + const wrapper = shallow(); + const { pagination } = wrapper.find(EuiBasicTable).props(); + expect(pagination).toEqual({ + pageIndex: 0, + pageSize: 0, + totalItemCount: 0, + hidePerPageOptions: true, + }); + }); + }); + + describe('columns', () => { + let columns: any[]; + + beforeAll(() => { + const wrapper = shallow(); + columns = wrapper.find(EuiBasicTable).props().columns; + }); + + describe('column 1 (name)', () => { + const token = { + ...apiToken, + name: 'some-name', + }; + + it('renders correctly', () => { + const column = columns[0]; + const wrapper = shallow(
{column.render(token)}
); + expect(wrapper.text()).toEqual('some-name'); + }); + }); + + describe('column 2 (type)', () => { + const token = { + ...apiToken, + type: ApiTokenTypes.Private, + }; + + it('renders correctly', () => { + const column = columns[1]; + const wrapper = shallow(
{column.render(token)}
); + expect(wrapper.text()).toEqual('Private API Key'); + }); + }); + + describe('column 3 (key)', () => { + const testToken = { + ...apiToken, + key: 'abc-123', + }; + + it('renders the credential and a button to copy it', () => { + const copyMock = jest.fn(); + const column = columns[2]; + const wrapper = shallow(
{column.render(testToken)}
); + const children = wrapper.find(EuiCopy).props().children; + const copyEl = shallow(
{children(copyMock)}
); + expect(copyEl.find('EuiButtonIcon').props().onClick).toEqual(copyMock); + expect(copyEl.text()).toContain('abc-123'); + }); + + it('renders nothing if no key is present', () => { + const tokenWithNoKey = { + key: undefined, + }; + const column = columns[2]; + const wrapper = shallow(
{column.render(tokenWithNoKey)}
); + expect(wrapper.text()).toBe(''); + }); + }); + + describe('column 4 (modes)', () => { + const token = { + ...apiToken, + type: ApiTokenTypes.Admin, + }; + + it('renders correctly', () => { + const column = columns[3]; + const wrapper = shallow(
{column.render(token)}
); + expect(wrapper.text()).toEqual('--'); + }); + }); + + describe('column 5 (engines)', () => { + const token = { + ...apiToken, + type: ApiTokenTypes.Private, + access_all_engines: true, + }; + + it('renders correctly', () => { + const column = columns[4]; + const wrapper = shallow(
{column.render(token)}
); + expect(wrapper.text()).toEqual('all'); + }); + }); + + describe('column 6 (edit action)', () => { + const token = apiToken; + + it('calls showCredentialsForm when clicked', () => { + const action = columns[5].actions[0]; + action.onClick(token); + expect(actions.showCredentialsForm).toHaveBeenCalledWith(token); + }); + }); + + describe('column 7 (delete action)', () => { + const token = { + ...apiToken, + name: 'some-name', + }; + + it('calls deleteApiKey when clicked', () => { + const action = columns[5].actions[1]; + action.onClick(token); + expect(actions.deleteApiKey).toHaveBeenCalledWith('some-name'); + }); + }); + }); + + describe('onChange', () => { + it('will handle pagination by calling `fetchCredentials`', () => { + const wrapper = shallow(); + const { onChange } = wrapper.find(EuiBasicTable).props(); + + onChange({ + page: { + size: 10, + index: 2, + }, + }); + + expect(actions.fetchCredentials).toHaveBeenCalledWith(3); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.tsx new file mode 100644 index 000000000000..065601feeb4d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/credentials_list.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { EuiBasicTable, EuiBasicTableColumn, EuiButtonIcon, EuiCopy } from '@elastic/eui'; +import { CriteriaWithPagination } from '@elastic/eui/src/components/basic_table/basic_table'; +import { useActions, useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { CredentialsLogic } from '../credentials_logic'; +import { IApiToken } from '../types'; +import { TOKEN_TYPE_DISPLAY_NAMES } from '../constants'; +import { apiTokenSort } from '../utils/api_token_sort'; +import { getModeDisplayText, getEnginesDisplayText } from '../utils'; + +export const CredentialsList: React.FC = () => { + const { deleteApiKey, fetchCredentials, showCredentialsForm } = useActions(CredentialsLogic); + + const { apiTokens, meta } = useValues(CredentialsLogic); + + const items = useMemo(() => apiTokens.slice().sort(apiTokenSort), [apiTokens]); + + const columns: Array> = [ + { + name: 'Name', + width: '12%', + render: (token: IApiToken) => token.name, + }, + { + name: 'Type', + width: '15%', + render: (token: IApiToken) => TOKEN_TYPE_DISPLAY_NAMES[token.type], + }, + { + name: 'Key', + width: '36%', + render: (token: IApiToken) => { + if (!token.key) return null; + return ( + + {(copy) => ( + <> + + {token.key} + + )} + + ); + }, + }, + { + name: 'Modes', + width: '10%', + render: (token: IApiToken) => getModeDisplayText(token), + }, + { + name: 'Engines', + width: '18%', + render: (token: IApiToken) => getEnginesDisplayText(token), + }, + { + actions: [ + { + name: i18n.translate('xpack.enterpriseSearch.actions.edit', { + defaultMessage: 'Edit', + }), + description: i18n.translate('xpack.enterpriseSearch.appSearch.credentials.editKey', { + defaultMessage: 'Edit API Key', + }), + type: 'icon', + icon: 'pencil', + color: 'primary', + onClick: (token: IApiToken) => showCredentialsForm(token), + }, + { + name: i18n.translate('xpack.enterpriseSearch.actions.delete', { + defaultMessage: 'Delete', + }), + description: i18n.translate('xpack.enterpriseSearch.appSearch.credentials.deleteKey', { + defaultMessage: 'Delete API Key', + }), + type: 'icon', + icon: 'trash', + color: 'danger', + onClick: (token: IApiToken) => deleteApiKey(token.name), + }, + ], + }, + ]; + + const pagination = { + pageIndex: meta.page ? meta.page.current - 1 : 0, + pageSize: meta.page ? meta.page.size : 0, + totalItemCount: meta.page ? meta.page.total_results : 0, + hidePerPageOptions: true, + }; + + const onTableChange = ({ page }: CriteriaWithPagination) => { + const { index: current } = page; + fetchCredentials(current + 1); + }; + + return ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/index.ts new file mode 100644 index 000000000000..5f254c1c716b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_list/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { CredentialsList } from './credentials_list'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts index 56fc825493b8..11b1253332cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts @@ -7,7 +7,7 @@ import { resetContext } from 'kea'; import { CredentialsLogic } from './credentials_logic'; -import { ADMIN, PRIVATE } from './constants'; +import { ApiTokenTypes } from './constants'; jest.mock('../../../shared/http', () => ({ HttpLogic: { values: { http: { get: jest.fn(), delete: jest.fn() } } }, @@ -22,7 +22,7 @@ describe('CredentialsLogic', () => { const DEFAULT_VALUES = { activeApiToken: { name: '', - type: PRIVATE, + type: ApiTokenTypes.Private, read: true, write: true, access_all_engines: true, @@ -62,7 +62,7 @@ describe('CredentialsLogic', () => { const newToken = { id: 1, name: 'myToken', - type: PRIVATE, + type: ApiTokenTypes.Private, read: true, write: true, access_all_engines: true, @@ -270,7 +270,7 @@ describe('CredentialsLogic', () => { describe('apiTokens', () => { const existingToken = { name: 'some_token', - type: PRIVATE, + type: ApiTokenTypes.Private, }; it('should add the provided token to the apiTokens list', () => { @@ -376,7 +376,7 @@ describe('CredentialsLogic', () => { describe('apiTokens', () => { const existingToken = { name: 'some_token', - type: PRIVATE, + type: ApiTokenTypes.Private, }; it('should replace the existing token with the new token by name', () => { @@ -385,7 +385,7 @@ describe('CredentialsLogic', () => { }); const updatedExistingToken = { ...existingToken, - type: ADMIN, + type: ApiTokenTypes.Admin, }; CredentialsLogic.actions.onApiTokenUpdateSuccess(updatedExistingToken); @@ -402,7 +402,7 @@ describe('CredentialsLogic', () => { }); const brandNewToken = { name: 'brand new token', - type: ADMIN, + type: ApiTokenTypes.Admin, }; CredentialsLogic.actions.onApiTokenUpdateSuccess(brandNewToken); @@ -419,7 +419,10 @@ describe('CredentialsLogic', () => { activeApiToken: newToken, }); - CredentialsLogic.actions.onApiTokenUpdateSuccess({ ...newToken, type: ADMIN }); + CredentialsLogic.actions.onApiTokenUpdateSuccess({ + ...newToken, + type: ApiTokenTypes.Admin, + }); expect(CredentialsLogic.values).toEqual({ ...values, activeApiToken: DEFAULT_VALUES.activeApiToken, @@ -433,7 +436,10 @@ describe('CredentialsLogic', () => { activeApiTokenRawName: 'foo', }); - CredentialsLogic.actions.onApiTokenUpdateSuccess({ ...newToken, type: ADMIN }); + CredentialsLogic.actions.onApiTokenUpdateSuccess({ + ...newToken, + type: ApiTokenTypes.Admin, + }); expect(CredentialsLogic.values).toEqual({ ...values, activeApiTokenRawName: DEFAULT_VALUES.activeApiTokenRawName, @@ -447,7 +453,10 @@ describe('CredentialsLogic', () => { shouldShowCredentialsForm: true, }); - CredentialsLogic.actions.onApiTokenUpdateSuccess({ ...newToken, type: ADMIN }); + CredentialsLogic.actions.onApiTokenUpdateSuccess({ + ...newToken, + type: ApiTokenTypes.Admin, + }); expect(CredentialsLogic.values).toEqual({ ...values, shouldShowCredentialsForm: false, @@ -650,7 +659,7 @@ describe('CredentialsLogic', () => { }; describe('activeApiToken.access_all_engines', () => { - describe('when value is ADMIN', () => { + describe('when value is admin', () => { it('updates access_all_engines to false', () => { mount({ activeApiToken: { @@ -659,7 +668,7 @@ describe('CredentialsLogic', () => { }, }); - CredentialsLogic.actions.setTokenType(ADMIN); + CredentialsLogic.actions.setTokenType(ApiTokenTypes.Admin); expect(CredentialsLogic.values).toEqual({ ...values, activeApiToken: { @@ -670,7 +679,7 @@ describe('CredentialsLogic', () => { }); }); - describe('when value is not ADMIN', () => { + describe('when value is not admin', () => { it('will maintain access_all_engines value when true', () => { mount({ activeApiToken: { @@ -679,7 +688,7 @@ describe('CredentialsLogic', () => { }, }); - CredentialsLogic.actions.setTokenType(PRIVATE); + CredentialsLogic.actions.setTokenType(ApiTokenTypes.Private); expect(CredentialsLogic.values).toEqual({ ...values, activeApiToken: { @@ -697,7 +706,7 @@ describe('CredentialsLogic', () => { }, }); - CredentialsLogic.actions.setTokenType(PRIVATE); + CredentialsLogic.actions.setTokenType(ApiTokenTypes.Private); expect(CredentialsLogic.values).toEqual({ ...values, activeApiToken: { @@ -710,7 +719,7 @@ describe('CredentialsLogic', () => { }); describe('activeApiToken.engines', () => { - describe('when value is ADMIN', () => { + describe('when value is admin', () => { it('clears the array', () => { mount({ activeApiToken: { @@ -719,7 +728,7 @@ describe('CredentialsLogic', () => { }, }); - CredentialsLogic.actions.setTokenType(ADMIN); + CredentialsLogic.actions.setTokenType(ApiTokenTypes.Admin); expect(CredentialsLogic.values).toEqual({ ...values, activeApiToken: { @@ -730,7 +739,7 @@ describe('CredentialsLogic', () => { }); }); - describe('when value is not ADMIN', () => { + describe('when value is not admin', () => { it('will maintain engines array', () => { mount({ activeApiToken: { @@ -739,7 +748,7 @@ describe('CredentialsLogic', () => { }, }); - CredentialsLogic.actions.setTokenType(PRIVATE); + CredentialsLogic.actions.setTokenType(ApiTokenTypes.Private); expect(CredentialsLogic.values).toEqual({ ...values, activeApiToken: { @@ -752,7 +761,7 @@ describe('CredentialsLogic', () => { }); describe('activeApiToken.write', () => { - describe('when value is PRIVATE', () => { + describe('when value is private', () => { it('sets this to true', () => { mount({ activeApiToken: { @@ -761,7 +770,7 @@ describe('CredentialsLogic', () => { }, }); - CredentialsLogic.actions.setTokenType(PRIVATE); + CredentialsLogic.actions.setTokenType(ApiTokenTypes.Private); expect(CredentialsLogic.values).toEqual({ ...values, activeApiToken: { @@ -772,7 +781,7 @@ describe('CredentialsLogic', () => { }); }); - describe('when value is not PRIVATE', () => { + describe('when value is not private', () => { it('sets this to false', () => { mount({ activeApiToken: { @@ -781,7 +790,7 @@ describe('CredentialsLogic', () => { }, }); - CredentialsLogic.actions.setTokenType(ADMIN); + CredentialsLogic.actions.setTokenType(ApiTokenTypes.Admin); expect(CredentialsLogic.values).toEqual({ ...values, activeApiToken: { @@ -794,7 +803,7 @@ describe('CredentialsLogic', () => { }); describe('activeApiToken.read', () => { - describe('when value is PRIVATE', () => { + describe('when value is private', () => { it('sets this to true', () => { mount({ activeApiToken: { @@ -803,7 +812,7 @@ describe('CredentialsLogic', () => { }, }); - CredentialsLogic.actions.setTokenType(PRIVATE); + CredentialsLogic.actions.setTokenType(ApiTokenTypes.Private); expect(CredentialsLogic.values).toEqual({ ...values, activeApiToken: { @@ -814,7 +823,7 @@ describe('CredentialsLogic', () => { }); }); - describe('when value is not PRIVATE', () => { + describe('when value is not private', () => { it('sets this to false', () => { mount({ activeApiToken: { @@ -823,7 +832,7 @@ describe('CredentialsLogic', () => { }, }); - CredentialsLogic.actions.setTokenType(ADMIN); + CredentialsLogic.actions.setTokenType(ApiTokenTypes.Admin); expect(CredentialsLogic.values).toEqual({ ...values, activeApiToken: { @@ -840,16 +849,16 @@ describe('CredentialsLogic', () => { mount({ activeApiToken: { ...newToken, - type: ADMIN, + type: ApiTokenTypes.Admin, }, }); - CredentialsLogic.actions.setTokenType(PRIVATE); + CredentialsLogic.actions.setTokenType(ApiTokenTypes.Private); expect(CredentialsLogic.values).toEqual({ ...values, activeApiToken: { ...values.activeApiToken, - type: PRIVATE, + type: ApiTokenTypes.Private, }, }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.ts index 41897b8edbc1..c6f929c45eb2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.ts @@ -7,7 +7,7 @@ import { kea, MakeLogicType } from 'kea'; import { formatApiName } from '../../utils/format_api_name'; -import { ADMIN, PRIVATE } from './constants'; +import { ApiTokenTypes } from './constants'; import { HttpLogic } from '../../../shared/http'; import { IMeta } from '../../../../../common/types'; @@ -17,7 +17,7 @@ import { IApiToken, ICredentialsDetails, ITokenReadWrite } from './types'; const defaultApiToken: IApiToken = { name: '', - type: PRIVATE, + type: ApiTokenTypes.Private, read: true, write: true, access_all_engines: true, @@ -164,11 +164,12 @@ export const CredentialsLogic = kea< }), setTokenType: (activeApiToken, tokenType) => ({ ...activeApiToken, - access_all_engines: tokenType === ADMIN ? false : activeApiToken.access_all_engines, - engines: tokenType === ADMIN ? [] : activeApiToken.engines, - write: tokenType === PRIVATE, - read: tokenType === PRIVATE, - type: tokenType, + access_all_engines: + tokenType === ApiTokenTypes.Admin ? false : activeApiToken.access_all_engines, + engines: tokenType === ApiTokenTypes.Admin ? [] : activeApiToken.engines, + write: tokenType === ApiTokenTypes.Private, + read: tokenType === ApiTokenTypes.Private, + type: tokenType as ApiTokenTypes, }), showCredentialsForm: (_, activeApiToken) => activeApiToken, }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/index.ts new file mode 100644 index 000000000000..bceda234175a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Credentials } from './credentials'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/types.ts index bbf7a54da10d..9ca4d086d55c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/types.ts @@ -5,6 +5,7 @@ */ import { IEngine } from '../../types'; +import { ApiTokenTypes } from './constants'; export interface ICredentialsDetails { engines: IEngine[]; @@ -17,7 +18,7 @@ export interface IApiToken { id?: number; name: string; read?: boolean; - type: string; + type: ApiTokenTypes; write?: boolean; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/api_token_sort.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/api_token_sort.test.ts new file mode 100644 index 000000000000..84818322b357 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/api_token_sort.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { apiTokenSort } from '.'; +import { ApiTokenTypes } from '../constants'; + +import { IApiToken } from '../types'; + +describe('apiTokenSort', () => { + const apiToken: IApiToken = { + name: '', + type: ApiTokenTypes.Private, + read: true, + write: true, + access_all_engines: true, + key: 'abc-1234', + }; + + it('sorts items by id', () => { + const apiTokens = [ + { + ...apiToken, + id: 2, + }, + { + ...apiToken, + id: undefined, + }, + { + ...apiToken, + id: 1, + }, + ]; + + expect(apiTokens.sort(apiTokenSort).map((t) => t.id)).toEqual([undefined, 1, 2]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/api_token_sort.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/api_token_sort.ts new file mode 100644 index 000000000000..80a46f30e093 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/api_token_sort.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IApiToken } from '../types'; + +export const apiTokenSort = (apiTokenA: IApiToken, apiTokenB: IApiToken): number => { + if (!apiTokenA.id) { + return -1; + } + if (!apiTokenB.id) { + return 1; + } + return apiTokenA.id - apiTokenB.id; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/get_engines_display_text.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/get_engines_display_text.test.tsx new file mode 100644 index 000000000000..b06ed63f8616 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/get_engines_display_text.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { getEnginesDisplayText } from './get_engines_display_text'; +import { IApiToken } from '../types'; +import { ApiTokenTypes } from '../constants'; + +const apiToken: IApiToken = { + name: '', + type: ApiTokenTypes.Private, + read: true, + write: true, + access_all_engines: true, + engines: ['engine1', 'engine2', 'engine3'], +}; + +describe('getEnginesDisplayText', () => { + it('returns "--" when the token is an admin token', () => { + const wrapper = shallow( +
{getEnginesDisplayText({ ...apiToken, type: ApiTokenTypes.Admin })}
+ ); + expect(wrapper.text()).toEqual('--'); + }); + + it('returns "all" when access_all_engines is true', () => { + const wrapper = shallow( +
{getEnginesDisplayText({ ...apiToken, access_all_engines: true })}
+ ); + expect(wrapper.text()).toEqual('all'); + }); + + it('returns a list of engines if access_all_engines is false', () => { + const wrapper = shallow( +
{getEnginesDisplayText({ ...apiToken, access_all_engines: false })}
+ ); + + expect(wrapper.find('li').map((e) => e.text())).toEqual(['engine1', 'engine2', 'engine3']); + }); + + it('returns "--" when the token is an admin token, even if access_all_engines is true', () => { + const wrapper = shallow( +
+ {getEnginesDisplayText({ + ...apiToken, + access_all_engines: true, + type: ApiTokenTypes.Admin, + })} +
+ ); + expect(wrapper.text()).toEqual('--'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/get_engines_display_text.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/get_engines_display_text.tsx new file mode 100644 index 000000000000..1b216c46307d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/get_engines_display_text.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ApiTokenTypes, ALL } from '../constants'; +import { IApiToken } from '../types'; + +export const getEnginesDisplayText = (apiToken: IApiToken): JSX.Element | string => { + const { type, access_all_engines: accessAll, engines = [] } = apiToken; + if (type === ApiTokenTypes.Admin) { + return '--'; + } + if (accessAll) { + return ALL; + } + return ( +
    + {engines.map((engine) => ( +
  • {engine}
  • + ))} +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/get_mode_display_text.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/get_mode_display_text.test.ts new file mode 100644 index 000000000000..b2083f22c8e1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/get_mode_display_text.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ApiTokenTypes } from '../constants'; +import { IApiToken } from '../types'; + +import { getModeDisplayText } from './get_mode_display_text'; + +const apiToken: IApiToken = { + name: '', + type: ApiTokenTypes.Private, + read: true, + write: true, + access_all_engines: true, + engines: ['engine1', 'engine2', 'engine3'], +}; + +describe('getModeDisplayText', () => { + it('will return read/write when read and write are enabled', () => { + expect(getModeDisplayText({ ...apiToken, read: true, write: true })).toEqual('read/write'); + }); + + it('will return read-only when only read is enabled', () => { + expect(getModeDisplayText({ ...apiToken, read: true, write: false })).toEqual('read-only'); + }); + + it('will return write-only when only write is enabled', () => { + expect(getModeDisplayText({ ...apiToken, read: false, write: true })).toEqual('write-only'); + }); + + it('will return "search" if the key is a search key, regardless of read/write state', () => { + expect( + getModeDisplayText({ ...apiToken, type: ApiTokenTypes.Search, read: false, write: true }) + ).toEqual('search'); + }); + + it('will return "--" if the key is an admin key, regardless of read/write state', () => { + expect( + getModeDisplayText({ ...apiToken, type: ApiTokenTypes.Admin, read: false, write: true }) + ).toEqual('--'); + }); + + it('will default read and write to false', () => { + expect( + getModeDisplayText({ + name: 'test', + type: ApiTokenTypes.Private, + }) + ).toEqual('read-only'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/get_mode_display_text.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/get_mode_display_text.ts new file mode 100644 index 000000000000..9c8758d83882 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/get_mode_display_text.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ApiTokenTypes, READ_ONLY, READ_WRITE, SEARCH_DISPLAY, WRITE_ONLY } from '../constants'; +import { IApiToken } from '../types'; + +export const getModeDisplayText = (apiToken: IApiToken): string => { + const { read = false, write = false, type } = apiToken; + + switch (type) { + case ApiTokenTypes.Admin: + return '--'; + case ApiTokenTypes.Search: + return SEARCH_DISPLAY; + default: + if (read && write) { + return READ_WRITE; + } + return write ? WRITE_ONLY : READ_ONLY; + } +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/index.ts new file mode 100644 index 000000000000..9aca7f44be03 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/utils/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { apiTokenSort } from './api_token_sort'; +export { getEnginesDisplayText } from './get_engines_display_text'; +export { getModeDisplayText } from './get_mode_display_text'; 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 ab5b3c9faeea..546ea311ad33 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 @@ -121,9 +121,7 @@ describe('AppSearchNav', () => { setMockValues({ myRole: { canViewAccountCredentials: true } }); const wrapper = shallow(); - expect(wrapper.find(SideNavLink).last().prop('to')).toEqual( - 'http://localhost:3002/as/credentials' - ); + expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('/credentials'); }); it('renders the Role Mappings link', () => { 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 9aa2cce9c74d..ec5f5b164a7f 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 @@ -32,6 +32,7 @@ import { SetupGuide } from './components/setup_guide'; import { ErrorConnecting } from './components/error_connecting'; import { NotFound } from '../shared/not_found'; import { EngineOverview } from './components/engine_overview'; +import { Credentials } from './components/credentials'; export const AppSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); @@ -75,6 +76,9 @@ export const AppSearchConfigured: React.FC = (props) => { + + + @@ -106,7 +110,7 @@ export const AppSearchNav: React.FC = () => { )} {canViewAccountCredentials && ( - + {i18n.translate('xpack.enterpriseSearch.appSearch.nav.credentials', { defaultMessage: 'Credentials', })}