From 37cf8f3e8c16eaaa756ccc4feee6519c28024a84 Mon Sep 17 00:00:00 2001 From: Bailey Cash Date: Fri, 21 Feb 2025 14:25:54 -0500 Subject: [PATCH] [Synthetics] introduce new spaces field for synthetics api keys (#211816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary - Resolves #211049 - Adds the ability for a user to create an API Key in synthetics settings that applies to specified space(s) - Reuses existing spaces combo box from private locations, enhances the component to incorporate a generic interface and help text prop to enable additional uses - Modifies functionality of Generate API Key button to consider a blank spaces field before creating the key - Currently, in private locations, if the spaces field is blank, the save button has no functionality, so this was copied here. ![Screenshot 2025-02-19 at 3 59 24 PM](https://github.com/user-attachments/assets/4bd7cf33-636a-4bba-a7fd-97b2315fcff1) ![Screenshot 2025-02-19 at 4 00 44 PM](https://github.com/user-attachments/assets/21b7cab6-8f95-44e9-b91d-f06e15cbac0c) ### Release Notes Adds the ability for a user to create an API Key in synthetics settings that applies only to specified space(s) --------- Co-authored-by: Shahzad (cherry picked from commit de7d33dec296a491ae9abd9f3b74859c0c8e78c7) --- .../common/runtime_types/settings/api_key.ts | 14 ++ .../settings/components/spaces_select.tsx | 18 +- .../private_locations/location_form.tsx | 9 +- .../project_api_keys/api_key_btn.test.tsx | 10 +- .../settings/project_api_keys/api_key_btn.tsx | 8 +- .../project_api_keys.test.tsx | 1 - .../project_api_keys/project_api_keys.tsx | 169 +++++++++++------- .../state/monitor_management/api.ts | 4 +- .../routes/monitor_cruds/get_api_key.ts | 4 +- .../server/synthetics_service/get_api_key.ts | 4 +- 10 files changed, 153 insertions(+), 88 deletions(-) create mode 100644 x-pack/solutions/observability/plugins/synthetics/common/runtime_types/settings/api_key.ts diff --git a/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/settings/api_key.ts b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/settings/api_key.ts new file mode 100644 index 0000000000000..d67710539053f --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/common/runtime_types/settings/api_key.ts @@ -0,0 +1,14 @@ +/* + * 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 * as t from 'io-ts'; + +export const APIKeyCodec = t.type({ + spaces: t.array(t.string), +}); + +export type SyntheticsProjectAPIKey = t.TypeOf; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/components/spaces_select.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/components/spaces_select.tsx index ce002a189c8ef..51fecfef7f94e 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/components/spaces_select.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/components/spaces_select.tsx @@ -9,15 +9,17 @@ import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { EuiComboBox, EuiFormRow } from '@elastic/eui'; -import { Controller, useFormContext } from 'react-hook-form'; +import { Controller, FieldValues, Path, useFormContext } from 'react-hook-form'; import { ALL_SPACES_ID } from '@kbn/security-plugin/public'; import { ClientPluginsStart } from '../../../../../plugin'; -import { PrivateLocation } from '../../../../../../common/runtime_types'; -export const NAMESPACES_NAME = 'spaces'; +interface SpaceSelectorProps { + helpText: string; +} -export const SpaceSelector: React.FC = () => { +export const SpaceSelector = ({ helpText }: SpaceSelectorProps) => { + const NAMESPACES_NAME = 'spaces' as Path; const { services } = useKibana(); const [spacesList, setSpacesList] = React.useState>([]); const data = services.spaces?.ui.useSpaces(); @@ -26,7 +28,7 @@ export const SpaceSelector: React.FC = () => { control, formState: { isSubmitted }, trigger, - } = useFormContext(); + } = useFormContext(); const { isTouched, error } = control.getFieldState(NAMESPACES_NAME); const showFieldInvalid = (isSubmitted || isTouched) && !!error; @@ -49,7 +51,7 @@ export const SpaceSelector: React.FC = () => { @@ -121,7 +123,3 @@ const allSpacesOption = { const SPACES_LABEL = i18n.translate('xpack.synthetics.privateLocation.spacesLabel', { defaultMessage: 'Spaces ', }); - -const HELP_TEXT = i18n.translate('xpack.synthetics.privateLocation.spacesHelpText', { - defaultMessage: 'Select the spaces where this location will be available.', -}); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/location_form.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/location_form.tsx index ee58ed2165e42..48b4b1e6dbad8 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/location_form.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/private_locations/location_form.tsx @@ -62,7 +62,7 @@ export const LocationForm = ({ privateLocations }: { privateLocations: PrivateLo - + ); @@ -95,6 +95,13 @@ export const LOCATION_NAME_LABEL = i18n.translate( } ); +const LOCATION_HELP_TEXT = i18n.translate( + 'xpack.synthetics.privateLocation.locationSpacesHelpText', + { + defaultMessage: 'Select the spaces where this location will be available.', + } +); + const NAME_ALREADY_EXISTS = i18n.translate('xpack.synthetics.monitorManagement.alreadyExists', { defaultMessage: 'Location name already exists.', }); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/api_key_btn.test.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/api_key_btn.test.tsx index 1053fc4e5f731..13697ec94f77d 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/api_key_btn.test.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/api_key_btn.test.tsx @@ -12,25 +12,25 @@ import { ApiKeyBtn } from './api_key_btn'; import { render } from '../../../utils/testing'; describe('', () => { - const setLoadAPIKey = jest.fn(); + const clickCallback = jest.fn(); it('calls delete monitor on monitor deletion', async () => { - render(); + render(); expect(screen.getByText('Generate Project API key')).toBeInTheDocument(); await userEvent.click(screen.getByTestId('uptimeMonitorManagementApiKeyGenerate')); - expect(setLoadAPIKey).toHaveBeenCalled(); + expect(clickCallback).toHaveBeenCalled(); }); it('shows correct content on loading', () => { - render(); + render(); expect(screen.getByText('Generating API key')).toBeInTheDocument(); }); it('shows api key when available and hides button', () => { const apiKey = 'sampleApiKey'; - render(); + render(); expect(screen.queryByText('Generate Project API key')).not.toBeInTheDocument(); }); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/api_key_btn.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/api_key_btn.tsx index 6cbf3760f3e08..e206a9f0ea053 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/api_key_btn.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/api_key_btn.tsx @@ -12,12 +12,12 @@ export const ApiKeyBtn = ({ isDisabled, apiKey, loading, - setLoadAPIKey, + onClick: callback, }: { loading?: boolean; isDisabled?: boolean; apiKey?: string; - setLoadAPIKey: (val: boolean) => void; + onClick: Function; }) => { return ( <> @@ -30,9 +30,7 @@ export const ApiKeyBtn = ({ fullWidth={true} isLoading={loading} color="primary" - onClick={() => { - setLoadAPIKey(true); - }} + onClick={() => callback()} data-test-subj="uptimeMonitorManagementApiKeyGenerate" > {loading ? GET_API_KEY_LOADING_LABEL : GET_API_KEY_LABEL} diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/project_api_keys.test.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/project_api_keys.test.tsx index 90eef39022817..0cee0a6dc5a07 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/project_api_keys.test.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/project_api_keys.test.tsx @@ -45,7 +45,6 @@ describe('', () => { }); it('shows appropriate content when user does not have correct uptime save permissions', () => { - // const apiKey = 'sampleApiKey'; render(, { state, core: makeUptimePermissionsCore({ save: false }), diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/project_api_keys.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/project_api_keys.tsx index 4a8752e8b9926..71064382e240b 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/project_api_keys.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/settings/project_api_keys/project_api_keys.tsx @@ -4,21 +4,28 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { EuiText, EuiLink, EuiEmptyPrompt, EuiSwitch, EuiSpacer } from '@elastic/eui'; +import { EuiText, EuiLink, EuiEmptyPrompt, EuiSwitch, EuiSpacer, EuiForm } from '@elastic/eui'; +import { SpacesContextProps } from '@kbn/spaces-plugin/public'; import { i18n } from '@kbn/i18n'; import { useFetcher } from '@kbn/observability-shared-plugin/public'; import { IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser'; +import { ALL_SPACES_ID } from '@kbn/security-plugin/public'; +import { FormProvider } from 'react-hook-form'; +import { SyntheticsProjectAPIKey } from '../../../../../../common/runtime_types/settings/api_key'; import { HelpCommands } from './help_commands'; import { LoadingState } from '../../monitors_page/overview/overview/monitor_detail_flyout'; import { fetchProjectAPIKey } from '../../../state/monitor_management/api'; import { ClientPluginsStart } from '../../../../../plugin'; -import { ApiKeyBtn } from './api_key_btn'; import { useEnablement } from '../../../hooks'; +import { SpaceSelector } from '../components/spaces_select'; +import { useFormWrapped } from '../../../../../hooks/use_form_wrapped'; +import { ApiKeyBtn } from './api_key_btn'; const syntheticsTestRunDocsLink = 'https://www.elastic.co/guide/en/observability/current/synthetic-run-tests.html'; +const getEmptyFunctionComponent: React.FC = ({ children }) => <>{children}; export const ProjectAPIKeys = () => { const { loading: enablementLoading, canManageApiKeys } = useEnablement(); @@ -26,6 +33,26 @@ export const ProjectAPIKeys = () => { const [loadAPIKey, setLoadAPIKey] = useState(false); const [accessToElasticManagedLocations, setAccessToElasticManagedLocations] = useState(true); + const form = useFormWrapped({ + mode: 'onSubmit', + reValidateMode: 'onChange', + shouldFocusError: true, + defaultValues: { + spaces: [ALL_SPACES_ID], + }, + }); + + const { handleSubmit } = form; + + const { spaces: spacesApi } = useKibana().services; + const spaces = useMemo(() => form.getValues()?.spaces, [form]); + + const ContextWrapper = useMemo( + () => + spacesApi ? spacesApi.ui.components.getSpacesContextProvider : getEmptyFunctionComponent, + [spacesApi] + ); + const kServices = useKibana().services; const canSaveIntegrations: boolean = !!kServices?.fleet?.authz.integrations.writeIntegrationPolicies; @@ -35,13 +62,22 @@ export const ProjectAPIKeys = () => { const { data, loading, error } = useFetcher(async () => { if (loadAPIKey) { - return fetchProjectAPIKey(accessToElasticManagedLocations && Boolean(canUsePublicLocations)); + return fetchProjectAPIKey( + accessToElasticManagedLocations && Boolean(canUsePublicLocations), + spaces + ); } return null; // FIXME: Dario thinks there is a better way to do this but // he's getting tired and maybe the Synthetics folks can fix it // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadAPIKey, canUsePublicLocations]); + }, [loadAPIKey, canUsePublicLocations, spaces]); + + const onSubmit = (formData: SyntheticsProjectAPIKey) => { + if (formData.spaces?.length) { + setLoadAPIKey(true); + } + }; useEffect(() => { if (data?.apiKey) { @@ -69,64 +105,67 @@ export const ProjectAPIKeys = () => { } return ( - <> - {GET_API_KEY_GENERATE}} - body={ - canSave && canManageApiKeys ? ( - <> - - {GET_API_KEY_LABEL_DESCRIPTION}{' '} - {!canSaveIntegrations ? `${API_KEY_DISCLAIMER} ` : ''} - - {LEARN_MORE_LABEL} - - - - { - setAccessToElasticManagedLocations(!accessToElasticManagedLocations); - }} - disabled={!canUsePublicLocations} - /> - - ) : ( - <> - - {GET_API_KEY_REDUCED_PERMISSIONS_LABEL}{' '} - - {LEARN_MORE_LABEL} - - - - ) - } - actions={ - - } - /> - {apiKey && } - + + + {GET_API_KEY_GENERATE}} + body={ + canSave && canManageApiKeys ? ( + + + {GET_API_KEY_LABEL_DESCRIPTION}{' '} + {!canSaveIntegrations ? `${API_KEY_DISCLAIMER} ` : ''} + + {LEARN_MORE_LABEL} + + + + { + setAccessToElasticManagedLocations(!accessToElasticManagedLocations); + }} + disabled={!canUsePublicLocations} + /> + + + ) : ( + <> + + {GET_API_KEY_REDUCED_PERMISSIONS_LABEL}{' '} + + {LEARN_MORE_LABEL} + + + + ) + } + actions={ + + } + /> + {apiKey && } + + ); }; @@ -163,3 +202,7 @@ const GET_API_KEY_REDUCED_PERMISSIONS_LABEL = i18n.translate( '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.', } ); + +const API_KEY_HELP_TEXT = i18n.translate('xpack.synthetics.privateLocation.apiKeySpacesHelpText', { + defaultMessage: 'Select the spaces where this API key will be available.', +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_management/api.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_management/api.ts index 6ee33a03b9df7..ae2d4c0dfd93a 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_management/api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/state/monitor_management/api.ts @@ -63,10 +63,12 @@ export const updateMonitorAPI = async ({ }; export const fetchProjectAPIKey = async ( - accessToElasticManagedLocations: boolean + accessToElasticManagedLocations: boolean, + spaces: string[] ): Promise => { return await apiService.get(SYNTHETICS_API_URLS.SYNTHETICS_PROJECT_APIKEY, { accessToElasticManagedLocations, + spaces: JSON.stringify(spaces), }); }; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/get_api_key.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/get_api_key.ts index 8e4f06f904dc3..a0ce0c2145381 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/get_api_key.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/get_api_key.ts @@ -21,6 +21,7 @@ export const getAPIKeySyntheticsRoute: SyntheticsRestApiRouteFactory = () => ({ path: SYNTHETICS_API_URLS.SYNTHETICS_PROJECT_APIKEY, validate: { query: schema.object({ + spaces: schema.maybe(schema.arrayOf(schema.string())), accessToElasticManagedLocations: schema.maybe(schema.boolean()), }), }, @@ -29,7 +30,7 @@ export const getAPIKeySyntheticsRoute: SyntheticsRestApiRouteFactory = () => ({ server, response, }): Promise => { - const { accessToElasticManagedLocations } = request.query; + const { accessToElasticManagedLocations, spaces } = request.query; if (accessToElasticManagedLocations) { const elasticManagedLocationsEnabled = @@ -52,6 +53,7 @@ export const getAPIKeySyntheticsRoute: SyntheticsRestApiRouteFactory = () => ({ request, server, accessToElasticManagedLocations, + spaces, }); return { apiKey }; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_api_key.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_api_key.ts index 46b96f32b2a4a..b43f1356b098c 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_api_key.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_api_key.ts @@ -117,10 +117,12 @@ export const generateProjectAPIKey = async ({ server, request, accessToElasticManagedLocations = true, + spaces = [ALL_SPACES_ID], }: { server: SyntheticsServerSetup; request: KibanaRequest; accessToElasticManagedLocations?: boolean; + spaces?: string[]; }): Promise => { const { security } = server; const isApiKeysEnabled = await security.authc.apiKeys?.areAPIKeysEnabled(); @@ -138,7 +140,7 @@ export const generateProjectAPIKey = async ({ kibana: [ { base: [], - spaces: [ALL_SPACES_ID], + spaces, feature: { uptime: [accessToElasticManagedLocations ? 'all' : 'minimal_all'], },