Skip to content

Commit

Permalink
[9.0] [Synthetics] introduce new spaces field for synthetics api keys (
Browse files Browse the repository at this point in the history
…elastic#211816) (elastic#212150)

# Backport

This will backport the following commits from `main` to `9.0`:
- [[Synthetics] introduce new spaces field for synthetics api keys
(elastic#211816)](elastic#211816)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Bailey
Cash","email":"bailey.cash@elastic.co"},"sourceCommit":{"committedDate":"2025-02-21T19:25:54Z","message":"[Synthetics]
introduce new spaces field for synthetics api keys (elastic#211816)\n\n###
Summary\n\n- Resolves elastic#211049\n- Adds the ability for a user to create
an API Key in synthetics\nsettings that applies to specified space(s)\n-
Reuses existing spaces combo box from private locations, enhances
the\ncomponent to incorporate a generic interface and help text prop
to\nenable additional uses\n- Modifies functionality of Generate API Key
button to consider a blank\nspaces field before creating the key\n-
Currently, in private locations, if the spaces field is blank, the\nsave
button has no functionality, so this was copied here.\n\n![Screenshot
2025-02-19 at 3
59\n24 PM](https://github.com/user-attachments/assets/4bd7cf33-636a-4bba-a7fd-97b2315fcff1)\n\n![Screenshot
2025-02-19 at 4
00\n44 PM](https://github.com/user-attachments/assets/21b7cab6-8f95-44e9-b91d-f06e15cbac0c)\n\n###
Release Notes\nAdds the ability for a user to create an API Key in
synthetics settings\nthat applies only to specified
space(s)\n\n---------\n\nCo-authored-by: Shahzad
<shahzad31comp@gmail.com>","sha":"de7d33dec296a491ae9abd9f3b74859c0c8e78c7","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:enhancement","v9.0.0","backport:prev-minor","Team:obs-ux-management","v9.1.0","v8.19.0"],"title":"[Synthetics]
introduce new spaces field for synthetics api
keys","number":211816,"url":"https://github.com/elastic/kibana/pull/211816","mergeCommit":{"message":"[Synthetics]
introduce new spaces field for synthetics api keys (elastic#211816)\n\n###
Summary\n\n- Resolves elastic#211049\n- Adds the ability for a user to create
an API Key in synthetics\nsettings that applies to specified space(s)\n-
Reuses existing spaces combo box from private locations, enhances
the\ncomponent to incorporate a generic interface and help text prop
to\nenable additional uses\n- Modifies functionality of Generate API Key
button to consider a blank\nspaces field before creating the key\n-
Currently, in private locations, if the spaces field is blank, the\nsave
button has no functionality, so this was copied here.\n\n![Screenshot
2025-02-19 at 3
59\n24 PM](https://github.com/user-attachments/assets/4bd7cf33-636a-4bba-a7fd-97b2315fcff1)\n\n![Screenshot
2025-02-19 at 4
00\n44 PM](https://github.com/user-attachments/assets/21b7cab6-8f95-44e9-b91d-f06e15cbac0c)\n\n###
Release Notes\nAdds the ability for a user to create an API Key in
synthetics settings\nthat applies only to specified
space(s)\n\n---------\n\nCo-authored-by: Shahzad
<shahzad31comp@gmail.com>","sha":"de7d33dec296a491ae9abd9f3b74859c0c8e78c7"}},"sourceBranch":"main","suggestedTargetBranches":["9.0","8.x"],"targetPullRequestStates":[{"branch":"9.0","label":"v9.0.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/211816","number":211816,"mergeCommit":{"message":"[Synthetics]
introduce new spaces field for synthetics api keys (elastic#211816)\n\n###
Summary\n\n- Resolves elastic#211049\n- Adds the ability for a user to create
an API Key in synthetics\nsettings that applies to specified space(s)\n-
Reuses existing spaces combo box from private locations, enhances
the\ncomponent to incorporate a generic interface and help text prop
to\nenable additional uses\n- Modifies functionality of Generate API Key
button to consider a blank\nspaces field before creating the key\n-
Currently, in private locations, if the spaces field is blank, the\nsave
button has no functionality, so this was copied here.\n\n![Screenshot
2025-02-19 at 3
59\n24 PM](https://github.com/user-attachments/assets/4bd7cf33-636a-4bba-a7fd-97b2315fcff1)\n\n![Screenshot
2025-02-19 at 4
00\n44 PM](https://github.com/user-attachments/assets/21b7cab6-8f95-44e9-b91d-f06e15cbac0c)\n\n###
Release Notes\nAdds the ability for a user to create an API Key in
synthetics settings\nthat applies only to specified
space(s)\n\n---------\n\nCo-authored-by: Shahzad
<shahzad31comp@gmail.com>","sha":"de7d33dec296a491ae9abd9f3b74859c0c8e78c7"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Bailey Cash <bailey.cash@elastic.co>
  • Loading branch information
kibanamachine and baileycash-elastic authored Feb 21, 2025
1 parent 7711ae2 commit b8a0cef
Show file tree
Hide file tree
Showing 10 changed files with 153 additions and 88 deletions.
Original file line number Diff line number Diff line change
@@ -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<typeof APIKeyCodec>;
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T extends FieldValues>({ helpText }: SpaceSelectorProps) => {
const NAMESPACES_NAME = 'spaces' as Path<T>;
const { services } = useKibana<ClientPluginsStart>();
const [spacesList, setSpacesList] = React.useState<Array<{ id: string; label: string }>>([]);
const data = services.spaces?.ui.useSpaces();
Expand All @@ -26,7 +28,7 @@ export const SpaceSelector: React.FC = () => {
control,
formState: { isSubmitted },
trigger,
} = useFormContext<PrivateLocation>();
} = useFormContext<T>();
const { isTouched, error } = control.getFieldState(NAMESPACES_NAME);

const showFieldInvalid = (isSubmitted || isTouched) && !!error;
Expand All @@ -49,7 +51,7 @@ export const SpaceSelector: React.FC = () => {
<EuiFormRow
fullWidth
label={SPACES_LABEL}
helpText={HELP_TEXT}
helpText={helpText}
isInvalid={showFieldInvalid}
error={showFieldInvalid ? NAMESPACES_NAME : undefined}
>
Expand Down Expand Up @@ -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.',
});
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const LocationForm = ({ privateLocations }: { privateLocations: PrivateLo
<EuiSpacer />
<BrowserMonitorCallout />
<EuiSpacer />
<SpaceSelector />
<SpaceSelector helpText={LOCATION_HELP_TEXT} />
</EuiForm>
</>
);
Expand Down Expand Up @@ -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.',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,25 @@ import { ApiKeyBtn } from './api_key_btn';
import { render } from '../../../utils/testing';

describe('<APIKeyButton />', () => {
const setLoadAPIKey = jest.fn();
const clickCallback = jest.fn();

it('calls delete monitor on monitor deletion', async () => {
render(<ApiKeyBtn setLoadAPIKey={setLoadAPIKey} apiKey="" loading={false} />);
render(<ApiKeyBtn apiKey="" loading={false} onClick={clickCallback} />);

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(<ApiKeyBtn setLoadAPIKey={setLoadAPIKey} apiKey="" loading={true} />);
render(<ApiKeyBtn apiKey="" loading={true} onClick={clickCallback} />);

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} />);
render(<ApiKeyBtn apiKey={apiKey} loading={false} onClick={clickCallback} />);

expect(screen.queryByText('Generate Project API key')).not.toBeInTheDocument();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ describe('<ProjectAPIKeys />', () => {
});

it('shows appropriate content when user does not have correct uptime save permissions', () => {
// const apiKey = 'sampleApiKey';
render(<ProjectAPIKeys />, {
state,
core: makeUptimePermissionsCore({ save: false }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,55 @@
* 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<SpacesContextProps> = ({ children }) => <>{children}</>;

export const ProjectAPIKeys = () => {
const { loading: enablementLoading, canManageApiKeys } = useEnablement();
const [apiKey, setApiKey] = useState<string | undefined>(undefined);
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<ClientPluginsStart>().services;
const spaces = useMemo(() => form.getValues()?.spaces, [form]);

const ContextWrapper = useMemo(
() =>
spacesApi ? spacesApi.ui.components.getSpacesContextProvider : getEmptyFunctionComponent,
[spacesApi]
);

const kServices = useKibana<ClientPluginsStart>().services;
const canSaveIntegrations: boolean =
!!kServices?.fleet?.authz.integrations.writeIntegrationPolicies;
Expand All @@ -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) {
Expand Down Expand Up @@ -69,64 +105,67 @@ export const ProjectAPIKeys = () => {
}

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
data-test-subj="syntheticsProjectAPIKeysLink"
href={syntheticsTestRunDocsLink}
external
target="_blank"
>
{LEARN_MORE_LABEL}
</EuiLink>
</EuiText>
<EuiSpacer />
<EuiSwitch
label={i18n.translate('xpack.synthetics.features.elasticManagedLocations', {
defaultMessage: 'Elastic managed locations enabled',
})}
checked={accessToElasticManagedLocations && Boolean(canUsePublicLocations)}
onChange={() => {
setAccessToElasticManagedLocations(!accessToElasticManagedLocations);
}}
disabled={!canUsePublicLocations}
/>
</>
) : (
<>
<EuiText>
{GET_API_KEY_REDUCED_PERMISSIONS_LABEL}{' '}
<EuiLink
data-test-subj="syntheticsProjectAPIKeysLink"
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} />}
</>
<ContextWrapper>
<FormProvider {...form}>
<EuiEmptyPrompt
style={{ maxWidth: '50%' }}
title={<h2>{GET_API_KEY_GENERATE}</h2>}
body={
canSave && canManageApiKeys ? (
<EuiForm component="form" noValidate>
<EuiText>
{GET_API_KEY_LABEL_DESCRIPTION}{' '}
{!canSaveIntegrations ? `${API_KEY_DISCLAIMER} ` : ''}
<EuiLink
data-test-subj="syntheticsProjectAPIKeysLink"
href={syntheticsTestRunDocsLink}
external
target="_blank"
>
{LEARN_MORE_LABEL}
</EuiLink>
</EuiText>
<EuiSpacer />
<EuiSwitch
label={i18n.translate('xpack.synthetics.features.elasticManagedLocations', {
defaultMessage: 'Elastic managed locations enabled',
})}
checked={accessToElasticManagedLocations && Boolean(canUsePublicLocations)}
onChange={() => {
setAccessToElasticManagedLocations(!accessToElasticManagedLocations);
}}
disabled={!canUsePublicLocations}
/>
<SpaceSelector helpText={API_KEY_HELP_TEXT} />
</EuiForm>
) : (
<>
<EuiText>
{GET_API_KEY_REDUCED_PERMISSIONS_LABEL}{' '}
<EuiLink
data-test-subj="syntheticsProjectAPIKeysLink"
href={syntheticsTestRunDocsLink}
external
target="_blank"
>
{LEARN_MORE_LABEL}
</EuiLink>
</EuiText>
</>
)
}
actions={
<ApiKeyBtn
loading={loading}
onClick={handleSubmit(onSubmit)}
apiKey={apiKey}
isDisabled={!canSave || !canManageApiKeys}
/>
}
/>
{apiKey && <HelpCommands apiKey={apiKey} />}
</FormProvider>
</ContextWrapper>
);
};

Expand Down Expand Up @@ -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.',
});
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,12 @@ export const updateMonitorAPI = async ({
};

export const fetchProjectAPIKey = async (
accessToElasticManagedLocations: boolean
accessToElasticManagedLocations: boolean,
spaces: string[]
): Promise<ProjectAPIKeyResponse> => {
return await apiService.get(SYNTHETICS_API_URLS.SYNTHETICS_PROJECT_APIKEY, {
accessToElasticManagedLocations,
spaces: JSON.stringify(spaces),
});
};

Expand Down
Loading

0 comments on commit b8a0cef

Please sign in to comment.