[Workplace Search] Add API Keys view to replace Access tokens (#120147) (#120565)

# Conflicts:
#	docs/development/core/public/kibana-plugin-core-public.doclinksstart.md
This commit is contained in:
Scotty Bollinger 2021-12-06 22:14:22 -06:00 committed by GitHub
parent a398d467b8
commit a07e827855
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 2001 additions and 60 deletions

View file

@ -90,6 +90,7 @@ readonly links: {
readonly usersAccess: string;
};
readonly workplaceSearch: {
readonly apiKeys: string;
readonly box: string;
readonly confluenceCloud: string;
readonly confluenceServer: string;

File diff suppressed because one or more lines are too long

View file

@ -113,6 +113,7 @@ export class DocLinksService {
usersAccess: `${ENTERPRISE_SEARCH_DOCS}users-access.html`,
},
workplaceSearch: {
apiKeys: `${WORKPLACE_SEARCH_DOCS}workplace-search-api-authentication.html`,
box: `${WORKPLACE_SEARCH_DOCS}workplace-search-box-connector.html`,
confluenceCloud: `${WORKPLACE_SEARCH_DOCS}workplace-search-confluence-cloud-connector.html`,
confluenceServer: `${WORKPLACE_SEARCH_DOCS}workplace-search-confluence-server-connector.html`,
@ -673,6 +674,7 @@ export interface DocLinksStart {
readonly usersAccess: string;
};
readonly workplaceSearch: {
readonly apiKeys: string;
readonly box: string;
readonly confluenceCloud: string;
readonly confluenceServer: string;

View file

@ -561,6 +561,7 @@ export interface DocLinksStart {
readonly usersAccess: string;
};
readonly workplaceSearch: {
readonly apiKeys: string;
readonly box: string;
readonly confluenceCloud: string;
readonly confluenceServer: string;

View file

@ -36,6 +36,7 @@ class DocLinks {
public enterpriseSearchMailService: string;
public enterpriseSearchUsersAccess: string;
public licenseManagement: string;
public workplaceSearchApiKeys: string;
public workplaceSearchBox: string;
public workplaceSearchConfluenceCloud: string;
public workplaceSearchConfluenceServer: string;
@ -92,6 +93,7 @@ class DocLinks {
this.enterpriseSearchMailService = '';
this.enterpriseSearchUsersAccess = '';
this.licenseManagement = '';
this.workplaceSearchApiKeys = '';
this.workplaceSearchBox = '';
this.workplaceSearchConfluenceCloud = '';
this.workplaceSearchConfluenceServer = '';
@ -149,6 +151,7 @@ class DocLinks {
this.enterpriseSearchMailService = docLinks.links.enterpriseSearch.mailService;
this.enterpriseSearchUsersAccess = docLinks.links.enterpriseSearch.usersAccess;
this.licenseManagement = docLinks.links.enterpriseSearch.licenseManagement;
this.workplaceSearchApiKeys = docLinks.links.workplaceSearch.apiKeys;
this.workplaceSearchBox = docLinks.links.workplaceSearch.box;
this.workplaceSearchConfluenceCloud = docLinks.links.workplaceSearch.confluenceCloud;
this.workplaceSearchConfluenceServer = docLinks.links.workplaceSearch.confluenceServer;

View file

@ -49,6 +49,11 @@ describe('useWorkplaceSearchNav', () => {
name: 'Users and roles',
href: '/users_and_roles',
},
{
id: 'apiKeys',
name: 'API keys',
href: '/api_keys',
},
{
id: 'security',
name: 'Security',

View file

@ -10,6 +10,7 @@ import { EuiSideNavItemType } from '@elastic/eui';
import { generateNavLink } from '../../../shared/layout';
import { NAV } from '../../constants';
import {
API_KEYS_PATH,
SOURCES_PATH,
SECURITY_PATH,
USERS_AND_ROLES_PATH,
@ -47,6 +48,11 @@ export const useWorkplaceSearchNav = () => {
name: NAV.ROLE_MAPPINGS,
...generateNavLink({ to: USERS_AND_ROLES_PATH }),
},
{
id: 'apiKeys',
name: NAV.API_KEYS,
...generateNavLink({ to: API_KEYS_PATH }),
},
{
id: 'security',
name: NAV.SECURITY,

View file

@ -42,6 +42,9 @@ export const NAV = {
ROLE_MAPPINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.roleMappings', {
defaultMessage: 'Users and roles',
}),
API_KEYS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.apiKeys', {
defaultMessage: 'API keys',
}),
SECURITY: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.security', {
defaultMessage: 'Security',
}),
@ -329,6 +332,20 @@ export const SOURCE_OBJ_TYPES = {
),
};
export const API_KEYS_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.apiKeysTitle',
{
defaultMessage: 'API keys',
}
);
export const API_KEY_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.apiKeyLabel',
{
defaultMessage: 'API key',
}
);
export const GITHUB_LINK_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.applicationLinkTitles.github',
{
@ -866,3 +883,14 @@ export const PLATINUM_FEATURE = i18n.translate(
defaultMessage: 'Platinum feature',
}
);
export const COPY_TOOLTIP = i18n.translate('xpack.enterpriseSearch.workplaceSearch.copy.tooltip', {
defaultMessage: 'Copy to clipboard',
});
export const COPIED_TOOLTIP = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.copied.tooltip',
{
defaultMessage: 'Copied!',
}
);

View file

@ -28,11 +28,13 @@ import {
PRIVATE_SOURCES_PATH,
ORG_SETTINGS_PATH,
USERS_AND_ROLES_PATH,
API_KEYS_PATH,
SECURITY_PATH,
PERSONAL_SETTINGS_PATH,
PERSONAL_PATH,
} from './routes';
import { AccountSettings } from './views/account_settings';
import { ApiKeys } from './views/api_keys';
import { SourcesRouter } from './views/content_sources';
import { SourceAdded } from './views/content_sources/components/source_added';
import { ErrorState } from './views/error_state';
@ -133,6 +135,9 @@ export const WorkplaceSearchConfigured: React.FC<InitialAppData> = (props) => {
<Route path={USERS_AND_ROLES_PATH}>
<RoleMappings />
</Route>
<Route path={API_KEYS_PATH}>
<ApiKeys />
</Route>
<Route path={SECURITY_PATH}>
<Security />
</Route>

View file

@ -60,6 +60,8 @@ export const SEARCH_AUTHORIZE_PATH = `${PERSONAL_PATH}/authorize_search`;
export const USERS_AND_ROLES_PATH = '/users_and_roles';
export const API_KEYS_PATH = '/api_keys';
export const SECURITY_PATH = '/security';
export const GROUPS_PATH = '/groups';

View file

@ -302,3 +302,9 @@ export interface WSRoleMapping extends RoleMapping {
allGroups: boolean;
groups: RoleGroup[];
}
export interface ApiToken {
key?: string;
id?: string;
name: string;
}

View file

@ -0,0 +1,102 @@
/*
* 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 { setMockValues, setMockActions } from '../../../__mocks__/kea_logic';
import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiEmptyPrompt, EuiCopy } from '@elastic/eui';
import { DEFAULT_META } from '../../../shared/constants';
import { externalUrl } from '../../../shared/enterprise_search_url';
import { ApiKeys } from './api_keys';
import { ApiKeyFlyout } from './components/api_key_flyout';
import { ApiKeysList } from './components/api_keys_list';
describe('ApiKeys', () => {
const fetchApiKeys = jest.fn();
const resetApiKeys = jest.fn();
const showApiKeysForm = jest.fn();
const apiToken = {
id: '1',
name: 'test',
key: 'foo',
};
const values = {
apiKeyFormVisible: false,
meta: DEFAULT_META,
dataLoading: false,
apiTokens: [apiToken],
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions({
fetchApiKeys,
resetApiKeys,
showApiKeysForm,
});
});
it('renders', () => {
const wrapper = shallow(<ApiKeys />);
expect(wrapper.find(ApiKeysList)).toHaveLength(1);
});
it('renders EuiEmptyPrompt when no api keys present', () => {
setMockValues({ ...values, apiTokens: [] });
const wrapper = shallow(<ApiKeys />);
expect(wrapper.find(ApiKeysList)).toHaveLength(0);
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
});
it('fetches data on mount', () => {
shallow(<ApiKeys />);
expect(fetchApiKeys).toHaveBeenCalledTimes(1);
});
it('calls resetApiKeys on unmount', () => {
shallow(<ApiKeys />);
unmountHandler();
expect(resetApiKeys).toHaveBeenCalledTimes(1);
});
it('renders the API endpoint and a button to copy it', () => {
externalUrl.enterpriseSearchUrl = 'http://localhost:3002';
const copyMock = jest.fn();
const wrapper = shallow(<ApiKeys />);
// We wrap children in a div so that `shallow` can render it.
const copyEl = shallow(<div>{wrapper.find(EuiCopy).props().children(copyMock)}</div>);
expect(copyEl.find('EuiButtonIcon').props().onClick).toEqual(copyMock);
expect(copyEl.text().replace('<EuiButtonIcon />', '')).toEqual('http://localhost:3002');
});
it('will render ApiKeyFlyout if apiKeyFormVisible is true', () => {
setMockValues({ ...values, apiKeyFormVisible: true });
const wrapper = shallow(<ApiKeys />);
expect(wrapper.find(ApiKeyFlyout)).toHaveLength(1);
});
it('will NOT render ApiKeyFlyout if apiKeyFormVisible is false', () => {
setMockValues({ ...values, apiKeyFormVisible: false });
const wrapper = shallow(<ApiKeys />);
expect(wrapper.find(ApiKeyFlyout)).toHaveLength(0);
});
});

View file

@ -0,0 +1,109 @@
/*
* 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, { useEffect } from 'react';
import { useActions, useValues } from 'kea';
import {
EuiButton,
EuiTitle,
EuiPanel,
EuiCopy,
EuiButtonIcon,
EuiSpacer,
EuiEmptyPrompt,
} from '@elastic/eui';
import { docLinks } from '../../../shared/doc_links';
import { externalUrl } from '../../../shared/enterprise_search_url/external_url';
import { WorkplaceSearchPageTemplate } from '../../components/layout';
import { NAV, API_KEYS_TITLE } from '../../constants';
import { ApiKeysLogic } from './api_keys_logic';
import { ApiKeyFlyout } from './components/api_key_flyout';
import { ApiKeysList } from './components/api_keys_list';
import {
API_KEYS_EMPTY_TITLE,
API_KEYS_EMPTY_BODY,
API_KEYS_EMPTY_BUTTON_LABEL,
CREATE_KEY_BUTTON_LABEL,
ENDPOINT_TITLE,
COPIED_TOOLTIP,
COPY_API_ENDPOINT_BUTTON_LABEL,
} from './constants';
export const ApiKeys: React.FC = () => {
const { fetchApiKeys, resetApiKeys, showApiKeyForm } = useActions(ApiKeysLogic);
const { meta, dataLoading, apiKeyFormVisible, apiTokens } = useValues(ApiKeysLogic);
useEffect(() => {
fetchApiKeys();
return resetApiKeys;
}, [meta.page.current]);
const hasApiKeys = apiTokens.length > 0;
const addKeyButton = (
<EuiButton fill onClick={showApiKeyForm}>
{CREATE_KEY_BUTTON_LABEL}
</EuiButton>
);
const emptyPrompt = (
<EuiEmptyPrompt
iconType="editorStrike"
title={<h2>{API_KEYS_EMPTY_TITLE}</h2>}
body={API_KEYS_EMPTY_BODY}
actions={
<EuiButton
size="s"
target="_blank"
iconType="popout"
href={docLinks.workplaceSearchApiKeys}
>
{API_KEYS_EMPTY_BUTTON_LABEL}
</EuiButton>
}
/>
);
return (
<WorkplaceSearchPageTemplate
pageChrome={[NAV.API_KEYS]}
pageHeader={{
pageTitle: API_KEYS_TITLE,
rightSideItems: [addKeyButton],
}}
isLoading={dataLoading}
emptyState={!hasApiKeys && emptyPrompt}
>
{apiKeyFormVisible && <ApiKeyFlyout />}
<EuiPanel color="subdued" className="eui-textCenter">
<EuiTitle size="s">
<h2>{ENDPOINT_TITLE}</h2>
</EuiTitle>
<EuiCopy textToCopy={externalUrl.enterpriseSearchUrl} afterMessage={COPIED_TOOLTIP}>
{(copy) => (
<>
<EuiButtonIcon
onClick={copy}
iconType="copyClipboard"
aria-label={COPY_API_ENDPOINT_BUTTON_LABEL}
/>
{externalUrl.enterpriseSearchUrl}
</>
)}
</EuiCopy>
</EuiPanel>
<EuiSpacer size="xxl" />
<EuiPanel hasBorder>{hasApiKeys ? <ApiKeysList /> : emptyPrompt}</EuiPanel>
</WorkplaceSearchPageTemplate>
);
};

View file

@ -0,0 +1,491 @@
/*
* 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 {
LogicMounter,
mockFlashMessageHelpers,
mockHttpValues,
} from '../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test/jest';
import { DEFAULT_META } from '../../../shared/constants';
import { itShowsServerErrorAsFlashMessage } from '../../../test_helpers';
import { ApiKeysLogic } from './api_keys_logic';
describe('ApiKeysLogic', () => {
const { mount } = new LogicMounter(ApiKeysLogic);
const { http } = mockHttpValues;
const { clearFlashMessages, flashSuccessToast } = mockFlashMessageHelpers;
const DEFAULT_VALUES = {
dataLoading: true,
apiTokens: [],
meta: DEFAULT_META,
nameInputBlurred: false,
activeApiToken: {
name: '',
},
activeApiTokenRawName: '',
apiKeyFormVisible: false,
apiTokenNameToDelete: '',
deleteModalVisible: false,
formErrors: [],
};
const newToken = {
id: '1',
name: 'myToken',
};
beforeEach(() => {
jest.clearAllMocks();
});
it('has expected default values', () => {
mount();
expect(ApiKeysLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('onApiTokenCreateSuccess', () => {
const values = {
...DEFAULT_VALUES,
apiTokens: expect.any(Array),
activeApiToken: expect.any(Object),
activeApiTokenRawName: expect.any(String),
apiKeyFormVisible: expect.any(Boolean),
formErrors: expect.any(Array),
};
describe('apiTokens', () => {
const existingToken = {
name: 'some_token',
};
it('should add the provided token to the apiTokens list', () => {
mount({
apiTokens: [existingToken],
});
ApiKeysLogic.actions.onApiTokenCreateSuccess(newToken);
expect(ApiKeysLogic.values).toEqual({
...values,
apiTokens: [existingToken, newToken],
});
});
});
describe('activeApiToken', () => {
it('should reset to the default value, which effectively clears out the current form', () => {
mount({
activeApiToken: newToken,
});
ApiKeysLogic.actions.onApiTokenCreateSuccess(newToken);
expect(ApiKeysLogic.values).toEqual({
...values,
activeApiToken: DEFAULT_VALUES.activeApiToken,
});
});
});
describe('activeApiTokenRawName', () => {
it('should reset to the default value, which effectively clears out the current form', () => {
mount({
activeApiTokenRawName: 'foo',
});
ApiKeysLogic.actions.onApiTokenCreateSuccess(newToken);
expect(ApiKeysLogic.values).toEqual({
...values,
activeApiTokenRawName: DEFAULT_VALUES.activeApiTokenRawName,
});
});
});
describe('apiKeyFormVisible', () => {
it('should reset to the default value, which closes the api key form', () => {
mount({
apiKeyFormVisible: true,
});
ApiKeysLogic.actions.onApiTokenCreateSuccess(newToken);
expect(ApiKeysLogic.values).toEqual({
...values,
apiKeyFormVisible: false,
});
});
});
describe('deleteModalVisible', () => {
const tokenName = 'my-token';
it('should set deleteModalVisible to true and set apiTokenNameToDelete', () => {
ApiKeysLogic.actions.stageTokenNameForDeletion(tokenName);
expect(ApiKeysLogic.values).toEqual({
...values,
deleteModalVisible: true,
apiTokenNameToDelete: tokenName,
});
});
it('should set deleteModalVisible to false and reset apiTokenNameToDelete', () => {
mount({
deleteModalVisible: true,
apiTokenNameToDelete: tokenName,
});
ApiKeysLogic.actions.hideDeleteModal();
expect(ApiKeysLogic.values).toEqual(values);
});
});
describe('formErrors', () => {
it('should reset `formErrors`', () => {
mount({
formErrors: ['I am an error'],
});
ApiKeysLogic.actions.onApiTokenCreateSuccess(newToken);
expect(ApiKeysLogic.values).toEqual({
...values,
formErrors: [],
});
});
});
});
describe('onApiTokenError', () => {
const values = {
...DEFAULT_VALUES,
formErrors: expect.any(Array),
};
describe('formErrors', () => {
it('should set `formErrors`', () => {
mount({
formErrors: ['I am an error'],
});
ApiKeysLogic.actions.onApiTokenError(['I am the NEW error']);
expect(ApiKeysLogic.values).toEqual({
...values,
formErrors: ['I am the NEW error'],
});
});
});
});
describe('setApiKeysData', () => {
const meta = {
page: {
current: 1,
size: 1,
total_pages: 1,
total_results: 1,
},
};
const values = {
...DEFAULT_VALUES,
dataLoading: false,
apiTokens: expect.any(Array),
meta: expect.any(Object),
};
describe('apiTokens', () => {
it('should be set', () => {
mount();
ApiKeysLogic.actions.setApiKeysData(meta, [newToken, newToken]);
expect(ApiKeysLogic.values).toEqual({
...values,
apiTokens: [newToken, newToken],
});
});
});
describe('meta', () => {
it('should be set', () => {
mount();
ApiKeysLogic.actions.setApiKeysData(meta, [newToken, newToken]);
expect(ApiKeysLogic.values).toEqual({
...values,
meta,
});
});
});
});
describe('setNameInputBlurred', () => {
const values = {
...DEFAULT_VALUES,
nameInputBlurred: expect.any(Boolean),
};
describe('nameInputBlurred', () => {
it('should set this value', () => {
mount({
nameInputBlurred: false,
});
ApiKeysLogic.actions.setNameInputBlurred(true);
expect(ApiKeysLogic.values).toEqual({
...values,
nameInputBlurred: true,
});
});
});
});
describe('setApiKeyName', () => {
const values = {
...DEFAULT_VALUES,
activeApiToken: expect.any(Object),
activeApiTokenRawName: expect.any(String),
};
describe('activeApiToken', () => {
it('update the name property on the activeApiToken, formatted correctly', () => {
mount({
activeApiToken: {
...newToken,
name: 'bar',
},
});
ApiKeysLogic.actions.setApiKeyName('New Name');
expect(ApiKeysLogic.values).toEqual({
...values,
activeApiToken: { ...newToken, name: 'new-name' },
});
});
});
describe('activeApiTokenRawName', () => {
it('updates the raw name, with no formatting applied', () => {
mount();
ApiKeysLogic.actions.setApiKeyName('New Name');
expect(ApiKeysLogic.values).toEqual({
...values,
activeApiTokenRawName: 'New Name',
});
});
});
});
describe('showApiKeyForm', () => {
const values = {
...DEFAULT_VALUES,
activeApiToken: expect.any(Object),
activeApiTokenRawName: expect.any(String),
formErrors: expect.any(Array),
apiKeyFormVisible: expect.any(Boolean),
};
describe('apiKeyFormVisible', () => {
it('should toggle `apiKeyFormVisible`', () => {
mount({
apiKeyFormVisible: false,
});
ApiKeysLogic.actions.showApiKeyForm();
expect(ApiKeysLogic.values).toEqual({
...values,
apiKeyFormVisible: true,
});
});
});
describe('formErrors', () => {
it('should reset `formErrors`', () => {
mount({
formErrors: ['I am an error'],
});
ApiKeysLogic.actions.showApiKeyForm();
expect(ApiKeysLogic.values).toEqual({
...values,
formErrors: [],
});
});
});
describe('listener side-effects', () => {
it('should clear flashMessages whenever the api key form flyout is opened', () => {
ApiKeysLogic.actions.showApiKeyForm();
expect(clearFlashMessages).toHaveBeenCalled();
});
});
});
describe('hideApiKeyForm', () => {
const values = {
...DEFAULT_VALUES,
apiKeyFormVisible: expect.any(Boolean),
activeApiTokenRawName: expect.any(String),
};
describe('activeApiTokenRawName', () => {
it('resets this value', () => {
mount({
activeApiTokenRawName: 'foo',
});
ApiKeysLogic.actions.hideApiKeyForm();
expect(ApiKeysLogic.values).toEqual({
...values,
activeApiTokenRawName: '',
});
});
});
describe('apiKeyFormVisible', () => {
it('resets this value', () => {
mount({
apiKeyFormVisible: true,
});
ApiKeysLogic.actions.hideApiKeyForm();
expect(ApiKeysLogic.values).toEqual({
...values,
apiKeyFormVisible: false,
});
});
});
});
describe('resetApiKeys', () => {
const values = {
...DEFAULT_VALUES,
formErrors: expect.any(Array),
};
describe('formErrors', () => {
it('should reset', () => {
mount({
formErrors: ['I am an error'],
});
ApiKeysLogic.actions.resetApiKeys();
expect(ApiKeysLogic.values).toEqual({
...values,
formErrors: [],
});
});
});
});
describe('onPaginate', () => {
it('should set meta.page.current', () => {
mount({ meta: DEFAULT_META });
ApiKeysLogic.actions.onPaginate(5);
expect(ApiKeysLogic.values).toEqual({
...DEFAULT_VALUES,
meta: {
page: {
...DEFAULT_META.page,
current: 5,
},
},
});
});
});
});
describe('listeners', () => {
describe('fetchApiKeys', () => {
const meta = {
page: {
current: 1,
size: 1,
total_pages: 1,
total_results: 1,
},
};
const results: object[] = [];
it('will call an API endpoint and set the results with the `setApiKeysData` action', async () => {
mount();
jest.spyOn(ApiKeysLogic.actions, 'setApiKeysData').mockImplementationOnce(() => {});
http.get.mockReturnValue(Promise.resolve({ meta, results }));
ApiKeysLogic.actions.fetchApiKeys();
expect(http.get).toHaveBeenCalledWith('/internal/workplace_search/api_keys', {
query: {
'page[current]': 1,
'page[size]': 10,
},
});
await nextTick();
expect(ApiKeysLogic.actions.setApiKeysData).toHaveBeenCalledWith(meta, results);
});
itShowsServerErrorAsFlashMessage(http.get, () => {
mount();
ApiKeysLogic.actions.fetchApiKeys();
});
});
describe('deleteApiKey', () => {
const tokenName = 'abc123';
it('will call an API endpoint and re-fetch the api keys list', async () => {
mount();
jest.spyOn(ApiKeysLogic.actions, 'fetchApiKeys').mockImplementationOnce(() => {});
http.delete.mockReturnValue(Promise.resolve());
ApiKeysLogic.actions.stageTokenNameForDeletion(tokenName);
ApiKeysLogic.actions.deleteApiKey();
expect(http.delete).toHaveBeenCalledWith(
`/internal/workplace_search/api_keys/${tokenName}`
);
await nextTick();
expect(ApiKeysLogic.actions.fetchApiKeys).toHaveBeenCalled();
expect(flashSuccessToast).toHaveBeenCalled();
});
itShowsServerErrorAsFlashMessage(http.delete, () => {
mount();
ApiKeysLogic.actions.deleteApiKey();
});
});
describe('onApiFormSubmit', () => {
it('calls a POST API endpoint that creates a new token if the active token does not exist yet', async () => {
const createdToken = {
name: 'new-key',
};
mount({
activeApiToken: createdToken,
});
jest.spyOn(ApiKeysLogic.actions, 'onApiTokenCreateSuccess');
http.post.mockReturnValue(Promise.resolve(createdToken));
ApiKeysLogic.actions.onApiFormSubmit();
expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/api_keys', {
body: JSON.stringify(createdToken),
});
await nextTick();
expect(ApiKeysLogic.actions.onApiTokenCreateSuccess).toHaveBeenCalledWith(createdToken);
expect(flashSuccessToast).toHaveBeenCalled();
});
itShowsServerErrorAsFlashMessage(http.post, () => {
mount();
ApiKeysLogic.actions.onApiFormSubmit();
});
});
});
});

View file

@ -0,0 +1,213 @@
/*
* 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 { kea, MakeLogicType } from 'kea';
import { Meta } from '../../../../../common/types';
import { DEFAULT_META } from '../../../shared/constants';
import {
clearFlashMessages,
flashSuccessToast,
flashAPIErrors,
} from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
import { updateMetaPageIndex } from '../../../shared/table_pagination';
import { ApiToken } from '../../types';
import { CREATE_MESSAGE, DELETE_MESSAGE } from './constants';
const formatApiName = (rawName: string): string =>
rawName
.trim()
.replace(/[^a-zA-Z0-9]+/g, '-') // Replace all special/non-alphanumerical characters with dashes
.replace(/^[-]+|[-]+$/g, '') // Strip all leading and trailing dashes
.toLowerCase();
export const defaultApiToken: ApiToken = {
name: '',
};
interface ApiKeysLogicActions {
onApiTokenCreateSuccess(apiToken: ApiToken): ApiToken;
onApiTokenError(formErrors: string[]): string[];
setApiKeysData(meta: Meta, apiTokens: ApiToken[]): { meta: Meta; apiTokens: ApiToken[] };
setNameInputBlurred(isBlurred: boolean): boolean;
setApiKeyName(name: string): string;
showApiKeyForm(): void;
hideApiKeyForm(): { value: boolean };
resetApiKeys(): { value: boolean };
fetchApiKeys(): void;
onPaginate(newPageIndex: number): { newPageIndex: number };
deleteApiKey(): void;
onApiFormSubmit(): void;
stageTokenNameForDeletion(tokenName: string): string;
hideDeleteModal(): void;
}
interface ApiKeysLogicValues {
activeApiToken: ApiToken;
activeApiTokenRawName: string;
apiTokens: ApiToken[];
dataLoading: boolean;
formErrors: string[];
meta: Meta;
nameInputBlurred: boolean;
apiKeyFormVisible: boolean;
deleteModalVisible: boolean;
apiTokenNameToDelete: string;
}
export const ApiKeysLogic = kea<MakeLogicType<ApiKeysLogicValues, ApiKeysLogicActions>>({
path: ['enterprise_search', 'workplace_search', 'api_keys_logic'],
actions: () => ({
onApiTokenCreateSuccess: (apiToken) => apiToken,
onApiTokenError: (formErrors) => formErrors,
setApiKeysData: (meta, apiTokens) => ({ meta, apiTokens }),
setNameInputBlurred: (nameInputBlurred) => nameInputBlurred,
setApiKeyName: (name) => name,
showApiKeyForm: true,
hideApiKeyForm: false,
resetApiKeys: false,
fetchApiKeys: true,
onPaginate: (newPageIndex) => ({ newPageIndex }),
deleteApiKey: true,
stageTokenNameForDeletion: (tokenName) => tokenName,
hideDeleteModal: true,
onApiFormSubmit: () => null,
}),
reducers: () => ({
dataLoading: [
true,
{
setApiKeysData: () => false,
},
],
apiTokens: [
[],
{
setApiKeysData: (_, { apiTokens }) => apiTokens,
onApiTokenCreateSuccess: (apiTokens, apiToken) => [...apiTokens, apiToken],
},
],
meta: [
DEFAULT_META,
{
setApiKeysData: (_, { meta }) => meta,
onPaginate: (state, { newPageIndex }) => updateMetaPageIndex(state, newPageIndex),
},
],
nameInputBlurred: [
false,
{
setNameInputBlurred: (_, nameInputBlurred) => nameInputBlurred,
},
],
activeApiToken: [
defaultApiToken,
{
onApiTokenCreateSuccess: () => defaultApiToken,
hideApiKeyForm: () => defaultApiToken,
setApiKeyName: (activeApiToken, name) => ({ ...activeApiToken, name: formatApiName(name) }),
},
],
activeApiTokenRawName: [
'',
{
setApiKeyName: (_, activeApiTokenRawName) => activeApiTokenRawName,
hideApiKeyForm: () => '',
onApiTokenCreateSuccess: () => '',
},
],
apiKeyFormVisible: [
false,
{
showApiKeyForm: () => true,
hideApiKeyForm: () => false,
onApiTokenCreateSuccess: () => false,
},
],
deleteModalVisible: [
false,
{
stageTokenNameForDeletion: () => true,
hideDeleteModal: () => false,
},
],
apiTokenNameToDelete: [
'',
{
stageTokenNameForDeletion: (_, tokenName) => tokenName,
hideDeleteModal: () => '',
},
],
formErrors: [
[],
{
onApiTokenError: (_, formErrors) => formErrors,
onApiTokenCreateSuccess: () => [],
showApiKeyForm: () => [],
resetApiKeys: () => [],
},
],
}),
listeners: ({ actions, values }) => ({
showApiKeyForm: () => {
clearFlashMessages();
},
fetchApiKeys: async () => {
try {
const { http } = HttpLogic.values;
const { meta } = values;
const query = {
'page[current]': meta.page.current,
'page[size]': meta.page.size,
};
const response = await http.get<{ meta: Meta; results: ApiToken[] }>(
'/internal/workplace_search/api_keys',
{ query }
);
actions.setApiKeysData(response.meta, response.results);
} catch (e) {
flashAPIErrors(e);
}
},
deleteApiKey: async () => {
const { apiTokenNameToDelete } = values;
try {
const { http } = HttpLogic.values;
await http.delete(`/internal/workplace_search/api_keys/${apiTokenNameToDelete}`);
actions.fetchApiKeys();
flashSuccessToast(DELETE_MESSAGE(apiTokenNameToDelete));
} catch (e) {
flashAPIErrors(e);
} finally {
actions.hideDeleteModal();
}
},
onApiFormSubmit: async () => {
const { name } = values.activeApiToken;
const data: ApiToken = {
name,
};
try {
const { http } = HttpLogic.values;
const body = JSON.stringify(data);
const response = await http.post<ApiToken>('/internal/workplace_search/api_keys', { body });
actions.onApiTokenCreateSuccess(response);
flashSuccessToast(CREATE_MESSAGE(name));
} catch (e) {
flashAPIErrors(e);
}
},
}),
});

View file

@ -0,0 +1,59 @@
/*
* 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 { shallow } from 'enzyme';
import { EuiButtonIcon } from '@elastic/eui';
import { ApiKey } from './api_key';
describe('ApiKey', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const props = {
copy: jest.fn(),
toggleIsHidden: jest.fn(),
isHidden: true,
text: 'some-api-key',
};
it('renders', () => {
const wrapper = shallow(<ApiKey {...props} />);
expect(wrapper.find(EuiButtonIcon).length).toEqual(2);
});
it('will call copy when the first button is clicked', () => {
const wrapper = shallow(<ApiKey {...props} />);
wrapper.find(EuiButtonIcon).first().simulate('click');
expect(props.copy).toHaveBeenCalled();
});
it('will call hide when the second button is clicked', () => {
const wrapper = shallow(<ApiKey {...props} />);
wrapper.find(EuiButtonIcon).last().simulate('click');
expect(props.toggleIsHidden).toHaveBeenCalled();
});
it('will render the "eye" icon when isHidden is true', () => {
const wrapper = shallow(<ApiKey {...props} />);
expect(wrapper.find(EuiButtonIcon).last().prop('iconType')).toBe('eye');
});
it('will render the "eyeClosed" icon when isHidden is false', () => {
const wrapper = shallow(<ApiKey {...{ ...props, isHidden: false }} />);
expect(wrapper.find(EuiButtonIcon).last().prop('iconType')).toBe('eyeClosed');
});
it('will render the provided text', () => {
const wrapper = shallow(<ApiKey {...props} />);
expect(wrapper.text()).toContain('some-api-key');
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { EuiButtonIcon } from '@elastic/eui';
import { SHOW_API_KEY_LABEL, HIDE_API_KEY_LABEL, COPY_API_KEY_BUTTON_LABEL } from '../constants';
interface Props {
copy: () => void;
toggleIsHidden: () => void;
isHidden: boolean;
text: React.ReactNode;
}
export const ApiKey: React.FC<Props> = ({ copy, toggleIsHidden, isHidden, text }) => {
const hideIcon = isHidden ? 'eye' : 'eyeClosed';
const hideIconLabel = isHidden ? SHOW_API_KEY_LABEL : HIDE_API_KEY_LABEL;
return (
<>
<EuiButtonIcon
onClick={copy}
iconType="copyClipboard"
aria-label={COPY_API_KEY_BUTTON_LABEL}
/>
<EuiButtonIcon
onClick={toggleIsHidden}
iconType={hideIcon}
aria-label={hideIconLabel}
aria-pressed={!isHidden}
style={{ marginRight: '0.25em' }}
/>
{text}
</>
);
};

View file

@ -0,0 +1,94 @@
/*
* 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 { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiFlyout, EuiForm, EuiFieldText, EuiFormRow } from '@elastic/eui';
import { ApiKeyFlyout } from './api_key_flyout';
describe('ApiKeyFlyout', () => {
const setNameInputBlurred = jest.fn();
const setApiKeyName = jest.fn();
const onApiFormSubmit = jest.fn();
const hideApiKeyForm = jest.fn();
const apiKey = {
id: '123',
name: 'test',
};
const values = {
activeApiToken: apiKey,
};
beforeEach(() => {
setMockValues(values);
setMockActions({
setNameInputBlurred,
setApiKeyName,
onApiFormSubmit,
hideApiKeyForm,
});
});
it('renders', () => {
const wrapper = shallow(<ApiKeyFlyout />);
const flyout = wrapper.find(EuiFlyout);
expect(flyout).toHaveLength(1);
expect(flyout.prop('onClose')).toEqual(hideApiKeyForm);
});
it('calls onApiTokenChange on form submit', () => {
const wrapper = shallow(<ApiKeyFlyout />);
const preventDefault = jest.fn();
wrapper.find(EuiForm).simulate('submit', { preventDefault });
expect(preventDefault).toHaveBeenCalled();
expect(onApiFormSubmit).toHaveBeenCalled();
});
it('shows help text if the raw name does not match the expected name', () => {
setMockValues({
...values,
activeApiToken: { name: 'my-api-key' },
activeApiTokenRawName: 'my api key!!',
});
const wrapper = shallow(<ApiKeyFlyout />);
expect(wrapper.find(EuiFormRow).prop('helpText')).toEqual('Your key will be named: my-api-key');
});
it('controls the input value', () => {
setMockValues({
...values,
activeApiTokenRawName: 'test',
});
const wrapper = shallow(<ApiKeyFlyout />);
expect(wrapper.find(EuiFieldText).prop('value')).toEqual('test');
});
it('calls setApiKeyName when the input value is changed', () => {
const wrapper = shallow(<ApiKeyFlyout />);
wrapper.find(EuiFieldText).simulate('change', { target: { value: 'changed' } });
expect(setApiKeyName).toHaveBeenCalledWith('changed');
});
it('calls setNameInputBlurred when the user stops focusing the input', () => {
const wrapper = shallow(<ApiKeyFlyout />);
wrapper.find(EuiFieldText).simulate('blur');
expect(setNameInputBlurred).toHaveBeenCalledWith(true);
});
});

View file

@ -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 { useValues, useActions } from 'kea';
import {
EuiPortal,
EuiFormRow,
EuiFieldText,
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiButton,
EuiForm,
EuiTitle,
} from '@elastic/eui';
import { CLOSE_BUTTON_LABEL, SAVE_BUTTON_LABEL } from '../../../../shared/constants';
import { FlashMessages } from '../../../../shared/flash_messages';
import { ApiKeysLogic } from '../api_keys_logic';
import {
API_KEY_FLYOUT_TITLE,
API_KEY_FORM_LABEL,
API_KEY_FORM_HELP_TEXT,
API_KEY_NAME_PLACEHOLDER,
} from '../constants';
export const ApiKeyFlyout: React.FC = () => {
const { setNameInputBlurred, setApiKeyName, onApiFormSubmit, hideApiKeyForm } =
useActions(ApiKeysLogic);
const {
activeApiToken: { name },
activeApiTokenRawName: rawName,
} = useValues(ApiKeysLogic);
return (
<EuiPortal>
<EuiFlyout onClose={hideApiKeyForm} hideCloseButton ownFocus size="s">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{API_KEY_FLYOUT_TITLE}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<FlashMessages />
<EuiForm
onSubmit={(e) => {
e.preventDefault();
onApiFormSubmit();
}}
component="form"
>
<EuiFormRow
label={API_KEY_FORM_LABEL}
helpText={!!name && name !== rawName ? API_KEY_FORM_HELP_TEXT(name) : ''}
fullWidth
>
<EuiFieldText
name="raw_name"
id="raw_name"
placeholder={API_KEY_NAME_PLACEHOLDER}
data-test-subj="APIKeyField"
value={rawName}
onChange={(e) => setApiKeyName(e.target.value)}
onBlur={() => setNameInputBlurred(true)}
autoComplete="off"
maxLength={64}
required
fullWidth
autoFocus
/>
</EuiFormRow>
</EuiForm>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={hideApiKeyForm}>
{CLOSE_BUTTON_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={onApiFormSubmit} fill data-test-subj="APIKeyActionButton">
{SAVE_BUTTON_LABEL}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiPortal>
);
};

View file

@ -0,0 +1,193 @@
/*
* 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 { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiBasicTable, EuiCopy, EuiConfirmModal } from '@elastic/eui';
import { HiddenText } from '../../../../shared/hidden_text';
import { ApiKey } from './api_key';
import { ApiKeysList } from './api_keys_list';
describe('ApiKeysList', () => {
const stageTokenNameForDeletion = jest.fn();
const hideDeleteModal = jest.fn();
const deleteApiKey = jest.fn();
const onPaginate = jest.fn();
const apiToken = {
id: '1',
name: 'test',
key: 'foo',
};
const apiTokens = [apiToken];
const meta = {
page: {
current: 1,
size: 10,
total_pages: 1,
total_results: 5,
},
};
const values = { apiTokens, meta, dataLoading: false };
beforeEach(() => {
setMockValues(values);
setMockActions({ deleteApiKey, onPaginate, stageTokenNameForDeletion, hideDeleteModal });
});
it('renders', () => {
const wrapper = shallow(<ApiKeysList />);
expect(wrapper.find(EuiBasicTable)).toHaveLength(1);
});
describe('loading state', () => {
it('renders as loading when dataLoading is true', () => {
setMockValues({
...values,
dataLoading: true,
});
const wrapper = shallow(<ApiKeysList />);
expect(wrapper.find(EuiBasicTable).prop('loading')).toBe(true);
});
});
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(<ApiKeysList />);
const { pagination } = wrapper.find(EuiBasicTable).props();
expect(pagination).toEqual({
pageIndex: 5,
pageSize: 55,
totalItemCount: 1004,
hidePerPageOptions: true,
});
});
});
it('handles confirmModal submission', () => {
setMockValues({
...values,
deleteModalVisible: true,
});
const wrapper = shallow(<ApiKeysList />);
const modal = wrapper.find(EuiConfirmModal);
modal.prop('onConfirm')!({} as any);
expect(deleteApiKey).toHaveBeenCalled();
});
describe('columns', () => {
let columns: any[];
beforeAll(() => {
setMockValues(values);
const wrapper = shallow(<ApiKeysList />);
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(<div>{column.render(token)}</div>);
expect(wrapper.text()).toEqual('some-name');
});
});
describe('column 2 (key)', () => {
const token = {
...apiToken,
key: 'abc-123',
};
it('renders nothing if no key is present', () => {
const tokenWithNoKey = {
key: undefined,
};
const column = columns[1];
const wrapper = shallow(<div>{column.render(tokenWithNoKey)}</div>);
expect(wrapper.text()).toBe('');
});
it('renders an EuiCopy component with the key', () => {
const column = columns[1];
const wrapper = shallow(<div>{column.render(token)}</div>);
expect(wrapper.find(EuiCopy).props().textToCopy).toEqual('abc-123');
});
it('renders a HiddenText component with the key', () => {
const column = columns[1];
const wrapper = shallow(<div>{column.render(token)}</div>)
.find(EuiCopy)
.dive();
expect(wrapper.find(HiddenText).props().text).toEqual('abc-123');
});
it('renders a Key component', () => {
const column = columns[1];
const wrapper = shallow(<div>{column.render(token)}</div>)
.find(EuiCopy)
.dive()
.find(HiddenText)
.dive();
expect(wrapper.find(ApiKey).props()).toEqual({
copy: expect.any(Function),
toggleIsHidden: expect.any(Function),
isHidden: expect.any(Boolean),
text: (
<span aria-label="Hidden text">
<span aria-hidden></span>
</span>
),
});
});
});
describe('column 3 (delete action)', () => {
const token = {
...apiToken,
name: 'some-name',
};
it('calls stageTokenNameForDeletion when clicked', () => {
const action = columns[2].actions[0];
action.onClick(token);
expect(stageTokenNameForDeletion).toHaveBeenCalledWith('some-name');
});
});
});
});

View file

@ -0,0 +1,112 @@
/*
* 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 { EuiBasicTable, EuiBasicTableColumn, EuiCopy, EuiConfirmModal } from '@elastic/eui';
import { DELETE_BUTTON_LABEL, CANCEL_BUTTON_LABEL } from '../../../../shared/constants';
import { HiddenText } from '../../../../shared/hidden_text';
import { convertMetaToPagination, handlePageChange } from '../../../../shared/table_pagination';
import { ApiToken } from '../../../types';
import { ApiKeysLogic } from '../api_keys_logic';
import {
DELETE_API_KEY_BUTTON_DESCRIPTION,
COPIED_TOOLTIP,
NAME_TITLE,
KEY_TITLE,
API_KEYS_CONFIRM_DELETE_TITLE,
API_KEYS_CONFIRM_DELETE_LABEL,
} from '../constants';
import { ApiKey } from './api_key';
export const ApiKeysList: React.FC = () => {
const { deleteApiKey, onPaginate, stageTokenNameForDeletion, hideDeleteModal } =
useActions(ApiKeysLogic);
const { apiTokens, meta, dataLoading, deleteModalVisible } = useValues(ApiKeysLogic);
const deleteModal = (
<EuiConfirmModal
title={API_KEYS_CONFIRM_DELETE_TITLE}
onCancel={hideDeleteModal}
onConfirm={deleteApiKey}
cancelButtonText={CANCEL_BUTTON_LABEL}
confirmButtonText={DELETE_BUTTON_LABEL}
buttonColor="danger"
defaultFocusedButton="confirm"
>
<p>{API_KEYS_CONFIRM_DELETE_LABEL}</p>
</EuiConfirmModal>
);
const columns: Array<EuiBasicTableColumn<ApiToken>> = [
{
name: NAME_TITLE,
render: (token: ApiToken) => token.name,
},
{
name: KEY_TITLE,
className: 'eui-textBreakAll',
render: (token: ApiToken) => {
const { key } = token;
if (!key) return null;
return (
<EuiCopy textToCopy={key} afterMessage={COPIED_TOOLTIP}>
{(copy) => (
<HiddenText text={key}>
{({ hiddenText, isHidden, toggle }) => (
<ApiKey
copy={copy}
toggleIsHidden={toggle}
isHidden={isHidden}
text={hiddenText}
/>
)}
</HiddenText>
)}
</EuiCopy>
);
},
mobileOptions: {
width: '100%',
},
},
{
actions: [
{
name: DELETE_BUTTON_LABEL,
description: DELETE_API_KEY_BUTTON_DESCRIPTION,
type: 'icon',
icon: 'trash',
color: 'danger',
onClick: (token: ApiToken) => stageTokenNameForDeletion(token.name),
},
],
},
];
return (
<>
{deleteModalVisible && deleteModal}
<EuiBasicTable
columns={columns}
items={apiTokens}
loading={dataLoading}
pagination={{
...convertMetaToPagination(meta),
hidePerPageOptions: true,
}}
onChange={handlePageChange(onPaginate)}
/>
</>
);
};

View file

@ -0,0 +1,149 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const CREATE_KEY_BUTTON_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.createKey.buttonLabel',
{
defaultMessage: 'Create key',
}
);
export const ENDPOINT_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.endpointTitle',
{
defaultMessage: 'Endpoint',
}
);
export const NAME_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.nameTitle',
{
defaultMessage: 'Name',
}
);
export const KEY_TITLE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.apiKeys.keyTitle', {
defaultMessage: 'Key',
});
export const COPIED_TOOLTIP = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.copied.tooltip',
{
defaultMessage: 'Copied',
}
);
export const COPY_API_ENDPOINT_BUTTON_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.copyApiEndpoint.buttonLabel',
{
defaultMessage: 'Copy API Endpoint to clipboard.',
}
);
export const COPY_API_KEY_BUTTON_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.copyApiKey.buttonLabel',
{
defaultMessage: 'Copy API Key to clipboard.',
}
);
export const DELETE_API_KEY_BUTTON_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.deleteApiKey.buttonDescription',
{
defaultMessage: 'Delete API key',
}
);
export const CREATE_MESSAGE = (name: string) =>
i18n.translate('xpack.enterpriseSearch.workplaceSearch.apiKeys.createdMessage', {
defaultMessage: "API key '{name}' was created",
values: { name },
});
export const DELETE_MESSAGE = (name: string) =>
i18n.translate('xpack.enterpriseSearch.workplaceSearch.apiKeys.deletedMessage', {
defaultMessage: "API key '{name}' was deleted",
values: { name },
});
export const API_KEY_FLYOUT_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.flyoutTitle',
{
defaultMessage: 'Create a new key',
}
);
export const API_KEY_FORM_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.formLabel',
{
defaultMessage: 'Key name',
}
);
export const API_KEY_FORM_HELP_TEXT = (name: string) =>
i18n.translate('xpack.enterpriseSearch.workplaceSearch.apiKeys.formHelpText', {
defaultMessage: 'Your key will be named: {name}',
values: { name },
});
export const API_KEY_NAME_PLACEHOLDER = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.namePlaceholder',
{
defaultMessage: 'i.e., my-api-key',
}
);
export const SHOW_API_KEY_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.showApiKeyLabel',
{
defaultMessage: 'Show API Key',
}
);
export const HIDE_API_KEY_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.hideApiKeyLabel',
{
defaultMessage: 'Hide API Key',
}
);
export const API_KEYS_EMPTY_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.emptyTitle',
{
defaultMessage: 'Create your first API key',
}
);
export const API_KEYS_EMPTY_BODY = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.emptyBody',
{
defaultMessage: 'Allow applications to access Elastic Workplace Search on your behalf.',
}
);
export const API_KEYS_EMPTY_BUTTON_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.emptyButtonLabel',
{
defaultMessage: 'Learn about API keys',
}
);
export const API_KEYS_CONFIRM_DELETE_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.confirmDeleteTitle',
{
defaultMessage: 'Delete API key',
}
);
export const API_KEYS_CONFIRM_DELETE_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.apiKeys.confirmDeleteLabel',
{
defaultMessage: 'Are you sure you want to delete this API key? This action cannot be undone.',
}
);

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { ApiKeys } from './api_keys';

View file

@ -282,7 +282,7 @@ export const SAVE_CUSTOM_BODY1 = i18n.translate(
export const SAVE_CUSTOM_BODY2 = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body2',
{
defaultMessage: 'Be sure to copy your API keys below.',
defaultMessage: 'Be sure to copy your Source Identifier below.',
}
);
@ -293,20 +293,6 @@ export const SAVE_CUSTOM_RETURN_BUTTON = i18n.translate(
}
);
export const SAVE_CUSTOM_API_KEYS_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.apiKeys.title',
{
defaultMessage: 'API Keys',
}
);
export const SAVE_CUSTOM_API_KEYS_BODY = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.apiKeys.body',
{
defaultMessage: "You'll need these keys to sync documents for this custom source.",
}
);
export const SAVE_CUSTOM_VISUAL_WALKTHROUGH_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.visualWalkthrough.title',
{

View file

@ -36,7 +36,7 @@ describe('SaveCustom', () => {
const wrapper = shallow(<SaveCustom {...props} />);
expect(wrapper.find(EuiPanel)).toHaveLength(1);
expect(wrapper.find(EuiTitle)).toHaveLength(5);
expect(wrapper.find(EuiTitle)).toHaveLength(4);
expect(wrapper.find(EuiLinkTo)).toHaveLength(1);
});

View file

@ -26,7 +26,6 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { LicensingLogic } from '../../../../../shared/licensing';
import { EuiLinkTo } from '../../../../../shared/react_router_helpers';
import { CredentialItem } from '../../../../components/shared/credential_item';
import { LicenseBadge } from '../../../../components/shared/license_badge';
import {
SOURCES_PATH,
@ -37,14 +36,14 @@ import {
getSourcesPath,
} from '../../../../routes';
import { CustomSource } from '../../../../types';
import { ACCESS_TOKEN_LABEL, ID_LABEL, LEARN_CUSTOM_FEATURES_BUTTON } from '../../constants';
import { LEARN_CUSTOM_FEATURES_BUTTON } from '../../constants';
import { SourceIdentifier } from '../source_identifier';
import {
SAVE_CUSTOM_BODY1,
SAVE_CUSTOM_BODY2,
SAVE_CUSTOM_RETURN_BUTTON,
SAVE_CUSTOM_API_KEYS_TITLE,
SAVE_CUSTOM_API_KEYS_BODY,
SAVE_CUSTOM_VISUAL_WALKTHROUGH_TITLE,
SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK,
SAVE_CUSTOM_STYLING_RESULTS_TITLE,
@ -62,7 +61,7 @@ interface SaveCustomProps {
export const SaveCustom: React.FC<SaveCustomProps> = ({
documentationUrl,
newCustomSource: { id, accessToken, name },
newCustomSource: { id, name },
isOrganization,
header,
}) => {
@ -106,24 +105,8 @@ export const SaveCustom: React.FC<SaveCustomProps> = ({
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule />
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<h4>{SAVE_CUSTOM_API_KEYS_TITLE}</h4>
</EuiTitle>
<EuiText grow={false} size="s" color="subdued">
<p>{SAVE_CUSTOM_API_KEYS_BODY}</p>
</EuiText>
<EuiSpacer />
<CredentialItem label={ID_LABEL} value={id} testSubj="ContentSourceId" />
<EuiSpacer />
<CredentialItem
label={ACCESS_TOKEN_LABEL}
value={accessToken}
testSubj="AccessToken"
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<SourceIdentifier id={id} />
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={1}>

View file

@ -37,7 +37,6 @@ import { EuiListGroupItemTo, EuiLinkTo } from '../../../../shared/react_router_h
import { AppLogic } from '../../../app_logic';
import aclImage from '../../../assets/supports_acl.svg';
import { ComponentLoader } from '../../../components/shared/component_loader';
import { CredentialItem } from '../../../components/shared/credential_item';
import { LicenseBadge } from '../../../components/shared/license_badge';
import { StatusItem } from '../../../components/shared/status_item';
import { ViewContentHeader } from '../../../components/shared/view_content_header';
@ -78,8 +77,6 @@ import {
STATUS_TEXT,
ADDITIONAL_CONFIG_HEADING,
EXTERNAL_IDENTITIES_LINK,
ACCESS_TOKEN_LABEL,
ID_LABEL,
LEARN_CUSTOM_FEATURES_BUTTON,
DOC_PERMISSIONS_DESCRIPTION,
CUSTOM_CALLOUT_TITLE,
@ -92,6 +89,7 @@ import {
} from '../constants';
import { SourceLogic } from '../source_logic';
import { SourceIdentifier } from './source_identifier';
import { SourceLayout } from './source_layout';
export const Overview: React.FC = () => {
@ -106,7 +104,6 @@ export const Overview: React.FC = () => {
groups,
details,
custom,
accessToken,
licenseSupportsPermissions,
serviceTypeSupportsPermissions,
indexPermissions,
@ -432,9 +429,7 @@ export const Overview: React.FC = () => {
</h6>
</EuiText>
<EuiSpacer size="s" />
<CredentialItem label={ID_LABEL} value={id} testSubj="ContentSourceId" />
<EuiSpacer size="s" />
<CredentialItem label={ACCESS_TOKEN_LABEL} value={accessToken} testSubj="AccessToken" />
<SourceIdentifier id={id} />
</EuiPanel>
);

View file

@ -0,0 +1,32 @@
/*
* 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 { shallow } from 'enzyme';
import { EuiCopy, EuiButtonIcon, EuiFieldText } from '@elastic/eui';
import { SourceIdentifier } from './source_identifier';
describe('SourceIdentifier', () => {
const id = 'foo123';
it('renders the Source Identifier', () => {
const wrapper = shallow(<SourceIdentifier id={id} />);
expect(wrapper.find(EuiFieldText).prop('value')).toEqual(id);
});
it('renders the copy button', () => {
const copyMock = jest.fn();
const wrapper = shallow(<SourceIdentifier id={id} />);
const copyEl = shallow(<div>{wrapper.find(EuiCopy).props().children(copyMock)}</div>);
expect(copyEl.find(EuiButtonIcon).props().onClick).toEqual(copyMock);
});
});

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiCopy,
EuiButtonIcon,
EuiFieldText,
EuiSpacer,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiLinkTo } from '../../../../shared/react_router_helpers';
import { API_KEY_LABEL, COPY_TOOLTIP, COPIED_TOOLTIP } from '../../../constants';
import { API_KEYS_PATH } from '../../../routes';
import { ID_LABEL } from '../constants';
interface Props {
id: string;
}
export const SourceIdentifier: React.FC<Props> = ({ id }) => (
<>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText size="s">
<strong>{ID_LABEL}</strong>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy beforeMessage={COPY_TOOLTIP} afterMessage={COPIED_TOOLTIP} textToCopy={id}>
{(copy) => (
<EuiButtonIcon
aria-label={COPY_TOOLTIP}
onClick={copy}
iconType="copy"
color="primary"
/>
)}
</EuiCopy>
</EuiFlexItem>
<EuiFlexItem>
<EuiFieldText value={id} readOnly />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiText size="s">
<p>
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.sources.identifier.helpText"
defaultMessage="Use the Source Identifier with an {apiKeyLink} to sync documents for this custom source."
values={{
apiKeyLink: (
<EuiLinkTo target="_blank" to={API_KEYS_PATH}>
{API_KEY_LABEL}
</EuiLinkTo>
),
}}
/>
</p>
</EuiText>
</>
);

View file

@ -147,13 +147,6 @@ export const EXTERNAL_IDENTITIES_LINK = i18n.translate(
}
);
export const ACCESS_TOKEN_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.accessToken.label',
{
defaultMessage: 'Access Token',
}
);
export const ID_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.id.label', {
defaultMessage: 'Source Identifier',
});

View file

@ -0,0 +1,92 @@
/*
* 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 { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__';
import { registerApiKeysRoute } from './api_keys';
describe('api keys routes', () => {
describe('GET /internal/workplace_search/api_keys', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'get',
path: '/internal/workplace_search/api_keys',
});
registerApiKeysRoute({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/ws/org/api_tokens',
});
});
});
describe('POST /internal/workplace_search/api_keys', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'post',
path: '/internal/workplace_search/api_keys',
});
registerApiKeysRoute({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/ws/org/api_tokens',
});
});
describe('validates', () => {
it('correctly', () => {
const request = {
body: {
name: 'my-api-key',
},
};
mockRouter.shouldValidate(request);
});
});
});
describe('DELETE /internal/workplace_search/api_keys/{tokenName}', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'delete',
path: '/internal/workplace_search/api_keys/{tokenName}',
});
registerApiKeysRoute({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/ws/org/api_tokens/:tokenName',
});
});
});
});

View file

@ -0,0 +1,57 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { RouteDependencies } from '../../plugin';
export function registerApiKeysRoute({
router,
enterpriseSearchRequestHandler,
}: RouteDependencies) {
router.get(
{
path: '/internal/workplace_search/api_keys',
validate: false,
},
enterpriseSearchRequestHandler.createRequest({
path: '/ws/org/api_tokens',
})
);
router.post(
{
path: '/internal/workplace_search/api_keys',
validate: {
body: schema.object({
name: schema.string(),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/ws/org/api_tokens',
})
);
router.delete(
{
path: '/internal/workplace_search/api_keys/{tokenName}',
validate: {
params: schema.object({
tokenName: schema.string(),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/ws/org/api_tokens/:tokenName',
})
);
}
export const registerApiKeysRoutes = (dependencies: RouteDependencies) => {
registerApiKeysRoute(dependencies);
};

View file

@ -7,6 +7,7 @@
import { RouteDependencies } from '../../plugin';
import { registerApiKeysRoutes } from './api_keys';
import { registerGroupsRoutes } from './groups';
import { registerOAuthRoutes } from './oauth';
import { registerOverviewRoute } from './overview';
@ -16,6 +17,7 @@ import { registerSettingsRoutes } from './settings';
import { registerSourcesRoutes } from './sources';
export const registerWorkplaceSearchRoutes = (dependencies: RouteDependencies) => {
registerApiKeysRoutes(dependencies);
registerOverviewRoute(dependencies);
registerOAuthRoutes(dependencies);
registerGroupsRoutes(dependencies);

View file

@ -9491,8 +9491,6 @@
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.button": "構成を保存",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep1": "組織の{sourceName}アカウントでOAuthアプリを作成する",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep2": "適切な構成情報を入力する",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.apiKeys.body": "このカスタムソースでドキュメントを同期するには、これらのキーが必要です。",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.apiKeys.title": "API キー",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body1": "エンドポイントは要求を承認できます。",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body2": "必ず次のAPIキーをコピーしてください。",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.displaySettings.text": "{link}を使用して、検索結果内でドキュメントが表示される方法をカスタマイズします。デフォルトでは、Workplace Searchは英字順でフィールドを使用します。",
@ -9817,7 +9815,6 @@
"xpack.enterpriseSearch.workplaceSearch.sourceRow.remoteLabel": "リモート",
"xpack.enterpriseSearch.workplaceSearch.sourceRow.remoteTooltip": "リモートソースは直接ソースの検索サービスに依存しています。コンテンツはWorkplace Searchでインデックスされません。結果の速度と完全性はサードパーティサービスの正常性とパフォーマンスの機能です。",
"xpack.enterpriseSearch.workplaceSearch.sourceRow.searchableToggleLabel": "ソース検索可能トグル",
"xpack.enterpriseSearch.workplaceSearch.sources.accessToken.label": "アクセストークン",
"xpack.enterpriseSearch.workplaceSearch.sources.additionalConfig.heading": "追加の構成が必要",
"xpack.enterpriseSearch.workplaceSearch.sources.applicationLinkTitles.github": "GitHub開発者ポータル",
"xpack.enterpriseSearch.workplaceSearch.sources.baseUrlTitles.github": "GitHub Enterprise URL",

View file

@ -9578,8 +9578,6 @@
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.button": "保存配置",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep1": "在组织的 {sourceName} 帐户中创建 OAuth 应用",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep2": "提供适当的配置信息",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.apiKeys.body": "您将需要这些密钥以便为此定制源同步文档。",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.apiKeys.title": "API 密钥",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body1": "您的终端已准备好接受请求。",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body2": "确保在下面复制您的 API 密钥。",
"xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.displaySettings.text": "请使用 {link} 定制您的文档在搜索结果内显示的方式。Workplace Search 默认按字母顺序使用字段。",
@ -9904,7 +9902,6 @@
"xpack.enterpriseSearch.workplaceSearch.sourceRow.remoteLabel": "远程",
"xpack.enterpriseSearch.workplaceSearch.sourceRow.remoteTooltip": "远程源直接依赖于源的搜索服务,且没有内容使用 Workplace Search 进行索引。速度和结果完整性取决于第三方服务的运行状况和性能。",
"xpack.enterpriseSearch.workplaceSearch.sourceRow.searchableToggleLabel": "源可搜索切换",
"xpack.enterpriseSearch.workplaceSearch.sources.accessToken.label": "访问令牌",
"xpack.enterpriseSearch.workplaceSearch.sources.additionalConfig.heading": "需要其他配置",
"xpack.enterpriseSearch.workplaceSearch.sources.applicationLinkTitles.github": "GitHub 开发者门户",
"xpack.enterpriseSearch.workplaceSearch.sources.baseUrlTitles.github": "GitHub Enterprise URL",