Interactive Setup UI enhancements (#113011)

This commit is contained in:
Thom Heymann 2021-10-06 23:56:42 +01:00 committed by GitHub
parent 4d8d7ee0ad
commit 94b2e30bd7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1095 additions and 267 deletions

View file

@ -7,3 +7,11 @@
*/
export const VERIFICATION_CODE_LENGTH = 6;
export const ERROR_OUTSIDE_PREBOOT_STAGE = 'outside_preboot_stage';
export const ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED = 'elasticsearch_connection_configured';
export const ERROR_KIBANA_CONFIG_NOT_WRITABLE = 'kibana_config_not_writable';
export const ERROR_KIBANA_CONFIG_FAILURE = 'kibana_config_failure';
export const ERROR_ENROLL_FAILURE = 'enroll_failure';
export const ERROR_CONFIGURE_FAILURE = 'configure_failure';
export const ERROR_PING_FAILURE = 'ping_failure';

View file

@ -8,4 +8,13 @@
export type { InteractiveSetupViewState, EnrollmentToken, Certificate, PingResult } from './types';
export { ElasticsearchConnectionStatus } from './elasticsearch_connection_status';
export { VERIFICATION_CODE_LENGTH } from './constants';
export {
ERROR_CONFIGURE_FAILURE,
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
ERROR_ENROLL_FAILURE,
ERROR_KIBANA_CONFIG_FAILURE,
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
ERROR_OUTSIDE_PREBOOT_STAGE,
ERROR_PING_FAILURE,
VERIFICATION_CODE_LENGTH,
} from './constants';

View file

@ -28,7 +28,7 @@ describe('ClusterAddressForm', () => {
const onSuccess = jest.fn();
const { findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<Providers services={coreStart}>
<ClusterAddressForm onSuccess={onSuccess} />
</Providers>
);
@ -52,7 +52,7 @@ describe('ClusterAddressForm', () => {
const onSuccess = jest.fn();
const { findAllByText, findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<Providers services={coreStart}>
<ClusterAddressForm onSuccess={onSuccess} />
</Providers>
);
@ -63,7 +63,7 @@ describe('ClusterAddressForm', () => {
fireEvent.click(await findByRole('button', { name: 'Check address', hidden: true }));
await findAllByText(/Enter a valid address including protocol/i);
await findAllByText(/Enter a valid address/i);
expect(coreStart.http.post).not.toHaveBeenCalled();
});

View file

@ -9,7 +9,6 @@
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
@ -22,12 +21,12 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import type { IHttpFetchError } from 'kibana/public';
import type { PingResult } from '../common';
import { SubmitErrorCallout } from './submit_error_callout';
import type { ValidationErrors } from './use_form';
import { useForm } from './use_form';
import { useHttp } from './use_http';
import { useKibana } from './use_kibana';
export interface ClusterAddressFormValues {
host: string;
@ -46,7 +45,7 @@ export const ClusterAddressForm: FunctionComponent<ClusterAddressFormProps> = ({
onCancel,
onSuccess,
}) => {
const http = useHttp();
const { http } = useKibana();
const [form, eventHandlers] = useForm({
defaultValues,
@ -65,7 +64,7 @@ export const ClusterAddressForm: FunctionComponent<ClusterAddressFormProps> = ({
}
} catch (error) {
errors.host = i18n.translate('interactiveSetup.clusterAddressForm.hostInvalidError', {
defaultMessage: 'Enter a valid address including protocol.',
defaultMessage: "Enter a valid address, including 'http' or 'https'.",
});
}
}
@ -88,14 +87,12 @@ export const ClusterAddressForm: FunctionComponent<ClusterAddressFormProps> = ({
<EuiForm component="form" noValidate {...eventHandlers}>
{form.submitError && (
<>
<EuiCallOut
color="danger"
title={i18n.translate('interactiveSetup.clusterAddressForm.submitErrorTitle', {
<SubmitErrorCallout
error={form.submitError}
defaultTitle={i18n.translate('interactiveSetup.clusterAddressForm.submitErrorTitle', {
defaultMessage: "Couldn't check address",
})}
>
{(form.submitError as IHttpFetchError).body?.message}
</EuiCallOut>
/>
<EuiSpacer />
</>
)}
@ -112,6 +109,7 @@ export const ClusterAddressForm: FunctionComponent<ClusterAddressFormProps> = ({
name="host"
value={form.values.host}
isInvalid={form.touched.host && !!form.errors.host}
placeholder="https://localhost:9200"
fullWidth
/>
</EuiFormRow>

View file

@ -21,14 +21,14 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
describe('ClusterConfigurationForm', () => {
jest.setTimeout(20_000);
it('calls enrollment API when submitting form', async () => {
it('calls enrollment API for https addresses when submitting form', async () => {
const coreStart = coreMock.createStart();
coreStart.http.post.mockResolvedValue({});
const onSuccess = jest.fn();
const { findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<Providers services={coreStart}>
<ClusterConfigurationForm
host="https://localhost:9200"
authRequired
@ -53,7 +53,7 @@ describe('ClusterConfigurationForm', () => {
target: { value: 'changeme' },
});
fireEvent.click(await findByLabelText('Certificate authority'));
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
fireEvent.click(await findByRole('button', { name: 'Configure Elastic', hidden: true }));
await waitFor(() => {
expect(coreStart.http.post).toHaveBeenLastCalledWith(
@ -71,12 +71,43 @@ describe('ClusterConfigurationForm', () => {
});
});
it('calls enrollment API for http addresses when submitting form', async () => {
const coreStart = coreMock.createStart();
coreStart.http.post.mockResolvedValue({});
const onSuccess = jest.fn();
const { findByRole } = render(
<Providers services={coreStart}>
<ClusterConfigurationForm
host="http://localhost:9200"
authRequired={false}
certificateChain={[]}
onSuccess={onSuccess}
/>
</Providers>
);
fireEvent.click(await findByRole('button', { name: 'Configure Elastic', hidden: true }));
await waitFor(() => {
expect(coreStart.http.post).toHaveBeenLastCalledWith(
'/internal/interactive_setup/configure',
{
body: JSON.stringify({
host: 'http://localhost:9200',
}),
}
);
expect(onSuccess).toHaveBeenCalled();
});
});
it('validates form', async () => {
const coreStart = coreMock.createStart();
const onSuccess = jest.fn();
const { findAllByText, findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<Providers services={coreStart}>
<ClusterConfigurationForm
host="https://localhost:9200"
authRequired
@ -95,7 +126,7 @@ describe('ClusterConfigurationForm', () => {
</Providers>
);
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
fireEvent.click(await findByRole('button', { name: 'Configure Elastic', hidden: true }));
await findAllByText(/Enter a password/i);
await findAllByText(/Confirm that you recognize and trust this certificate/i);
@ -104,7 +135,7 @@ describe('ClusterConfigurationForm', () => {
target: { value: 'elastic' },
});
await findAllByText(/User 'elastic' can't be used as Kibana system user/i);
await findAllByText(/User 'elastic' can't be used as the Kibana system user/i);
expect(coreStart.http.post).not.toHaveBeenCalled();
});

View file

@ -7,6 +7,7 @@
*/
import {
EuiBadge,
EuiButton,
EuiButtonEmpty,
EuiCallOut,
@ -19,25 +20,32 @@ import {
EuiFormRow,
EuiIcon,
EuiLink,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import React, { useState } from 'react';
import useUpdateEffect from 'react-use/lib/useUpdateEffect';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import type { IHttpFetchError } from 'kibana/public';
import { euiThemeVars } from '@kbn/ui-shared-deps-src/theme';
import type { Certificate } from '../common';
import { DocLink } from './doc_link';
import { SubmitErrorCallout } from './submit_error_callout';
import { TextTruncate } from './text_truncate';
import type { ValidationErrors } from './use_form';
import { useForm } from './use_form';
import { useHtmlId } from './use_html_id';
import { useHttp } from './use_http';
import { useKibana } from './use_kibana';
import { useVerification } from './use_verification';
import { useVisibility } from './use_visibility';
@ -68,7 +76,7 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
onCancel,
onSuccess,
}) => {
const http = useHttp();
const { http } = useKibana();
const { status, getCode } = useVerification();
const [form, eventHandlers] = useForm({
defaultValues,
@ -87,7 +95,7 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
errors.username = i18n.translate(
'interactiveSetup.clusterConfigurationForm.usernameReservedError',
{
defaultMessage: "User 'elastic' can't be used as Kibana system user.",
defaultMessage: "User 'elastic' can't be used as the Kibana system user.",
}
);
}
@ -102,7 +110,7 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
}
}
if (certificateChain && !values.caCert) {
if (certificateChain && certificateChain.length > 0 && !values.caCert) {
errors.caCert = i18n.translate(
'interactiveSetup.clusterConfigurationForm.caCertConfirmationRequiredError',
{
@ -117,9 +125,9 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
await http.post('/internal/interactive_setup/configure', {
body: JSON.stringify({
host,
username: values.username,
password: values.password,
caCert: values.caCert,
username: authRequired ? values.username : undefined,
password: authRequired ? values.password : undefined,
caCert: certificateChain && certificateChain.length > 0 ? values.caCert : undefined,
code: getCode(),
}),
});
@ -139,17 +147,19 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
<EuiForm component="form" noValidate {...eventHandlers}>
{status !== 'unverified' && !form.isSubmitting && !form.isValidating && form.submitError && (
<>
<EuiCallOut
color="danger"
title={i18n.translate('interactiveSetup.clusterConfigurationForm.submitErrorTitle', {
defaultMessage: "Couldn't connect to cluster",
})}
>
{(form.submitError as IHttpFetchError).body?.message}
</EuiCallOut>
<SubmitErrorCallout
error={form.submitError}
defaultTitle={i18n.translate(
'interactiveSetup.clusterConfigurationForm.submitErrorTitle',
{
defaultMessage: "Couldn't configure Elastic",
}
)}
/>
<EuiSpacer />
</>
)}
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
<EuiFlexItem grow={false} className="eui-textNoWrap">
<FormattedMessage
@ -204,27 +214,27 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
<>
<EuiCallOut
color="warning"
iconType="alert"
title={i18n.translate(
'interactiveSetup.clusterConfigurationForm.insecureClusterTitle',
{
defaultMessage: 'This cluster is not secure.',
defaultMessage: 'This cluster is not secure',
}
)}
size="s"
>
<p>
<FormattedMessage
tagName="div"
id="interactiveSetup.clusterConfigurationForm.insecureClusterDescription"
defaultMessage="Anyone with the address can access your data."
/>
<EuiSpacer size="xs" />
<DocLink app="elasticsearch" doc="configuring-stack-security.html">
<FormattedMessage
id="interactiveSetup.clusterConfigurationForm.insecureClusterDescription"
defaultMessage="Anyone with the address could access your data."
id="interactiveSetup.clusterConfigurationForm.insecureClusterLink"
defaultMessage="Learn how to enable security features."
/>
</p>
<p>
<EuiLink color="warning">
<FormattedMessage
id="interactiveSetup.clusterConfigurationForm.insecureClusterLink"
defaultMessage="Learn how to enable security features."
/>
</EuiLink>
</p>
</DocLink>
</EuiCallOut>
<EuiSpacer />
</>
@ -253,7 +263,7 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
form.setValue('caCert', form.values.caCert ? '' : intermediateCa.raw);
}}
>
<CertificatePanel certificate={certificateChain[0]} />
<CertificateChain certificateChain={certificateChain} />
</EuiCheckableCard>
</EuiFormRow>
<EuiSpacer />
@ -279,7 +289,7 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
>
<FormattedMessage
id="interactiveSetup.clusterConfigurationForm.submitButton"
defaultMessage="{isSubmitting, select, true{Connecting to cluster…} other{Connect to cluster}}"
defaultMessage="{isSubmitting, select, true{Configuring Elastic…} other{Configure Elastic}}"
values={{ isSubmitting: form.isSubmitting }}
/>
</EuiButton>
@ -290,40 +300,169 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
};
export interface CertificatePanelProps {
certificate: Certificate;
certificate: Omit<Certificate, 'raw'>;
compressed?: boolean;
type?: 'root' | 'intermediate';
onClick?(): void;
}
export const CertificatePanel: FunctionComponent<CertificatePanelProps> = ({ certificate }) => {
export const CertificatePanel: FunctionComponent<CertificatePanelProps> = ({
certificate,
onClick,
type,
compressed = false,
}) => {
return (
<EuiPanel color="subdued">
<EuiPanel color={compressed ? 'subdued' : undefined} hasBorder={!compressed}>
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiIcon type="document" size="l" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xxs">
<h3>{certificate.subject.O || certificate.subject.CN}</h3>
</EuiTitle>
<EuiText size="xs">
<FormattedMessage
id="interactiveSetup.certificatePanel.issuer"
defaultMessage="Issued by: {issuer}"
values={{
issuer: certificate.issuer.O || certificate.issuer.CN,
}}
/>
</EuiText>
<EuiFlexGroup responsive={false} gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="xxs">
<h3>{certificate.subject.O || certificate.subject.CN}</h3>
</EuiTitle>
</EuiFlexItem>
{!compressed && (
<EuiFlexItem grow={false}>
<EuiBadge>
{type === 'root'
? i18n.translate('interactiveSetup.certificatePanel.rootCertificateAuthority', {
defaultMessage: 'Root CA',
})
: type === 'intermediate'
? i18n.translate(
'interactiveSetup.certificatePanel.intermediateCertificateAuthority',
{
defaultMessage: 'Intermediate CA',
}
)
: i18n.translate('interactiveSetup.certificatePanel.serverCertificate', {
defaultMessage: 'Server certificate',
})}
</EuiBadge>
</EuiFlexItem>
)}
</EuiFlexGroup>
{compressed && (
<EuiText size="xs">
<FormattedMessage
id="interactiveSetup.certificatePanel.issuer"
defaultMessage="Issued by: {issuer}"
values={{
issuer: onClick ? (
<EuiLink onClick={onClick}>
{certificate.issuer.O || certificate.issuer.CN}
</EuiLink>
) : (
certificate.issuer.O || certificate.issuer.CN
),
}}
/>
</EuiText>
)}
{!compressed && (
<EuiText size="xs">
<FormattedMessage
id="interactiveSetup.certificatePanel.validFrom"
defaultMessage="Issued on: {validFrom}"
values={{
validFrom: certificate.valid_from,
}}
/>
</EuiText>
)}
<EuiText size="xs">
<FormattedMessage
id="interactiveSetup.certificatePanel.validTo"
defaultMessage="Expires: {validTo}"
defaultMessage="Expires on: {validTo}"
values={{
validTo: certificate.valid_to,
}}
/>
</EuiText>
{!compressed && (
<EuiText size="xs">
<FormattedMessage
id="interactiveSetup.certificatePanel.fingerprint"
defaultMessage="Fingerprint (SHA-256): {fingerprint}"
values={{
fingerprint: certificate.fingerprint256.replace(/\:/g, ' '),
}}
/>
</EuiText>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};
export interface CertificateChainProps {
certificateChain: Certificate[];
}
const CertificateChain: FunctionComponent<CertificateChainProps> = ({ certificateChain }) => {
const [showModal, setShowModal] = useState(false);
return (
<>
<CertificatePanel
certificate={certificateChain[0]}
onClick={() => setShowModal(true)}
compressed
/>
{showModal && (
<EuiModal onClose={() => setShowModal(false)} maxWidth={euiThemeVars.euiBreakpoints.s}>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
id="interactiveSetup.certificateChain.title"
defaultMessage="Certificate chain"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
{certificateChain
.slice()
.reverse()
.map(({ raw, ...certificate }, i) => (
<>
{i > 0 && (
<>
<EuiSpacer size="s" />
<EuiFlexGroup responsive={false} justifyContent="center">
<EuiFlexItem grow={false}>
<EuiIcon type="sortDown" color="subdued" />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
</>
)}
<CertificatePanel
certificate={certificate}
type={
i === 0
? 'root'
: i < certificateChain.length - 1
? 'intermediate'
: undefined
}
/>
</>
))}
</EuiModalBody>
<EuiModalFooter>
<EuiButton fill onClick={() => setShowModal(false)}>
<FormattedMessage
id="interactiveSetup.certificateChain.cancelButton"
defaultMessage="Close"
/>
</EuiButton>
</EuiModalFooter>
</EuiModal>
)}
</>
);
};

View file

@ -0,0 +1,62 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { EuiLinkAnchorProps } from '@elastic/eui';
import { EuiLink } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React, { useCallback } from 'react';
import type { CoreStart } from 'src/core/public';
import { useKibana } from './use_kibana';
export type DocLinks = CoreStart['docLinks']['links'];
export type GetDocLinkFunction = (app: string, doc: string) => string;
/**
* Creates links to the documentation.
*
* @see {@link DocLink} for a component that creates a link to the docs.
*
* @example
* ```typescript
* <DocLink app="elasticsearch" doc="built-in-roles.html">
* Learn what privileges individual roles grant.
* </DocLink>
* ```
*
* @example
* ```typescript
* const [docs] = useDocLinks();
*
* <EuiLink href={docs.dashboard.guide} target="_blank" external>
* Learn how to get started with dashboards.
* </EuiLink>
* ```
*/
export function useDocLinks(): [DocLinks, GetDocLinkFunction] {
const { docLinks } = useKibana();
const { links, ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks;
const getDocLink = useCallback<GetDocLinkFunction>(
(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 extends Omit<EuiLinkAnchorProps, 'href'> {
app: string;
doc: string;
}
export const DocLink: FunctionComponent<DocLinkProps> = ({ app, doc, ...props }) => {
const [, getDocLink] = useDocLinks();
return <EuiLink href={getDocLink(app, doc)} target="_blank" external {...props} />;
};

View file

@ -36,14 +36,14 @@ describe('EnrollmentTokenForm', () => {
const onSuccess = jest.fn();
const { findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<Providers services={coreStart}>
<EnrollmentTokenForm onSuccess={onSuccess} />
</Providers>
);
fireEvent.change(await findByLabelText('Enrollment token'), {
target: { value: btoa(JSON.stringify(token)) },
});
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
fireEvent.click(await findByRole('button', { name: 'Configure Elastic', hidden: true }));
await waitFor(() => {
expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/interactive_setup/enroll', {
@ -62,12 +62,12 @@ describe('EnrollmentTokenForm', () => {
const onSuccess = jest.fn();
const { findAllByText, findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<Providers services={coreStart}>
<EnrollmentTokenForm onSuccess={onSuccess} />
</Providers>
);
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
fireEvent.click(await findByRole('button', { name: 'Configure Elastic', hidden: true }));
await findAllByText(/Enter an enrollment token/i);

View file

@ -9,7 +9,6 @@
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
@ -24,13 +23,14 @@ import useUpdateEffect from 'react-use/lib/useUpdateEffect';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import type { IHttpFetchError } from 'kibana/public';
import type { EnrollmentToken } from '../common';
import { DocLink } from './doc_link';
import { SubmitErrorCallout } from './submit_error_callout';
import { TextTruncate } from './text_truncate';
import type { ValidationErrors } from './use_form';
import { useForm } from './use_form';
import { useHttp } from './use_http';
import { useKibana } from './use_kibana';
import { useVerification } from './use_verification';
import { useVisibility } from './use_visibility';
@ -51,7 +51,7 @@ export const EnrollmentTokenForm: FunctionComponent<EnrollmentTokenFormProps> =
onCancel,
onSuccess,
}) => {
const http = useHttp();
const { http } = useKibana();
const { status, getCode } = useVerification();
const [form, eventHandlers] = useForm({
defaultValues,
@ -100,14 +100,12 @@ export const EnrollmentTokenForm: FunctionComponent<EnrollmentTokenFormProps> =
<EuiForm component="form" noValidate {...eventHandlers}>
{status !== 'unverified' && !form.isSubmitting && !form.isValidating && form.submitError && (
<>
<EuiCallOut
color="danger"
title={i18n.translate('interactiveSetup.enrollmentTokenForm.submitErrorTitle', {
defaultMessage: "Couldn't connect to cluster",
<SubmitErrorCallout
error={form.submitError}
defaultTitle={i18n.translate('interactiveSetup.enrollmentTokenForm.submitErrorTitle', {
defaultMessage: "Couldn't configure Elastic",
})}
>
{(form.submitError as IHttpFetchError).body?.message}
</EuiCallOut>
/>
<EuiSpacer />
</>
)}
@ -118,7 +116,18 @@ export const EnrollmentTokenForm: FunctionComponent<EnrollmentTokenFormProps> =
})}
error={form.errors.token}
isInvalid={form.touched.token && !!form.errors.token}
helpText={enrollmentToken && <EnrollmentTokenDetails token={enrollmentToken} />}
helpText={
enrollmentToken ? (
<EnrollmentTokenDetails token={enrollmentToken} />
) : (
<DocLink app="elasticsearch" doc="configuring-stack-security.html">
<FormattedMessage
id="interactiveSetup.enrollmentTokenForm.tokenHelpText"
defaultMessage="Where do I find this?"
/>
</DocLink>
)
}
fullWidth
>
<EuiTextArea
@ -126,7 +135,7 @@ export const EnrollmentTokenForm: FunctionComponent<EnrollmentTokenFormProps> =
value={form.values.token}
isInvalid={form.touched.token && !!form.errors.token}
placeholder={i18n.translate('interactiveSetup.enrollmentTokenForm.tokenPlaceholder', {
defaultMessage: 'Paste enrollment token from terminal',
defaultMessage: 'Paste enrollment token from terminal.',
})}
fullWidth
/>
@ -152,7 +161,7 @@ export const EnrollmentTokenForm: FunctionComponent<EnrollmentTokenFormProps> =
>
<FormattedMessage
id="interactiveSetup.enrollmentTokenForm.submitButton"
defaultMessage="{isSubmitting, select, true{Connecting to cluster…} other{Connect to cluster}}"
defaultMessage="{isSubmitting, select, true{Configuring Elastic…} other{Configure Elastic}}"
values={{ isSubmitting: form.isSubmitting }}
/>
</EuiButton>

View file

@ -11,10 +11,10 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { I18nProvider } from '@kbn/i18n/react';
import type { CoreSetup, CoreStart, HttpSetup, Plugin } from 'src/core/public';
import type { CoreSetup, CoreStart, Plugin } from 'src/core/public';
import { App } from './app';
import { HttpProvider } from './use_http';
import { KibanaProvider } from './use_kibana';
import { VerificationProvider } from './use_verification';
export class InteractiveSetupPlugin implements Plugin<void, void, {}, {}> {
@ -24,16 +24,17 @@ export class InteractiveSetupPlugin implements Plugin<void, void, {}, {}> {
title: 'Configure Elastic to get started',
appRoute: '/',
chromeless: true,
mount: (params) => {
mount: async (params) => {
const url = new URL(window.location.href);
const defaultCode = url.searchParams.get('code') || undefined;
const onSuccess = () => {
url.searchParams.delete('code');
window.location.replace(url.href);
};
const [services] = await core.getStartServices();
ReactDOM.render(
<Providers defaultCode={defaultCode} http={core.http}>
<Providers defaultCode={defaultCode} services={services}>
<App onSuccess={onSuccess} />
</Providers>,
params.element
@ -47,14 +48,18 @@ export class InteractiveSetupPlugin implements Plugin<void, void, {}, {}> {
}
export interface ProvidersProps {
http: HttpSetup;
services: CoreStart;
defaultCode?: string;
}
export const Providers: FunctionComponent<ProvidersProps> = ({ defaultCode, http, children }) => (
export const Providers: FunctionComponent<ProvidersProps> = ({
defaultCode,
services,
children,
}) => (
<I18nProvider>
<HttpProvider http={http}>
<KibanaProvider services={services}>
<VerificationProvider defaultCode={defaultCode}>{children}</VerificationProvider>
</HttpProvider>
</KibanaProvider>
</I18nProvider>
);

View file

@ -9,20 +9,21 @@
import type { EuiStepProps } from '@elastic/eui';
import { EuiPanel, EuiSteps } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import useTimeoutFn from 'react-use/lib/useTimeoutFn';
import { i18n } from '@kbn/i18n';
import type { IHttpFetchError } from 'kibana/public';
import { useHttp } from './use_http';
import { useKibana } from './use_kibana';
export interface ProgressIndicatorProps {
onSuccess?(): void;
}
export const ProgressIndicator: FunctionComponent<ProgressIndicatorProps> = ({ onSuccess }) => {
const http = useHttp();
const { http } = useKibana();
const [status, checkStatus] = useAsyncFn(async () => {
let isAvailable: boolean | undefined = false;
let isPastPreboot: boolean | undefined = false;
@ -30,7 +31,8 @@ export const ProgressIndicator: FunctionComponent<ProgressIndicatorProps> = ({ o
const { response } = await http.get('/api/status', { asResponse: true });
isAvailable = response ? response.status < 500 : undefined;
isPastPreboot = response?.headers.get('content-type')?.includes('application/json');
} catch ({ response }) {
} catch (error) {
const { response } = error as IHttpFetchError;
isAvailable = response ? response.status < 500 : undefined;
isPastPreboot = response?.headers.get('content-type')?.includes('application/json');
}
@ -91,16 +93,20 @@ export interface LoadingStepsProps {
}
export const LoadingSteps: FunctionComponent<LoadingStepsProps> = ({ currentStepId, steps }) => {
const [stepIndex, setStepIndex] = useState(0);
const currentStepIndex = steps.findIndex((step) => step.id === currentStepId);
// Ensure that loading progress doesn't move backwards
useEffect(() => {
if (currentStepIndex > stepIndex) {
setStepIndex(currentStepIndex);
}
}, [currentStepIndex, stepIndex]);
return (
<EuiSteps
steps={steps.map((step, i) => ({
status:
i <= currentStepIndex
? 'complete'
: steps[i - 1]?.id === currentStepId
? 'loading'
: 'incomplete',
status: i <= stepIndex ? 'complete' : i - 1 === stepIndex ? 'loading' : 'incomplete',
children: null,
...step,
}))}

View file

@ -0,0 +1,389 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { errors } from '@elastic/elasticsearch';
import { shallow } from 'enzyme';
import React from 'react';
import {
ERROR_CONFIGURE_FAILURE,
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
ERROR_ENROLL_FAILURE,
ERROR_KIBANA_CONFIG_FAILURE,
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
ERROR_OUTSIDE_PREBOOT_STAGE,
ERROR_PING_FAILURE,
} from '../common';
import { interactiveSetupMock } from '../server/mocks'; // eslint-disable-line @kbn/eslint/no-restricted-paths
import { SubmitErrorCallout } from './submit_error_callout';
describe('SubmitErrorCallout', () => {
it('renders unknown errors correctly', async () => {
const wrapper = shallow(
<SubmitErrorCallout error={new Error('Unknown error')} defaultTitle="Something went wrong" />
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiCallOut
color="danger"
title="Something went wrong"
>
Unknown error
</EuiCallOut>
`);
});
it('renders 403 errors correctly', async () => {
const wrapper = shallow(
<SubmitErrorCallout
error={
new errors.ResponseError(
interactiveSetupMock.createApiResponse({
body: {
statusCode: 403,
},
})
)
}
defaultTitle="Something went wrong"
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiCallOut
color="danger"
title={
<FormattedMessage
defaultMessage="Verification required"
id="interactiveSetup.submitErrorCallout.forbiddenErrorTitle"
values={Object {}}
/>
}
>
<FormattedMessage
defaultMessage="Retry to configure Elastic."
id="interactiveSetup.submitErrorCallout.forbiddenErrorDescription"
values={Object {}}
/>
</EuiCallOut>
`);
});
it('renders 404 errors correctly', async () => {
const wrapper = shallow(
<SubmitErrorCallout
error={
new errors.ResponseError(
interactiveSetupMock.createApiResponse({
body: {
statusCode: 404,
},
})
)
}
defaultTitle="Something went wrong"
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiCallOut
color="primary"
title={
<FormattedMessage
defaultMessage="Elastic is already configured"
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredErrorTitle"
values={Object {}}
/>
}
>
<EuiButton
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Continue to Kibana"
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredSubmitButton"
values={Object {}}
/>
</EuiButton>
</EuiCallOut>
`);
});
it('renders ERROR_CONFIGURE_FAILURE errors correctly', async () => {
const wrapper = shallow(
<SubmitErrorCallout
error={
new errors.ResponseError(
interactiveSetupMock.createApiResponse({
body: {
statusCode: 500,
attributes: { type: ERROR_CONFIGURE_FAILURE },
},
})
)
}
defaultTitle="Something went wrong"
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiCallOut
color="danger"
title="Something went wrong"
>
<FormattedMessage
defaultMessage="Retry or update the {config} file manually."
id="interactiveSetup.submitErrorCallout.kibanaConfigFailureErrorDescription"
values={
Object {
"config": <strong>
kibana.yml
</strong>,
}
}
/>
</EuiCallOut>
`);
});
it('renders ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED errors correctly', async () => {
const wrapper = shallow(
<SubmitErrorCallout
error={
new errors.ResponseError(
interactiveSetupMock.createApiResponse({
body: {
statusCode: 500,
attributes: { type: ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED },
},
})
)
}
defaultTitle="Something went wrong"
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiCallOut
color="primary"
title={
<FormattedMessage
defaultMessage="Elastic is already configured"
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredErrorTitle"
values={Object {}}
/>
}
>
<EuiButton
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Continue to Kibana"
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredSubmitButton"
values={Object {}}
/>
</EuiButton>
</EuiCallOut>
`);
});
it('renders ERROR_ENROLL_FAILURE errors correctly', async () => {
const wrapper = shallow(
<SubmitErrorCallout
error={
new errors.ResponseError(
interactiveSetupMock.createApiResponse({
body: {
statusCode: 500,
attributes: { type: ERROR_ENROLL_FAILURE },
},
})
)
}
defaultTitle="Something went wrong"
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiCallOut
color="danger"
title="Something went wrong"
>
<FormattedMessage
defaultMessage="Generate a new enrollment token or configure manually."
id="interactiveSetup.submitErrorCallout.EnrollFailureErrorDescription"
values={Object {}}
/>
</EuiCallOut>
`);
});
it('renders ERROR_KIBANA_CONFIG_FAILURE errors correctly', async () => {
const wrapper = shallow(
<SubmitErrorCallout
error={
new errors.ResponseError(
interactiveSetupMock.createApiResponse({
body: {
statusCode: 500,
attributes: { type: ERROR_KIBANA_CONFIG_FAILURE },
},
})
)
}
defaultTitle="Something went wrong"
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiCallOut
color="danger"
title={
<FormattedMessage
defaultMessage="Couldn't write to config file"
id="interactiveSetup.submitErrorCallout.kibanaConfigNotWritableErrorTitle"
values={Object {}}
/>
}
>
<FormattedMessage
defaultMessage="Retry or update the {config} file manually."
id="interactiveSetup.submitErrorCallout.kibanaConfigFailureErrorDescription"
values={
Object {
"config": <strong>
kibana.yml
</strong>,
}
}
/>
</EuiCallOut>
`);
});
it('renders ERROR_KIBANA_CONFIG_NOT_WRITABLE errors correctly', async () => {
const wrapper = shallow(
<SubmitErrorCallout
error={
new errors.ResponseError(
interactiveSetupMock.createApiResponse({
body: {
statusCode: 500,
attributes: { type: ERROR_KIBANA_CONFIG_NOT_WRITABLE },
},
})
)
}
defaultTitle="Something went wrong"
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiCallOut
color="danger"
title={
<FormattedMessage
defaultMessage="Couldn't write to config file"
id="interactiveSetup.submitErrorCallout.kibanaConfigNotWritableErrorTitle"
values={Object {}}
/>
}
>
<FormattedMessage
defaultMessage="Check the file permissions and ensure {config} is writable by the Kibana process."
id="interactiveSetup.submitErrorCallout.kibanaConfigNotWritableErrorDescription"
values={
Object {
"config": <strong>
kibana.yml
</strong>,
}
}
/>
</EuiCallOut>
`);
});
it('renders ERROR_OUTSIDE_PREBOOT_STAGE errors correctly', async () => {
const wrapper = shallow(
<SubmitErrorCallout
error={
new errors.ResponseError(
interactiveSetupMock.createApiResponse({
body: {
statusCode: 500,
attributes: { type: ERROR_OUTSIDE_PREBOOT_STAGE },
},
})
)
}
defaultTitle="Something went wrong"
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiCallOut
color="primary"
title={
<FormattedMessage
defaultMessage="Elastic is already configured"
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredErrorTitle"
values={Object {}}
/>
}
>
<EuiButton
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Continue to Kibana"
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredSubmitButton"
values={Object {}}
/>
</EuiButton>
</EuiCallOut>
`);
});
it('renders ERROR_PING_FAILURE errors correctly', async () => {
const wrapper = shallow(
<SubmitErrorCallout
error={
new errors.ResponseError(
interactiveSetupMock.createApiResponse({
body: {
statusCode: 500,
attributes: { type: ERROR_PING_FAILURE },
},
})
)
}
defaultTitle="Something went wrong"
/>
);
expect(wrapper).toMatchInlineSnapshot(`
<EuiCallOut
color="danger"
title={
<FormattedMessage
defaultMessage="Couldn't connect to cluster"
id="interactiveSetup.submitErrorCallout.pingFailureErrorTitle"
values={Object {}}
/>
}
>
<FormattedMessage
defaultMessage="Check the address and retry."
id="interactiveSetup.submitErrorCallout.pingFailureErrorDescription"
values={Object {}}
/>
</EuiCallOut>
`);
});
});

View file

@ -0,0 +1,127 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiButton, EuiCallOut } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import type { IHttpFetchError } from 'kibana/public';
import {
ERROR_CONFIGURE_FAILURE,
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
ERROR_ENROLL_FAILURE,
ERROR_KIBANA_CONFIG_FAILURE,
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
ERROR_OUTSIDE_PREBOOT_STAGE,
ERROR_PING_FAILURE,
} from '../common';
export interface SubmitErrorCalloutProps {
error: Error;
defaultTitle: React.ReactNode;
}
export const SubmitErrorCallout: FunctionComponent<SubmitErrorCalloutProps> = (props) => {
const error = props.error as IHttpFetchError;
if (
error.body?.statusCode === 404 ||
error.body?.attributes?.type === ERROR_OUTSIDE_PREBOOT_STAGE ||
error.body?.attributes?.type === ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED
) {
return (
<EuiCallOut
color="primary"
title={
<FormattedMessage
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredErrorTitle"
defaultMessage="Elastic is already configured"
/>
}
>
<EuiButton
onClick={() => {
const url = new URL(window.location.href);
url.searchParams.delete('code');
window.location.replace(url.href);
}}
>
<FormattedMessage
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredSubmitButton"
defaultMessage="Continue to Kibana"
/>
</EuiButton>
</EuiCallOut>
);
}
return (
<EuiCallOut
color="danger"
title={
error.body?.statusCode === 403 ? (
<FormattedMessage
id="interactiveSetup.submitErrorCallout.forbiddenErrorTitle"
defaultMessage="Verification required"
/>
) : error.body?.attributes?.type === ERROR_KIBANA_CONFIG_NOT_WRITABLE ||
error.body?.attributes?.type === ERROR_KIBANA_CONFIG_FAILURE ? (
<FormattedMessage
id="interactiveSetup.submitErrorCallout.kibanaConfigNotWritableErrorTitle"
defaultMessage="Couldn't write to config file"
/>
) : error.body?.attributes?.type === ERROR_PING_FAILURE ? (
<FormattedMessage
id="interactiveSetup.submitErrorCallout.pingFailureErrorTitle"
defaultMessage="Couldn't connect to cluster"
/>
) : (
props.defaultTitle
)
}
>
{error.body?.statusCode === 403 ? (
<FormattedMessage
id="interactiveSetup.submitErrorCallout.forbiddenErrorDescription"
defaultMessage="Retry to configure Elastic."
/>
) : error.body?.attributes?.type === ERROR_KIBANA_CONFIG_NOT_WRITABLE ? (
<FormattedMessage
id="interactiveSetup.submitErrorCallout.kibanaConfigNotWritableErrorDescription"
defaultMessage="Check the file permissions and ensure {config} is writable by the Kibana process."
values={{
config: <strong>kibana.yml</strong>,
}}
/>
) : error.body?.attributes?.type === ERROR_KIBANA_CONFIG_FAILURE ||
error.body?.attributes?.type === ERROR_CONFIGURE_FAILURE ? (
<FormattedMessage
id="interactiveSetup.submitErrorCallout.kibanaConfigFailureErrorDescription"
defaultMessage="Retry or update the {config} file manually."
values={{
config: <strong>kibana.yml</strong>,
}}
/>
) : error.body?.attributes?.type === ERROR_ENROLL_FAILURE ? (
<FormattedMessage
id="interactiveSetup.submitErrorCallout.EnrollFailureErrorDescription"
defaultMessage="Generate a new enrollment token or configure manually."
/>
) : error.body?.attributes?.type === ERROR_PING_FAILURE ? (
<FormattedMessage
id="interactiveSetup.submitErrorCallout.pingFailureErrorDescription"
defaultMessage="Check the address and retry."
/>
) : (
error.body?.message || error.message
)}
</EuiCallOut>
);
};

View file

@ -8,8 +8,8 @@
import constate from 'constate';
import type { HttpSetup } from 'src/core/public';
import type { CoreStart } from 'src/core/public';
export const [HttpProvider, useHttp] = constate(({ http }: { http: HttpSetup }) => {
return http;
export const [KibanaProvider, useKibana] = constate(({ services }: { services: CoreStart }) => {
return services;
});

View file

@ -11,7 +11,9 @@ import constate from 'constate';
import type { FunctionComponent } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useHttp } from './use_http';
import { euiThemeVars } from '@kbn/ui-shared-deps-src/theme';
import { useKibana } from './use_kibana';
import { VerificationCodeForm } from './verification_code_form';
export interface VerificationProps {
@ -37,7 +39,7 @@ const [OuterVerificationProvider, useVerification] = constate(
);
const InnerVerificationProvider: FunctionComponent = ({ children }) => {
const http = useHttp();
const { http } = useKibana();
const { status, setStatus, setCode } = useVerification();
useEffect(() => {
@ -54,7 +56,7 @@ const InnerVerificationProvider: FunctionComponent = ({ children }) => {
return (
<>
{status === 'unverified' && (
<EuiModal onClose={() => setStatus('unknown')}>
<EuiModal onClose={() => setStatus('unknown')} maxWidth={euiThemeVars.euiBreakpoints.s}>
<EuiModalHeader>
<VerificationCodeForm
onSuccess={(values) => {

View file

@ -28,7 +28,7 @@ describe('VerificationCodeForm', () => {
const onSuccess = jest.fn();
const { findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<Providers services={coreStart}>
<VerificationCodeForm onSuccess={onSuccess} />
</Providers>
);
@ -65,14 +65,14 @@ describe('VerificationCodeForm', () => {
const onSuccess = jest.fn();
const { findAllByText, findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<Providers services={coreStart}>
<VerificationCodeForm onSuccess={onSuccess} />
</Providers>
);
fireEvent.click(await findByRole('button', { name: 'Verify', hidden: true }));
await findAllByText(/Enter a verification code/i);
await findAllByText(/Enter the verification code from the Kibana server/i);
fireEvent.input(await findByLabelText('Digit 1'), {
target: { value: '1' },

View file

@ -8,7 +8,6 @@
import {
EuiButton,
EuiCallOut,
EuiCode,
EuiEmptyPrompt,
EuiForm,
@ -25,9 +24,10 @@ import type { IHttpFetchError } from 'kibana/public';
import { VERIFICATION_CODE_LENGTH } from '../common';
import { SingleCharsField } from './single_chars_field';
import { SubmitErrorCallout } from './submit_error_callout';
import type { ValidationErrors } from './use_form';
import { useForm } from './use_form';
import { useHttp } from './use_http';
import { useKibana } from './use_kibana';
export interface VerificationCodeFormValues {
code: string;
@ -44,7 +44,7 @@ export const VerificationCodeForm: FunctionComponent<VerificationCodeFormProps>
},
onSuccess,
}) => {
const http = useHttp();
const { http } = useKibana();
const [form, eventHandlers] = useForm({
defaultValues,
validate: async (values) => {
@ -52,7 +52,7 @@ export const VerificationCodeForm: FunctionComponent<VerificationCodeFormProps>
if (!values.code) {
errors.code = i18n.translate('interactiveSetup.verificationCodeForm.codeRequiredError', {
defaultMessage: 'Enter a verification code.',
defaultMessage: 'Enter the verification code from the Kibana server.',
});
} else if (values.code.length !== VERIFICATION_CODE_LENGTH) {
errors.code = i18n.translate('interactiveSetup.verificationCodeForm.codeMinLengthError', {
@ -97,24 +97,32 @@ export const VerificationCodeForm: FunctionComponent<VerificationCodeFormProps>
<>
{form.submitError && (
<>
<EuiCallOut
color="danger"
title={i18n.translate('interactiveSetup.verificationCodeForm.submitErrorTitle', {
defaultMessage: "Couldn't verify code",
})}
>
{(form.submitError as IHttpFetchError).body?.message}
</EuiCallOut>
<SubmitErrorCallout
error={form.submitError}
defaultTitle={i18n.translate(
'interactiveSetup.verificationCodeForm.submitErrorTitle',
{
defaultMessage: "Couldn't verify code",
}
)}
/>
<EuiSpacer />
</>
)}
<EuiText>
<p>
<FormattedMessage
id="interactiveSetup.verificationCodeForm.codeDescription"
defaultMessage="Copy the code from the Kibana server or run {command} to retrieve it."
values={{
command: <EuiCode lang="bash">./bin/kibana-verification-code</EuiCode>,
command: (
<EuiCode language="bash">
{window.navigator.userAgent.includes('Win')
? 'bin\\kibana-verification-code.bat'
: 'bin/kibana-verification-code'}
</EuiCode>
),
}}
/>
</p>

View file

@ -13,7 +13,14 @@ import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from
import { kibanaResponseFactory } from 'src/core/server';
import { httpServerMock } from 'src/core/server/mocks';
import { ElasticsearchConnectionStatus } from '../../common';
import {
ElasticsearchConnectionStatus,
ERROR_CONFIGURE_FAILURE,
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
ERROR_KIBANA_CONFIG_FAILURE,
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
ERROR_OUTSIDE_PREBOOT_STAGE,
} from '../../common';
import { interactiveSetupMock } from '../mocks';
import { defineConfigureRoute } from './configure';
import { routeDefinitionParamsMock } from './index.mock';
@ -135,11 +142,17 @@ describe('Configure routes', () => {
body: { host: 'host1' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 400,
options: { body: 'Cannot process request outside of preboot stage.' },
payload: 'Cannot process request outside of preboot stage.',
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 400,
payload: {
attributes: {
type: ERROR_OUTSIDE_PREBOOT_STAGE,
},
message: 'Cannot process request outside of preboot stage.',
},
})
);
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
@ -156,19 +169,15 @@ describe('Configure routes', () => {
body: { host: 'host1' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 400,
options: {
body: {
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 400,
payload: {
message: 'Elasticsearch connection is already configured.',
attributes: { type: 'elasticsearch_connection_configured' },
attributes: { type: ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED },
},
},
payload: {
message: 'Elasticsearch connection is already configured.',
attributes: { type: 'elasticsearch_connection_configured' },
},
});
})
);
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
@ -186,20 +195,15 @@ describe('Configure routes', () => {
body: { host: 'host1' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 500,
options: {
body: {
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 500,
payload: {
message: 'Kibana process does not have enough permissions to write to config file.',
attributes: { type: 'kibana_config_not_writable' },
attributes: { type: ERROR_KIBANA_CONFIG_NOT_WRITABLE },
},
statusCode: 500,
},
payload: {
message: 'Kibana process does not have enough permissions to write to config file.',
attributes: { type: 'kibana_config_not_writable' },
},
});
})
);
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
@ -225,14 +229,15 @@ describe('Configure routes', () => {
body: { host: 'host1' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 500,
options: {
body: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } },
statusCode: 500,
},
payload: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 500,
payload: {
message: 'Failed to configure.',
attributes: { type: ERROR_CONFIGURE_FAILURE },
},
})
);
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1);
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
@ -253,20 +258,15 @@ describe('Configure routes', () => {
body: { host: 'host1' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 500,
options: {
body: {
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 500,
payload: {
message: 'Failed to save configuration.',
attributes: { type: 'kibana_config_failure' },
attributes: { type: ERROR_KIBANA_CONFIG_FAILURE },
},
statusCode: 500,
},
payload: {
message: 'Failed to save configuration.',
attributes: { type: 'kibana_config_failure' },
},
});
})
);
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1);
expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1);
@ -285,11 +285,12 @@ describe('Configure routes', () => {
body: { host: 'host', username: 'username', password: 'password', caCert: 'der' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 204,
options: {},
payload: undefined,
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 204,
payload: undefined,
})
);
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1);
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledWith({

View file

@ -11,7 +11,14 @@ import { first } from 'rxjs/operators';
import { schema } from '@kbn/config-schema';
import type { RouteDefinitionParams } from '.';
import { ElasticsearchConnectionStatus } from '../../common';
import {
ElasticsearchConnectionStatus,
ERROR_CONFIGURE_FAILURE,
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
ERROR_KIBANA_CONFIG_FAILURE,
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
ERROR_OUTSIDE_PREBOOT_STAGE,
} from '../../common';
import type { AuthenticateParameters } from '../elasticsearch_service';
import { ElasticsearchService } from '../elasticsearch_service';
import type { WriteConfigParameters } from '../kibana_config_writer';
@ -66,7 +73,12 @@ export function defineConfigureRoute({
if (!preboot.isSetupOnHold()) {
logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`);
return response.badRequest({ body: 'Cannot process request outside of preboot stage.' });
return response.badRequest({
body: {
message: 'Cannot process request outside of preboot stage.',
attributes: { type: ERROR_OUTSIDE_PREBOOT_STAGE },
},
});
}
const connectionStatus = await elasticsearch.connectionStatus$.pipe(first()).toPromise();
@ -77,7 +89,7 @@ export function defineConfigureRoute({
return response.badRequest({
body: {
message: 'Elasticsearch connection is already configured.',
attributes: { type: 'elasticsearch_connection_configured' },
attributes: { type: ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED },
},
});
}
@ -93,7 +105,7 @@ export function defineConfigureRoute({
statusCode: 500,
body: {
message: 'Kibana process does not have enough permissions to write to config file.',
attributes: { type: 'kibana_config_not_writable' },
attributes: { type: ERROR_KIBANA_CONFIG_NOT_WRITABLE },
},
});
}
@ -114,7 +126,7 @@ export function defineConfigureRoute({
// request or we just couldn't connect to any of the provided hosts.
return response.customError({
statusCode: 500,
body: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } },
body: { message: 'Failed to configure.', attributes: { type: ERROR_CONFIGURE_FAILURE } },
});
}
@ -126,7 +138,7 @@ export function defineConfigureRoute({
statusCode: 500,
body: {
message: 'Failed to save configuration.',
attributes: { type: 'kibana_config_failure' },
attributes: { type: ERROR_KIBANA_CONFIG_FAILURE },
},
});
}

View file

@ -13,7 +13,14 @@ import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from
import { kibanaResponseFactory } from 'src/core/server';
import { httpServerMock } from 'src/core/server/mocks';
import { ElasticsearchConnectionStatus } from '../../common';
import {
ElasticsearchConnectionStatus,
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
ERROR_ENROLL_FAILURE,
ERROR_KIBANA_CONFIG_FAILURE,
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
ERROR_OUTSIDE_PREBOOT_STAGE,
} from '../../common';
import { interactiveSetupMock } from '../mocks';
import { defineEnrollRoutes } from './enroll';
import { routeDefinitionParamsMock } from './index.mock';
@ -153,11 +160,17 @@ describe('Enroll routes', () => {
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 400,
options: { body: 'Cannot process request outside of preboot stage.' },
payload: 'Cannot process request outside of preboot stage.',
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 400,
payload: {
attributes: {
type: ERROR_OUTSIDE_PREBOOT_STAGE,
},
message: 'Cannot process request outside of preboot stage.',
},
})
);
expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled();
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
@ -174,19 +187,15 @@ describe('Enroll routes', () => {
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 400,
options: {
body: {
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 400,
payload: {
message: 'Elasticsearch connection is already configured.',
attributes: { type: 'elasticsearch_connection_configured' },
attributes: { type: ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED },
},
},
payload: {
message: 'Elasticsearch connection is already configured.',
attributes: { type: 'elasticsearch_connection_configured' },
},
});
})
);
expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled();
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
@ -204,20 +213,15 @@ describe('Enroll routes', () => {
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 500,
options: {
body: {
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 500,
payload: {
message: 'Kibana process does not have enough permissions to write to config file.',
attributes: { type: 'kibana_config_not_writable' },
attributes: { type: ERROR_KIBANA_CONFIG_NOT_WRITABLE },
},
statusCode: 500,
},
payload: {
message: 'Kibana process does not have enough permissions to write to config file.',
attributes: { type: 'kibana_config_not_writable' },
},
});
})
);
expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled();
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
@ -243,14 +247,12 @@ describe('Enroll routes', () => {
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 500,
options: {
body: { message: 'Failed to enroll.', attributes: { type: 'enroll_failure' } },
statusCode: 500,
},
payload: { message: 'Failed to enroll.', attributes: { type: 'enroll_failure' } },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 500,
payload: { message: 'Failed to enroll.', attributes: { type: ERROR_ENROLL_FAILURE } },
})
);
expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledTimes(1);
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
@ -276,20 +278,15 @@ describe('Enroll routes', () => {
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 500,
options: {
body: {
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 500,
payload: {
message: 'Failed to save configuration.',
attributes: { type: 'kibana_config_failure' },
attributes: { type: ERROR_KIBANA_CONFIG_FAILURE },
},
statusCode: 500,
},
payload: {
message: 'Failed to save configuration.',
attributes: { type: 'kibana_config_failure' },
},
});
})
);
expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledTimes(1);
expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1);
@ -313,11 +310,12 @@ describe('Enroll routes', () => {
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 204,
options: {},
payload: undefined,
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 204,
payload: undefined,
})
);
expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledTimes(1);
expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledWith({

View file

@ -10,7 +10,14 @@ import { first } from 'rxjs/operators';
import { schema } from '@kbn/config-schema';
import { ElasticsearchConnectionStatus } from '../../common';
import {
ElasticsearchConnectionStatus,
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
ERROR_ENROLL_FAILURE,
ERROR_KIBANA_CONFIG_FAILURE,
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
ERROR_OUTSIDE_PREBOOT_STAGE,
} from '../../common';
import type { EnrollResult } from '../elasticsearch_service';
import type { WriteConfigParameters } from '../kibana_config_writer';
import type { RouteDefinitionParams } from './';
@ -48,7 +55,12 @@ export function defineEnrollRoutes({
if (!preboot.isSetupOnHold()) {
logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`);
return response.badRequest({ body: 'Cannot process request outside of preboot stage.' });
return response.badRequest({
body: {
message: 'Cannot process request outside of preboot stage.',
attributes: { type: ERROR_OUTSIDE_PREBOOT_STAGE },
},
});
}
const connectionStatus = await elasticsearch.connectionStatus$.pipe(first()).toPromise();
@ -59,7 +71,7 @@ export function defineEnrollRoutes({
return response.badRequest({
body: {
message: 'Elasticsearch connection is already configured.',
attributes: { type: 'elasticsearch_connection_configured' },
attributes: { type: ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED },
},
});
}
@ -75,7 +87,7 @@ export function defineEnrollRoutes({
statusCode: 500,
body: {
message: 'Kibana process does not have enough permissions to write to config file.',
attributes: { type: 'kibana_config_not_writable' },
attributes: { type: ERROR_KIBANA_CONFIG_NOT_WRITABLE },
},
});
}
@ -100,7 +112,7 @@ export function defineEnrollRoutes({
// request or we just couldn't connect to any of the provided hosts.
return response.customError({
statusCode: 500,
body: { message: 'Failed to enroll.', attributes: { type: 'enroll_failure' } },
body: { message: 'Failed to enroll.', attributes: { type: ERROR_ENROLL_FAILURE } },
});
}
@ -112,7 +124,7 @@ export function defineEnrollRoutes({
statusCode: 500,
body: {
message: 'Failed to save configuration.',
attributes: { type: 'kibana_config_failure' },
attributes: { type: ERROR_KIBANA_CONFIG_FAILURE },
},
});
}

View file

@ -13,6 +13,7 @@ import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from
import { kibanaResponseFactory } from 'src/core/server';
import { httpServerMock } from 'src/core/server/mocks';
import { ERROR_OUTSIDE_PREBOOT_STAGE, ERROR_PING_FAILURE } from '../../common';
import { interactiveSetupMock } from '../mocks';
import { routeDefinitionParamsMock } from './index.mock';
import { definePingRoute } from './ping';
@ -66,11 +67,17 @@ describe('Configure routes', () => {
body: { host: 'host' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 400,
options: { body: 'Cannot process request outside of preboot stage.' },
payload: 'Cannot process request outside of preboot stage.',
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 400,
payload: {
attributes: {
type: ERROR_OUTSIDE_PREBOOT_STAGE,
},
message: 'Cannot process request outside of preboot stage.',
},
})
);
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
@ -91,14 +98,12 @@ describe('Configure routes', () => {
body: { host: 'host' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 500,
options: {
body: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } },
statusCode: 500,
},
payload: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 500,
payload: { message: 'Failed to ping cluster.', attributes: { type: ERROR_PING_FAILURE } },
})
);
expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledTimes(1);
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
@ -111,11 +116,12 @@ describe('Configure routes', () => {
body: { host: 'host' },
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
status: 200,
options: {},
payload: undefined,
});
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
expect.objectContaining({
status: 200,
payload: undefined,
})
);
expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledTimes(1);
expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledWith('host');

View file

@ -9,6 +9,7 @@
import { schema } from '@kbn/config-schema';
import type { RouteDefinitionParams } from '.';
import { ERROR_OUTSIDE_PREBOOT_STAGE, ERROR_PING_FAILURE } from '../../common';
import type { PingResult } from '../../common/types';
export function definePingRoute({ router, logger, elasticsearch, preboot }: RouteDefinitionParams) {
@ -25,7 +26,12 @@ export function definePingRoute({ router, logger, elasticsearch, preboot }: Rout
async (context, request, response) => {
if (!preboot.isSetupOnHold()) {
logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`);
return response.badRequest({ body: 'Cannot process request outside of preboot stage.' });
return response.badRequest({
body: {
message: 'Cannot process request outside of preboot stage.',
attributes: { type: ERROR_OUTSIDE_PREBOOT_STAGE },
},
});
}
let result: PingResult;
@ -34,7 +40,7 @@ export function definePingRoute({ router, logger, elasticsearch, preboot }: Rout
} catch {
return response.customError({
statusCode: 500,
body: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } },
body: { message: 'Failed to ping cluster.', attributes: { type: ERROR_PING_FAILURE } },
});
}