Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ readonly links: {
readonly usersAccess: string;
};
readonly workplaceSearch: {
readonly apiKeys: string;
readonly box: string;
readonly confluenceCloud: string;
readonly confluenceServer: string;
Expand Down

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/core/public/doc_links/doc_links_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -673,6 +674,7 @@ export interface DocLinksStart {
readonly usersAccess: string;
};
readonly workplaceSearch: {
readonly apiKeys: string;
readonly box: string;
readonly confluenceCloud: string;
readonly confluenceServer: string;
Expand Down
1 change: 1 addition & 0 deletions src/core/public/public.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ export interface DocLinksStart {
readonly usersAccess: string;
};
readonly workplaceSearch: {
readonly apiKeys: string;
readonly box: string;
readonly confluenceCloud: string;
readonly confluenceServer: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -92,6 +93,7 @@ class DocLinks {
this.enterpriseSearchMailService = '';
this.enterpriseSearchUsersAccess = '';
this.licenseManagement = '';
this.workplaceSearchApiKeys = '';
this.workplaceSearchBox = '';
this.workplaceSearchConfluenceCloud = '';
this.workplaceSearchConfluenceServer = '';
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
Expand Down Expand Up @@ -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',
{
Expand Down Expand Up @@ -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!',
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,9 @@ export interface WSRoleMapping extends RoleMapping {
allGroups: boolean;
groups: RoleGroup[];
}

export interface ApiToken {
key?: string;
id?: string;
name: string;
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading