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',
})}