Skip to content

Commit

Permalink
Interactive setup mode (#106881)
Browse files Browse the repository at this point in the history
* Interactive setup mode

* remove first attempt

* Added suggestions from code review

* Verify CA before writing config

* fix i18n path

* updated plugin list

* Updated page bundle limits

* added manual configuration

* fetch certificate chain

* Fix race condition when calling multiple form methods

* Fix i18n errors

* added types and refactored slightly

* welcome page styling

* Remove holdsetup config option

* typo

* fix build errors

* Updated manual configuration form

* Remove issuer link

* Add tests for decode enrollment token

* Removed unused class names

* fix issue where credentials got inherited from base config

* Added tooltips and text overflow

* styling fixes

* refactored text truncate

* Added unit tests

* added suggestions from code review

* Fixed typo and tests

* Styling fixes

* Fix i18n errors

* Added suggestions from code review

* Added route tests

* Explicit type exports

* Fix server url

* Added unit tests

* Added product not supported scenario
  • Loading branch information
thomheymann committed Aug 30, 2021
1 parent 234f7f6 commit 035937a
Show file tree
Hide file tree
Showing 31 changed files with 2,503 additions and 84 deletions.
2 changes: 1 addition & 1 deletion packages/kbn-optimizer/limits.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ pageLoadAssetSize:
expressionImage: 19288
expressionMetric: 22238
expressionShape: 34008
interactiveSetup: 18532
interactiveSetup: 70000
expressionTagcloud: 27505
expressions: 239290
securitySolution: 231753
2 changes: 1 addition & 1 deletion src/plugins/interactive_setup/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
* Side Public License, v 1.
*/

export type { InteractiveSetupViewState, EnrollmentToken } from './types';
export type { InteractiveSetupViewState, EnrollmentToken, Certificate, PingResult } from './types';
export { ElasticsearchConnectionStatus } from './elasticsearch_connection_status';
23 changes: 23 additions & 0 deletions src/plugins/interactive_setup/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* Side Public License, v 1.
*/

import type { PeerCertificate } from 'tls';

import type { ElasticsearchConnectionStatus } from './elasticsearch_connection_status';

/**
Expand Down Expand Up @@ -43,3 +45,24 @@ export interface EnrollmentToken {
*/
key: string;
}

export interface Certificate {
issuer: Partial<PeerCertificate['issuer']>;
valid_from: PeerCertificate['valid_from'];
valid_to: PeerCertificate['valid_to'];
subject: Partial<PeerCertificate['subject']>;
fingerprint256: PeerCertificate['fingerprint256'];
raw: string;
}

export interface PingResult {
/**
* Indicates whether the cluster requires authentication.
*/
authRequired: boolean;

/**
* Full certificate chain of cluster at requested address. Only present if cluster uses HTTPS.
*/
certificateChain?: Certificate[];
}
26 changes: 26 additions & 0 deletions src/plugins/interactive_setup/public/app.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.interactiveSetup {
@include kibanaFullScreenGraphics;
}

.interactiveSetup__header {
position: relative;
z-index: 10;
padding: $euiSizeXL;
}

.interactiveSetup__logo {
@include kibanaCircleLogo;
@include euiBottomShadowMedium;

margin-bottom: $euiSizeXL;
}

.interactiveSetup__content {
position: relative;
z-index: 10;
margin: auto;
margin-bottom: $euiSizeXL;
max-width: map-get($euiBreakpoints, 's') - $euiSizeXL;
padding-left: $euiSizeXL;
padding-right: $euiSizeXL;
}
84 changes: 69 additions & 15 deletions src/plugins/interactive_setup/public/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,76 @@
* Side Public License, v 1.
*/

import { EuiPageTemplate, EuiPanel, EuiText } from '@elastic/eui';
import React from 'react';
import './app.scss';

import { EuiIcon, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React, { useState } from 'react';

import { FormattedMessage } from '@kbn/i18n/react';

import { ClusterAddressForm } from './cluster_address_form';
import type { ClusterConfigurationFormProps } from './cluster_configuration_form';
import { ClusterConfigurationForm } from './cluster_configuration_form';
import { EnrollmentTokenForm } from './enrollment_token_form';
import { ProgressIndicator } from './progress_indicator';

export const App: FunctionComponent = () => {
const [page, setPage] = useState<'token' | 'manual' | 'success'>('token');
const [cluster, setCluster] = useState<
Omit<ClusterConfigurationFormProps, 'onCancel' | 'onSuccess'>
>();

export const App = () => {
return (
<EuiPageTemplate
restrictWidth={false}
template="empty"
pageHeader={{
iconType: 'logoElastic',
pageTitle: 'Welcome to Elastic',
}}
>
<EuiPanel>
<EuiText>Kibana server is not ready yet.</EuiText>
</EuiPanel>
</EuiPageTemplate>
<div className="interactiveSetup">
<header className="interactiveSetup__header eui-textCenter">
<EuiSpacer size="xxl" />
<span className="interactiveSetup__logo">
<EuiIcon type="logoElastic" size="xxl" />
</span>
<EuiTitle size="m">
<h1>
<FormattedMessage
id="interactiveSetup.app.pageTitle"
defaultMessage="Configure Elastic to get started"
/>
</h1>
</EuiTitle>
<EuiSpacer size="xl" />
</header>
<div className="interactiveSetup__content">
<EuiPanel paddingSize="l">
<div hidden={page !== 'token'}>
<EnrollmentTokenForm
onCancel={() => setPage('manual')}
onSuccess={() => setPage('success')}
/>
</div>
<div hidden={page !== 'manual'}>
{cluster ? (
<ClusterConfigurationForm
onCancel={() => setCluster(undefined)}
onSuccess={() => setPage('success')}
{...cluster}
/>
) : (
<ClusterAddressForm
onCancel={() => setPage('token')}
onSuccess={(result, values) =>
setCluster({
host: values.host,
authRequired: result.authRequired,
certificateChain: result.certificateChain,
})
}
/>
)}
</div>
{page === 'success' && (
<ProgressIndicator onSuccess={() => window.location.replace(window.location.href)} />
)}
</EuiPanel>
</div>
</div>
);
};
70 changes: 70 additions & 0 deletions src/plugins/interactive_setup/public/cluster_address_form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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 { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';

import { coreMock } from 'src/core/public/mocks';

import { ClusterAddressForm } from './cluster_address_form';
import { Providers } from './plugin';

jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
htmlIdGenerator: () => () => `id-${Math.random()}`,
}));

describe('ClusterAddressForm', () => {
jest.setTimeout(20_000);

it('calls enrollment API when submitting form', async () => {
const coreStart = coreMock.createStart();
coreStart.http.post.mockResolvedValue({});

const onSuccess = jest.fn();

const { findByRole, findByLabelText } = render(
<Providers http={coreStart.http}>
<ClusterAddressForm onSuccess={onSuccess} />
</Providers>
);
fireEvent.change(await findByLabelText('Address'), {
target: { value: 'https://localhost' },
});
fireEvent.click(await findByRole('button', { name: 'Check address', hidden: true }));

await waitFor(() => {
expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/interactive_setup/ping', {
body: JSON.stringify({
host: 'https://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}>
<ClusterAddressForm onSuccess={onSuccess} />
</Providers>
);

fireEvent.change(await findByLabelText('Address'), {
target: { value: 'localhost' },
});

fireEvent.click(await findByRole('button', { name: 'Check address', hidden: true }));

await findAllByText(/Enter a valid address including protocol/i);

expect(coreStart.http.post).not.toHaveBeenCalled();
});
});
146 changes: 146 additions & 0 deletions src/plugins/interactive_setup/public/cluster_address_form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*
* 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,
EuiButtonEmpty,
EuiCallOut,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiSpacer,
} from '@elastic/eui';
import type { FunctionComponent } from 'react';
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 type { ValidationErrors } from './use_form';
import { useForm } from './use_form';
import { useHttp } from './use_http';

export interface ClusterAddressFormValues {
host: string;
}

export interface ClusterAddressFormProps {
defaultValues?: ClusterAddressFormValues;
onCancel?(): void;
onSuccess?(result: PingResult, values: ClusterAddressFormValues): void;
}

export const ClusterAddressForm: FunctionComponent<ClusterAddressFormProps> = ({
defaultValues = {
host: 'https://localhost:9200',
},
onCancel,
onSuccess,
}) => {
const http = useHttp();

const [form, eventHandlers] = useForm({
defaultValues,
validate: async (values) => {
const errors: ValidationErrors<typeof values> = {};

if (!values.host) {
errors.host = i18n.translate('interactiveSetup.clusterAddressForm.hostRequiredError', {
defaultMessage: 'Enter an address.',
});
} else {
try {
const url = new URL(values.host);
if (!url.protocol || !url.hostname) {
throw new Error();
}
} catch (error) {
errors.host = i18n.translate('interactiveSetup.clusterAddressForm.hostInvalidError', {
defaultMessage: 'Enter a valid address including protocol.',
});
}
}

return errors;
},
onSubmit: async (values) => {
const url = new URL(values.host);
const host = `${url.protocol}//${url.hostname}:${url.port || 9200}`;

const result = await http.post<PingResult>('/internal/interactive_setup/ping', {
body: JSON.stringify({ host }),
});

onSuccess?.(result, { host });
},
});

return (
<EuiForm component="form" noValidate {...eventHandlers}>
{form.submitError && (
<>
<EuiCallOut
color="danger"
title={i18n.translate('interactiveSetup.clusterAddressForm.submitErrorTitle', {
defaultMessage: "Couldn't check address",
})}
>
{(form.submitError as IHttpFetchError).body?.message}
</EuiCallOut>
<EuiSpacer />
</>
)}

<EuiFormRow
label={i18n.translate('interactiveSetup.clusterAddressForm.hostLabel', {
defaultMessage: 'Address',
})}
error={form.errors.host}
isInvalid={form.touched.host && !!form.errors.host}
fullWidth
>
<EuiFieldText
name="host"
value={form.values.host}
isInvalid={form.touched.host && !!form.errors.host}
fullWidth
/>
</EuiFormRow>
<EuiSpacer />

<EuiFlexGroup responsive={false} justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty flush="right" iconType="arrowLeft" onClick={onCancel}>
<FormattedMessage
id="interactiveSetup.clusterAddressForm.cancelButton"
defaultMessage="Back"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
type="submit"
isLoading={form.isSubmitting}
isDisabled={form.isSubmitted && form.isInvalid}
fill
>
<FormattedMessage
id="interactiveSetup.clusterAddressForm.submitButton"
defaultMessage="{isSubmitting, select, true{Checking address…} other{Check address}}"
values={{ isSubmitting: form.isSubmitting }}
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
);
};
Loading

0 comments on commit 035937a

Please sign in to comment.