[Workplace Search] Add Account Settings page imported from Security plugin (#99791)

* Copy lazy_wrapper and suspense_error_boundary from Spaces plugin

These components are needed to enable async loading of Security components into Enterprise Search.

The components are copied without any changes except for i18n ids, so it's easier to DRY out in the future if needed.

* Create async versions of personal_info and change_password components

* Create ui_api that allows to load Security components asuncronously

The patterns were mostly copied from Spaces plugin

* Make ui_api available through Security components's lifecycle methods

* Import Security plugin into Enterprise Search

* Add Security plugin and Notifications service to Kibana Logic file

* Export the required components from the Security plugin and
use them in the new AccountSettings component

* Update link to the Account Settings page

* Move getUiApi call to security start and pass core instead of getStartServices

* Simplify import of change_password_async component by providing...
... `notifications` and `userAPIClient` props in the security plugin

* Remove UserAPIClient from ui_api

It's not needed anymore since the components are initiated with this prop already passed

* Export ChangePasswordProps and PersonalInfoProps from account_management/index.ts

This makes it easier to import these props from outside the account_management folder

* Remove notifications service from kibana_logic

It is not needed anymore since we're initializing security components with notifications already provided

* Add UiApi to SecurityPluginStart interface

* Utilize index files for exporting Props types

* Replace Pick<...> with two separate interfaces as it doesn't work well with our docs

* Add a comment explaining why we're not loading async components through index file
This commit is contained in:
Vadim Yakhin 2021-06-10 14:20:30 -03:00 committed by GitHub
parent 5ee5720cb7
commit a9a834a105
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 338 additions and 8 deletions

View file

@ -7,6 +7,8 @@
import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks';
import { securityMock } from '../../../../../security/public/mocks';
import { mockHistory } from '../react_router/state.mock';
export const mockKibanaValues = {
@ -18,6 +20,7 @@ export const mockKibanaValues = {
},
history: mockHistory,
navigateToUrl: jest.fn(),
security: securityMock.createStart(),
setBreadcrumbs: jest.fn(),
setChromeIsVisible: jest.fn(),
setDocTitle: jest.fn(),

View file

@ -12,6 +12,7 @@ import { getContext } from 'kea';
import { coreMock } from '../../../../../src/core/public/mocks';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
import { licensingMock } from '../../../licensing/public/mocks';
import { securityMock } from '../../../security/public/mocks';
import { AppSearch } from './app_search';
import { EnterpriseSearch } from './enterprise_search';
@ -27,6 +28,7 @@ describe('renderApp', () => {
plugins: {
licensing: licensingMock.createStart(),
charts: chartPluginMock.createStartContract(),
security: securityMock.createStart(),
},
} as any;
const pluginData = {

View file

@ -48,6 +48,7 @@ export const renderApp = (
cloud: plugins.cloud || {},
history: params.history,
navigateToUrl: core.application.navigateToUrl,
security: plugins.security || {},
setBreadcrumbs: core.chrome.setBreadcrumbs,
setChromeIsVisible: core.chrome.setIsVisible,
setDocTitle: core.chrome.docTitle.change,

View file

@ -13,6 +13,7 @@ import { kea, MakeLogicType } from 'kea';
import { ApplicationStart, ChromeBreadcrumb } from '../../../../../../../src/core/public';
import { ChartsPluginStart } from '../../../../../../../src/plugins/charts/public';
import { CloudSetup } from '../../../../../cloud/public';
import { SecurityPluginStart } from '../../../../../security/public';
import { HttpLogic } from '../http';
import { createHref, CreateHrefOptions } from '../react_router_helpers';
@ -23,6 +24,7 @@ interface KibanaLogicProps {
cloud: Partial<CloudSetup>;
charts: ChartsPluginStart;
navigateToUrl: ApplicationStart['navigateToUrl'];
security: Partial<SecurityPluginStart>;
setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void;
setChromeIsVisible(isVisible: boolean): void;
setDocTitle(title: string): void;
@ -47,6 +49,7 @@ export const KibanaLogic = kea<MakeLogicType<KibanaValues>>({
},
{},
],
security: [props.security || {}, {}],
setBreadcrumbs: [props.setBreadcrumbs, {}],
setChromeIsVisible: [props.setChromeIsVisible, {}],
setDocTitle: [props.setDocTitle, {}],

View file

@ -27,7 +27,7 @@ import { getWorkplaceSearchUrl } from '../../../../shared/enterprise_search_url'
import { EuiButtonEmptyTo } from '../../../../shared/react_router_helpers';
import { AppLogic } from '../../../app_logic';
import { WORKPLACE_SEARCH_TITLE, ACCOUNT_NAV } from '../../../constants';
import { PERSONAL_SOURCES_PATH, LOGOUT_ROUTE, KIBANA_ACCOUNT_ROUTE } from '../../../routes';
import { PERSONAL_SOURCES_PATH, LOGOUT_ROUTE, PERSONAL_SETTINGS_PATH } from '../../../routes';
export const AccountHeader: React.FC = () => {
const [isPopoverOpen, setPopover] = useState(false);
@ -44,8 +44,7 @@ export const AccountHeader: React.FC = () => {
const accountNavItems = [
<EuiContextMenuItem key="accountSettings">
{/* TODO: Once auth is completed, we need to have non-admins redirect to the self-hosted form */}
<EuiButtonEmpty href={KIBANA_ACCOUNT_ROUTE}>{ACCOUNT_NAV.SETTINGS}</EuiButtonEmpty>
<EuiButtonEmptyTo to={PERSONAL_SETTINGS_PATH}>{ACCOUNT_NAV.SETTINGS}</EuiButtonEmptyTo>
</EuiContextMenuItem>,
<EuiContextMenuItem key="logout">
<EuiButtonEmpty href={LOGOUT_ROUTE}>{ACCOUNT_NAV.LOGOUT}</EuiButtonEmpty>

View file

@ -28,7 +28,9 @@ import {
ORG_SETTINGS_PATH,
ROLE_MAPPINGS_PATH,
SECURITY_PATH,
PERSONAL_SETTINGS_PATH,
} from './routes';
import { AccountSettings } from './views/account_settings';
import { SourcesRouter } from './views/content_sources';
import { SourceAdded } from './views/content_sources/components/source_added';
import { SourceSubNav } from './views/content_sources/components/source_sub_nav';
@ -103,6 +105,11 @@ export const WorkplaceSearchConfigured: React.FC<InitialAppData> = (props) => {
<SourcesRouter />
</PrivateSourcesLayout>
</Route>
<Route path={PERSONAL_SETTINGS_PATH}>
<PrivateSourcesLayout restrictWidth readOnlyMode={readOnlyMode}>
<AccountSettings />
</PrivateSourcesLayout>
</Route>
<Route path={SOURCES_PATH}>
<Layout
navigation={<WorkplaceSearchNav sourcesSubNav={showSourcesSubnav && <SourceSubNav />} />}

View file

@ -13,7 +13,6 @@ export const SETUP_GUIDE_PATH = '/setup_guide';
export const NOT_FOUND_PATH = '/404';
export const LOGOUT_ROUTE = '/logout';
export const KIBANA_ACCOUNT_ROUTE = '/security/account';
export const LEAVE_FEEDBACK_EMAIL = 'support@elastic.co';
export const LEAVE_FEEDBACK_URL = `mailto:${LEAVE_FEEDBACK_EMAIL}?Subject=Elastic%20Workplace%20Search%20Feedback`;

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useEffect, useMemo } from 'react';
import { useValues } from 'kea';
import type { AuthenticatedUser } from '../../../../../../security/public';
import { KibanaLogic } from '../../../shared/kibana/kibana_logic';
export const AccountSettings: React.FC = () => {
const { security } = useValues(KibanaLogic);
const [currentUser, setCurrentUser] = useState<AuthenticatedUser | null>(null);
useEffect(() => {
security!.authc!.getCurrentUser().then(setCurrentUser);
}, [security.authc]);
const PersonalInfo = useMemo(() => security!.uiApi!.components.getPersonalInfo, [security.uiApi]);
const ChangePassword = useMemo(() => security!.uiApi!.components.getChangePassword, [
security.uiApi,
]);
if (!currentUser) {
return null;
}
return (
<>
<PersonalInfo user={currentUser} />
<ChangePassword user={currentUser} />
</>
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { AccountSettings } from './account_settings';

View file

@ -21,6 +21,7 @@ import {
} from '../../../../src/plugins/home/public';
import { CloudSetup } from '../../cloud/public';
import { LicensingPluginStart } from '../../licensing/public';
import { SecurityPluginSetup, SecurityPluginStart } from '../../security/public';
import {
APP_SEARCH_PLUGIN,
@ -42,11 +43,13 @@ export interface ClientData extends InitialAppData {
interface PluginsSetup {
cloud?: CloudSetup;
home?: HomePublicPluginSetup;
security?: SecurityPluginSetup;
}
export interface PluginsStart {
cloud?: CloudSetup;
licensing: LicensingPluginStart;
charts: ChartsPluginStart;
security?: SecurityPluginStart;
}
export class EnterpriseSearchPlugin implements Plugin {

View file

@ -17,13 +17,16 @@ import { canUserChangePassword } from '../../../common/model';
import type { UserAPIClient } from '../../management/users';
import { ChangePasswordForm } from '../../management/users/components/change_password_form';
interface Props {
export interface ChangePasswordProps {
user: AuthenticatedUser;
}
export interface ChangePasswordPropsInternal extends ChangePasswordProps {
userAPIClient: PublicMethodsOf<UserAPIClient>;
notifications: NotificationsSetup;
}
export class ChangePassword extends Component<Props, {}> {
export class ChangePassword extends Component<ChangePasswordPropsInternal, {}> {
public render() {
const canChangePassword = canUserChangePassword(this.props.user);

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { CoreStart } from 'src/core/public';
import { UserAPIClient } from '../../management/users';
import type { ChangePasswordProps } from './change_password';
export const getChangePasswordComponent = async (
core: CoreStart
): Promise<React.FC<ChangePasswordProps>> => {
const { ChangePassword } = await import('./change_password');
return (props: ChangePasswordProps) => {
return (
<ChangePassword
notifications={core.notifications}
userAPIClient={new UserAPIClient(core.http)}
{...props}
/>
);
};
};

View file

@ -6,3 +6,5 @@
*/
export { ChangePassword } from './change_password';
export type { ChangePasswordProps } from './change_password';

View file

@ -6,3 +6,6 @@
*/
export { accountManagementApp } from './account_management_app';
export type { ChangePasswordProps } from './change_password';
export type { PersonalInfoProps } from './personal_info';

View file

@ -6,3 +6,5 @@
*/
export { PersonalInfo } from './personal_info';
export type { PersonalInfoProps } from './personal_info';

View file

@ -12,11 +12,11 @@ import { FormattedMessage } from '@kbn/i18n/react';
import type { AuthenticatedUser } from '../../../common/model';
interface Props {
export interface PersonalInfoProps {
user: AuthenticatedUser;
}
export const PersonalInfo = (props: Props) => {
export const PersonalInfo = (props: PersonalInfoProps) => {
return (
<EuiDescribedFormGroup
fullWidth

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { PersonalInfoProps } from './personal_info';
export const getPersonalInfoComponent = async (): Promise<React.FC<PersonalInfoProps>> => {
const { PersonalInfo } = await import('./personal_info');
return (props: PersonalInfoProps) => {
return <PersonalInfo {...props} />;
};
};

View file

@ -11,6 +11,7 @@ import { mockAuthenticatedUser } from '../common/model/authenticated_user.mock';
import { authenticationMock } from './authentication/index.mock';
import { navControlServiceMock } from './nav_control/index.mock';
import { createSessionTimeoutMock } from './session/session_timeout.mock';
import { getUiApiMock } from './ui_api/index.mock';
function createSetupMock() {
return {
@ -23,6 +24,7 @@ function createStartMock() {
return {
authc: authenticationMock.createStart(),
navControlService: navControlServiceMock.createStart(),
uiApi: getUiApiMock.createStart(),
};
}

View file

@ -100,6 +100,12 @@ describe('Security Plugin', () => {
features: {} as FeaturesPluginStart,
})
).toEqual({
uiApi: {
components: {
getChangePassword: expect.any(Function),
getPersonalInfo: expect.any(Function),
},
},
authc: {
getCurrentUser: expect.any(Function),
areAPIKeysEnabled: expect.any(Function),

View file

@ -30,6 +30,8 @@ import type { SecurityNavControlServiceStart } from './nav_control';
import { SecurityNavControlService } from './nav_control';
import { SecurityCheckupService } from './security_checkup';
import { SessionExpired, SessionTimeout, UnauthorizedResponseHttpInterceptor } from './session';
import type { UiApi } from './ui_api';
import { getUiApi } from './ui_api';
export interface PluginSetupDependencies {
licensing: LicensingPluginSetup;
@ -150,6 +152,7 @@ export class SecurityPlugin
}
return {
uiApi: getUiApi({ core }),
navControlService: this.navControlService.start({ core }),
authc: this.authc as AuthenticationServiceStart,
};
@ -184,4 +187,8 @@ export interface SecurityPluginStart {
* Exposes authentication information about the currently logged in user.
*/
authc: AuthenticationServiceStart;
/**
* Exposes UI components that will be loaded asynchronously.
*/
uiApi: UiApi;
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { SuspenseErrorBoundary } from './suspense_error_boundary';

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiLoadingSpinner } from '@elastic/eui';
import type { PropsWithChildren } from 'react';
import React, { Component, Suspense } from 'react';
import { i18n } from '@kbn/i18n';
import type { NotificationsStart } from 'src/core/public';
interface Props {
notifications: NotificationsStart;
}
interface State {
error: Error | null;
}
export class SuspenseErrorBoundary extends Component<PropsWithChildren<Props>, State> {
state: State = {
error: null,
};
static getDerivedStateFromError(error: Error) {
// Update state so next render shows fallback UI.
return { error };
}
public componentDidCatch(error: Error) {
const { notifications } = this.props;
if (notifications) {
const title = i18n.translate('xpack.security.uiApi.errorBoundaryToastTitle', {
defaultMessage: 'Failed to load Kibana asset',
});
const toastMessage = i18n.translate('xpack.security.uiApi.errorBoundaryToastMessage', {
defaultMessage: 'Reload page to continue.',
});
notifications.toasts.addError(error, { title, toastMessage });
}
}
render() {
const { children, notifications } = this.props;
const { error } = this.state;
if (!notifications || error) {
return null;
}
return <Suspense fallback={<EuiLoadingSpinner />}>{children}</Suspense>;
}
}

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FC, PropsWithChildren, PropsWithRef } from 'react';
import React from 'react';
import type { CoreStart } from 'src/core/public';
/**
* We're importing specific files here instead of passing them
* through the index file. It helps to keep the bundle size low.
*
* Importing async components through the index file increases the bundle size.
* It happens because the bundle starts to also include all the sync dependencies
* available through the index file.
*/
import { getChangePasswordComponent } from '../account_management/change_password/change_password_async';
import { getPersonalInfoComponent } from '../account_management/personal_info/personal_info_async';
import { LazyWrapper } from './lazy_wrapper';
export interface GetComponentsOptions {
core: CoreStart;
}
export const getComponents = ({ core }: GetComponentsOptions) => {
/**
* Returns a function that creates a lazy-loading version of a component.
*/
function wrapLazy<T>(fn: () => Promise<FC<T>>) {
return (props: JSX.IntrinsicAttributes & PropsWithRef<PropsWithChildren<T>>) => (
<LazyWrapper fn={fn} core={core} props={props} />
);
}
return {
getPersonalInfo: wrapLazy(getPersonalInfoComponent),
getChangePassword: wrapLazy(() => getChangePasswordComponent(core)),
};
};

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UiApi } from './';
export const getUiApiMock = {
createStart: (): jest.Mocked<UiApi> => ({
components: {
getPersonalInfo: jest.fn(),
getChangePassword: jest.fn(),
},
}),
};

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ReactElement } from 'react';
import type { CoreStart } from 'src/core/public';
import type { ChangePasswordProps, PersonalInfoProps } from '../account_management';
import { getComponents } from './components';
interface GetUiApiOptions {
core: CoreStart;
}
type LazyComponentFn<T> = (props: T) => ReactElement;
export interface UiApi {
components: {
getPersonalInfo: LazyComponentFn<PersonalInfoProps>;
getChangePassword: LazyComponentFn<ChangePasswordProps>;
};
}
export const getUiApi = ({ core }: GetUiApiOptions): UiApi => {
const components = getComponents({ core });
return {
components,
};
};

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FC, PropsWithChildren, PropsWithRef, ReactElement } from 'react';
import React, { lazy, useMemo } from 'react';
import type { CoreStart } from 'src/core/public';
import { SuspenseErrorBoundary } from '../suspense_error_boundary';
interface InternalProps<T> {
fn: () => Promise<FC<T>>;
core: CoreStart;
props: JSX.IntrinsicAttributes & PropsWithRef<PropsWithChildren<T>>;
}
export const LazyWrapper: <T>(props: InternalProps<T>) => ReactElement | null = ({
fn,
core,
props,
}) => {
const { notifications } = core;
const LazyComponent = useMemo(() => lazy(() => fn().then((x) => ({ default: x }))), [fn]);
if (!notifications) {
return null;
}
return (
<SuspenseErrorBoundary notifications={notifications}>
<LazyComponent {...props} />
</SuspenseErrorBoundary>
);
};