[Synthetics] Add project api keys settings tab (#147106)

## Summary

Fixes https://github.com/elastic/kibana/issues/146039

Add API keys settings tab in synthetics settings page

### Initial State
<img width="1792" alt="image"
src="https://user-images.githubusercontent.com/3505601/206190656-8208368f-23a4-4a0b-a5a3-c6d2d4b98826.png">



### Final state

This API keys is from test env, so it's safe to display here

<img width="1791" alt="image"
src="https://user-images.githubusercontent.com/3505601/205942788-28b28b9f-a731-4090-8a26-4fcb88141fba.png">
This commit is contained in:
Shahzad 2022-12-08 20:26:48 +01:00 committed by GitHub
parent 39d27bb868
commit 7434d25fc1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 475 additions and 3 deletions

View file

@ -5,6 +5,7 @@
* 2.0.
*/
export * from './project_api_keys.journey';
export * from './getting_started.journey';
export * from './add_monitor.journey';
export * from './monitor_selector.journey';

View file

@ -72,7 +72,7 @@ journey(`PrivateLocationsSettings`, async ({ page, params }) => {
});
let locationId: string;
step('Click text=AlertingPrivate LocationsData Retention', async () => {
await page.click('text=AlertingPrivate LocationsData Retention');
await page.click('text=Private Locations');
await page.click('h1:has-text("Settings")');
const privateLocations = await getPrivateLocations(params);

View file

@ -0,0 +1,69 @@
/*
* 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 { journey, step, expect, before } from '@elastic/synthetics';
journey('ProjectAPIKeys', async ({ page }) => {
let apiKey = '';
page.setDefaultTimeout(3 * 30000);
before(async () => {
page.on('request', (evt) => {
if (evt.resourceType() === 'fetch' && evt.url().includes('uptime/service/api_key')) {
evt
.response()
?.then((res) => res?.json())
.then((res) => {
apiKey = res.apiKey.encoded;
});
}
});
});
step('Go to http://localhost:5620/login?next=%2Fapp%2Fsynthetics%2Fsettings', async () => {
await page.goto('http://localhost:5620/login?next=%2Fapp%2Fsynthetics%2Fsettings');
await page.click('input[name="username"]');
await page.fill('input[name="username"]', 'elastic');
await page.press('input[name="username"]', 'Tab');
await page.fill('input[name="password"]', 'changeme');
await Promise.all([
page.waitForNavigation({ url: 'http://localhost:5620/app/synthetics/settings/alerting' }),
page.click('button:has-text("Log in")'),
]);
});
step('Click text=Project API Keys', async () => {
await page.click('text=Project API Keys');
expect(page.url()).toBe('http://localhost:5620/app/synthetics/settings/api-keys');
await page.click('button:has-text("Generate Project API key")');
await page.click(
'text=This API key will only be shown once. Please keep a copy for your own records.'
);
await page.click('strong:has-text("API key")');
await page.click('text=Use as environment variable');
await page.click(`text=${apiKey}`);
await page.click('[aria-label="Account menu"]');
});
step('Click text=Log out', async () => {
await page.click('text=Log out');
expect(page.url()).toBe('http://localhost:5620/login?msg=LOGGED_OUT');
await page.fill('input[name="username"]', 'viewer');
await page.press('input[name="username"]', 'Tab');
await page.fill('input[name="password"]', 'changeme');
await Promise.all([
page.waitForNavigation({ url: 'http://localhost:5620/app/home' }),
page.click('button:has-text("Log in")'),
]);
await page.goto('http://localhost:5620/app/synthetics/settings/api-keys', {
waitUntil: 'networkidle',
});
});
step('Click text=Synthetics', async () => {
expect(page.url()).toBe('http://localhost:5620/app/synthetics/settings/api-keys');
await page.isDisabled('button:has-text("Generate Project API key")');
});
});

View file

@ -20,9 +20,9 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib
const isRemote = Boolean(process.env.SYNTHETICS_REMOTE_ENABLED);
const basePath = isRemote ? remoteKibanaUrl : kibanaUrl;
const monitorManagement = `${basePath}/app/synthetics/monitors`;
const settingsPage = `${basePath}/app/synthetics/settings`;
const addMonitor = `${basePath}/app/synthetics/add-monitor`;
const overview = `${basePath}/app/synthetics`;
const settingsPage = `${basePath}/app/synthetics/settings`;
return {
...loginPageProvider({

View file

@ -10,7 +10,12 @@ import { EuiPageHeaderProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SYNTHETICS_SETTINGS_ROUTE } from '../../../../../common/constants';
export type SettingsTabId = 'data-retention' | 'params' | 'alerting' | 'private-locations';
export type SettingsTabId =
| 'data-retention'
| 'params'
| 'alerting'
| 'private-locations'
| 'api-keys';
export const getSettingsPageHeader = (
history: ReturnType<typeof useHistory>,
@ -56,6 +61,13 @@ export const getSettingsPageHeader = (
isSelected: tabId === 'data-retention',
href: replaceTab('data-retention'),
},
{
label: i18n.translate('xpack.synthetics.settingsTabs.apiKeys', {
defaultMessage: 'Project API Keys',
}),
isSelected: tabId === 'api-keys',
href: replaceTab('api-keys'),
},
],
};
};

View file

@ -0,0 +1,37 @@
/*
* 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 userEvent from '@testing-library/user-event';
import { screen } from '@testing-library/react';
import { ApiKeyBtn } from './api_key_btn';
import { render } from '../../../utils/testing';
describe('<APIKeyButton />', () => {
const setLoadAPIKey = jest.fn();
it('calls delete monitor on monitor deletion', () => {
render(<ApiKeyBtn setLoadAPIKey={setLoadAPIKey} apiKey="" loading={false} />);
expect(screen.getByText('Generate Project API key')).toBeInTheDocument();
userEvent.click(screen.getByTestId('uptimeMonitorManagementApiKeyGenerate'));
expect(setLoadAPIKey).toHaveBeenCalled();
});
it('shows correct content on loading', () => {
render(<ApiKeyBtn setLoadAPIKey={setLoadAPIKey} apiKey="" loading={true} />);
expect(screen.getByText('Generating API key')).toBeInTheDocument();
});
it('shows api key when available and hides button', () => {
const apiKey = 'sampleApiKey';
render(<ApiKeyBtn setLoadAPIKey={setLoadAPIKey} apiKey={apiKey} loading={false} />);
expect(screen.queryByText('Generate Project API key')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,59 @@
/*
* 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 { EuiButton, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export const ApiKeyBtn = ({
isDisabled,
apiKey,
loading,
setLoadAPIKey,
}: {
loading?: boolean;
isDisabled?: boolean;
apiKey?: string;
setLoadAPIKey: (val: boolean) => void;
}) => {
return (
<>
<EuiSpacer size="m" />
{!apiKey && (
<>
<EuiButton
fill
isDisabled={isDisabled}
fullWidth={true}
isLoading={loading}
color="primary"
onClick={() => {
setLoadAPIKey(true);
}}
data-test-subj="uptimeMonitorManagementApiKeyGenerate"
>
{loading ? GET_API_KEY_LOADING_LABEL : GET_API_KEY_LABEL}
</EuiButton>
<EuiSpacer size="s" />
</>
)}
</>
);
};
const GET_API_KEY_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.getProjectApiKey.label',
{
defaultMessage: 'Generate Project API key',
}
);
const GET_API_KEY_LOADING_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.getAPIKeyLabel.loading',
{
defaultMessage: 'Generating API key',
}
);

View file

@ -0,0 +1,66 @@
/*
* 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 { EuiCallOut, EuiCodeBlock, EuiSpacer, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export const HelpCommands = ({ apiKey }: { apiKey: string }) => {
return (
<div className="text-left">
<EuiCallOut title={API_KEY_WARNING_LABEL} iconType="iInCircle" size="s" />
<EuiSpacer size="s" />
<EuiText size="s">
<strong>{API_KEY_LABEL}</strong>
</EuiText>
<EuiSpacer size="s" />
<EuiCodeBlock language="javascript" isCopyable fontSize="s" paddingSize="m" whiteSpace="pre">
{apiKey}
</EuiCodeBlock>
<EuiSpacer size="m" />
<EuiText size="s">
<strong>{USE_AS_ENV}</strong>
</EuiText>
<EuiSpacer size="s" />
<EuiCodeBlock language="javascript" isCopyable fontSize="s" paddingSize="m">
export SYNTHETICS_API_KEY={apiKey}
</EuiCodeBlock>
<EuiSpacer size="m" />
<EuiSpacer size="m" />
<EuiText size="s">
<strong>{PROJECT_PUSH_COMMAND}</strong>
</EuiText>
<EuiSpacer size="s" />
<EuiCodeBlock language="javascript" isCopyable fontSize="s" paddingSize="m">
SYNTHETICS_API_KEY={apiKey} npm run push
</EuiCodeBlock>
</div>
);
};
const API_KEY_LABEL = i18n.translate('xpack.synthetics.monitorManagement.apiKey.label', {
defaultMessage: 'API key',
});
const USE_AS_ENV = i18n.translate('xpack.synthetics.monitorManagement.useEnv.label', {
defaultMessage: 'Use as environment variable',
});
const PROJECT_PUSH_COMMAND = i18n.translate(
'xpack.synthetics.monitorManagement.projectPush.label',
{
defaultMessage: 'Project push command',
}
);
const API_KEY_WARNING_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.apiKeyWarning.label',
{
defaultMessage:
'This API key will only be shown once. Please keep a copy for your own records.',
}
);

View file

@ -0,0 +1,71 @@
/*
* 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 * as observabilityPublic from '@kbn/observability-plugin/public';
import { screen } from '@testing-library/react';
import { ProjectAPIKeys } from './project_api_keys';
import { makeUptimePermissionsCore, render } from '../../../utils/testing';
jest.mock('@kbn/observability-plugin/public');
describe('<ProjectAPIKeys />', () => {
const state = {
syntheticsEnablement: {
enablement: {
canManageApiKeys: true,
},
},
};
beforeAll(() => {
jest.spyOn(observabilityPublic, 'useFetcher').mockReturnValue({
data: undefined,
status: observabilityPublic.FETCH_STATUS.SUCCESS,
refetch: () => {},
});
});
it('shows the button', () => {
jest.spyOn(observabilityPublic, 'useFetcher').mockReturnValue({
data: undefined,
status: observabilityPublic.FETCH_STATUS.SUCCESS,
refetch: () => {},
});
render(<ProjectAPIKeys />);
expect(screen.queryByText('Generate API key')).not.toBeInTheDocument();
expect(
screen.getByText(/Use an API key to push monitors remotely from a CLI or CD pipeline/)
).toBeInTheDocument();
});
it('shows appropriate content when user does not have correct uptime save permissions', () => {
// const apiKey = 'sampleApiKey';
render(<ProjectAPIKeys />, {
state,
core: makeUptimePermissionsCore({ save: false }),
});
expect(screen.getByText(/Please contact your administrator./)).toBeInTheDocument();
});
it('shows appropriate content when user does not api key management permissions', () => {
render(<ProjectAPIKeys />, {
state: {
syntheticsEnablement: {
enablement: {
canManageApiKeys: false,
},
},
},
core: makeUptimePermissionsCore({ save: true }),
});
expect(screen.getByText(/Please contact your administrator./)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,124 @@
/*
* 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, { useEffect, useState } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { EuiText, EuiLink, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useFetcher } from '@kbn/observability-plugin/public';
import { HelpCommands } from './help_commands';
import { LoadingState } from '../../monitors_page/overview/overview/monitor_detail_flyout';
import { fetchServiceAPIKey } from '../../../state/monitor_management/api';
import { ClientPluginsStart } from '../../../../../plugin';
import { ApiKeyBtn } from './api_key_btn';
import { useEnablement } from '../../../hooks';
const syntheticsTestRunDocsLink =
'https://www.elastic.co/guide/en/observability/current/synthetic-run-tests.html';
export const ProjectAPIKeys = () => {
const {
loading: enablementLoading,
enablement: { canManageApiKeys },
} = useEnablement();
const [apiKey, setApiKey] = useState<string | undefined>(undefined);
const [loadAPIKey, setLoadAPIKey] = useState(false);
const kServices = useKibana<ClientPluginsStart>().services;
const canSaveIntegrations: boolean =
!!kServices?.fleet?.authz.integrations.writeIntegrationPolicies;
const { data, loading } = useFetcher(async () => {
if (loadAPIKey) {
return fetchServiceAPIKey();
}
return null;
}, [loadAPIKey]);
useEffect(() => {
setApiKey(data?.apiKey.encoded);
}, [data]);
const canSave: boolean = !!useKibana().services?.application?.capabilities.uptime.save;
if (enablementLoading) {
return <LoadingState />;
}
return (
<>
<EuiEmptyPrompt
style={{ maxWidth: '50%' }}
title={<h2>{GET_API_KEY_GENERATE}</h2>}
body={
canSave && canManageApiKeys ? (
<>
<EuiText>
{GET_API_KEY_LABEL_DESCRIPTION}{' '}
{!canSaveIntegrations ? `${API_KEY_DISCLAIMER} ` : ''}
<EuiLink href={syntheticsTestRunDocsLink} external target="_blank">
{LEARN_MORE_LABEL}
</EuiLink>
</EuiText>
</>
) : (
<>
<EuiText>
{GET_API_KEY_REDUCED_PERMISSIONS_LABEL}{' '}
<EuiLink href={syntheticsTestRunDocsLink} external target="_blank">
{LEARN_MORE_LABEL}
</EuiLink>
</EuiText>
</>
)
}
actions={
<ApiKeyBtn
loading={loading}
setLoadAPIKey={setLoadAPIKey}
apiKey={apiKey}
isDisabled={!canSave || !canManageApiKeys}
/>
}
/>
{apiKey && <HelpCommands apiKey={apiKey} />}
</>
);
};
const LEARN_MORE_LABEL = i18n.translate('xpack.synthetics.monitorManagement.learnMore.label', {
defaultMessage: 'Learn more',
});
const GET_API_KEY_GENERATE = i18n.translate(
'xpack.synthetics.monitorManagement.getProjectAPIKeyLabel.generate',
{
defaultMessage: 'Generate Project API Key',
}
);
const GET_API_KEY_LABEL_DESCRIPTION = i18n.translate(
'xpack.synthetics.monitorManagement.getAPIKeyLabel.description',
{
defaultMessage: 'Use an API key to push monitors remotely from a CLI or CD pipeline.',
}
);
const API_KEY_DISCLAIMER = i18n.translate(
'xpack.synthetics.monitorManagement.getAPIKeyLabel.disclaimer',
{
defaultMessage:
'Please note: In order to use push monitors using private testing locations, you must generate this API key with a user who has Fleet and Integrations write permissions.',
}
);
const GET_API_KEY_REDUCED_PERMISSIONS_LABEL = i18n.translate(
'xpack.synthetics.monitorManagement.getAPIKeyReducedPermissions.description',
{
defaultMessage:
'Use an API key to push monitors remotely from a CLI or CD pipeline. To generate an API key, you must have permissions to manage API keys and Uptime write access. Please contact your administrator.',
}
);

View file

@ -8,6 +8,7 @@
import React from 'react';
import { Redirect, useParams } from 'react-router-dom';
import { SettingsTabId } from './page_header';
import { ProjectAPIKeys } from './project_api_keys/project_api_keys';
import { DataRetentionTab } from './data_retention';
import { useSettingsBreadcrumbs } from './use_settings_breadcrumbs';
import { ManagePrivateLocations } from './private_locations/manage_private_locations';
@ -19,6 +20,8 @@ export const SettingsPage = () => {
const renderTab = () => {
switch (tabId) {
case 'api-keys':
return <ProjectAPIKeys />;
case 'private-locations':
return <ManagePrivateLocations />;
case 'data-retention':

View file

@ -28,6 +28,7 @@ export function useEnablement() {
return {
enablement: {
areApiKeysEnabled: enablement?.areApiKeysEnabled,
canManageApiKeys: enablement?.canManageApiKeys,
canEnable: enablement?.canEnable,
isEnabled: enablement?.isEnabled,
},

View file

@ -40,3 +40,9 @@ export const getMonitorAPI = async ({
}): Promise<DecryptedSyntheticsMonitorSavedObject> => {
return await apiService.get(`${API_URLS.SYNTHETICS_MONITORS}/${id}`);
};
export const fetchServiceAPIKey = async (): Promise<{
apiKey: { encoded: string };
}> => {
return await apiService.get(API_URLS.SYNTHETICS_APIKEY);
};

View file

@ -366,3 +366,26 @@ const wrappedInClass = (element: HTMLElement | Element, classWrapper: string): b
export const forMobileOnly = finderWithClassWrapper('hideForDesktop');
export const forDesktopOnly = finderWithClassWrapper('hideForMobile');
export const makeUptimePermissionsCore = (
permissions: Partial<{
'alerting:save': boolean;
configureSettings: boolean;
save: boolean;
show: boolean;
}>
) => {
return {
application: {
capabilities: {
uptime: {
'alerting:save': true,
configureSettings: true,
save: true,
show: true,
...permissions,
},
},
},
};
};