diff --git a/package.json b/package.json index 38cf150094677b..77e466cc8bca4e 100644 --- a/package.json +++ b/package.json @@ -283,7 +283,7 @@ "react-resizable": "^1.7.5", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", - "react-use": "^13.27.0", + "react-use": "^15.3.4", "recompose": "^0.26.0", "redux": "^4.0.5", "redux-actions": "^2.6.5", diff --git a/src/plugins/kibana_react/public/notifications/create_notifications.test.tsx b/src/plugins/kibana_react/public/notifications/create_notifications.test.tsx index 4f64a2b95f512f..23500e8480ebb8 100644 --- a/src/plugins/kibana_react/public/notifications/create_notifications.test.tsx +++ b/src/plugins/kibana_react/public/notifications/create_notifications.test.tsx @@ -54,16 +54,12 @@ test('can display string element as title', () => { expect(notifications.toasts.add).toHaveBeenCalledTimes(1); expect(notifications.toasts.add.mock.calls[0][0]).toMatchInlineSnapshot(` Object { - "color": undefined, - "iconType": undefined, - "onClose": undefined, "text": MountPoint { "reactNode": , }, "title": MountPoint { "reactNode": "foo", }, - "toastLifeTimeMs": undefined, } `); }); @@ -120,7 +116,6 @@ test('can set toast properties', () => { Object { "color": "danger", "iconType": "foo", - "onClose": undefined, "text": MountPoint { "reactNode": 1 @@ -147,42 +142,36 @@ test('can display success, warning and danger toasts', () => { Object { "color": "success", "iconType": "check", - "onClose": undefined, "text": MountPoint { "reactNode": , }, "title": MountPoint { "reactNode": "1", }, - "toastLifeTimeMs": undefined, } `); expect(notifications.toasts.add.mock.calls[1][0]).toMatchInlineSnapshot(` Object { "color": "warning", "iconType": "help", - "onClose": undefined, "text": MountPoint { "reactNode": , }, "title": MountPoint { "reactNode": "2", }, - "toastLifeTimeMs": undefined, } `); expect(notifications.toasts.add.mock.calls[2][0]).toMatchInlineSnapshot(` Object { "color": "danger", "iconType": "alert", - "onClose": undefined, "text": MountPoint { "reactNode": , }, "title": MountPoint { "reactNode": "3", }, - "toastLifeTimeMs": undefined, } `); }); diff --git a/src/plugins/kibana_react/public/notifications/create_notifications.tsx b/src/plugins/kibana_react/public/notifications/create_notifications.tsx index 826435ec5b587a..dc6430e2f18a95 100644 --- a/src/plugins/kibana_react/public/notifications/create_notifications.tsx +++ b/src/plugins/kibana_react/public/notifications/create_notifications.tsx @@ -23,24 +23,14 @@ import { KibanaReactNotifications } from './types'; import { toMountPoint } from '../util'; export const createNotifications = (services: KibanaServices): KibanaReactNotifications => { - const show: KibanaReactNotifications['toasts']['show'] = ({ - title, - body, - color, - iconType, - toastLifeTimeMs, - onClose, - }) => { + const show: KibanaReactNotifications['toasts']['show'] = ({ title, body, ...rest }) => { if (!services.notifications) { throw new TypeError('Could not show notification as notifications service is not available.'); } services.notifications!.toasts.add({ title: toMountPoint(title), text: toMountPoint(<>{body || null}), - color, - iconType, - toastLifeTimeMs, - onClose, + ...rest, }); }; diff --git a/x-pack/plugins/security/public/account_management/account_details_page/account_details_page.tsx b/x-pack/plugins/security/public/account_management/account_details_page/account_details_page.tsx new file mode 100644 index 00000000000000..0d446a631a37df --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_details_page/account_details_page.tsx @@ -0,0 +1,33 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { ChangePassword } from './change_password'; +import { PersonalInfo } from './personal_info'; +import { AuthenticatedUser } from '../../../common/model'; +import { UserAPIClient } from '../../management'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { NotificationsSetup } from '../../../../../../src/core/public'; + +export interface AccountDetailsPageProps { + user: AuthenticatedUser; + notifications: NotificationsSetup; +} + +export const AccountDetailsPage: FunctionComponent = ({ + user, + notifications, +}) => { + const { services } = useKibana(); + const userAPIClient = new UserAPIClient(services.http!); + + return ( + <> + + + + ); +}; diff --git a/x-pack/plugins/security/public/account_management/change_password/change_password.tsx b/x-pack/plugins/security/public/account_management/account_details_page/change_password.tsx similarity index 100% rename from x-pack/plugins/security/public/account_management/change_password/change_password.tsx rename to x-pack/plugins/security/public/account_management/account_details_page/change_password.tsx diff --git a/x-pack/plugins/security/public/account_management/change_password/index.ts b/x-pack/plugins/security/public/account_management/account_details_page/index.ts similarity index 63% rename from x-pack/plugins/security/public/account_management/change_password/index.ts rename to x-pack/plugins/security/public/account_management/account_details_page/index.ts index ccd810bb814c00..a1b31254e87cb2 100644 --- a/x-pack/plugins/security/public/account_management/change_password/index.ts +++ b/x-pack/plugins/security/public/account_management/account_details_page/index.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export { AccountDetailsPage as default } from './account_details_page'; // eslint-disable-line import/no-default-export export { ChangePassword } from './change_password'; +export { PersonalInfo } from './personal_info'; diff --git a/x-pack/plugins/security/public/account_management/personal_info/personal_info.tsx b/x-pack/plugins/security/public/account_management/account_details_page/personal_info.tsx similarity index 59% rename from x-pack/plugins/security/public/account_management/personal_info/personal_info.tsx rename to x-pack/plugins/security/public/account_management/account_details_page/personal_info.tsx index 9cbbc242e8400e..a46b10ea997b8f 100644 --- a/x-pack/plugins/security/public/account_management/personal_info/personal_info.tsx +++ b/x-pack/plugins/security/public/account_management/account_details_page/personal_info.tsx @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { EuiDescribedFormGroup, EuiFormRow, EuiText } from '@elastic/eui'; +import { + EuiDescribedFormGroup, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { AuthenticatedUser } from '../../../common/model'; @@ -31,23 +36,19 @@ export const PersonalInfo = (props: Props) => { /> } > - - -
-
- {props.user.username} -
-
- {props.user.email || ( - - )} -
-
-
-
+ + + {props.user.username} + + + {props.user.email || ( + + )} + + ); }; diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts deleted file mode 100644 index c41bd43872beed..00000000000000 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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. - */ - -jest.mock('./account_management_page'); - -import { AppMount, AppNavLinkStatus } from 'src/core/public'; -import { UserAPIClient } from '../management'; -import { accountManagementApp } from './account_management_app'; - -import { coreMock, scopedHistoryMock } from '../../../../../src/core/public/mocks'; -import { securityMock } from '../mocks'; - -describe('accountManagementApp', () => { - it('properly registers application', () => { - const coreSetupMock = coreMock.createSetup(); - - accountManagementApp.create({ - application: coreSetupMock.application, - getStartServices: coreSetupMock.getStartServices, - authc: securityMock.createSetup().authc, - }); - - expect(coreSetupMock.application.register).toHaveBeenCalledTimes(1); - - const [[appRegistration]] = coreSetupMock.application.register.mock.calls; - expect(appRegistration).toEqual({ - id: 'security_account', - appRoute: '/security/account', - navLinkStatus: AppNavLinkStatus.hidden, - title: 'Account Management', - mount: expect.any(Function), - }); - }); - - it('properly sets breadcrumbs and renders application', async () => { - const coreSetupMock = coreMock.createSetup(); - const coreStartMock = coreMock.createStart(); - coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}, {}]); - - const authcMock = securityMock.createSetup().authc; - const containerMock = document.createElement('div'); - - accountManagementApp.create({ - application: coreSetupMock.application, - getStartServices: coreSetupMock.getStartServices, - authc: authcMock, - }); - - const [[{ mount }]] = coreSetupMock.application.register.mock.calls; - await (mount as AppMount)({ - element: containerMock, - appBasePath: '', - onAppLeave: jest.fn(), - setHeaderActionMenu: jest.fn(), - history: scopedHistoryMock.create(), - }); - - expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1); - expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledWith([ - { text: 'Account Management' }, - ]); - - const mockRenderApp = jest.requireMock('./account_management_page').renderAccountManagementPage; - expect(mockRenderApp).toHaveBeenCalledTimes(1); - expect(mockRenderApp).toHaveBeenCalledWith(coreStartMock.i18n, containerMock, { - userAPIClient: expect.any(UserAPIClient), - authc: authcMock, - notifications: coreStartMock.notifications, - }); - }); -}); diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.tsx b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx new file mode 100644 index 00000000000000..c5e7784e46fa43 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx @@ -0,0 +1,65 @@ +/* + * 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 ReactDOM from 'react-dom'; +import { act, screen, fireEvent } from '@testing-library/react'; +import { AppMount, AppNavLinkStatus, ScopedHistory } from '../../../../../src/core/public'; +import { coreMock } from '../../../../../src/core/public/mocks'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { securityMock } from '../mocks'; +import { accountManagementApp } from './account_management_app'; +import { createMemoryHistory } from 'history'; + +describe('accountManagementApp', () => { + it('registers application', () => { + const { application, getStartServices } = coreMock.createSetup(); + const { authc } = securityMock.createSetup(); + accountManagementApp.create({ application, getStartServices, authc }); + expect(application.register).toHaveBeenLastCalledWith({ + id: 'security_account', + appRoute: '/security/account', + navLinkStatus: AppNavLinkStatus.hidden, + title: 'Account Management', + mount: expect.any(Function), + }); + }); + + it('renders application and sets breadcrumbs', async () => { + const { application, getStartServices } = coreMock.createSetup(); + const coreStartMock = coreMock.createStart(); + getStartServices.mockResolvedValue([coreStartMock, {}, {}]); + const { authc } = securityMock.createSetup(); + authc.getCurrentUser.mockResolvedValue( + mockAuthenticatedUser({ username: 'some-user', full_name: undefined }) + ); + accountManagementApp.create({ application, getStartServices, authc }); + const [[{ mount }]] = application.register.mock.calls; + const element = document.body.appendChild(document.createElement('div')); + + await act(async () => { + await (mount as AppMount)({ + element, + appBasePath: '', + onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), + history: (createMemoryHistory() as unknown) as ScopedHistory, + }); + }); + expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenLastCalledWith([ + expect.objectContaining({ text: 'Account Management' }), + ]); + + fireEvent.click(screen.getByRole('tab', { name: 'API Keys' })); + expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenLastCalledWith([ + expect.objectContaining({ text: 'Account Management' }), + expect.objectContaining({ text: 'API Keys' }), + ]); + + // Need to cleanup manually since `mount` renders the app straight to the DOM + ReactDOM.unmountComponentAtNode(element); + document.body.removeChild(element); + }); +}); diff --git a/x-pack/plugins/security/public/account_management/account_management_app.ts b/x-pack/plugins/security/public/account_management/account_management_app.ts deleted file mode 100644 index 0bb7785259c0ea..00000000000000 --- a/x-pack/plugins/security/public/account_management/account_management_app.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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 { i18n } from '@kbn/i18n'; -import { - ApplicationSetup, - AppMountParameters, - AppNavLinkStatus, - StartServicesAccessor, -} from '../../../../../src/core/public'; -import { AuthenticationServiceSetup } from '../authentication'; - -interface CreateDeps { - application: ApplicationSetup; - authc: AuthenticationServiceSetup; - getStartServices: StartServicesAccessor; -} - -export const accountManagementApp = Object.freeze({ - id: 'security_account', - create({ application, authc, getStartServices }: CreateDeps) { - const title = i18n.translate('xpack.security.account.breadcrumb', { - defaultMessage: 'Account Management', - }); - application.register({ - id: this.id, - title, - navLinkStatus: AppNavLinkStatus.hidden, - appRoute: '/security/account', - async mount({ element }: AppMountParameters) { - const [ - [coreStart], - { renderAccountManagementPage }, - { UserAPIClient }, - ] = await Promise.all([ - getStartServices(), - import('./account_management_page'), - import('../management'), - ]); - - coreStart.chrome.setBreadcrumbs([{ text: title }]); - - return renderAccountManagementPage(coreStart.i18n, element, { - authc, - notifications: coreStart.notifications, - userAPIClient: new UserAPIClient(coreStart.http), - }); - }, - }); - }, -}); diff --git a/x-pack/plugins/security/public/account_management/account_management_app.tsx b/x-pack/plugins/security/public/account_management/account_management_app.tsx new file mode 100644 index 00000000000000..e4bab6bddf3224 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.tsx @@ -0,0 +1,176 @@ +/* + * 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, { lazy, Suspense, FunctionComponent } from 'react'; +import ReactDOM from 'react-dom'; +import { Router, useHistory } from 'react-router-dom'; +import { History } from 'history'; +import useAsync from 'react-use/lib/useAsync'; +import { i18n } from '@kbn/i18n'; +import { + EuiAvatar, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { + ApplicationSetup, + AppMountParameters, + AppNavLinkStatus, + StartServicesAccessor, + NotificationsSetup, + CoreStart, +} from '../../../../../src/core/public'; +import { + KibanaContextProvider, + reactRouterNavigate, +} from '../../../../../src/plugins/kibana_react/public'; +import { getUserDisplayName } from '../../common/model'; +import { AuthenticationServiceSetup } from '../authentication'; +import { TabbedRoutes } from './components/tabbed_routes'; +import { Breadcrumb } from './components/breadcrumb'; + +interface CreateDeps { + application: ApplicationSetup; + authc: AuthenticationServiceSetup; + getStartServices: StartServicesAccessor; +} + +export const accountManagementApp = Object.freeze({ + id: 'security_account', + create({ application, authc, getStartServices }: CreateDeps) { + const title = i18n.translate('xpack.security.account.breadcrumb', { + defaultMessage: 'Account Management', + }); + + application.register({ + id: this.id, + title, + navLinkStatus: AppNavLinkStatus.hidden, + appRoute: '/security/account', + async mount({ element, history }: AppMountParameters) { + const [coreStart] = await getStartServices(); + + ReactDOM.render( + + + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); + }, + }); + }, +}); + +export interface ProvidersProps { + services: CoreStart; + history: History; +} + +export const Providers: FunctionComponent = ({ services, history, children }) => ( + + + {children} + + +); + +export interface AccountManagementProps { + authc: AuthenticationServiceSetup; + notifications: NotificationsSetup; +} + +export const AccountManagement: FunctionComponent = ({ + authc, + notifications, +}) => { + const history = useHistory(); + const userState = useAsync(authc.getCurrentUser, [authc]); + const AccountDetailsPage = lazy(() => import('./account_details_page')); + const ApiKeysPage = lazy(() => import('./api_keys_page')); + + if (!userState.value) { + return null; + } + + const displayName = getUserDisplayName(userState.value); + + return ( + + + + + + + + + + + +

{displayName}

+
+ {userState.value.email} +
+
+
+
+ + }> + + + ), + }, + { + id: 'api-keys', + path: '/api-keys', + to: 'api-keys', + name: i18n.translate('xpack.security.accountManagement.ApiKeysTab', { + defaultMessage: 'API Keys', + }), + content: ( + + }> + + + + ), + }, + ]} + /> + +
+
+
+ ); +}; diff --git a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx deleted file mode 100644 index b677f6a1fe8bba..00000000000000 --- a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* - * 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 { act } from '@testing-library/react'; -import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { AuthenticatedUser } from '../../common/model'; -import { AccountManagementPage } from './account_management_page'; -import { coreMock } from 'src/core/public/mocks'; -import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; -import { securityMock } from '../mocks'; -import { userAPIClientMock } from '../management/users/index.mock'; - -interface Options { - withFullName?: boolean; - withEmail?: boolean; - realm?: string; -} -const createUser = ({ withFullName = true, withEmail = true, realm = 'native' }: Options = {}) => { - return mockAuthenticatedUser({ - full_name: withFullName ? 'Casey Smith' : '', - username: 'csmith', - email: withEmail ? 'csmith@domain.com' : '', - roles: [], - authentication_realm: { - type: realm, - name: realm, - }, - lookup_realm: { - type: realm, - name: realm, - }, - }); -}; - -function getSecuritySetupMock({ currentUser }: { currentUser: AuthenticatedUser }) { - const securitySetupMock = securityMock.createSetup(); - securitySetupMock.authc.getCurrentUser.mockResolvedValue(currentUser); - return securitySetupMock; -} - -describe('', () => { - it(`displays users full name, username, and email address`, async () => { - const user = createUser(); - const wrapper = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual( - user.full_name - ); - expect(wrapper.find('[data-test-subj="username"]').text()).toEqual(user.username); - expect(wrapper.find('[data-test-subj="email"]').text()).toEqual(user.email); - }); - - it(`displays username when full_name is not provided`, async () => { - const user = createUser({ withFullName: false }); - const wrapper = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual(user.username); - }); - - it(`displays a placeholder when no email address is provided`, async () => { - const user = createUser({ withEmail: false }); - const wrapper = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('[data-test-subj="email"]').text()).toEqual('no email address'); - }); - - it(`displays change password form for users in the native realm`, async () => { - const user = createUser(); - const wrapper = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('EuiFieldPassword[data-test-subj="currentPassword"]')).toHaveLength(1); - expect(wrapper.find('EuiFieldPassword[data-test-subj="newPassword"]')).toHaveLength(1); - }); - - it(`does not display change password form for users in the saml realm`, async () => { - const user = createUser({ realm: 'saml' }); - const wrapper = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('EuiFieldText[data-test-subj="currentPassword"]')).toHaveLength(0); - expect(wrapper.find('EuiFieldText[data-test-subj="newPassword"]')).toHaveLength(0); - }); -}); diff --git a/x-pack/plugins/security/public/account_management/account_management_page.tsx b/x-pack/plugins/security/public/account_management/account_management_page.tsx deleted file mode 100644 index 2c870bf788ceb7..00000000000000 --- a/x-pack/plugins/security/public/account_management/account_management_page.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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, useState } from 'react'; -import ReactDOM from 'react-dom'; -import { EuiPage, EuiPageBody, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; -import type { PublicMethodsOf } from '@kbn/utility-types'; -import { CoreStart, NotificationsStart } from 'src/core/public'; -import { getUserDisplayName, AuthenticatedUser } from '../../common/model'; -import { AuthenticationServiceSetup } from '../authentication'; -import { UserAPIClient } from '../management'; -import { ChangePassword } from './change_password'; -import { PersonalInfo } from './personal_info'; - -interface Props { - authc: AuthenticationServiceSetup; - userAPIClient: PublicMethodsOf; - notifications: NotificationsStart; -} - -export const AccountManagementPage = ({ userAPIClient, authc, notifications }: Props) => { - const [currentUser, setCurrentUser] = useState(null); - useEffect(() => { - authc.getCurrentUser().then(setCurrentUser); - }, [authc]); - - if (!currentUser) { - return null; - } - - return ( - - - - -

{getUserDisplayName(currentUser)}

-
- - - - - - -
-
-
- ); -}; - -export function renderAccountManagementPage( - i18nStart: CoreStart['i18n'], - element: Element, - props: Props -) { - ReactDOM.render( - - - , - element - ); - - return () => ReactDOM.unmountComponentAtNode(element); -} diff --git a/x-pack/plugins/security/public/account_management/api_keys_page/api_keys_empty_prompt.tsx b/x-pack/plugins/security/public/account_management/api_keys_page/api_keys_empty_prompt.tsx new file mode 100644 index 00000000000000..395768811f8c3c --- /dev/null +++ b/x-pack/plugins/security/public/account_management/api_keys_page/api_keys_empty_prompt.tsx @@ -0,0 +1,110 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DocLink } from '../components/doc_link'; + +export interface ApiKeysEmptyPromptProps { + error?: Error; +} + +export const ApiKeysEmptyPrompt: FunctionComponent = ({ + error, + children, +}) => { + if (error) { + if (doesErrorIndicateAPIKeysAreDisabled(error)) { + return ( + +

+ +

+

+ + + +

+ + } + /> + ); + } + + if (doesErrorIndicateUserHasNoPermissionsToManageAPIKeys(error)) { + return ( + + +

+ } + /> + ); + } + + return ( + + +

+ } + actions={children} + /> + ); + } + + return ( + + + + } + body={ +

+ +

+ } + actions={children} + /> + ); +}; + +function doesErrorIndicateAPIKeysAreDisabled(error: Record) { + const message = error.body?.message || ''; + return message.indexOf('disabled.feature="api_keys"') !== -1; +} + +function doesErrorIndicateUserHasNoPermissionsToManageAPIKeys(error: Record) { + return error.body?.statusCode === 403; +} diff --git a/x-pack/plugins/security/public/account_management/api_keys_page/api_keys_page.test.tsx b/x-pack/plugins/security/public/account_management/api_keys_page/api_keys_page.test.tsx new file mode 100644 index 00000000000000..44728f4d674977 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/api_keys_page/api_keys_page.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 { render, fireEvent, within, waitForElementToBeRemoved } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { Providers } from '../account_management_app'; +import { ApiKeysPage } from './api_keys_page'; + +const getApiKeysResponse = { + apiKeys: [ + { + creation: 1571322182082, + expiration: 1571408582082, + id: '0QQZ2m0BO2XZwgJFuWTT', + invalidated: false, + name: 'my-api-key', + realm: 'reserved', + username: 'elastic', + }, + ], +}; + +describe('ApiKeysPage', () => { + it('fetches API keys on mount', async () => { + const coreStart = coreMock.createStart(); + const history = createMemoryHistory(); + coreStart.http.get.mockResolvedValue(getApiKeysResponse); + + const { queryByText } = render( + + + + ); + + expect(coreStart.http.get).toHaveBeenLastCalledWith('/internal/security/api_key', { + query: { isAdmin: false }, + }); + + await waitForElementToBeRemoved(queryByText(/Loading API keys/i)); + }); + + it('creates API key on form submit', async () => { + const coreStart = coreMock.createStart(); + const history = createMemoryHistory({ initialEntries: ['/api-keys/create'] }); + coreStart.http.get.mockResolvedValue(getApiKeysResponse); + + const { findByRole } = render( + + + + ); + + const flyout = await findByRole('dialog'); + fireEvent.change(within(flyout).getByLabelText(/Name/i), { target: { value: 'Test Key' } }); + fireEvent.click(within(flyout).getByRole('button', { name: /Create API key/i })); + + expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/security/api_key', { + body: '{"name":"Test Key"}', + }); + + await waitForElementToBeRemoved(() => within(flyout).queryByText(/Creating API key/i)); + }); + + it('invalidates API key after confirmation', async () => { + const coreStart = coreMock.createStart(); + const history = createMemoryHistory(); + coreStart.http.get.mockResolvedValue(getApiKeysResponse); + + const { findByRole, getByRole, queryByText } = render( + + + + ); + + const table = await findByRole('table'); + fireEvent.click(within(table).getByRole('button', { name: /Invalidate/i })); + fireEvent.click(getByRole('button', { name: /Invalidate this key/i })); + + expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/security/api_key/invalidate', { + body: '{"apiKeys":[{"id":"0QQZ2m0BO2XZwgJFuWTT","name":"my-api-key"}],"isAdmin":false}', + }); + + await waitForElementToBeRemoved(() => queryByText(/Invalidating API key/i)); + }); +}); diff --git a/x-pack/plugins/security/public/account_management/api_keys_page/api_keys_page.tsx b/x-pack/plugins/security/public/account_management/api_keys_page/api_keys_page.tsx new file mode 100644 index 00000000000000..81ebf21ba97db8 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/api_keys_page/api_keys_page.tsx @@ -0,0 +1,156 @@ +/* + * 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, { FunctionComponent, useState } from 'react'; +import { Route, useHistory } from 'react-router-dom'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import useMount from 'react-use/lib/useMount'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { reactRouterNavigate, useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { SectionLoading } from '../../../../../../src/plugins/es_ui_shared/public'; +import { ApiKey } from '../../../common/model'; +import { + APIKeysAPIClient, + CreateApiKeyResponse, +} from '../../management/api_keys/api_keys_api_client'; +import { FieldTextWithCopyButton } from '../components/field_text_with_copy_button'; +import { Breadcrumb } from '../components/breadcrumb'; +import { CreateApiKeyFlyout } from './create_api_key_flyout'; +import { InvalidateApiKeyModal } from './invalidate_api_key_modal'; +import { ApiKeysTable } from './api_keys_table'; +import { ApiKeysEmptyPrompt } from './api_keys_empty_prompt'; + +export const ApiKeysPage: FunctionComponent = () => { + const history = useHistory(); + const { services } = useKibana(); + const [state, getApiKeys] = useAsyncFn(() => new APIKeysAPIClient(services.http!).getApiKeys(), [ + services.http, + ]); + const [createdApiKey, setCreatedApiKey] = useState(); + const [apiKeyToInvalidate, setApiKeyToInvalidate] = useState(); + + useMount(getApiKeys); + + if (!state.value) { + if (state.error && !state.loading) { + return ( + + + + + + ); + } + return ( + + + + ); + } + + return ( + <> + + + history.push({ pathname: '/api-keys' })} + onSuccess={(apiKey) => { + history.push({ pathname: '/api-keys' }); + setCreatedApiKey(apiKey); + getApiKeys(); + }} + /> + + + + {apiKeyToInvalidate && ( + setApiKeyToInvalidate(undefined)} + onSuccess={() => { + setApiKeyToInvalidate(undefined); + setCreatedApiKey(undefined); + getApiKeys(); + }} + /> + )} + + {!state.loading && state.value.apiKeys.length === 0 ? ( + + + + + + ) : ( + <> + + + + + + + + + + + + {createdApiKey && !state.loading && ( + <> + + +

+ +

+ +
+ + )} + + + + + )} + + ); +}; diff --git a/x-pack/plugins/security/public/account_management/api_keys_page/api_keys_table.tsx b/x-pack/plugins/security/public/account_management/api_keys_page/api_keys_table.tsx new file mode 100644 index 00000000000000..324f5d1e4d241a --- /dev/null +++ b/x-pack/plugins/security/public/account_management/api_keys_page/api_keys_table.tsx @@ -0,0 +1,144 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +// @ts-ignore +import { formatDate } from '@elastic/eui/lib/services/format'; +import { + EuiBadge, + EuiButtonEmpty, + EuiHideFor, + EuiInMemoryTable, + EuiInMemoryTableProps, + EuiShowFor, + EuiText, + EuiButtonIcon, +} from '@elastic/eui'; +import { ApiKey } from '../../../common/model'; + +export type ApiKeysTableProps = Omit, 'columns'> & { + createdItemId?: ApiKey['id']; + onInvalidateItem(item: ApiKey): void; +}; + +export const ApiKeysTable: FunctionComponent = ({ + createdItemId, + onInvalidateItem, + ...props +}) => { + const actions = [ + { + render: (item: ApiKey) => ( + <> + + onInvalidateItem(item)} + /> + + + onInvalidateItem(item)} + > + + + + + ), + }, + ]; + + return ( + + !expiration ? ( + + + + ) : ( + formatDate(expiration) + ), + width: '25%', + mobileOptions: { show: false }, + }, + { + field: 'creation', + name: i18n.translate('xpack.security.accountManagement.apiKeys.createdHeader', { + defaultMessage: 'Created', + }), + render: (creation: number, item: ApiKey) => + item.id === createdItemId ? ( + + + + ) : ( + formatDate(creation) + ), + width: '25%', + }, + { + actions, + width: '25%', + }, + ]} + sorting={{ + sort: { + field: 'creation', + direction: 'desc', + }, + }} + pagination={true} + /> + ); +}; diff --git a/x-pack/plugins/security/public/account_management/api_keys_page/create_api_key_flyout.tsx b/x-pack/plugins/security/public/account_management/api_keys_page/create_api_key_flyout.tsx new file mode 100644 index 00000000000000..d876ea75c4282a --- /dev/null +++ b/x-pack/plugins/security/public/account_management/api_keys_page/create_api_key_flyout.tsx @@ -0,0 +1,314 @@ +/* + * 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, { useRef, FunctionComponent } from 'react'; +import { + EuiCallOut, + EuiFieldNumber, + EuiFieldText, + EuiForm, + EuiPanel, + EuiFormRow, + EuiSpacer, + EuiSwitch, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useKibana, CodeEditor } from '../../../../../../src/plugins/kibana_react/public'; +import { + APIKeysAPIClient, + CreateApiKeyRequest, + CreateApiKeyResponse, +} from '../../management/api_keys/api_keys_api_client'; +import { useForm, ValidationErrors } from '../components/use_form'; +import { FormFlyout, FormFlyoutProps } from '../components/form_flyout'; +import { DocLink } from '../components/doc_link'; + +export interface FormValues { + name: string; + expiration: string; + customExpiration: boolean; + customPrivileges: boolean; + role_descriptors: string; +} + +export interface CreateApiKeyFlyoutProps { + defaultValues?: Partial; + onSuccess?: (apiKey: CreateApiKeyResponse) => void; + onError?: (error: Error) => void; + onClose: FormFlyoutProps['onClose']; +} + +export const CreateApiKeyFlyout: FunctionComponent = ({ + onSuccess, + onError, + onClose, + defaultValues, +}) => { + const { services } = useKibana(); + const [form, eventHandlers] = useForm( + { + onSubmit: (values) => new APIKeysAPIClient(services.http!).createApiKey(mapValues(values)), + onSubmitSuccess: onSuccess, + onSubmitError: onError, + validate, + defaultValues, + }, + [services.http] + ); + const nameInput = useRef(null); + + return ( + + {form.submitError && ( + <> + + {(form.submitError as any).body?.message || form.submitError.message} + + + + )} + + + + + + + form.setValue('customPrivileges', e.target.checked)} + /> + {form.values.customPrivileges && ( + <> + + + + + } + error={form.errors.role_descriptors} + isInvalid={!!form.errors.role_descriptors} + > + + form.setValue('role_descriptors', value)} + width="100%" + height="300px" + languageId="xjson" + options={{ + fixedOverflowWidgets: true, + folding: false, + lineNumbers: 'off', + scrollBeyondLastLine: false, + minimap: { + enabled: false, + }, + scrollbar: { + useShadows: false, + }, + wordBasedSuggestions: false, + wordWrap: 'on', + wrappingIndent: 'indent', + }} + /> + + + + )} + + + form.setValue('customExpiration', e.target.checked)} + /> + {form.values.customExpiration && ( + <> + + + + + + )} + + + ); +}; + +CreateApiKeyFlyout.defaultProps = { + defaultValues: { + customExpiration: false, + customPrivileges: false, + role_descriptors: JSON.stringify( + { + 'role-a': { + cluster: ['all'], + indices: [ + { + names: ['index-a*'], + privileges: ['read'], + }, + ], + }, + 'role-b': { + cluster: ['all'], + indices: [ + { + names: ['index-b*'], + privileges: ['all'], + }, + ], + }, + }, + null, + 2 + ), + }, +}; + +export function validate(v: Partial) { + const errors: ValidationErrors = {}; + + if (!v.name) { + errors.name = i18n.translate('xpack.security.management.apiKeys.createApiKey.nameRequired', { + defaultMessage: 'Enter a name.', + }); + } + + if (v.customExpiration && !v.expiration) { + errors.expiration = i18n.translate( + 'xpack.security.management.apiKeys.createApiKey.expirationRequired', + { + defaultMessage: 'Enter a duration or disable the option.', + } + ); + } + + if (v.customPrivileges) { + if (!v.role_descriptors) { + errors.role_descriptors = i18n.translate( + 'xpack.security.management.apiKeys.createApiKey.roleDescriptorsRequired', + { + defaultMessage: 'Enter role descriptors or disable the option.', + } + ); + } else { + try { + JSON.parse(v.role_descriptors); + } catch (e) { + errors.role_descriptors = i18n.translate( + 'xpack.security.management.apiKeys.createApiKey.invalidJsonError', + { + defaultMessage: 'Enter valid JSON.', + } + ); + } + } + } + + return errors; +} + +export function mapValues(values: FormValues): CreateApiKeyRequest { + return { + name: values.name, + expiration: values.customExpiration && values.expiration ? `${values.expiration}d` : undefined, + role_descriptors: + values.customPrivileges && values.role_descriptors + ? JSON.parse(values.role_descriptors) + : undefined, + }; +} diff --git a/x-pack/plugins/security/public/account_management/api_keys_page/index.ts b/x-pack/plugins/security/public/account_management/api_keys_page/index.ts new file mode 100644 index 00000000000000..d4821de33b201d --- /dev/null +++ b/x-pack/plugins/security/public/account_management/api_keys_page/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { ApiKeysEmptyPrompt } from './api_keys_empty_prompt'; +export { ApiKeysPage as default } from './api_keys_page'; // eslint-disable-line import/no-default-export +export { ApiKeysTable } from './api_keys_table'; +export { CreateApiKeyFlyout } from './create_api_key_flyout'; +export { InvalidateApiKeyModal } from './invalidate_api_key_modal'; diff --git a/x-pack/plugins/security/public/account_management/api_keys_page/invalidate_api_key_modal.tsx b/x-pack/plugins/security/public/account_management/api_keys_page/invalidate_api_key_modal.tsx new file mode 100644 index 00000000000000..2150826c53d724 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/api_keys_page/invalidate_api_key_modal.tsx @@ -0,0 +1,98 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText } from '@elastic/eui'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { ApiKeyToInvalidate } from '../../../common/model'; +import { + APIKeysAPIClient, + InvalidateApiKeysResponse, +} from '../../management/api_keys/api_keys_api_client'; +import { useSubmitHandler } from '../components/use_form'; +import { ConfirmModal, ConfirmModalProps } from '../components/confirm_modal'; + +export interface InvalidateApiKeyModalProps { + onCancel: ConfirmModalProps['onCancel']; + onSuccess?(result: InvalidateApiKeysResponse): any; + onError?(error: Error): any; + apiKey: ApiKeyToInvalidate; +} + +export const InvalidateApiKeyModal: FunctionComponent = ({ + onCancel, + onSuccess, + onError, + apiKey, +}) => { + const { services, notifications } = useKibana(); + const [state, invalidateApiKeys] = useSubmitHandler( + { + onSubmit: () => + new APIKeysAPIClient(services.http!).invalidateApiKeys([ + { id: apiKey.id, name: apiKey.name }, + ]), + onSubmitSuccess: (values) => { + notifications.toasts.success({ + iconType: 'check', + title: i18n.translate( + 'xpack.security.accountManagement.invalidateApiKey.successMessage', + { + defaultMessage: 'Invalidated API key “{name}”', + values: { name: apiKey.name }, + } + ), + }); + onSuccess?.(values); + }, + onSubmitError: (error) => { + notifications.toasts.danger({ + iconType: 'alert', + title: i18n.translate('xpack.security.accountManagement.invalidateApiKey.errorMessage', { + defaultMessage: 'Could not invalidate API key “{name}”', + values: { name: apiKey.name }, + }), + body: (error as any).body?.message || error.message, + }); + onError?.(error); + }, + }, + [services.http] + ); + + return ( + + +

+ +

+
+
+ ); +}; diff --git a/x-pack/plugins/security/public/account_management/components/breadcrumb.tsx b/x-pack/plugins/security/public/account_management/components/breadcrumb.tsx new file mode 100644 index 00000000000000..52cd721b658c35 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/components/breadcrumb.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { createContext, useEffect, useRef, FunctionComponent, ReactNode } from 'react'; +import { EuiBreadcrumb } from '@elastic/eui'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; + +/** + * Component that sets breadcrumbs and doc title based on the render tree. + * + * @example + * ```typescript + * + * + * {showForm && ( + * + *
+ *
+ * )} + * + * ``` + */ +export const Breadcrumb: FunctionComponent = ({ children, ...breadcrumb }) => ( + + {(value) => + value ? ( + + {children} + + ) : ( + {children} + ) + } + +); + +interface BreadcrumbContext { + parents: BreadcrumbProps[]; + onMount(breadcrumbs: BreadcrumbProps[]): void; + onUnmount(breadcrumbs: BreadcrumbProps[]): void; +} + +const { Provider, Consumer } = createContext(undefined); + +export interface RootProviderProps { + breadcrumb: BreadcrumbProps; + children: ReactNode; +} + +export const RootProvider: FunctionComponent = ({ breadcrumb, children }) => { + const { services } = useKibana(); + const breadcrumbsRef = useRef([]); + + const setBreadcrumbs = (breadcrumbs: BreadcrumbProps[]) => { + breadcrumbsRef.current = breadcrumbs; + services.chrome?.setBreadcrumbs(breadcrumbs); + services.chrome?.docTitle.change(getDocTitle(breadcrumbs)); + }; + + return ( + { + if (breadcrumbs.length > breadcrumbsRef.current.length) { + setBreadcrumbs(breadcrumbs); + } + }} + onUnmount={(breadcrumbs) => { + if (breadcrumbs.length < breadcrumbsRef.current.length) { + setBreadcrumbs(breadcrumbs); + } + }} + > + {children} + + ); +}; + +export function getDocTitle(breadcrumbs: BreadcrumbProps[]) { + return breadcrumbs + .slice() + .reverse() + .map(({ text }) => text); +} + +export const NestedProvider: FunctionComponent = ({ + parents, + onMount, + onUnmount, + breadcrumb, + children, +}) => { + const nextParents = [...parents, breadcrumb]; + + useEffect(() => { + onMount(nextParents); + return () => onUnmount(parents); + }, [breadcrumb.text, breadcrumb.href]); // eslint-disable-line react-hooks/exhaustive-deps + + return {children}; +}; + +export interface BreadcrumbProps extends EuiBreadcrumb { + text: string; +} diff --git a/x-pack/plugins/security/public/account_management/components/confirm_modal.tsx b/x-pack/plugins/security/public/account_management/components/confirm_modal.tsx new file mode 100644 index 00000000000000..ece2dd000490ea --- /dev/null +++ b/x-pack/plugins/security/public/account_management/components/confirm_modal.tsx @@ -0,0 +1,75 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { + EuiButton, + EuiButtonProps, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalProps, + EuiOverlayMask, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export interface ConfirmModalProps extends Omit { + confirmButtonColor?: EuiButtonProps['color']; + confirmButtonText: string; + isLoading?: EuiButtonProps['isLoading']; + onCancel(): void; + onConfirm(): void; + ownFocus?: boolean; +} + +export const ConfirmModal: FunctionComponent = ({ + children, + confirmButtonColor, + confirmButtonText, + isLoading, + onCancel, + onConfirm, + ownFocus, + title, + ...rest +}) => { + const modal = ( + + + {title} + + {children} + + + + + + + + + + {confirmButtonText} + + + + + + ); + + return ownFocus ? {modal} : modal; +}; + +ConfirmModal.defaultProps = { + ownFocus: true, +}; diff --git a/x-pack/plugins/security/public/account_management/components/doc_link.tsx b/x-pack/plugins/security/public/account_management/components/doc_link.tsx new file mode 100644 index 00000000000000..6faa675bdb8299 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/components/doc_link.tsx @@ -0,0 +1,39 @@ +/* + * 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, { useCallback, FunctionComponent } from 'react'; +import { EuiLink } from '@elastic/eui'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { CoreStart } from '../../../../../../src/core/public'; + +export type DocLinks = CoreStart['docLinks']['links']; +export type GetDocLinkFunction = (app: string, doc: string) => string; + +export function useDocLinks(): [DocLinks, GetDocLinkFunction] { + const { services } = useKibana(); + const { links, ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = services.docLinks!; + const getDocLink = useCallback( + (app, doc) => { + return `${ELASTIC_WEBSITE_URL}guide/en/${app}/reference/${DOC_LINK_VERSION}/${doc}`; + }, + [ELASTIC_WEBSITE_URL, DOC_LINK_VERSION] + ); + return [links, getDocLink]; +} + +export interface DocLinkProps { + app: string; + doc: string; +} + +export const DocLink: FunctionComponent = ({ app, doc, children }) => { + const [, getDocLink] = useDocLinks(); + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/security/public/account_management/components/field_text_with_copy_button.tsx b/x-pack/plugins/security/public/account_management/components/field_text_with_copy_button.tsx new file mode 100644 index 00000000000000..5c32b74f5af6bf --- /dev/null +++ b/x-pack/plugins/security/public/account_management/components/field_text_with_copy_button.tsx @@ -0,0 +1,34 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { EuiButtonIcon, EuiCopy, EuiFieldText, EuiFieldTextProps } from '@elastic/eui'; + +export interface FieldTextWithCopyButtonProps extends Omit { + value: string; +} + +export const FieldTextWithCopyButton: FunctionComponent = (props) => ( + + {(copyText) => ( + + )} + + } + /> +); + +FieldTextWithCopyButton.defaultProps = { + readOnly: true, +}; diff --git a/x-pack/plugins/security/public/account_management/components/form_flyout.tsx b/x-pack/plugins/security/public/account_management/components/form_flyout.tsx new file mode 100644 index 00000000000000..92d282bc972ccf --- /dev/null +++ b/x-pack/plugins/security/public/account_management/components/form_flyout.tsx @@ -0,0 +1,82 @@ +/* + * 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, FunctionComponent, MouseEventHandler, RefObject } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiFlyout, + EuiFlyoutProps, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonProps, + EuiButtonEmpty, + EuiPortal, +} from '@elastic/eui'; + +export interface FormFlyoutProps extends Omit { + title: string; + isLoading?: EuiButtonProps['isLoading']; + initialFocus?: RefObject; + onSubmit: MouseEventHandler; + submitButtonText: string; + submitButtonColor?: EuiButtonProps['color']; +} + +export const FormFlyout: FunctionComponent = ({ + title, + submitButtonText, + submitButtonColor, + onSubmit, + isLoading, + children, + initialFocus, + ...rest +}) => { + useEffect(() => { + if (initialFocus && initialFocus.current) { + initialFocus.current.focus(); + } + }, [initialFocus]); + + const flyout = ( + + + +

{title}

+
+
+ {children} + + + + + + + + + + {submitButtonText} + + + + +
+ ); + + return rest.ownFocus ? {flyout} : flyout; +}; + +FormFlyout.defaultProps = { + ownFocus: true, +}; diff --git a/x-pack/plugins/security/public/account_management/components/tabbed_routes.tsx b/x-pack/plugins/security/public/account_management/components/tabbed_routes.tsx new file mode 100644 index 00000000000000..0acbbd08ba68eb --- /dev/null +++ b/x-pack/plugins/security/public/account_management/components/tabbed_routes.tsx @@ -0,0 +1,43 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { EuiTabs, EuiTab, EuiTabbedContentTab, EuiSpacer } from '@elastic/eui'; +import { Switch, Route, RouteProps, RedirectProps } from 'react-router-dom'; +import { reactRouterNavigate } from '../../../../../../src/plugins/kibana_react/public'; + +export type TabbedRoutesTab = EuiTabbedContentTab & + Pick & + Pick; + +export interface TabbedRoutesProps { + routes: TabbedRoutesTab[]; +} +export const TabbedRoutes: FunctionComponent = ({ routes }) => { + return ( + <> + + {routes.map((route) => ( + + {({ match, history }) => ( + + {route.name} + + )} + + ))} + + + + {routes.map((route) => ( + + {route.content} + + ))} + + + ); +}; diff --git a/x-pack/plugins/security/public/account_management/components/use_form.ts b/x-pack/plugins/security/public/account_management/components/use_form.ts new file mode 100644 index 00000000000000..1b9cadfba46809 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/components/use_form.ts @@ -0,0 +1,235 @@ +/* + * 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 { + useState, + useEffect, + DependencyList, + FocusEventHandler, + ChangeEventHandler, + ReactEventHandler, +} from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +export interface FormOptions extends SubmitHandlerOptions { + validate: ValidateFunction; + defaultValues?: Partial; +} + +export interface FormProps { + onSubmit: ReactEventHandler; + onChange: ChangeEventHandler; + onBlur: FocusEventHandler; + noValidate: boolean; +} + +export type FormReturnTuple = [ + ValidationState & SubmitState, + FormProps +]; + +/** + * Returns state and {@link HTMLFormElement} event handlers useful for creating + * forms with inline validation. + * + * @see {@link useInlineValidation} if you don't want to use {@link HTMLFormElement}. + * @see {@link useSubmitHandler} if you don't require inline validation. + * + * @example + * ```typescript + * const [form, eventHandlers] = useForm({ + * onSubmit: (values) => apiClient.create(values), + * validate: (values) => !values.email ? { email: 'Required' } : {} + * }); + * + * + * + * Submit + * + * ``` + */ +export function useForm( + options: FormOptions, + deps?: DependencyList +): FormReturnTuple { + const [submitState, submit] = useSubmitHandler(options, deps); + const validationState = useInlineValidation(options.validate, options.defaultValues); + + const eventHandlers: FormProps = { + onSubmit: (event) => { + event.preventDefault(); + validationState.handleSubmit(submit); + }, + onChange: (event) => { + const { name, type, checked, value } = event.target; + validationState.setValue(name, type === 'checkbox' ? checked : value); + }, + onBlur: (event) => { + validationState.trigger(event.target.name); + }, + noValidate: true, // Native browser validation gets in the way of EUI + }; + + return [{ ...validationState, ...submitState }, eventHandlers]; +} + +export type ValidateFunction = (values: Partial) => ValidationErrors; +export type ValidationErrors = Partial>; +export type DirtyFields = Partial>; +export type SubmitCallback = (values: Values) => Promise; + +export interface ValidationState { + setValue(name: string, value: any): void; + setError(name: string, message: string): void; + trigger(name: string): void; + handleSubmit(onSubmit: SubmitCallback): Promise; + values: Partial; + errors: ValidationErrors; + isInvalid: boolean; + isSubmitted: boolean; +} + +/** + * Returns state useful for creating forms with inline validation. + * + * @example + * ```typescript + * const form = useInlineValidation((values) => !values.toggle ? { toggle: 'Required' } : {}); + * + * form.setValue('toggle', e.target.checked)} + * onBlur={() => form.trigger('toggle')} + * isInvalid={!!form.errors.toggle} + * /> + * apiClient.create(values))}> + * Submit + * + * ``` + */ +export function useInlineValidation( + validate: ValidateFunction, + defaultValues: Partial = {} +): ValidationState { + const [values, setValues] = useState>(defaultValues); + const [errors, setErrors] = useState>({}); + const [dirty, setDirty] = useState>({}); + const [submitCount, setSubmitCount] = useState(0); + + const isSubmitted = submitCount > 0; + const isInvalid = Object.keys(errors).length > 0; + + const inlineValidation = (nextValues: Partial, nextDirty: DirtyFields) => { + const nextErrors = getIntersection(validate(nextValues), nextDirty); + setErrors(nextErrors); + if (Object.keys(nextErrors).length === 0) { + setSubmitCount(0); + } + }; + + return { + setValue: (name, value) => { + const nextValues = { ...values, [name]: value }; + setValues(nextValues); + inlineValidation(nextValues, dirty); + }, + setError: (name, message) => { + setErrors({ ...errors, [name]: message }); + setDirty({ ...dirty, [name]: true }); + }, + trigger: (name) => { + const nextDirty = { ...dirty, [name]: true }; + setDirty(nextDirty); + inlineValidation(values, nextDirty); + }, + handleSubmit: async (onSubmit) => { + const nextErrors = validate(values); + setDirty({ ...dirty, ...nextErrors }); + setErrors(nextErrors); + setSubmitCount(submitCount + 1); + if (Object.keys(nextErrors).length === 0) { + return await onSubmit(values as Values); + } + }, + values, + errors, + isInvalid, + isSubmitted, + }; +} + +export function getIntersection( + errors: ValidationErrors, + dirty: DirtyFields +) { + const names = Object.keys(errors) as Array; + return names.reduce>((acc, name) => { + if (dirty[name]) { + acc[name] = errors[name]; + } + return acc; + }, {}); +} + +export type AsyncFunction = (...args: any[]) => Promise; + +export interface SubmitHandlerOptions { + onSubmit: AsyncFunction; + onSubmitSuccess?: (result: Result) => void; + onSubmitError?: (error: Error) => void; +} + +export interface SubmitState { + isSubmitting: boolean; + submitError?: Error; + submitResult?: Result; +} + +export type SubmitHandlerReturnTuple = [SubmitState, AsyncFunction]; + +/** + * Tracks state of an async function and triggers callbacks with the outcome. + * + * @example + * ```typescript + * const [state, deleteUser] = useSubmitHandler({ + * onSubmit: () => apiClient.deleteUser(), + * onSubmitSuccess: (result) => toasts.addSuccess('Deleted user'), + * onSubmitError: (error) => toasts.addError('Could not delete user'), + * }); + * + * + * Delete user + * + * ``` + */ +export function useSubmitHandler( + { onSubmit, onSubmitSuccess, onSubmitError }: SubmitHandlerOptions, + deps?: DependencyList +): SubmitHandlerReturnTuple { + const [state, callback] = useAsyncFn(onSubmit, deps); + + useEffect(() => { + if (state.value && onSubmitSuccess) { + onSubmitSuccess(state.value); + } + }, [state.value]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (state.error && onSubmitError) { + onSubmitError(state.error); + } + }, [state.error]); // eslint-disable-line react-hooks/exhaustive-deps + + return [ + { + isSubmitting: state.loading, + submitError: state.loading ? undefined : state.error, + submitResult: state.value, + }, + callback, + ]; +} diff --git a/x-pack/plugins/security/public/account_management/personal_info/index.ts b/x-pack/plugins/security/public/account_management/personal_info/index.ts deleted file mode 100644 index 5980157f5b76e2..00000000000000 --- a/x-pack/plugins/security/public/account_management/personal_info/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * 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 { PersonalInfo } from './personal_info'; diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts index 2a45d497029f41..fac8f2e1b5b24a 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts @@ -4,10 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import type { PublicMethodsOf } from '@kbn/utility-types'; +import { APIKeysAPIClient } from './api_keys_api_client'; + export const apiKeysAPIClientMock = { - create: () => ({ + create: (): jest.Mocked> => ({ checkPrivileges: jest.fn(), getApiKeys: jest.fn(), invalidateApiKeys: jest.fn(), + createApiKey: jest.fn(), }), }; diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts index a127379d972413..7d78a5ae5e9aa8 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts @@ -5,23 +5,38 @@ */ import { HttpStart } from 'src/core/public'; -import { ApiKey, ApiKeyToInvalidate } from '../../../common/model'; +import { ApiKey, ApiKeyToInvalidate, Role } from '../../../common/model'; -interface CheckPrivilegesResponse { +export interface CheckPrivilegesResponse { areApiKeysEnabled: boolean; isAdmin: boolean; canManage: boolean; } -interface InvalidateApiKeysResponse { +export interface InvalidateApiKeysResponse { itemsInvalidated: ApiKeyToInvalidate[]; errors: any[]; } -interface GetApiKeysResponse { +export interface GetApiKeysResponse { apiKeys: ApiKey[]; } +export interface CreateApiKeyRequest { + name: string; + expiration?: string; + role_descriptors?: { + [key in string]: Role['elasticsearch']; + }; +} + +export interface CreateApiKeyResponse { + id: string; + name: string; + expiration: number; + api_key: string; +} + const apiKeysUrl = '/internal/security/api_key'; export class APIKeysAPIClient { @@ -40,4 +55,10 @@ export class APIKeysAPIClient { body: JSON.stringify({ apiKeys, isAdmin }), }); } + + public async createApiKey(apiKey: CreateApiKeyRequest) { + return await this.http.post(apiKeysUrl, { + body: JSON.stringify(apiKey), + }); + } } diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/empty_prompt/empty_prompt.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/empty_prompt/empty_prompt.tsx index 9b2ccfcb99ef30..330637087d9d8c 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/empty_prompt/empty_prompt.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/empty_prompt/empty_prompt.tsx @@ -7,7 +7,7 @@ import React, { Fragment } from 'react'; import { ApplicationStart } from 'kibana/public'; -import { EuiEmptyPrompt, EuiButton, EuiLink } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { DocumentationLinksService } from '../../documentation_links'; @@ -44,17 +44,7 @@ export const EmptyPrompt: React.FunctionComponent = ({

- - - ), - }} + defaultMessage="You can create an API key from your account." />

@@ -62,12 +52,12 @@ export const EmptyPrompt: React.FunctionComponent = ({ actions={ navigateToApp('dev_tools')} - data-test-subj="goToConsoleButton" + onClick={() => navigateToApp('security_account', { path: 'api-keys' })} + data-test-subj="goToAccountButton" > } diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index c22308fa8a43e0..ee90eb1379d0ab 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -127,6 +127,7 @@ export class SecurityNavControl extends Component { ), icon: , href: editProfileUrl, + onClick: this.closeMenu, 'data-test-subj': 'profileLink', }; @@ -139,6 +140,7 @@ export class SecurityNavControl extends Component { ), icon: , href: logoutUrl, + onClick: this.closeMenu, 'data-test-subj': 'logoutLink', }; @@ -153,6 +155,7 @@ export class SecurityNavControl extends Component { name: {label}, icon: , href, + onClick: this.closeMenu, 'data-test-subj': `userMenuLink__${label}`, })); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts index 5b9788d67500b9..4c3c733b771b71 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts @@ -56,34 +56,38 @@ describe('SecurityNavControlService', () => { expect(target).toMatchInlineSnapshot(`
- + +
+ +
diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index 5d2e7d7dfb7336..93c5f3dc625d04 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -11,7 +11,7 @@ import { CoreStart } from 'src/core/public'; import ReactDOM from 'react-dom'; import React from 'react'; - +import { RedirectAppLinks } from '../../../../../src/plugins/kibana_react/public'; import { SecurityLicense } from '../../common/licensing'; import { SecurityNavControl, UserMenuLink } from './nav_control_component'; import { AuthenticationServiceSetup } from '../authentication'; @@ -106,7 +106,9 @@ export class SecurityNavControlService { }; ReactDOM.render( - + + + , el ); diff --git a/x-pack/plugins/security/server/routes/api_keys/create.test.ts b/x-pack/plugins/security/server/routes/api_keys/create.test.ts new file mode 100644 index 00000000000000..6f4c2a9b85c48b --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/create.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { routeDefinitionParamsMock } from '../index.mock'; +import { defineCreateApiKeyRoutes } from './create'; + +jest.mock('../licensed_route_handler'); + +const createLicensedRouteHandlerMock = createLicensedRouteHandler as jest.Mock; +createLicensedRouteHandlerMock.mockImplementation((handler) => handler); + +const contextMock = ({} as unknown) as RequestHandlerContext; + +describe('defineCreateApiKeyRoutes', () => { + beforeEach(() => { + createLicensedRouteHandlerMock.mockClear(); + }); + + test('creates licensed route handler', () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + defineCreateApiKeyRoutes(mockRouteDefinitionParams); + expect(createLicensedRouteHandlerMock).toHaveBeenCalledTimes(1); + }); + + test('validates request body', () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + defineCreateApiKeyRoutes(mockRouteDefinitionParams); + const [[route]] = mockRouteDefinitionParams.router.post.mock.calls; + expect(() => (route.validate as any).body.validate({})).toThrow( + '[name]: expected value of type [string] but got [undefined]' + ); + }); + + test('creates API key', async () => { + const createAPIKeyResult = { + id: 'ID', + name: 'NAME', + api_key: 'API_KEY', + }; + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authc.createAPIKey.mockResolvedValue(createAPIKeyResult); + defineCreateApiKeyRoutes(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.post.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'post', + path: '/internal/security/api_key', + body: {}, + }); + const response = await handler(contextMock, mockRequest, kibanaResponseFactory); + expect(mockRouteDefinitionParams.authc.createAPIKey).toHaveBeenCalledWith( + mockRequest, + mockRequest.body + ); + expect(response.status).toBe(200); + expect(response.payload).toEqual(createAPIKeyResult); + }); + + test('returns bad request if api keys are not available', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authc.createAPIKey.mockResolvedValue(null); + defineCreateApiKeyRoutes(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.post.mock.calls; + + const response = await handler( + contextMock, + httpServerMock.createKibanaRequest({ + method: 'post', + path: '/internal/security/api_key', + body: {}, + }), + kibanaResponseFactory + ); + expect(response.status).toBe(400); + expect(response.payload).toEqual({ message: 'API Keys are not available' }); + }); + + test('returns bad request if api key could not be created', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authc.createAPIKey.mockRejectedValue(new Error('Error')); + defineCreateApiKeyRoutes(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.post.mock.calls; + + const response = await handler( + contextMock, + httpServerMock.createKibanaRequest({ + method: 'post', + path: '/internal/security/api_key', + body: {}, + }), + kibanaResponseFactory + ); + expect(response.status).toBe(500); + }); +}); diff --git a/x-pack/plugins/security/server/routes/api_keys/create.ts b/x-pack/plugins/security/server/routes/api_keys/create.ts new file mode 100644 index 00000000000000..2bb6c077d16c29 --- /dev/null +++ b/x-pack/plugins/security/server/routes/api_keys/create.ts @@ -0,0 +1,44 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { RouteDefinitionParams } from '..'; + +export function defineCreateApiKeyRoutes({ router, authc }: RouteDefinitionParams) { + router.post( + { + path: '/internal/security/api_key', + validate: { + body: schema.object({ + name: schema.string(), + expiration: schema.maybe(schema.string()), + role_descriptors: schema.recordOf( + schema.string(), + schema.object({}, { unknowns: 'allow' }), + { + defaultValue: {}, + } + ), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const apiKey = await authc.createAPIKey(request, request.body); + + if (!apiKey) { + return response.badRequest({ body: { message: `API Keys are not available` } }); + } + + return response.ok({ body: apiKey }); + } catch (error) { + return response.customError(wrapIntoCustomErrorResponse(error)); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/api_keys/index.ts b/x-pack/plugins/security/server/routes/api_keys/index.ts index 7ac37bbead6136..aa148732e78708 100644 --- a/x-pack/plugins/security/server/routes/api_keys/index.ts +++ b/x-pack/plugins/security/server/routes/api_keys/index.ts @@ -8,6 +8,7 @@ import { defineGetApiKeysRoutes } from './get'; import { defineCheckPrivilegesRoutes } from './privileges'; import { defineInvalidateApiKeysRoutes } from './invalidate'; import { defineEnabledApiKeysRoutes } from './enabled'; +import { defineCreateApiKeyRoutes } from './create'; import { RouteDefinitionParams } from '..'; export function defineApiKeysRoutes(params: RouteDefinitionParams) { @@ -15,4 +16,5 @@ export function defineApiKeysRoutes(params: RouteDefinitionParams) { defineGetApiKeysRoutes(params); defineCheckPrivilegesRoutes(params); defineInvalidateApiKeysRoutes(params); + defineCreateApiKeyRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts index e0af14f90d01c5..5660dfe5057582 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts @@ -15,7 +15,7 @@ import { ElasticsearchRole } from './elasticsearch_role'; * Elasticsearch specific portion of the role definition. * See more details at https://www.elastic.co/guide/en/elasticsearch/reference/master/security-api.html#security-role-apis. */ -const elasticsearchRoleSchema = schema.object({ +export const elasticsearchRoleSchema = schema.object({ /** * An optional list of cluster privileges. These privileges define the cluster level actions that * users with this role are able to execute diff --git a/x-pack/plugins/security/server/routes/views/account_management.ts b/x-pack/plugins/security/server/routes/views/account_management.ts index 696a5e12b64c13..2ccca46ea3418f 100644 --- a/x-pack/plugins/security/server/routes/views/account_management.ts +++ b/x-pack/plugins/security/server/routes/views/account_management.ts @@ -13,4 +13,12 @@ export function defineAccountManagementRoutes({ httpResources }: RouteDefinition httpResources.register({ path: '/security/account', validate: false }, (context, req, res) => res.renderCoreApp() ); + httpResources.register( + { path: '/security/account/api-keys', validate: false }, + (context, req, res) => res.renderCoreApp() + ); + httpResources.register( + { path: '/security/account/api-keys/create', validate: false }, + (context, req, res) => res.renderCoreApp() + ); } diff --git a/x-pack/plugins/security/server/routes/views/index.test.ts b/x-pack/plugins/security/server/routes/views/index.test.ts index fa2088a80b1833..1be0a8e11a0fe8 100644 --- a/x-pack/plugins/security/server/routes/views/index.test.ts +++ b/x-pack/plugins/security/server/routes/views/index.test.ts @@ -22,6 +22,8 @@ describe('View routes', () => { Array [ "/security/access_agreement", "/security/account", + "/security/account/api-keys", + "/security/account/api-keys/create", "/security/logged_out", "/logout", "/security/overwritten_session", @@ -49,6 +51,8 @@ describe('View routes', () => { "/login", "/security/access_agreement", "/security/account", + "/security/account/api-keys", + "/security/account/api-keys/create", "/security/logged_out", "/logout", "/security/overwritten_session", @@ -77,6 +81,8 @@ describe('View routes', () => { "/login", "/security/access_agreement", "/security/account", + "/security/account/api-keys", + "/security/account/api-keys/create", "/security/logged_out", "/logout", "/security/overwritten_session", @@ -105,6 +111,8 @@ describe('View routes', () => { "/login", "/security/access_agreement", "/security/account", + "/security/account/api-keys", + "/security/account/api-keys/create", "/security/logged_out", "/logout", "/security/overwritten_session", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8c4a66975d6efd..fb6183ae665812 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -15899,9 +15899,6 @@ "xpack.security.management.apiKeys.table.apiKeysTitle": "API キー", "xpack.security.management.apiKeys.table.creationDateColumnName": "作成済み", "xpack.security.management.apiKeys.table.emptyPromptAdminTitle": "API キーがありません", - "xpack.security.management.apiKeys.table.emptyPromptConsoleButtonMessage": "コンソールに移動してください", - "xpack.security.management.apiKeys.table.emptyPromptDescription": "コンソールで {link} を作成できます。", - "xpack.security.management.apiKeys.table.emptyPromptDocsLinkMessage": "API キー", "xpack.security.management.apiKeys.table.emptyPromptNonAdminTitle": "まだ API キーがありません", "xpack.security.management.apiKeys.table.expirationDateColumnName": "有効期限", "xpack.security.management.apiKeys.table.expirationDateNeverMessage": "無し", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 60b1a33c8f5c97..78b0a9d20cf3b2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -15917,9 +15917,6 @@ "xpack.security.management.apiKeys.table.apiKeysTitle": "API 密钥", "xpack.security.management.apiKeys.table.creationDateColumnName": "创建时间", "xpack.security.management.apiKeys.table.emptyPromptAdminTitle": "无 API 密钥", - "xpack.security.management.apiKeys.table.emptyPromptConsoleButtonMessage": "前往 Console", - "xpack.security.management.apiKeys.table.emptyPromptDescription": "您可以从 Console 创建 {link}。", - "xpack.security.management.apiKeys.table.emptyPromptDocsLinkMessage": "API 密钥", "xpack.security.management.apiKeys.table.emptyPromptNonAdminTitle": "您未有任何 API 密钥", "xpack.security.management.apiKeys.table.expirationDateColumnName": "过期", "xpack.security.management.apiKeys.table.expirationDateNeverMessage": "永远不", diff --git a/x-pack/test/functional/apps/api_keys/home_page.ts b/x-pack/test/functional/apps/api_keys/home_page.ts index 39d8449218ffad..52355aba3a5c7b 100644 --- a/x-pack/test/functional/apps/api_keys/home_page.ts +++ b/x-pack/test/functional/apps/api_keys/home_page.ts @@ -34,8 +34,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const headers = await testSubjects.findAll('noApiKeysHeader'); if (headers.length > 0) { expect(await headers[0].getVisibleText()).to.be('No API keys'); - const goToConsoleButton = await pageObjects.apiKeys.getGoToConsoleButton(); - expect(await goToConsoleButton.isDisplayed()).to.be(true); + const goToAccountButton = await pageObjects.apiKeys.getGoToAccountButton(); + expect(await goToAccountButton.isDisplayed()).to.be(true); } else { // page may already contain EiTable with data, then check API Key Admin text const description = await pageObjects.apiKeys.getApiKeyAdminDesc(); diff --git a/x-pack/test/functional/page_objects/api_keys_page.ts b/x-pack/test/functional/page_objects/api_keys_page.ts index fa10c5a574c095..d1aeda653f8a0b 100644 --- a/x-pack/test/functional/page_objects/api_keys_page.ts +++ b/x-pack/test/functional/page_objects/api_keys_page.ts @@ -18,8 +18,8 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { return await testSubjects.getVisibleText('apiKeyAdminDescriptionCallOut'); }, - async getGoToConsoleButton() { - return await testSubjects.find('goToConsoleButton'); + async getGoToAccountButton() { + return await testSubjects.find('goToAccountButton'); }, async apiKeysPermissionDeniedMessage() { diff --git a/x-pack/test/security_functional/account_management.config.ts b/x-pack/test/security_functional/account_management.config.ts new file mode 100644 index 00000000000000..9c336fc0b833b2 --- /dev/null +++ b/x-pack/test/security_functional/account_management.config.ts @@ -0,0 +1,51 @@ +/* + * 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 { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services } from '../functional/services'; +import { pageObjects } from '../functional/page_objects'; + +// the default export of config files must be a config provider +// that returns an object with the projects config values +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const kibanaCommonConfig = await readConfigFile( + require.resolve('../../../test/common/config.js') + ); + const kibanaFunctionalConfig = await readConfigFile( + require.resolve('../../../test/functional/config.js') + ); + + return { + testFiles: [resolve(__dirname, './tests/account_management')], + services, + pageObjects, + servers: kibanaFunctionalConfig.get('servers'), + esTestCluster: { + ...kibanaCommonConfig.get('esTestCluster'), + license: 'trial', + serverArgs: [ + ...kibanaCommonConfig.get('esTestCluster.serverArgs'), + 'xpack.security.authc.api_key.enabled=true', + ], + }, + kbnTestServer: kibanaCommonConfig.get('kbnTestServer'), + apps: { + ...kibanaFunctionalConfig.get('apps'), + accountManagement: { + pathname: '/security/account', + }, + accountManagementApiKeys: { + pathname: '/security/account/api-keys', + }, + }, + esArchiver: { directory: resolve(__dirname, 'es_archives') }, + screenshots: { directory: resolve(__dirname, 'screenshots') }, + junit: { + reportName: 'Chrome X-Pack Security Functional Tests (Account Management)', + }, + }; +} diff --git a/x-pack/test/security_functional/tests/account_management/api_keys_page.ts b/x-pack/test/security_functional/tests/account_management/api_keys_page.ts new file mode 100644 index 00000000000000..90f1e3fc222853 --- /dev/null +++ b/x-pack/test/security_functional/tests/account_management/api_keys_page.ts @@ -0,0 +1,43 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const pageObjects = getPageObjects(['common']); + const log = getService('log'); + const find = getService('find'); + const security = getService('security'); + + describe('API keys page', function () { + before(async () => { + await security.role.create('allow_manage_own_api_key_role', { + elasticsearch: { + cluster: ['manage_own_api_key'], + }, + }); + await security.testUser.setRoles(['allow_manage_own_api_key_role']); + await pageObjects.common.navigateToApp('accountManagementApiKeys'); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('creates API key', async () => { + log.debug('Checking for section header'); + + await find.clickByLinkText('Create API key'); + + const nameInput = await find.byName('name'); + await nameInput.type('test'); + + await find.clickByButtonText('Create API key'); + + await find.byCssSelector('.euiCallOut--success'); + }); + }); +} diff --git a/x-pack/test/security_functional/tests/account_management/index.ts b/x-pack/test/security_functional/tests/account_management/index.ts new file mode 100644 index 00000000000000..a39109faf6b89c --- /dev/null +++ b/x-pack/test/security_functional/tests/account_management/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('security app - Account Management', function () { + this.tags('ciGroup4'); + + loadTestFile(require.resolve('./api_keys_page')); + }); +} diff --git a/yarn.lock b/yarn.lock index b59c134968c18f..bf61486122f503 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1192,13 +1192,20 @@ dependencies: regenerator-runtime "^0.12.0" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.11.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.1.2": + version "7.12.1" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.1.tgz#b4116a6b6711d010b2dad3b7b6e43bf1b9954740" + integrity sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.10.4", "@babel/template@^7.3.3", "@babel/template@^7.4.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" @@ -5008,10 +5015,10 @@ dependencies: "@types/sizzle" "*" -"@types/js-cookie@2.2.5": - version "2.2.5" - resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.5.tgz#38dfaacae8623b37cc0b0d27398e574e3fc28b1e" - integrity sha512-cpmwBRcHJmmZx0OGU7aPVwGWGbs4iKwVYchk9iuMtxNCA2zorwdaTz4GkLgs2WGxiRZRFKnV1k6tRUHX7tBMxg== +"@types/js-cookie@2.2.6": + version "2.2.6" + resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.6.tgz#f1a1cb35aff47bc5cfb05cb0c441ca91e914c26f" + integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw== "@types/js-search@^1.4.0": version "1.4.0" @@ -6264,10 +6271,10 @@ dependencies: tslib "^1.9.3" -"@xobotyi/scrollbar-width@1.9.4": - version "1.9.4" - resolved "https://registry.yarnpkg.com/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.4.tgz#a7dce20b7465bcad29cd6bbb557695e4ea7863cb" - integrity sha512-o12FCQt/X5n3pgKEWGpt0f/7Eg4mfv3uRwPUrctiOT8ZuxbH3cNLGWfH/8y6KxVJg4L2885ucuXQ6XECZzUiJA== +"@xobotyi/scrollbar-width@1.9.5": + version "1.9.5" + resolved "https://registry.yarnpkg.com/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz#80224a6919272f405b87913ca13b92929bdf3c4d" + integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ== "@xtuc/ieee754@^1.2.0": version "1.2.0" @@ -10142,13 +10149,20 @@ copy-props@^2.0.1: each-props "^1.3.0" is-plain-object "^2.0.1" -copy-to-clipboard@^3.0.8, copy-to-clipboard@^3.2.0: +copy-to-clipboard@^3.0.8: version "3.2.0" resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.2.0.tgz#d2724a3ccbfed89706fac8a894872c979ac74467" integrity sha512-eOZERzvCmxS8HWzugj4Uxl8OJxa7T2k1Gi0X5qavwydHIfuSHq2dTD09LOg/XyGq4Zpb5IsR/2OJ5lbOegz78w== dependencies: toggle-selection "^1.0.6" +copy-to-clipboard@^3.2.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz#115aa1a9998ffab6196f93076ad6da3b913662ae" + integrity sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw== + dependencies: + toggle-selection "^1.0.6" + copy-webpack-plugin@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-6.0.2.tgz#10efc6ad219a61acbf2f5fb50af83da38431bc34" @@ -10529,7 +10543,7 @@ css-to-react-native@^3.0.0: css-color-keywords "^1.0.0" postcss-value-parser "^4.0.2" -css-tree@1.0.0-alpha.37, css-tree@^1.0.0-alpha.28: +css-tree@1.0.0-alpha.37: version "1.0.0-alpha.37" resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.37.tgz#98bebd62c4c1d9f960ec340cf9f7522e30709a22" integrity sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg== @@ -10537,6 +10551,14 @@ css-tree@1.0.0-alpha.37, css-tree@^1.0.0-alpha.28: mdn-data "2.0.4" source-map "^0.6.1" +css-tree@^1.0.0-alpha.28: + version "1.0.0-alpha.39" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.39.tgz#2bff3ffe1bb3f776cf7eefd91ee5cba77a149eeb" + integrity sha512-7UvkEYgBAHRG9Nt980lYxjsTrCyHFN53ky3wVsDkiMdVqylqRt+Zc+jm5qw7/qyOvN2dHSYtX0e4MbCCExSvnA== + dependencies: + mdn-data "2.0.6" + source-map "^0.6.1" + css-what@2.1, css-what@^2.1.2: version "2.1.3" resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" @@ -10612,11 +10634,16 @@ cssstyle@^2.2.0: dependencies: cssom "~0.3.6" -csstype@^2.2.0, csstype@^2.5.5, csstype@^2.5.7, csstype@^2.6.7: +csstype@^2.2.0, csstype@^2.5.7, csstype@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.7.tgz#20b0024c20b6718f4eda3853a1f5a1cce7f5e4a5" integrity sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ== +csstype@^2.5.5: + version "2.6.13" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.13.tgz#a6893015b90e84dd6e85d0e3b442a1e84f2dbe0f" + integrity sha512-ul26pfSQTZW8dcOnD2iiJssfXw0gdNVX9IJDH/X3K5DGPfj+fUYe3kB+swUY6BF3oZDxaID3AJt+9/ojSAE05A== + cucumber-expressions@^5.0.13: version "5.0.18" resolved "https://registry.yarnpkg.com/cucumber-expressions/-/cucumber-expressions-5.0.18.tgz#6c70779efd3aebc5e9e7853938b1110322429596" @@ -12382,7 +12409,7 @@ error-stack-parser@^1.3.5: dependencies: stackframe "^0.3.1" -error-stack-parser@^2.0.4, error-stack-parser@^2.0.6: +error-stack-parser@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.0.6.tgz#5a99a707bd7a4c58a797902d48d82803ede6aad8" integrity sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ== @@ -13338,7 +13365,7 @@ fancy-log@^1.3.2: color-support "^1.1.3" time-stamp "^1.0.0" -fast-deep-equal@^3.1.1, fast-deep-equal@~3.1.3: +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3, fast-deep-equal@~3.1.3: version "3.1.1" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4" integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA== @@ -16017,9 +16044,9 @@ hyperlinker@^1.0.0: integrity sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ== hyphenate-style-name@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48" - integrity sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ== + version "1.0.4" + resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d" + integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ== i18n-iso-countries@^4.3.1: version "4.3.1" @@ -19643,6 +19670,11 @@ mdn-data@2.0.4: resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" integrity sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA== +mdn-data@2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978" + integrity sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA== + mdurl@^1.0.0, mdurl@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" @@ -20421,9 +20453,9 @@ nan@^2.12.1, nan@^2.13.2, nan@^2.14.0, nan@^2.14.1: integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== nano-css@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/nano-css/-/nano-css-5.2.1.tgz#73b8470fa40b028a134d3393ae36bbb34b9fa332" - integrity sha512-T54okxMAha0+de+W8o3qFtuWhTxYvqQh2ku1cYEqTTP9mR62nWV2lLK9qRuAGWmoaYWhU7K4evT9Lc1iF65wuw== + version "5.3.0" + resolved "https://registry.yarnpkg.com/nano-css/-/nano-css-5.3.0.tgz#9d3cd29788d48b6a07f52aa4aec7cf4da427b6b5" + integrity sha512-uM/9NGK9/E9/sTpbIZ/bQ9xOLOIHZwrrb/CRlbDHBU/GFS7Gshl24v/WJhwsVViWkpOXUmiZ66XO7fSB4Wd92Q== dependencies: css-tree "^1.0.0-alpha.28" csstype "^2.5.5" @@ -23581,24 +23613,30 @@ react-transition-group@^4.3.0: loose-envify "^1.4.0" prop-types "^15.6.2" -react-use@^13.27.0: - version "13.27.0" - resolved "https://registry.yarnpkg.com/react-use/-/react-use-13.27.0.tgz#53a619dc9213e2cbe65d6262e8b0e76641ade4aa" - integrity sha512-2lyTyqJWyvnaP/woVtDcFS4B5pUYz0FQWI9pVHk/6TBWom2x3/ziJthkEn/LbCA9Twv39xSQU7Dn0zdIWfsNTQ== +react-universal-interface@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/react-universal-interface/-/react-universal-interface-0.6.2.tgz#5e8d438a01729a4dbbcbeeceb0b86be146fe2b3b" + integrity sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw== + +react-use@^15.3.4: + version "15.3.4" + resolved "https://registry.yarnpkg.com/react-use/-/react-use-15.3.4.tgz#f853d310bd71f75b38900a8caa3db93f6dc6e872" + integrity sha512-cHq1dELW6122oi1+xX7lwNyE/ugZs5L902BuO8eFJCfn2api1KeuPVG1M/GJouVARoUf54S2dYFMKo5nQXdTag== dependencies: - "@types/js-cookie" "2.2.5" - "@xobotyi/scrollbar-width" "1.9.4" + "@types/js-cookie" "2.2.6" + "@xobotyi/scrollbar-width" "1.9.5" copy-to-clipboard "^3.2.0" - fast-deep-equal "^3.1.1" + fast-deep-equal "^3.1.3" fast-shallow-equal "^1.0.0" js-cookie "^2.2.1" nano-css "^5.2.1" + react-universal-interface "^0.6.2" resize-observer-polyfill "^1.5.1" screenfull "^5.0.0" set-harmonic-interval "^1.0.1" throttle-debounce "^2.1.0" ts-easing "^0.2.0" - tslib "^1.10.0" + tslib "^2.0.0" react-virtualized-auto-sizer@^1.0.2: version "1.0.2" @@ -24055,11 +24093,16 @@ regenerator-runtime@^0.12.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg== -regenerator-runtime@^0.13.1, regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4: +regenerator-runtime@^0.13.1, regenerator-runtime@^0.13.3: version "0.13.5" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697" integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA== +regenerator-runtime@^0.13.4: + version "0.13.7" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" + integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== + regenerator-transform@^0.14.2: version "0.14.4" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.4.tgz#5266857896518d1616a78a0479337a30ea974cc7" @@ -24794,9 +24837,9 @@ rsvp@^4.8.4: integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== rtl-css-js@^1.9.0: - version "1.13.1" - resolved "https://registry.yarnpkg.com/rtl-css-js/-/rtl-css-js-1.13.1.tgz#80deabf6e8f36d6767d495cd3eb60fecb20c67e1" - integrity sha512-jgkIDj6Xi25kAEm5oYM3ZMFiOQhpLEcXi2LY/6bVr91cVz73hciHKneL5AMVPxOcks/JuizSaaNsvNRkeAWe3w== + version "1.14.0" + resolved "https://registry.yarnpkg.com/rtl-css-js/-/rtl-css-js-1.14.0.tgz#daa4f192a92509e292a0519f4b255e6e3c076b7d" + integrity sha512-Dl5xDTeN3e7scU1cWX8c9b6/Nqz3u/HgR4gePc1kWXYiQWVQbKCEyK6+Hxve9LbcJ5EieHy1J9nJCN3grTtGwg== dependencies: "@babel/runtime" "^7.1.2" @@ -25035,9 +25078,9 @@ scope-analyzer@^2.0.1: get-assigned-identifiers "^1.1.0" screenfull@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.0.0.tgz#5c2010c0e84fd4157bf852877698f90b8cbe96f6" - integrity sha512-yShzhaIoE9OtOhWVyBBffA6V98CDCoyHTsp8228blmqYy1Z5bddzE/4FPiJKlr8DVR4VBiiUyfPzIQPIYDkeMA== + version "5.0.2" + resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.0.2.tgz#b9acdcf1ec676a948674df5cd0ff66b902b0bed7" + integrity sha512-cCF2b+L/mnEiORLN5xSAz6H3t18i2oHh9BA8+CQlAh5DRw2+NFAGQJOSYbcGw8B2k04g/lVvFcfZ83b3ysH5UQ== scss-tokenizer@^0.2.3: version "0.2.3" @@ -25665,9 +25708,9 @@ source-map@^0.7.3: integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== sourcemap-codec@^1.4.1: - version "1.4.6" - resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.6.tgz#e30a74f0402bad09807640d39e971090a08ce1e9" - integrity sha512-1ZooVLYFxC448piVLBbtOxFcXwnymH9oUF8nRd3CuYDVvkRBxRl6pB4Mtas5a4drtL+E8LDgFkQNcgIw6tc8Hg== + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== space-separated-tokens@^1.0.0: version "1.1.2" @@ -25875,12 +25918,12 @@ stack-chain@^2.0.0: resolved "https://registry.yarnpkg.com/stack-chain/-/stack-chain-2.0.0.tgz#d73d1172af89565f07438b5bcc086831b6689b2d" integrity sha512-GGrHXePi305aW7XQweYZZwiRwR7Js3MWoK/EHzzB9ROdc75nCnjSJVi21rdAGxFl+yCx2L2qdfl5y7NO4lTyqg== -stack-generator@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.4.tgz#027513eab2b195bbb43b9c8360ba2dd0ab54de09" - integrity sha512-ha1gosTNcgxwzo9uKTQ8zZ49aUp5FIUW58YHFxCqaAHtE0XqBg0chGFYA1MfmW//x1KWq3F4G7Ug7bJh4RiRtg== +stack-generator@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.5.tgz#fb00e5b4ee97de603e0773ea78ce944d81596c36" + integrity sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q== dependencies: - stackframe "^1.1.0" + stackframe "^1.1.1" stack-trace@0.0.10, stack-trace@0.0.x: version "0.0.10" @@ -25904,10 +25947,10 @@ stackframe@^0.3.1: resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-0.3.1.tgz#33aa84f1177a5548c8935533cbfeb3420975f5a4" integrity sha1-M6qE8Rd6VUjIk1Uzy/6zQgl19aQ= -stackframe@^1.1.0, stackframe@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.1.1.tgz#ffef0a3318b1b60c3b58564989aca5660729ec71" - integrity sha512-0PlYhdKh6AfFxRyK/v+6/k+/mMfyiEBbTM5L94D0ZytQnJ166wuwoTYLHFWGbs2dpA8Rgq763KGWmN1EQEYHRQ== +stackframe@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.2.0.tgz#52429492d63c62eb989804c11552e3d22e779303" + integrity sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA== stackman@^4.0.1: version "4.0.1" @@ -25920,22 +25963,22 @@ stackman@^4.0.1: error-callsites "^2.0.3" load-source-map "^1.0.0" -stacktrace-gps@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/stacktrace-gps/-/stacktrace-gps-3.0.3.tgz#b89f84cc13bb925b96607e737b617c8715facf57" - integrity sha512-51Rr7dXkyFUKNmhY/vqZWK+EvdsfFSRiQVtgHTFlAdNIYaDD7bVh21yBHXaNWAvTD+w+QSjxHg7/v6Tz4veExA== +stacktrace-gps@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/stacktrace-gps/-/stacktrace-gps-3.0.4.tgz#7688dc2fc09ffb3a13165ebe0dbcaf41bcf0c69a" + integrity sha512-qIr8x41yZVSldqdqe6jciXEaSCKw1U8XTXpjDuy0ki/apyTn/r3w9hDAAQOhZdxvsC93H+WwwEu5cq5VemzYeg== dependencies: source-map "0.5.6" - stackframe "^1.1.0" + stackframe "^1.1.1" stacktrace-js@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/stacktrace-js/-/stacktrace-js-2.0.1.tgz#ebdb0e9a16e6f171f96ca7878404e7f15c3d42ba" - integrity sha512-13oDNgBSeWtdGa4/2BycNyKqe+VktCoJ8VLx4pDoJkwGGJVtiHdfMOAj3aW9xTi8oR2v34z9IcvfCvT6XNdNAw== + version "2.0.2" + resolved "https://registry.yarnpkg.com/stacktrace-js/-/stacktrace-js-2.0.2.tgz#4ca93ea9f494752d55709a081d400fdaebee897b" + integrity sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg== dependencies: - error-stack-parser "^2.0.4" - stack-generator "^2.0.4" - stacktrace-gps "^3.0.3" + error-stack-parser "^2.0.6" + stack-generator "^2.0.5" + stacktrace-gps "^3.0.4" state-toggle@^1.0.0: version "1.0.0" @@ -26956,9 +26999,9 @@ throat@^5.0.0: integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== throttle-debounce@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.1.0.tgz#257e648f0a56bd9e54fe0f132c4ab8611df4e1d5" - integrity sha512-AOvyNahXQuU7NN+VVvOOX+uW6FPaWdAOdRP5HfwYxAfCzXTFKRMoIMk+n+po318+ktcChx+F1Dd91G3YHeMKyg== + version "2.3.0" + resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2" + integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ== throttleit@^1.0.0: version "1.0.0"